Complete quantstats HTML tearsheets from SQL - one long table in, one full report per instrument out, benchmarks included
Installing and Loading
INSTALL duckfn_quantstats FROM community;
LOAD duckfn_quantstats;
Example
-- One long table in, one full quantstats tearsheet per instrument out - and no GROUP BY anywhere.
-- prices.csv is a snapshot of daily closes for GOOGL, MSFT and the S&P 500 index (SPX); it is
-- committed to the repository and served at the URL below.
WITH prices AS (
SELECT * FROM read_csv('https://shijianjs.github.io/duckfn-quantstats/demo/prices.csv')
)
SELECT (r).symbol, (r).strategy_title, length((r).html) AS html_bytes, (r).file_path
FROM (
SELECT unnest(qs_html_reports_by_prices(
symbol, date, price,
{'title': symbol,
'strategy_title': symbol,
'open_in_browser': true}::qs_html_report_options)) AS r
FROM prices
);
-- With a benchmark: SPX is just another symbol of that table, named in the options. It is input
-- only and gets no report of its own.
WITH prices AS (
SELECT * FROM read_csv('https://shijianjs.github.io/duckfn-quantstats/demo/prices.csv')
)
SELECT (r).symbol, (r).file_path
FROM (
SELECT unnest(qs_html_reports_by_prices(
symbol, date, price,
{'benchmark': ['SPX'],
'benchmark_title': ['S&P 500'],
'title': symbol,
'strategy_title': symbol,
'rf': 0.04,
'open_in_browser': true}::qs_html_report_options)) AS r
FROM prices
);
About duckfn_quantstats
Two aggregate functions that turn one date-ordered long table into a whole set of quantstats HTML tearsheets, one report per instrument (and one per benchmark, when the options name several):
| Function | Input |
|---|---|
qs_html_reports(symbol, date, period_return, options) |
periodic returns |
qs_html_reports_by_prices(symbol, date, price, options) |
prices or NAVs, differenced into returns inside the function |
Both return the same STRUCT(symbol, benchmark, strategy_title, benchmark_title, html, file_path)[],
so unnest(...) spreads it into rows, list_transform(...) picks fields, and (…)[1].html grabs a
single report. No GROUP BY appears in the SQL: symbol is the grouping key and the function splits
by it internally. Reports are ordered by symbol (and by the benchmark list within one symbol),
independent of input order and thread count.
A benchmark is just another symbol of the same table, named in the benchmark option as a list
(['SPX', 'NDX']); those symbols are input only and get no report of their own, so "one instrument
against M benchmarks" comes back as M reports differing in the benchmark field. The options are a
named STRUCT (qs_html_report_options, created at load time) evaluated per row, which is how
each instrument gets its own title / strategy_title — build it out of the symbol column:
{'title': symbol, 'strategy_title': symbol, 'benchmark': ['SPX']}::qs_html_report_options.
Other keys: rf (annualized risk-free rate), periods_per_year, match_dates, lang,
output_dir and open_in_browser.
With lang the report's own fixed texts (headings, metric names, chart titles, months, the
legend) come out in that language, each one carrying a short note the browser pops up from the
element it sits on. en, zh-CN, ja, de, fr and es are built in; 'en' keeps the English
text and only adds notes, and leaving the key unset leaves the report untouched. The table lives in the running
DuckDB process: qs_set_translation(lang, [{key, label, description}]) overwrites or deletes
entries (a NULL label deletes one, a NULL list deletes the language, a NULL description keeps the
existing note) and qs_list_translations() lists the whole table. Nothing is persisted — reloading
the extension restores the built-in data.
With output_dir each report is also written through DuckDB's VFS — local disk, s3://… once
httpfs is loaded, and the wasm build's file system all take the same path — under a name the
function generates (<time>-<strategy>-<benchmark>-<random>.html, nothing is ever overwritten), and
the path it actually wrote comes back in file_path. With open_in_browser the reports are handed
to the system default browser instead. Reports are a few hundred KB of HTML each (a dozen inline
SVGs), so a directory or a browser tab is friendlier than unnest-ing them into a terminal.
Requires DuckDB 1.5 or newer: the host file system used for output_dir only reached DuckDB's
C API in 1.5. On wasm, output_dir works and open_in_browser is ignored (there is no browser
process to launch; the HTML string comes back to the host as it is).
The full option table, the error paths and the development notes: https://shijianjs.github.io/duckfn-quantstats/docs/intro.
Added Functions
| function_name | function_type | description | comment | examples |
|---|---|---|---|---|
| qs_html_reports | aggregate | Renders one quantstats HTML report per symbol from a long table of periodic returns | Groups by symbol internally, so the SQL needs no GROUP BY; a benchmark is just another symbol of the same table, serves as input only and gets no report of its own | [SELECT unnest(qs_html_reports(symbol, trade_date, daily_return, NULL)) FROM daily_returns; SELECT unnest(qs_html_reports(symbol, trade_date, daily_return, {'benchmark': ['SPX'], 'title': symbol, 'output_dir': 'reports/'}::qs_html_report_options)) FROM daily_returns] |
| qs_html_reports_by_prices | aggregate | Renders one quantstats HTML report per symbol from a long table of prices or NAVs, differencing them into returns first | The value column is a level, not a change: every symbol, the benchmark included, is converted with price_t / price_{t-1} - 1 before rendering | [SELECT unnest(qs_html_reports_by_prices(symbol, date, price, NULL)) FROM prices; SELECT unnest(qs_html_reports_by_prices(symbol, date, price, {'benchmark': ['SPX'], 'title': symbol, 'output_dir': 'reports/'}::qs_html_report_options)) FROM prices] |
| qs_list_translations | table | Lists the translations of this DuckDB process, ordered by lang and key | Reflects the built-in data plus every qs_set_translation() call of this process; nothing is persisted, so reloading the extension restores the built-in table | [SELECT * FROM qs_list_translations() WHERE key LIKE 'metric.%'] |
| qs_set_translation | scalar | Overwrites or deletes translations of one language in this DuckDB process, entry by entry | Setting 'label' to NULL or an empty string deletes that key and its note; a NULL entries list deletes the whole language; a NULL 'description' keeps the existing note. Nothing is persisted: reloading the extension or restarting the process restores the built-in data | [SELECT qs_set_translation('de', [{'key': 'month.jan', 'label': 'Januar', 'description': 'January'}]); SELECT qs_set_translation('zh-CN', NULL)] |
Overloaded Functions
This extension does not add any function overloads.
Added Types
| type_name | type_size | logical_type | type_category | internal |
|---|---|---|---|---|
| qs_html_report_options | 0 | STRUCT | COMPOSITE | false |
| qs_translation_entry | 0 | STRUCT | COMPOSITE | false |
Added Settings
This extension does not add any settings.