面向Google编程CHARLES ZHANG

AI DAILY / 2026-09-10

Qanat:以DAG为原语的智能体量化回测工作流引擎

fidetolabs/qanat

Agent 开发GitHub · 2026-09-05

全文中文翻译 · AI 生成,仅供学习交流

原文配图

Qanat 把 alpha 声明为 DAG(Directed Acyclic Graph,有向无环图)。把回测交给一个智能体(agent)来跑。

快速上手 智能体 工作原理 控制台 阶段契约(stage contract) 一个项目文件 向后与向前 回测 连接器 状态 贡献 词汇表 Qanat 是一个面向智能体(agent-native)的工作流引擎,用于把 alpha 构建和回测为 DAG。

一个 alpha 就是写出权重表(weights table)的那一步:每个标的(symbol)一个目标权重,不带预算。

它的背后是一个 DAG , 它读入的特征(feature)以及这些特征来自的表,一直追溯到源头。

你把数据源(source)指向已分阶段(staged)的表,每一步写成 .sql 或 .py 文件。Qanat 解析图,按顺序执行步骤,并提供一个控制台(console)让你看着表被一行行填满。

一个项目里四个 alpha。每个 alpha 写出自己的权重表,旁边各有一张 pnl 表,记录它赚到的收益。

low_vol + momentum 是其中的两个被当作一本书(book)来定价的,这也是为什么有两支箭头指向它的结果。

qanat init --demo 会构建一个像这样的项目。

存储就是一个本地数据库:默认是一个 DuckDB 文件,也可以是 localhost 上的 Postgres(qanat init --postgres,或 docker compose)。Qanat 自身不会发起网络请求;唯一的出站请求就是你配置的数据源发起的那些。附带了一份真实数据集 , examples/fx-bundled 里 27 年的 ECB 参考汇率,其他数据源都需要你自己配置。

第一个正式打包的发布版本,也是一次公开测试。

它在我自己的项目上确实做到了本页所说的事情,但还没有其他人跑过。如果哪里出错或者看起来不对劲,请提 issue 或在 Discord 上说一声。

快速上手

两种入口。两种都会到达同一个控制台:http://127.0.0.1:8420

安装。

需要 Python 3.10+:

uv tool install qanat-fdtl
# 或者:
pip install qanat-fdtl
qanat init my-alpha --demo && cd my-alpha
qanat serve

或者在 Docker 中运行。

只需要 Docker,自带 Postgres:

git clone https://github.com/fidetolabs/qanat.git && cd qanat
docker compose up --build

--demo 会接入四个现成的 alpha,跑通整条管道(pipeline)并为每一个定价,因此控制台打开时已经是一本带数字的书。大约十秒。去掉 --demo 就得到一个空的起步项目,然后用 qanat check 把它按阶段契约(stage contract)校验一遍,再用 qanat run 跑一遍图。

无论哪种方式,数据都是合成的(synthetic),所以第一次运行无需 API key 也无需网络。

准备好之后,把数据源指向一个真实的数据源。其它什么都不用改 , 这就是 examples/fx-bundledexamples/fx-real,同一个项目,一个读文件,一个通过 HTTP 抓取。

其它运行方式,以及 Docker 启动失败时怎么办

PyPI 上的包名是 qanat-fdtl;它安装的命令是 qanat。还没有 uv?

curl -LsSf https://astral.sh/uv/install.sh | sh

从一份克隆下来的代码运行,把 qanat … 换成 uv run qanat …,详见 CONTRIBUTING.md

Postgres,不要 Docker

qanat init my-alpha --postgres , 用 localhost:5432 上的真实服务器代替文件

只要控制台

qanat serve --no-schedule , 读取表并手动运行,不靠定时器

单次跑一遍,不开服务器

qanat run , 把图跑一遍就退出。可以放进你自己的 cron(定时任务)或 CI 里在 Docker 中,Postgres 在 localhost:5433 而不是 5432,因为 5432 经常已被占用。用户名、密码和数据库名都是 qanat。要使用你自己的项目而不是 demo,请把一个目录绑定到 /project

清掉 Postgres 和项目,从头开始:

docker compose down -v && docker compose up --build

保留 Postgres 数据,只重置 demo 项目:

QANAT_RESET=1 docker compose up --build

端口已被占用?自行设置主机端口:

POSTGRES_HOST_PORT=5434 QANAT_HOST_PORT=8421 docker compose up --build

