跳到主要内容

快速开始

除了扩展本身,什么都不用编译、什么都不用安装:只要你能把 SQL 发给 DuckDB —— 命令行、Python、 任何客户端都行 —— 就能出报告。本页每一块都跑在同一份快照上,而且每一块都能在这里的浏览器里跑。

前置条件​

  • DuckDB 1.3 及以上。 本扩展依据 DuckDB 1.5.5 的头文件构建,但元数据里带的是下限而非逐字匹配: 实测跑过 1.3.2、1.4.0、1.4.5、1.5.0、1.5.5、1.5.6。
  • 任何能把 SQL 发给它的方式都行。本文档用 duckdb 命令行,Python / Java / Node 客户端或图形界面 完全一样。

1. 安装并加载​

扩展发布在 DuckDB 的社区仓, 一条 INSTALL 就把当前平台的签名产物取回来:

INSTALL duckfn_quantstats FROM community; -- 只需一次,需要网络
LOAD duckfn_quantstats; -- 之后每个会话只要这一句

2. 准备数据​

两个函数都吃长表 —— 一行一个(标的, 日期, 值):

列类型含义
symbolVARCHAR标的。它同时是分组依据:一个不同取值出一份报告。
dateDATE这个值属于哪一期。
值DOUBLE周期收益率;给 qs_html_reports_by_prices 时是价格/净值。

基准就是同一张表里另一个 symbol,不需要任何 join。示例都读本站旁边发布的那份快照: GOOGL、MSFT 与标普 500 指数(SPX),各 1435 个交易日,区间 2021-01-04 … 2026-09-21:

SELECT count(*) AS rows,
count(DISTINCT symbol) AS instruments,
min(date) AS first_day,
max(date) AS last_day
FROM read_csv('https://shijianjs.github.io/duckfn-quantstats/demo/prices.csv');

3. 产出报告​

整张表一次调用、每个标的一份报告 —— 不写 GROUP BY:symbol 列就是分组依据。

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;

两行结果 —— SPX 只作输入:它是两份报告的基准,自己不出报告。html 里是整份自包含的 tearsheet, file_path 是你要求落盘时它写到了哪。

4. 看一眼报告​

一份报告就是一个 HTML 文档、图表都内联在里面,所以可以直接在这里渲染。点 Run,再切上面两个标签页:

WITH prices AS (
SELECT *
FROM read_csv('https://shijianjs.github.io/duckfn-quantstats/demo/prices.csv')
)
SELECT (r).symbol AS symbol, (r).html AS html
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;

GOOGL 那份完整尺寸的报告也放在文档站旁边 —— 就是首页上那一份。

5. 把它放到你想放的地方​

一份报告是几百 KB 的 HTML,终端里一般不会想让它铺在结果集里。output_dir 每份报告写一个文件, file_path 会告诉你每份写到了哪:

-- 每份报告一个文件,写到当前目录。只给目录,文件名由函数生成,所以两次调用不会撞名。
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,
'output_dir': './'}::qs_html_report_options)) AS r
FROM prices
);
这块为什么不能在这里跑

查询本身是真的,拿到本地 DuckDB 上就能把文件写出来。而 wasm 构建一个文件都不写:那边整个跳过文件 操作,于是每一行的 file_path 都会是 NULL,报告待在 html 列里 —— 这是平台的限制,不是扩展的问题。 浏览器里任何路径都会被报成已存在,哪怕它并不存在(一条 1 字节的幻影条目,连 DuckDB 自带的 glob 与 file_size 也认它),所以「这个文件名空着吗」没有一个值得信的答案。把同一段拿到你自己的 DuckDB 里跑, 文件就会出现在 ./。

在桌面端,open_in_browser 能省掉去文件管理器里翻文件这一步;但它需要一个浏览器进程来启动, 所以在这一页里它是空操作(file_path 也是空的):

-- 这里跑不了:wasm 里没有可以把报告交给它的浏览器进程。
SELECT unnest(qs_html_reports_by_prices(
symbol, date, price,
{'benchmark': ['SPX'], 'title': symbol, 'open_in_browser': true}::qs_html_report_options)) AS report
FROM read_csv('https://shijianjs.github.io/duckfn-quantstats/demo/prices.csv');

只要清单、不想把 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;

常用写法​

只要一个标的 —— 先过滤,仍然不需要 GROUP BY:

WITH prices AS (
SELECT *
FROM read_csv('https://shijianjs.github.io/duckfn-quantstats/demo/prices.csv')
WHERE symbol IN ('GOOGL', 'SPX')
)
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}
::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 IN ('GOOGL', 'SPX', 'MSFT')
)
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', 'MSFT'],
'benchmark_title': ['S&P 500', 'Microsoft'],
'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, (r).benchmark, length((r).html) AS html_bytes
FROM (
SELECT unnest(qs_html_reports(
symbol, date, period_return,
{'benchmark': ['SPX'], 'title': symbol, 'strategy_title': symbol}
::qs_html_report_options)) AS r
FROM returns
)
ORDER BY (r).symbol;

多个标的共用一个基准是最常见的情况 —— 不用额外写什么:

WITH prices AS (
SELECT *
FROM read_csv('https://shijianjs.github.io/duckfn-quantstats/demo/prices.csv')
)
SELECT (r).benchmark, count(*) AS reports, count(DISTINCT (r).symbol) AS instruments
FROM (
SELECT unnest(qs_html_reports_by_prices(
symbol, date, price,
{'benchmark': ['SPX'], 'title': symbol}::qs_html_report_options)) AS r
FROM prices
)
GROUP BY (r).benchmark;

跑不通的时候​

最值得早点认清的两种失败 —— 下面两块本来就该失败,点 Run 看到的是报文而不是结果:

-- 配置字面量必须带上类型,否则 DuckDB 会去找一个并不存在的 STRUCT(title VARCHAR) 重载。
WITH prices AS (
SELECT * FROM read_csv('https://shijianjs.github.io/duckfn-quantstats/demo/prices.csv')
)
SELECT unnest(qs_html_reports_by_prices(symbol, date, price, {'title': symbol})) AS report
FROM prices;
-- 'benchmark' 写的是 symbol,每一个都必须在被聚合的这张表里存在。
WITH prices AS (
SELECT * FROM read_csv('https://shijianjs.github.io/duckfn-quantstats/demo/prices.csv')
)
SELECT unnest(qs_html_reports_by_prices(
symbol, date, price, {'benchmark': ['NDX']}::qs_html_report_options)) AS report
FROM prices;
其它现象原因
every symbol must use the same benchmark listbenchmark 逐行读取,但整次调用必须一致。
报 cannot write the report to '…'output_dir 必须是已经存在的本地目录;函数不会替你创建,远端路径(s3://…)在这里也写不进去。
only local file paths can be opened in a browseropen_in_browser 打不开 s3://…、memory://…,那种路径只能不用这个选项、只落盘。

完整的行为清单见错误路径。

接下来​

  • 函数 —— 返回形状、排序、基准怎么变成多份报告。
  • 配置字段 —— qs_html_report_options 的每一个字段。
  • 价格/净值序列 —— 价格那一支对值做了什么。
  • 落盘与浏览器 —— 文件写到哪、怎么命名。
  • 翻译 —— lang 配置项,以及每个元素上的浮出说明。

构建或测试扩展本身是另一个话题,在开发指南。