Functions
Two aggregate function names, one signature each, folding one long table into a whole set of reports:
| Signature | Input | Returns |
|---|---|---|
qs_html_reports(symbol, date, period_return, options) | return series | STRUCT(symbol, benchmark, strategy_title, benchmark_title, html, file_path)[] |
qs_html_reports_by_prices(symbol, date, price, options) | price/NAV series | the same; returns are derived inside the function |
Every block below runs in your browser against the demo snapshot this site serves
(GOOGL, MSFT and the S&P 500 index, 1435 trading days each) — swap the read_csv(…) for your own
table and the query is the same.
The essentials
- One call produces the whole set of reports. There is no
GROUP BYin SQL: thesymbolcolumn is the grouping key, the function splits by it internally and renders one full report per symbol. A hundred instruments mean a hundred full reports (each with a dozen inline SVGs), so time and memory grow linearly with the number of instruments — that is expected, not a performance bug. - The result is a list, each element being
{symbol, benchmark, strategy_title, benchmark_title, html, file_path}:unnest(...)spreads it into rows,list_transform(...)picks fields, or(qs_html_reports(...))[1].htmlgrabs one report directly. - The order is ascending by
symbol, and within one symbol the order of thebenchmarklist; it is independent of input order and thread count. - The benchmark is one or more ordinary symbols in the table: the
benchmarkoption is a list (['SPX', 'NDX']) and the symbols it names are input only — they do not appear in the result. A report can carry one benchmark, so "one instrument against M benchmarks" is M reports: the samesymbolshows up in M rows, told apart by thebenchmarkfield (several instruments sharing one benchmark is a different thing — that is simply one report per instrument). symbolis aVARCHAR,dateis aDATE,period_returnis the return per period (DOUBLE) andpriceis that day's price or NAV (DOUBLE). A row whose value isNULLin any of the four is skipped entirely (an empty-stringsymboltoo), like any other SQL aggregate.- The options argument always comes last and is required — pass
NULLwhen you need no options. It is a nullable config whose type is the named STRUCTqs_html_report_options, created at load time. - The options are evaluated per row (see Options), which is exactly how "each instrument gets its own title and display name" works.
- No
ORDER BYis needed: the aggregate only concatenates and lets the report sort by date. (TheORDER BYin these examples only tidies the rows you see.) - Why two names:
(symbol, date, price, options)and(symbol, date, period_return, options)have exactly the same type sequence (VARCHAR, DATE, DOUBLE, STRUCT), so one name could not dispatch them.
The whole call, from the table to the returned list:
Usage
The whole table, with a benchmark — the ordinary case:
WITH prices AS (
SELECT * FROM read_csv('https://shijianjs.github.io/duckfn-quantstats/demo/prices.csv')
)
SELECT (r).symbol, (r).benchmark, (r).benchmark_title, length((r).html) AS html_bytes
FROM (
SELECT unnest(qs_html_reports_by_prices(
symbol, date, price,
{'benchmark': ['SPX'],
'benchmark_title': ['S&P 500'],
'title': symbol,
'strategy_title': symbol}::qs_html_report_options)) AS r
FROM prices
)
ORDER BY (r).symbol;
One instrument, no benchmark — filter first:
WITH prices AS (
SELECT * FROM read_csv('https://shijianjs.github.io/duckfn-quantstats/demo/prices.csv')
WHERE symbol = 'GOOGL'
)
SELECT (r).symbol, (r).benchmark, length((r).html) AS html_bytes
FROM (
SELECT unnest(qs_html_reports_by_prices(symbol, date, price, NULL)) AS r
FROM prices
);
One instrument against two benchmarks — the benchmark list decides the order of the reports:
WITH prices AS (
SELECT * FROM read_csv('https://shijianjs.github.io/duckfn-quantstats/demo/prices.csv')
WHERE symbol IN ('GOOGL', 'SPX', 'MSFT')
)
SELECT (r).symbol, (r).benchmark, length((r).html) AS html_bytes
FROM (
SELECT unnest(qs_html_reports_by_prices(
symbol, date, price,
{'benchmark': ['SPX', 'MSFT'], 'title': symbol}::qs_html_report_options)) AS r
FROM prices
)
ORDER BY (r).benchmark;
A return series instead of a price series:
WITH prices AS (
SELECT * FROM read_csv('https://shijianjs.github.io/duckfn-quantstats/demo/prices.csv')
),
returns AS (
SELECT symbol, date,
price / lag(price) OVER (PARTITION BY symbol ORDER BY date) - 1.0 AS period_return
FROM prices
)
SELECT (r).symbol, length((r).html) AS html_bytes
FROM (
SELECT unnest(qs_html_reports(symbol, date, period_return, NULL)) AS r
FROM returns
)
ORDER BY (r).symbol;
Keep only the list, without dragging the HTML along (see Output and browser for writing the reports out as well):
WITH prices AS (
SELECT * FROM read_csv('https://shijianjs.github.io/duckfn-quantstats/demo/prices.csv')
)
SELECT list_transform(
qs_html_reports_by_prices(symbol, date, price,
{'benchmark': ['SPX'], 'title': symbol}::qs_html_report_options),
lambda x: {'symbol': x.symbol, 'benchmark': x.benchmark, 'bytes': length(x.html)}) AS reports
FROM prices;
Behaviours worth knowing
- A struct literal must be cast with
::qs_html_report_options. Without it the literal is an anonymousSTRUCT(title VARCHAR)that matches no signature, and DuckDB reports that no function matches. '...'::JSON::qs_html_report_optionsmust spell out all 10 keys (DuckDB's JSON→STRUCT conversion rejects missing keys), so prefer the struct literal.benchmarknames symbols (a list), not a value series. Every entry has to be one of the values in thesymbolcolumn and the list has to be identical across the whole call; the symbols it names act as the benchmarks only and never show up in the returned list. Even a single benchmark is written['SPX'].benchmark_titleis a list too, paired withbenchmarkby index:['S&P 500', 'Nasdaq 100']belong to the first and second benchmark respectively. It is presentation only, hence lenient — a missing entry (shorter list, NULL, empty string) falls back to that report's own benchmark symbol, and extra entries are ignored.- On wasm nothing is written and no browser is opened: the file operation is skipped (
file_pathcomes backNULL) andopen_in_browserdoes nothing, since there is no browser process to launch; see Output and browser. - The demo snapshot is a good table to try all of this on —
read_csvit straight from this site:read_csv('https://shijianjs.github.io/duckfn-quantstats/demo/prices.csv'). See the demo dataset for what is in it.