把它交给智能体

大多数人会通过智能体而不是 CLI 来操作它。把它加到任何 MCP(Model Context Protocol,模型上下文协议)客户端:

{
  "mcpServers": {
    "qanat": {
      "command": "qanat",
      "args": ["mcp"],
      "cwd": "/path/to/my-alpha"
    }
  }
}

Claude Code:

claude mcp add qanat -- qanat mcp

加上 --read-only,它会保留 20 个读类工具,丢掉 7 个写类工具。

控制台和 MCP 服务器是同一层服务之上的两个薄适配器,在它们之上没有任何逻辑。这就是这里 "agent-native" 的含义,并且是可测试的:一个智能体能做控制台能做的所有事,而这两者对项目状态的看法不可能不一致。

27 个工具

- 发现(Discover) list_tables, describe_table, sample_table(支持 as_of), lineage, list_steps, read_step
- 校验(Validate) check, plan, stale_tables
- 挑选(Choose) list_alphas, read_alpha, use_alpha, alpha_book, backtest_conditions
- 运行(Run) run, backtest, list_backtests, report, period, weights, compare, list_runs
- 编写(Author) save_step, remove_step, save_source
- 观察(Watch) open_console, console_status

实际看上去什么样

> "这个项目里有什么,momentum 这个 alpha 的数据来自哪里?"

智能体调用 list_tables,然后是 lineage,拿到的是数据而不是一段话:

{
  "ref": "weights.momentum",
  "upstream": [
    { "from": "normalized.prices", "step": "alpha_momentum" }
  ],
  "breaks_portfolio": false
}

> "加一个 momentum alpha 并做回测。"

list_alphasuse_alpharunbacktest_conditionsbacktest。第四个调用是关键。它不会去猜窗口,而是返回这个项目实际能回答的内容,以及需要先问你的事项清单:

{
  "ask_the_person_for": [
    "alpha", "from", "to", "universe", "rebalance", "decay", "split"
  ],
  "window": {
    "data_available": {
      "earliest": "2025-07-13",
      "latest": "2026-09-05"
    }
  },
  "rebalance": {
    "default": "5d",
    "note": "shorter means more decisions and more turnover"
  },
  "why": "each one changes the number. A backtest run on conditions nobody chose is a guess with a decimal point on it."
}

> "我的哪些 alpha 真的有效?"

alpha_book 返回每一个 alphaset(alpha 组合)及其收益,智能体据此做比较而不是凭空猜测。

reportcompare 可以把任意两个并排打开。

> "为什么三月亏了钱?"

report 给出各个周期(period),period 完整打开其中之一 , 当时持有哪些持仓、每个名字的回报是多少、为了达到这个组合做了哪些交易 , 而 weights 显示它最终决定的组合。

> "看着我干活。"

open_console 从同一个会话中提供控制台,因此智能体运行时你可以看到图被点亮。一个进程,一个存储,没有什么需要保持同步。

工具面的相关说明:docs/agents.md

工作原理

所有内容都在一个 qanat.yaml 中声明:阶段(stage)、数据源(source)、步骤(step)、调度(schedule)、股票池(universe)。

没有什么藏在应用代码里。十三个命令作用于这个文件:

| 命令 | 作用 |
| --- | --- |
| qanat init | 脚手架(scaffold)一个项目 |
| qanat check | 把管道按阶段契约(stage contract)校验一遍 |
| qanat plan | 若应用这个文件,会发生什么变化。是配置(configuration),不是行级数值 |
| qanat prune | 删除不再有产物产生的表 |
| qanat ls | 列出 stage、table、job |
| qanat run | 把整张图跑一遍,或者跑一个 job |
| qanat serve | 调度器与控制台 |
| qanat backtest | 重放(replay)管道并对持仓定价 |
| qanat backtests | 列出本项目跑过的每一次回放 |
| qanat report | 一次回测,按周期展开 |
| qanat compare | 两次回测之间发生了什么变化 |
| qanat alphas | Qanat 自带的 alpha,以及如何接入一个 |
| qanat mcp | 把本项目通过 MCP(stdio)提供给智能体 |一个 step 是一个 .sql 文件,会被包裹成 CREATE OR REPLACE TABLE,或者一个 .py 文件,其中 run(ctx) 返回一个 DataFrame。

ctx.read() 会拒绝任何该步骤没有在 from: 中声明的表,因此缺失依赖是一个错误,而不是一个过期数字。

