交割单数据管道¶
目标:归因所需的一切在成交当下就写进数据库,导出只做 SELECT,不再"抢救式重建"。
本页说明交割单相关表结构、time_source 语义、券商对账单导入、历史回填、
导出脚本与自检项,以及上线顺序与已知边界。
为什么需要它¶
首次尝试导出标准交割单时暴露三个硬伤,根源都不在导出脚本,而在数据从未被记录:
| 硬伤 | 实测证据 | 根因 |
|---|---|---|
trade_time 不是成交时间 |
全库 280 条无一条来自 QMT,全是本地 datetime.now() |
构造成交记录时逐字段取了 traded_price/traded_volume/traded_id,唯独时间硬编码 now() |
commission 无真实值 |
非零值全部精确等于 amount×0.0003(纯佣金估算),2026-08 起 100% 为 0 |
QMT 的 XtTrade 结构体根本没有手续费字段 |
| 买入流水大面积缺失 | 51 只股票中 38 只"卖出多于买入" | 部分买入从未落库,且券商成交回报只推当日 |
结论:事后无法恢复,必须改成成交当下写入。
表结构¶
trade_records(扩展 17 列)¶
| 字段 | 说明 |
|---|---|
account |
账号标识(迁移时按库路径回填,如 25105132) |
deal_time / deal_time_str |
交易所成交时间(Unix 秒 + 可读串)。取不到时留空 |
time_source |
成交时间来源,四态见下 |
recorded_at |
落库时刻 |
order_id |
委托编号。网格路径写 trade_id 的本就是它,历史行由回填补齐 |
fill_ids / fills |
原始成交编号(分号分隔)/ 合并笔数。库里存原始 deal 粒度,fills 恒为 1 |
strategy_code / strategy_label |
策略内部标识 / 中文枚举标签(落库时写死) |
is_simulation |
是否模拟成交 |
commission_source / commission_rate |
手续费来源(broker/estimated/unknown)与实际使用的费率 |
side_source |
买卖方向来源(deal/order/broker) |
row_status / duplicate_of |
active/superseded + 指向保留行的 id |
trade_id_source |
traded_id(20 位成交编号)/ order_id(9-10 位)/ placeholder(已归档) |
commission 列建表时已存在,迁移逐列探测后跳过。
新增表¶
| 表 | 用途 |
|---|---|
position_snapshot |
每交易日 09:25(open) / 15:05(close) 全量持仓快照,主键 (account, snapshot_date, code, snapshot_type) |
account_equity_daily |
每日净值(open/close 两条)+ 恒等式校验 + 跳变标记 |
run_events |
结构化运行事件(logger.error 的镜像 + 对账失配告警) |
trade_records_sim |
模拟成交独立表,与实盘物理隔离 |
broker_deals / broker_orders |
券商对账单原始成交 / 委托,保留供审计 |
为什么持仓快照必须独立成表
positions 表是"当前持仓",会被持续覆盖写,不能当历史用。
对账基准只能取 position_snapshot —— 这也是快照必须尽早上线的原因。
time_source 四态语义¶
| 取值 | 含义 | 何时产生 |
|---|---|---|
exchange |
QMT 成交回报自带 traded_time |
正常实盘成交(deal_callback) |
local_fallback |
取不到交易所时间,trade_time 实为本地入库时刻 |
历史存量行 + 回报缺时间字段时 |
reconcile_backfill |
启动对账补记 | 网格对账重放合成成交 |
broker |
券商对账单回填的真实成交时间 | 导入对账单后 |
绝不把本地时间伪装成交易所时间
取不到成交时间时标记 local_fallback 并告警,deal_time 留空,
禁止用 now() 冒充。XtTrade.traded_time 实测有三种编码
(epoch 秒 / epoch 毫秒 / yyyymmddHHMMSS / HHMMSS),解析不出即判为
local_fallback。
成交写入:单一入口¶
所有成交写入统一走 settlement_db.record_trade(),用 INSERT OR IGNORE + 唯一索引
做原子幂等——check-then-insert 存在竞态窗口,是历史上同一笔成交被写两遍的直接原因。
deal 唯一键¶
UNIQUE(COALESCE(account,''), COALESCE(order_id,''), stock_code, trade_type,
trade_id, COALESCE(deal_time, epoch(trade_time), 0), volume, price)
WHERE trade_id IS NOT NULL AND row_status='active'
设计约束(每一条都是踩过的坑):
- 不能只按
(account, trade_id)——trade_id混存三种语义。9-10 位短 id 是网格 路径写入的str(order_id),不是全局唯一成交编号;实测 15 组重复 100% 跨标的 (同一order_id在不同股票/日期上复用),只按trade_id建键会互相冲突 trade_type入键 —— 同 id 同股同日但方向不同是两笔成交- 时间分量 NULL 安全,且只能用成交自身的属性 ——
deal_time对全部历史行为 NULL, 直接入键会让 SQLite 把 NULL 视为互不相等、索引彻底失效; 用recorded_at(落库时刻)兜底也是错的:同一秒落库的两笔不同成交会撞键 被静默丢弃。故用trade_time volume/price入键 —— 同一委托的分笔成交价量不同,必须都保留
rowcount=0 不等于重复
INSERT OR IGNORE 被挡下时必须比对冲突行:内容相同才算重复投递,
内容不同则落 run_events(deal_key_collision) 并强制写入(trade_id 加后缀,
原编号保留在 fill_ids)。只看 rowcount 会把不同成交当重复丢掉。
券商对账单导入¶
python scripts/import_broker_statement.py --dir "<对账单目录>" --dry-run
python scripts/import_broker_statement.py --dir "<对账单目录>"
这是历史成交时间的唯一来源
QMT 的 xttrader 没有任何历史成交查询接口(query_stock_trades 只返回当日),
数据库里既成的 local_fallback 记录只能靠对账单升级为 broker。
对账单为 GBK 编码,文件名形如 <账号>_<序号>_deals.csv。三级匹配:
| 优先级 | 条件 | 说明 |
|---|---|---|
| 1 | 成交编号 == trade_records.trade_id |
最强,唯一 |
| 2 | 订单编号 == trade_records.trade_id 且代码相同 |
order_id 跨股跨日复用,必须用代码消歧,否则一次匹配到多行 |
| 3 | 代码+方向+价量相同且时间邻近(默认 ±60 秒) | 兜底 |
实测 2026-09-11 的对账单 13/13 全部命中(11 笔走第 1 级、2 笔走第 2 级)。
对账单手续费为 0 时不覆盖本地估算
对账单里 0.00 通常表示"该字段未导出"而非"真的免费"。用它覆盖估算值只会更差,
因此仅在 > 0 时才采纳并标 commission_source='broker'。
历史回填¶
| 字段 | 回填口径 |
|---|---|
account |
按库路径推导(data_<id>/trading.db → <id>) |
strategy_label |
按 strategy 映射;映射不到写 UNKNOWN,不留空不猜 |
time_source |
一律 local_fallback —— 绝不伪装 exchange |
commission |
按证据判定:可证实等于旧代码 amount×0.0003 的用现行税费重算并标 estimated;来源不明的保留原值标 unknown |
order_id |
网格行 trade_id 本就是 order_id,据此回填 |
is_simulation |
按 SIM_ 前缀 / strategy 推断,并把模拟行物理迁移到 trade_records_sim |
幂等,可重复执行;输出前后行数对比(模拟行迁移造成的减少会单独说明)。
导出¶
python scripts/export_settlement.py --start 2026-09-14 --end 2026-10-10 \
--accounts all --out export/
只读、不依赖 logs/*.log(日志会滚动,不是数据源)。多账号时各自输出到
export/<account>/ 子目录。
输出文件¶
| 文件 | 内容 |
|---|---|
trading_events_<实际起>_<实际止>.csv |
主交割单。前 14 列是固定契约(顺序不变),另追加 time_source/order_id/row_status 三个诊断列 |
positions_begin.csv |
期初持仓。无快照时写 BLOCKER: no snapshot before <start> 行 |
positions_end.csv |
期末持仓,优先取区间内 position_snapshot |
account_daily.csv |
每日净值 |
cash_flows.csv |
出入金(当前恒为空表,QMT 无出入金接口) |
export_report.txt |
完整自检报告 |
changelog_since_last_export.txt |
与上次交付对比的逐行变更(id + 字段 + 旧值→新值) |
合并规则¶
merge_deals() 是全项目单点实现(导出与任何对账工具都必须调用它,
禁止复制第二套):同账户 + 同代码 + 同方向 + 同策略,相邻间隔 ≤ 10 秒,
不得跨日界。
报告自检项¶
总量与金额合计 · 合并参数(窗口 / 合并行数 / 最大跨度)· 时间异常(<09:15 / >15:00 / >20:00)·
time_source 分布 · trade_id 唯一性检查(分长度统计 + 重复组 + 跨标的重复组)·
代码合法性(6 位纯数字、前导零)· 数值自检 · commission_source 分布 ·
枚举越界检查 · 逐只股数闭合(期初+买−卖=期末,不平的逐只列原因)·
券商对账单匹配情况与未匹配原因 · 已处置重复行清单 · 期初/期末/净值/出入金来源。
两条硬性约束
1. 无期初快照时写 BLOCKER 行并以退出码 2 报错,绝不用 0 填充 ——
否则会把"缺数据"伪装成"期初空仓"。
2. 禁止在导出时剔除任何股票,股数不闭合的逐只列在报告里说明原因,由人决定。
缺口归因在无快照时无法判定
期初快照缺失时,负缺口既可能是期初持仓、也可能是区间内买入流水缺失, 二者无法区分。报告会如实写"无法判定",而不是猜一个原因。
配置项¶
| 配置 | 默认 | 说明 |
|---|---|---|
ENABLE_SETTLEMENT_SNAPSHOT |
True |
持仓快照与净值落库总开关 |
SETTLEMENT_CLOSE_SNAPSHOT_TIME |
"15:05:00" |
收盘快照时间 |
SETTLEMENT_SNAPSHOT_CHECK_INTERVAL |
300 |
收盘任务轮询间隔(秒) |
SETTLEMENT_SNAPSHOT_HEALTH_LOOKBACK |
7 |
快照完整性回溯天数 |
SETTLEMENT_ASSET_IDENTITY_TOLERANCE |
1.0 |
恒等式 total_asset = cash + frozen_cash + market_value 的容差(元) |
SETTLEMENT_ASSET_JUMP_RATIO / _ABSOLUTE |
0.02 / 5000 |
资产跳变告警阈值 |
SETTLEMENT_COMMISSION_RATE |
0.0003 |
佣金 0.03%(双边) |
SETTLEMENT_STAMP_DUTY_RATE |
0.0005 |
印花税 0.05%(仅卖出) |
SETTLEMENT_TRANSFER_FEE_RATE |
0.00001 |
过户费 0.001%(双边) |
snapshot_type 只有两种
open(09:25) 与 close(15:05)。曾经的 intraday(心跳采样)已删除 ——
它既把"QMT 未连接时的全零读数"引入了库里,又与 open/close 语义重叠。
上线顺序¶
顺序不可颠倒
占位流水未清理就建唯一索引会直接 IntegrityError。
# 1. 停止 miniQMT,确认无残留进程
tasklist | findstr python
# 2. 预演(只读,在临时副本上跑,不会写库)
python scripts/migrate_settlement.py --accounts all --dry-run
# 3. 正式迁移(自动备份到 data/backup/migrations/)
python scripts/migrate_settlement.py --accounts all
# 4. 历史回填
python scripts/backfill_trade_records.py --accounts all --dry-run
python scripts/backfill_trade_records.py --accounts all
# 5. 导入对账单
python scripts/import_broker_statement.py --dir "<目录>"
# 6. 重启,确认日志出现「启动收盘快照线程」
或在总控制台 [3] 数据与配置 页使用 [r]/[s]/[t]/[u]——
前三项内置停机守卫(有账号运行则拒绝执行)并强制先跑 dry-run。
代码与迁移的部署窗口
代码先上、迁移未跑:record_trade() 检测缺列后自动降级为旧列写入
(成交流水不丢,但归因字段缺失)并告警。
迁移先跑、代码未上:无影响。
已知边界¶
- 对账单覆盖范围决定历史时间能补多少。QMT 客户端导出通常只含导出当日; 区间内更早的成交无法由此补齐,需补导对应日期的对账单。
deal_time对历史行多为 NULL(local_fallback),唯一键的时间分量退回trade_time。- 出入金无接口。QMT 的
XtAsset只有cash/frozen_cash/market_value/total_asset四个数值字段,没有任何出入金查询接口;只能靠对账单导入或人工填报。 - 资产跳变无法区分盈亏与银证转账,超过阈值时标记
unexplained_delta供人工判断 (允许误报、不允许漏报)。