函数
两个聚合函数名字、各一个签名,都把「一张长表」归约成整套报告:
| 签名 | 输入 | 返回 |
|---|---|---|
qs_html_reports(symbol, date, period_return, options) | 收益率序列 | STRUCT(symbol, benchmark, strategy_title, benchmark_title, html, file_path)[] |
qs_html_reports_by_prices(symbol, date, price, options) | 价格/净值序列 | 同上;函数内部先换算成收益率 |
下面每一块都能在你的浏览器里跑,读的是本站发布的演示快照(GOOGL、MSFT 与标普 500 指数,
各 1435 个交易日)—— 把 read_csv(…) 换成你自己的表,查询的形状完全一样。
要点
- 一次调用出整套报告。 SQL 里不写
GROUP BY:symbol列就是分组依据,函数内部按它分组, 每个 symbol 渲染一份完整报告。100 个标的就是 100 份完整报告(每份内嵌十几张 SVG),耗时与内存随标的 数线性增长 —— 这是预期行为,不是性能 bug。 - 返回的是一个数组,每个元素是
{symbol, benchmark, strategy_title, benchmark_title, html, file_path}。unnest(...)把它铺成行,list_transform(...)只取需要的字段,也可以(qs_html_reports(...))[1].html直接取某一份。 - 顺序按
symbol升序,同一标的内按benchmark列表给出的顺序;与输入顺序、线程数都无关。 - 基准是表里一个或多个普通的 symbol:配置里的
benchmark是列表(['SPX', 'NDX']),列到的 symbol 只作输入、不出现在结果里。报告只能带一个基准,所以「一个标的对 M 个基准」就是M 份报告: 同一symbol出现 M 行,靠benchmark字段区分(一个基准多个标的则只是每个标的各出一份)。 symbol是VARCHAR,date是DATE,period_return是按周期计的收益率(DOUBLE),price是当天的价格或净值(DOUBLE)。这四者任一为NULL的行会被整行跳过(symbol是空串的行同样 跳过),与其它 SQL 聚合函数一致。- 配置参数
options固定在参数列表最后且必给 —— 不需要配置就写NULL。它是可空配置,类型是 加载期建好的命名 STRUCT 类型qs_html_report_options。 - 配置按行求值(见配置字段),所以「每个标的一套标题 / 显示名 / 落盘目录」就是用
symbol列把配置拼出来。 - SQL 里不需要
ORDER BY:聚合内部只做拼接,排序交给报告自己去排。(示例里的ORDER BY只是让 你看到的行整齐一点。) - 名字为什么是两个:
(symbol, date, price, options)与(symbol, date, period_return, options)的类型 序列完全一样(VARCHAR, DATE, DOUBLE, STRUCT),同一个名字下无法分派。
从长表到返回数组,整次调用是这样走的:
用法
整张表带基准 —— 最常见的情况:
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;
一个标的、不带基准 —— 先过滤:
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
);
一个标的对两个基准 —— benchmark 列表的顺序就是报告顺序:
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;
收进来的不是价格而是收益率:
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;
只要清单、不把 HTML 拖出来(落盘见落盘与浏览器):
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;
几个必须知道的行为
- 配置的 struct 字面量必须显式写
::qs_html_report_options。 不写的话它是匿名的STRUCT(title VARCHAR),匹配不上任何签名,DuckDB 会直接说找不到函数。 '...'::JSON::qs_html_report_options要把 10 个键写全(DuckDB 的 JSON→STRUCT 转换不允许缺键), 所以推荐直接用 struct 字面量。benchmark写的是 symbol 名(列表),不是值。 每一项都必须是表里symbol列的某个取值,整次调用 一致;列到的 symbol 只当基准,不出现在返回的数组里。一个基准也要写成['SPX']。benchmark_title也是列表,按下标与benchmark对齐:['S&P 500', 'Nasdaq 100']分别对应第一个与 第二个基准。它只是展示,所以很宽松 —— 缺项(列表短了、NULL、空串)就退回那一份报告所用的基准 symbol, 多出来的表项直接忽略。- wasm 下既不落盘也不开浏览器:那边整个跳过文件操作(
file_path回来是NULL),open_in_browser也不做任何事 —— 没有浏览器进程可启动。见落盘与浏览器。 - 演示快照很适合拿来试这些 —— 直接用
read_csv('https://shijianjs.github.io/duckfn-quantstats/demo/prices.csv')读本站这份即可; 里面有什么见演示数据。