状态在 job 成功时记录,而不是文件加载时。"Changed" 在 qanat plan 中指的是这个 job 与上一次成功时的状态不同,失败的运行从来不会成为后续 diff 的基线。

sources ─→ raw ─→ normalized ─→ features ─→ weights ─→ pnl
   │         │         │            │          │        │
   │         │         │            │          │        └─ 每次再平衡赚到的
   │         │         │            │          └─ 每个 alpha 一张表
   │         │         │            └─ 一连串的步骤(不带预算)
   │         │         └─ 类型化(typed)、去重的步骤链
   │         └─ 永远不会被编辑
   └─ sql / csv / rest

控制台

qanat serve 启动调度器和一个 Web 控制台。里面每一个方框都是一张表,每一支箭头都是生成它的步骤,按阶段从左到右排布。

你可以在控制台里配置整条管道 , source、step、alpha、stage、保留策略(retention) , 你修改的所有内容都会写回到磁盘上的 qanat.yaml。没有设置面板,也没有编辑模式。参见 docs/console.md

五个阶段,以其内容命名

一条管道就是 表 ,(步骤), 表 , …… , 表。表是你拥有的;步骤是它们之间那些有名字的工作。

- raw , 原样落地,永不修改。任何 step 都不得写入其中。
- normalized , 类型化(typed)、去重、统合到一套键(key set)上。
- features , 一条链:一个 feature 步骤可以读另一个 feature 步骤。
- weights , 一个 alpha 一张表。每个标的(symbol)一个目标权重,不带预算。写出这类表的步骤就是 alpha。它读入的内容就是它的血统(lineage)。
- pnl , 可选,且在最后。每个 alpha 在每个再平衡周期赚到了什么。没有人手工写它:qanat backtest 在知道组合价值之后会写它。

任何 alpha 都不得读另一个 alpha 的 weights,因此任何一条边都不会被计算两次。

qanat check 强制执行六条规则,并拒绝为破坏规则的项目提供服务。这些规则的详细说明和背后的道理写在 docs/contract.md

一个项目长什么样

project: equity
store: ./data/qanat.duckdb

universes:                     # 组合可以持有的标的
  id: sp500
  index: S&P 500
  symbols: ./universes/sp500.csv   # symbol,name,sector,from,to

stages:                        # 这里的顺序就是管道中的顺序
  { id: raw,        kind: raw }
  { id: normalized, kind: features }
  { id: features,   kind: features }
  { id: weights,    kind: weights }
  { id: pnl,        kind: pnl }  # 可选,最后一个;由 `qanat backtest` 写入

sources:                       # 多个 source 可以喂给同一个 stage
  id: prices
  to: [raw.daily_prices]
  connector: rest
  schedule: "*/5 * * * *"
  options:
    url: https://api.example.com/v1/bars
    headers: { Authorization: "Bearer ${PRICE_API_KEY}" }
  records: data.items

steps:                         # n:m , `from` 和 `to` 都是列表
  id: momentum
  from: [normalized.prices]
  to: [features.momentum]
  script: steps/momentum.py
  when: [normalized.prices]    # 或 `schedule:` 按时钟触发,或两者都不要
  options: { lookback: 20 }

  id: alpha_momentum            # 一个 alpha:写出 weights 表的步骤
  from: [features.momentum, features.risk]
  to: [weights.momentum]
  script: steps/alpha_momentum.py
  universe: sp500
  rebalance: 20d                # 这个 alpha 希望被怎样运行;一次回测
  decay:                       # 除非另行指定,否则使用这些默认

retention:                     # 一个挂钟上的 source 会永远填充它的表。
  raw.daily_prices: 7d          # 删除早于此的旧行;`24h`、`2w` 也支持
  normalized.prices: 30d

backtest:                      # 用来给组合定价的行情,以及成本
  prices: normalized.prices    # Backtest 章节下面有其它键
  fee_bps:
  slippage_bps:

保留策略(retention)每分钟运行一次。它会在表里查找 tstimestamptimedateas_ofdatetimecreated_atupdated_at 中存在的那个日期字段。

一个 .py 步骤实现 run(ctx)

def run(ctx):
    bars = ctx.read("normalized.prices")   # 仅限本步骤在 `from` 中声明的
    held = ctx.universe()                  # 它可以持有的标的
    ctx.log("scoring")
    return df                              # 或 {"table": df, ...} 用于 n:m

每个术语只有一个含义、一个拼写。参见 docs/words.md

向后与向前

同一张图可以两种方式运行。

向后 , 对已经发生的一段时间跑一遍。qanat backtest 每个 as-of 日期重放一次管道并对持仓定价。跑一次,读数字,结束。参见 Backtest。

向前 , 当新数据到达时,图持续跟进。有些数据源只会告诉你今天的数字,因此要拿到去年的,唯一办法就是曾经把它存下来。

一个 job(任务)是任意可运行的东西:一个 source 或一个 step。一个 job 以两种方式之一向前推进。

按时钟。

给这个 job 一条 cron 表达式:

id: prices                    # 一个 source:每五分钟拉取一次
schedule: "*/5 * * * *"

id: momentum                  # 一个 step:数据落地后重算 feature
schedule: "*/15 * * * *"      # cron 使用 UTC

在这里设置,或在控制台里:点击一个 source 或 step,填写 fetch again。然后这个图就会按 */5 * * * * 自动读取,而不是只在你请求时才读。一个仍在运行的 job 永远不会被启动两次。

当它的输入发生变化时。

时钟是对数据何时到达的一种猜测。这才是数据到达本身:

id: momentum
from: [normalized.prices]
when: [normalized.prices]     # 当这张表拿到新行时运行

每一个被唤醒的步骤又会去唤醒那些等待它自己那些表的步骤,因此一个 source 落地一行数据就能自己一直抵达图的末端。一个 step 可以同时设置 schedule:when:,或者都不设 , 都不设的话,它就在你请求时运行。它只能等待它所读的表。

数据源无论如何都按时钟运行。一个 source 等待的是 qanat 之外的东西,因此唯一的发现方式就是去问。

sh scripts/demo-run.sh 会轮询一个模拟数据源两分钟,期间控制台会被填满。

参见 examples/scheduled-ingest

Backtest

一次回测就是同一套运行循环,外加一个时钟和一份账单。

qanat backtest --from 2026-01-05 --to 2026-06-01 --rebalance 10d --seed 7
gross            +25.150%
fees              -0.636%
slippage          -1.272%
net              +23.242%
per period        +1.660%  over 14 periods
hit rate           64.3%

时钟。每次跑之前,每张表都被一个视图(view)遮蔽,视图里只保留那一刻已经存在的行。一个步骤读的是过去,而不会意识到自己在被重放,因此一个忘了过滤的步骤也无法看到未来。

账单。weights 表说明要持有什么。换手率(turnover)是为了达到这个组合而必须交易的量。手续费(fee)和滑点(slippage)按换手率收取,打印出来的是净值(net)。

qanat.yaml 中设置。任意一项都可以在命令行上为某一次运行覆盖:

backtest:
  prices: normalized.prices   # 每个标的每天的价格取自哪里
  price_column: close
  fee_bps:                     # 按换手率收取
  slippage_bps:                # 估算值,同样按换手率收取
  rebalance: 5d                # as-of 日期之间的间隔
  purge: 0d                    # 在步骤可读取前,把行向后保留这么长时间
  embargo: 0d                  # 在一次回报被计入前,在 as_of 之后等待这么长时间
  decay:                       # 把最近 N 个组合进行加权混合;0 或 1 即关闭

Decay 通常会改变结论。

一个 alpha 常常对方向判断正确,但判断频率错了。对每一次抖动都做出反应,是在为噪音付手续费。

--decay N 持有最近 N 个组合的加权混合,最新的权重最大:

              turnover      net
--decay off      45.00    -5.05%
--decay 4        25.32    -2.88%

再平衡(rebalancing)从另一侧做着同样的事。同一个 alpha、同一个窗口,只有 --rebalance 变了:

--rebalance 10d    turnover   10.8     net  +23.2%
--rebalance 1d     turnover  128.5     net  -27.1%

正因为这个差距,net 才是头条数字,gross 不是。把手续费一路调到把优势耗尽,是看出其中有多少成色最直接的办法。

样本内与样本外是分开的。

--split <date> 把运行切成两段并分别报告。回看窗口、再平衡和 decay 都是某个能看见样本内那一半的人选定的,因此那个数字部分地是在衡量"选择"本身:

in sample        -22.055%  19 periods,  -1.161% each
out of sample     -2.843%  14 periods,  -0.203% each

split at 2026-03-01 — out of sample is the line to believe

多个 alpha 可以被当作一本书来定价。

这与任何一个 alpha 单独使用都是不同的策略:

qanat backtest --alpha alpha_momentum,alpha_low_vol --allocation momentum=3,low_vol=1 \
  --from … --to … --rebalance 5d

每个 alpha 保留自己的 weights 表。这次运行持有它们的总和,做归一化使书的总规模保持为 1,结果落到一张由所有 alpha 共同喂入的 PnL 表中。

相同的种子,相同的答案。

--seed 钉住一个步骤可能用到的每一个随机数生成器,并把项目、窗口和种子的摘要记下来。两次摘要相同的运行给出不同数字,意味着底层有东西变了。

更多内容见 docs/backtest.md:点时(point-in-time)股票池与幸存者偏差(survivorship bias)、净值如何复利、打开单个再平衡、运行中打分,以及一个 alpha 如何声明自己的再平衡和 decay。

现成的 alpha

Qanat 自带四个朴素的 alpha,因此第一次重放(replay)只离三条命令之遥:

qanat alphas                              # 列出有哪些 alpha
qanat alphas momentum --reads normalized.prices   # 接入一个;想接几个都行
qanat run && qanat backtest --from … --to …

- momentum 按过去一段回报排序,持有排名靠前的名字。仅做多。
- reversal 同样的方法,但按日而不是按月,买入刚下跌的那些。
- low_vol 持有最"安静"的那些名字,按它们自身的波动率反比分配权重。
- neutral_momentum 去除市场平均后的 momentum。多空对等。

它们都是可以写在餐巾纸上的规则,这正是用意:第一天就有一个真实的东西可以重放。

qanat alphas <name> 会把一份普通的 step 脚本写到 steps/ 下。改它也好,扔了写自己的也好。

这里送出去的是工具。alpha 从来不是。

连接器

| connector | 说明 |
| --- | --- |
| rest | 返回 JSON 的 HTTP 端点。${ENV_VAR} 会在 urlparamsheaders 中展开,因此密钥永远不会写进文件 |
| sql | 任何 SQLAlchemy 能连上的数据库。pip install "qanat-fdtl[sql]" |
| csv | 本地路径或 URL |
| synthetic | 一个确定性的伪造市场,让一个新项目在还没有任何 API key 时就能跑绿 |一个 connector 就是一个函数 fetch(source, root) -> DataFrame,在 qanat/sources/__init__.py 里注册。这就是整个插件面。

状态

Beta。端到端可用:阶段契约(stage contract)、运行器、DuckDB/Postgres 存储、控制台、每表保留策略、cron 调度、Docker、plan/prune、点时重放引擎及其净值优势(net-edge)报告,以及 MCP 服务器。

附带四个示例:

- examples/equity 合成的价格,无需网络、无需密钥即可跑通。

  cd examples/equity && qanat run && qanat serve
  

- examples/fx-bundled 仓库里的真实数据:27 年的 ECB 汇率,126 KB,无需密钥、无需网络
- examples/fx-real 同一个项目,改为通过 HTTP 抓取同样的汇率,数据来自 api.frankfurter.dev
- examples/scheduled-ingest 一个挂在 cron 上的 source,一边通过 HTTP 抓取,你一边看着控制台尚未实现:实时回测(每个再平衡日期到达时对该周期进行打分)、回填(backfill)、增量窗口、对数据本身的 diff,以及实盘下单通道。Qanat 产出组合,不下订单。

Qanat 只跑一种管道:以一个组合收尾的 alpha。Airflow、Dagster 和 Prefect 处理的是任意 DAG、分布式执行和庞大的 connector 生态。当你需要那些时,再去找它们。

这些数字的已知局限。

这些是真实存在的,值得你在信任一个数字之前先知道:

没有基准。 没有任何东西能把 alpha 优势(alpha edge)和 beta 区分开,因此一个仅做多的 alpha 在上涨市里看起来很好,而报告无法告诉你为什么。

purge 和 embargo 是借来的词。 在这里它们分别表示"在步骤可读取前把行向后保留"和"在一次回报被计入前等待一段时间"。它们与交叉验证中的 purging/embargoing 相关,但并不相同(译注:统计学里原本的 purging/embargoing 主要指清除测试样本与训练样本之间因时间重叠导致的污染;这里借用其名但作用范围更宽泛)。

欢迎提 issue 和 pull request。参见 CONTRIBUTING.md。在 Discord 上提问。

License MIT License. See LICENSE.

Copyright (c) 2026 fidetolabs