|
|
||
|---|---|---|
| .idea | ||
| app | ||
| config | ||
| scripts | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| Dockerfile | ||
| Makefile | ||
| POSITION_MGMT_DESIGN.md | ||
| QMT_INTERFACE_REQUIREMENTS.md | ||
| QMT_SIDE_ACK_SEQ_PATCH.md | ||
| QMT_SIDE_CONTROL_PATH.md | ||
| QMT_SIDE_S3_CLOSEOUT.md | ||
| QMT_TEST_ENV_REQUIREMENTS.md | ||
| QMT_WS_PROTOCOL.md | ||
| README.md | ||
| UPSTREAM_PLAN_API.md | ||
| WS_INTEGRATION_STATUS.md | ||
| ddl_pms_v1.sql | ||
| docker-compose.dev.yml | ||
| docker-compose.yml | ||
| requirements.txt | ||
README.md
tradingSystem — 综合交易系统(PMS 持仓管理)
命令驱动的持仓管理系统:用户通过管理页面下达大方向命令(总规模/仓位上限/升降仓/对某股做T)→ 持仓系统制订分股分批方案并管理账本(批次、摊薄成本、安全垫、纪律)→ 决策系统负责研判与择时(其形态计算、每日定时分析等核心功能照旧运行)→ 下游系统(QMT 侧)挂单成交。
架构要点:持仓系统 ↔ 决策系统直接交互;持仓系统 ↔ 下游系统交互;决策系统与下游断开——对下游的管理权限(建仓审批、卖出指挥)由决策系统移交持仓系统,其全部分析产出保留并成为持仓系统的输入。上游量化系统输出不变,持仓系统的建仓计划围绕上游输出建立。
仓位框架:百分比制(规模 200 万 / 总仓 ≤60% / 单股 ≤8% / ≤15 只,均为用户参数命令、页面可调),单股分批 50/25/25,含一手可行性检查,行业集中度硬性拦截(划分依据留接口)。决策方式:规则引擎为主,定性研判委托决策系统(持仓系统不自建研判栈)。安全垫三义:分批建仓留缓冲、浮盈垫后加仓、做T降成本(命令授权制)。
文档与交付物
| 文件 | 内容 |
|---|---|
POSITION_MGMT_DESIGN.md |
总体设计 V0.4(定稿,开发启动):命令系统与管理页面/账本/仓位框架/动作引擎/两道关口/择时执行/下游通道。功能一次性开发,上线按依赖分三步切换 |
QMT_WS_PROTOCOL.md |
PMS ↔ QMT WebSocket 指令与回报协议 V1.0(定稿):传输与重连、Ed25519 签名与幂等、消息集、状态机、断线补发与对账兜底、部署前检查清单。这是下发通道的唯一实现依据 |
QMT_INTERFACE_REQUIREMENTS.md |
与 QMT 侧的数据与接口需求清单 V2.0:A 部分(只读数据)与 C 部分(切换约定)有效;B 部分的表通道已废止,改由上面的 ws 协议承担 |
UPSTREAM_PLAN_API.md |
上游选股计划接口 (/plan) 的接入记录:应答结构、PMS 侧五条口径、参数表,行业源换 gp_hybk 的定案(§8),以及榜单变化改由 PMS 自算的口径与取舍(§9) |
ddl_pms_v1.sql |
PMS 全部自有表建表语句(153 代理侧,15 张:设计 §11 的 10 张 + ws 通道 3 张 + 现金流水 1 张 + 计划名册快照 1 张) |
Makefile |
常用操作一句话入口(make help 看全部)。改了代码用 make deploy / make up,别用 docker compose restart——源码打进镜像,restart 跑的还是旧代码且一声不吭 |
config/settings.py |
配置(基础设施键名对齐 bionic;业务参数为初值,页面调参持久化到 pms_runtime_param 后优先) |
模块地图
app/
core/ 纯逻辑, 零外部依赖, 可单测 —— 系统的算数与纪律都在这里
sizer.py 批次拆分与一手合并 / 组合约束 / 风险敞口披露
cushion.py 摊薄成本 / 安全垫状态机 / 保垫触发 / 卖出核销次序
command_spec.py 命令目录(A/B/C 全量 27 类) / 参数校验 / 双状态机 / 冲突识别
planner.py 方案生成器: 降仓凑额四档 / 升仓 / 建仓 / 清仓 / 行业 / 撤单
recon.py 成交认领与入账映射 / 对账差异与修正 / 除权检测 / T+1 可用量
rule_gate.py 规则闸终检: 上限/一手/可卖/冻结/刹车/行业/不追高 (减持只放行不阻拦)
exec_timing.py 择时实现B: 分日配额 / 分笔 / 买卖出手判定 / 14:45 兜底 / 窗口收口
action_engine.py 动作引擎: FILL 回踩补足 / ADD 盈利加仓 / DCA 补仓 / TRIM 保垫减仓
signal_rules.py 决策系统两条信号流的解析与消化口径 (含置信度尺度归一)
plan_diff.py 上游榜单的版本比对: 名册指纹 / 新进掉榜 / 档位升降 / 榜尾噪音闸
rebuild_check.py 接管既有持仓前的成本价体检 (阻断判据 + §5.2 情形覆盖)
tradedays.py 交易日历: 调度守卫与执行窗口计算
ws_codec.py QMT 协议编解码: 规范化串 / Ed25519 签名验签 / 信封 / seq 水位推进
db/session.py 三库连接 + **严格单表访问守卫** (JOIN/逗号连表/跨表子查询一律拒绝)
repo/ 单表数据访问: pms_repo (自有 12 表) / qmt_repo (ws 通道 3 表)
/ downstream_repo (下游只读三表)
services/ 编排层
param_store.py 运行参数中心 (表值优先于 settings 初值, 页面调参即时生效)
portfolio.py 组合快照 (账本+行情+行业 → 方案/规则闸/页面的统一输入)
command_service.py 命令下达→校验→冲突→生效/规划→进度推进→撤销
executor.py 方案→指令→分日出手→窗口收口 (规则闸与择时的编排落点)
dispatcher.py 下发通道两适配器: shadow(默认) / ws(落 pms_qmt_order 出口表)
proposal_service.py 自主提议: 扫描→规则闸→研判闸→按自主档位分流 (执行/入队)
judge.py 研判闸客户端 (决策系统未接通时自动降级为人工确认)
signal_service.py 盘中信号订阅 (db2 广播 + db3 风控卖出) → 卖出指令或提议
ledger_service.py 成交回放 / 对账 / 除权 / 盘前 / 日终结算 / 运营日报
market.py 行情 (Redis db13) 与参考位 (决策系统主口径 + 兜底自算)
plan_feed.py **上游选股计划 /plan 接入**: 解析 / 交易日龄校验 / 候选筛选
/ 名册快照与榜单变化 (上游 changes 恒为 null, PMS 自己算)
industry.py 行业划分可插拔适配器 (gp_hybk / custom_table / 停用; 带实探不靠参数)
ws/runner.py **常驻连接进程 (pms-ws)**: 握手/心跳/重连/补发 + 出口出栈 + 上行落库确认
web/ FastAPI + 单页 (Vue3 + ElementPlus),页面四块 + 运维/日报抽屉
scheduler.py Celery beat 调度总表 (九个调度位 + 三条守卫)
scripts/
run_tests.py 一次跑完全部单测 (见下方「Docker 部署」)
test_core_units.py 仓位与安全垫核心逻辑 14 例
test_batch2_units.py 命令 / 方案 / 回放对账 纯逻辑 35 例
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 21 例
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 11 例
test_batch5_units.py 决策系统信号流解析与消化口径 8 例
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 65 例
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 49 例
test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 48 例
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 58 例
init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检)
check_db.py 实机连通性与表结构自检 (需真实 .env)
gen_keys.py ws 通道密钥: 生成 / 只取公钥(--pubkey) / PEM(--pem) / 自检(--check)
reset_ledger.py 清空账本 + 回放游标对齐 + 归零四个账本派生参数 (连续不一致天数、
高水位、刹车解除日); 影子运行期重来一次, 绝不碰下游表
probe_plan_api.py 上游计划实机探活: 通不通 / 字段口径 / 候选筛选结果 / 有没有价
/ 榜单变化 (只读; --snapshot 才落库, 那是它唯一的写操作)
rebuild_ledger.py 账本重建: 预检 → 执行 → 判收 (默认只预检; --yes 才改账)
ws_smoke.py ws 联调工具: status/watch/place/cancel/inbox (绕开 dispatch_mode)
code_fingerprint.py 当前 .py 源码的短哈希 —— `make test` 拿它比对容器与工作树,
防「git pull 了没 build, 跑的还是旧代码而且照样 PASS」
watch.py **盯盘就看这个** (`make watch`): 通道/账户/在途指令/出口委托/
账本持仓 一屏看完, 只读。`make watch N=10` 每 10 秒刷
三条铁律
- 命令至上:自动决策不得突破用户命令参数;冲突时命令优先;命令间冲突由用户裁决。
- 分工不越权:持仓系统管「做什么、多少」,决策系统管「该不该、何时」,下游只管执行;研判不可用时降级为保守规则 + 人工确认,不自建第二套研判。
- 先记账后动作 + 故障即守成:指令先落表再下发;故障不产生新指令;账本与下游定期对账,以下游为实际持仓事实源。
Docker 部署(项目统一以容器方式构建运行)
服务共用一个镜像:pms-web(管理页面,端口 38100)+ pms-beat / pms-worker(Celery 调度与执行,挂在 sched profile 下)。
日常操作有 Makefile 包了一层(make help 看全部):make deploy 一句话完成拉代码→重建镜像→重建容器→建表;make test / make check / make probe / make changes / make industry / make ws-status 是自检与实探。下面是这些命令展开后的样子——不用 make 也照样能跑,两者等价。
# 服务器首次部署
git clone <仓库地址> && cd tradingSystem
cp .env.example .env && vim .env # 填入真实连接串 (.env 不入库)
docker compose build # 默认走清华 PyPI 镜像; 可 --build-arg PIP_INDEX_URL=... 覆盖
# 构建验证 (不连库, 秒级): 应输出 ALL SUITES PASS
docker compose run --rm pms-web python scripts/run_tests.py
# 建表 (幂等; 不加 --yes 只演练打印)
docker compose run --rm pms-web python scripts/init_db.py --yes
# 实机自检 (连库, 需 .env): 库连通 + pms_* 十五表 + 下游表完整列定义 + 行情 Redis
docker compose run --rm pms-web python scripts/check_db.py
docker compose up -d # 管理页面
curl http://127.0.0.1:38100/health # 健康检查 + 配置装载自证 + 库连通自证
# 浏览器打开 http://<服务器IP>:38100/ → 参数设置 / 命令台 / 持仓与账本 / 提议确认
docker compose --profile sched up -d # 启用调度器 (beat + worker)
docker compose logs -f pms-beat pms-worker
docker compose --profile ws up -d pms-ws # 启用 QMT 直连 (先配好 .env 里的两把密钥)
docker compose logs -f pms-ws
# 日常更新 (= make deploy)
./scripts/deploy.sh
改了 Python 代码必须 build + force-recreate:源码是打进镜像的,docker compose restart 只是把老容器停了再起,跑的还是旧镜像里的旧代码——而且一点报错都没有。make deploy / make up 走的都是正确路径。
基础镜像 python:3.11-slim 拉取慢时,先给服务器 Docker 配置 registry 镜像加速。日志落 ./logs(已挂载卷);容器时区 Asia/Shanghai。
管理页面的两个坑(都踩过,写在这里):
- 前端资源走 unpkg CDN,浏览器需能访问外网。上不了外网的表现不是报错,而是只剩一个头部、下面全空——
el-*组件全部渲染不出来。离线办法:把 Vue3 / ElementPlus / axios 三个库放进app/web/static/vendor/,再把index.html头部那 5 个 URL 换成/static/vendor/xxx(后端已把static挂在/static,页面本身无构建步骤)。 - 模板里不能用自闭合的自定义标签。
<el-alert ... />这种写法浏览器 HTML 解析器不认——只有br/img/input这类 void 元素和 SVG 才认自闭合。写成<el-xxx/>会被当成开标签,后面所有内容都变成它的子节点;若它带v-if且条件为假,整页就跟着消失。2026-07-29 页面白屏即此故(75 处自闭合,第一处<el-alert v-if="err"/>把整页吞掉了)。一律写成<el-xxx ...></el-xxx>。
依赖版本坑:redis 必须锁在 5.x。redis-py 6.x 默认按 RESP3 握手(先发 HELLO 3),而 208/150 两台 Redis 是 6.0 以前的版本,会回 unknown command HELLO,行情与 Celery 总线一起断。requirements.txt 已锁版本,代码侧另有 protocol=2 双保险;升级依赖时别把这条放开。
调度总表(--profile sched 生效,设计 §10)
| 调度 | 时间 | 任务 | 本批状态 |
|---|---|---|---|
| 拉上游计划 | 交易日 08:40 | /plan 拉当日选股计划 → 刷候选池缓存 + theme 灌 pms_industry_map |
✅ |
| 盘前准备 | 交易日 08:50 | T+1 可卖重置 / 参考位取数 / 刹车结算 | ✅ |
| 命令轮询 | 每 1 分钟(全天) | 新命令解析 → 方案生成 → 状态机推进 | ✅ |
| 成交回放 | 交易时段每 1 分钟 | ws 逐笔入账 + trading_order 增量回放 + 盘中轻对账 |
✅ |
| 盘中执行 | 交易时段每 1 分钟 | 方案转指令 → 自主提议扫描 → 择时出手(规则闸终检 → 下发 → 记子单) | ✅ |
| 信号消化 | 交易时段每 1 分钟 | 订阅 db2 盘中广播 + db3 风控卖出 → 卖出指令或提议 | ✅ |
| T 仓平回 | 14:50 | 做T强制平回 | 🔜 二期(现只自证 T 仓为 0) |
| 日终结算 | 15:10 | 除权检测 / 全量对账 / 安全垫 / 命令进度日结 | ✅ |
| 运营日报 | 15:30 | 关注区 + 全量统计(页面「日报」按钮可查) | ✅ |
调度器三条守卫:交易日守卫、故障即守成(任务内异常吞掉记 ERROR,绝不因调度异常产生新指令)、全局暂停执行(休假模式下除对账与日报外全部跳过)。
指令下发通道(设计 §9,参数 PMS_DISPATCH_MODE,默认 shadow)
| 模式 | 行为 | 什么时候用 |
|---|---|---|
shadow(默认) |
指令照常过规则闸、照常置 DISPATCHED,但不写下游。你在 QMT 侧人工执行,成交由回放按 FIFO 认领回账本 | 当前仍是这个状态(切 ws 需先配密钥并跑完 S1/S2 联调) |
ws |
WebSocket 直连 QMT 执行服务,协议见 QMT_WS_PROTOCOL.md V1.0 |
目标形态。通道已实现(2026-07-28),联调通过即可切 |
ws 模式的进程边界(理解这条通道的关键):连接是一条常驻长连接,且协议 §1 规定 PMS 只准开一条(多开会让指令乱序),所以它由独立的 pms-ws 进程独占;而 executor 跑在 celery worker 这种短命任务进程里。两者只经数据库耦合:
executor (worker) ──写 pms_qmt_order(QUEUED)──▶ pms-ws ──签名──▶ QMT
│
QMT ──trade/order_update──▶ pms-ws ──落 pms_qmt_inbox──▶ 回放任务(worker) ──▶ 账本
出口队列落在业务表而不是内存或 Redis,是因为「先记账后动作」这条铁律在这里可以字面成立——落表就是记账;ws 进程崩了重启队列还在,页面查 pms_qmt_order 就能看到在途委托,也不引入任何新中间件。代价是亚秒级轮询延迟,而择时本就是分钟级节奏。
pms-ws 只做通道,不碰账本:成交入账仍然发生在 worker 里,账本变更保持单一来源,也不至于让一个慢查询把心跳拖到 15 秒超时断连。
放不放行(协议 §6.3「故障即守成」的落点):
| ws 进程 | 连接 | 买入 | 卖出 |
|---|---|---|---|
| 心跳陈旧(进程没了) | — | 拒发 | 拒发(排进队列也没人发) |
| 在线 | 断开/重连中 | 拒发 | 照常入队,重连后立即发出 |
| 在线 | 已连接 | 放行 | 放行 |
原
plan_x/channel_y两个适配器已于 2026-07-28 删除。前者写trading_buy_plan(is_active=6),那个 6 本身就是错的;后者写pms_order_request表由下游轮询,在通道改定 WebSocket 后作废。pms_order_request表保留未用,不必删表。留着作废路径比删掉更危险——后来者会以为它可用。
目标架构(2026-07-28 已定):trading_service 全量退出业务,只保留看板与统计展示;新写一个 QMT 直连服务承担挂单与订单/持仓/资金回写,PMS 经 WebSocket 与它直连;PMS 只管决策与账本。
当前处境(重要):旧通道(下游直接执行决策系统卖出信号、轮询 trading_buy_plan 自动挂单)已按双方约定即刻停用。新的 ws 通道代码已就绪,但 PMS_DISPATCH_MODE 仍是 shadow 且 PMS_QMT_WS_ENABLED=False,所以现在依然没有任何系统会自动下单——PMS 影子运行,指令照常生成、过闸、记账,但需人工在 QMT 侧执行。这是安全的状态,但要知道它是这个状态。切 ws 是两个开关加一次联调,不是再写代码。
影子模式下的完整闭环:页面下命令 → 方案落表 → 方案转指令 → 择时按日配额给出「今天该出多少、什么价、挂到几点」→ 你照着在 QMT 下单 → 5 分钟一次的回放把成交认领回批次账本 → 命令进度自动推进。整条链路除了「人手下单」这一步,其余与实盘接管后完全一致。
回放游标:首次启动不追认历史
trading_order 里躺着旧系统多年的成交。PMS 刚上线时一条在途指令都没有,若从表头开始回放,每条历史成交都会被判成「外部成交」并入 BASE 批次——账本上凭空长出一堆早已清掉的持仓,摊薄成本与安全垫全错,而补仓、盈利加仓、保垫减仓都挂在安全垫上,一错就是整条纪律链。
所以 PMS_REPLAY_CURSOR 为空时,replay_fills 只把游标对齐到当前最新成交、一条都不入账,并打一条 WARNING 说明。要补历史(页面「参数设置」改这个参数):
| 游标值 | 行为 |
|---|---|
| 留空 | 首次启动自动对齐到最新成交,不追认历史 |
某个 order_id |
从它之后开始回放 |
ALL |
从头全量回放 |
已经被历史成交污染的账本,用 scripts/reset_ledger.py 清掉重来(只删 PMS 自有表,绝不碰下游 trading_*),然后跑一次 POST /api/ops/reconcile?apply_fix=true 让对账以下游为准把真实持仓补回来。
接管既有持仓时的成本价口径
对账补仓位(ADD_RECON_LOT)的开仓价优先取下游 trading_position.cost_price,现价只在下游给不出成本价时兜底,并在 price_source 与 note 里注明是估的。
这条不是细节:拿现价当成本,摊薄成本就等于当天价、安全垫齐刷刷是 0——而安全垫是盈利加仓(≥3%)、保垫减仓(峰值≥6%)、补仓评估档(−8%/−15%)共同的判断依据。一只真实成本 20、现价 10 的票(实亏 50%)会被记成「不赚不亏」,该评估的补仓不评估;页面和日报上的浮动盈亏也全是 0。2026-07-29 首次接管 22 只持仓时踩到,已修并有单测守着。
但「优先取成本价」还不够——兜底是静默的。 下游那一列填 0、留空、或干脆等于现价时,对账照样建账,只在 note 里留一句「现价兜底」。结果是账本建起来了、页面一切正常、每个数都长得像真的,只有安全垫齐刷刷是 0,而没人会盯着一个「看起来就该是 0」的字段。所以 2026-07-31 加了一道首次建账的成本价硬闸(app/core/rebuild_check.py):
- 只在账本为空时生效——那一刻是不可逆的,这批 RECON 批次的开仓价会定死每只票的摊薄成本。日常漂移不走这道闸,否则每天拦一次对账才是真的坏事。
- 拦两类:任何一只成本价缺失/为 0/与现价差两个数量级/可用量大于总量;以及整组成本≈现价(默认 ≥80%)——单只可能是当日买入,整组都这样就是有人拿现价填的。
- 拦下来只是不建账,不改任何东西。确认这份数据就是对的,可以
--force放行,那等于声明你接受安全垫从 0 起算。
配套一条命令,默认只预检不改账:
make rebuild # 预检(只读):事实源 → 成本价体检 → 情形覆盖 → 开关现状
make rebuild GO=1 # 预检过了才执行,执行完自动判收
make rebuild-accept # 只看判收:安全垫分布 / 批次账 / 行业集中度
判收里最硬的一条是安全垫分布:如果重建后每一只票的安全垫都是 0,那就是踩了这个坑——页面上每个数都合理,只有这一处露馅。行业集中度那一项顺带回答 PMS_SECTOR_MAX_RATIO=40% 在三级粒度下偏不偏松(分母与 sizer.check_caps 一致,是组合持仓市值不是总规模,两边用不同分母会得出两个都自称「行业集中度」的数)。
这道闸判不了来历。 它体检的是数据可不可信(成本价合不合理、可用量对不对),判不了「这笔持仓是不是联调残留的测试数据」——那个信息不在数据里。2026-07-31 就撞到一次:600000.SH 1100 股 @ 9.273 成本价完全正常、闸放行了,但它正是 QMT_SIDE_S3_CLOSEOUT.md §5.1 问过对端、至今没答复的那笔联调残留。建账的第一笔要人工确认来历,闸只保证「数据本身没毛病」。
另外:清 PMS 账本只是一半。对端 trading_position / ws 快照里那笔还在的话,下次日终结算或 make rebuild GO=1 会把它再认领回来——要清得两边一起清。
联测 A 段:shadow 下走完整链路
候选池 → 建仓/升仓命令 → 方案生成 → 指令 → 规则闸终检 → 择时分日配额,这一截从来没有用真实候选池加真实账本跑过,只有单测——而单测覆盖的是 planner / rule_gate / exec_timing 各自的算数,没覆盖过「真实候选池的形状撞上真实持仓的上下文」。
shadow 正好是这一段的天然测试环境,不是妥协:指令照常生成、照常过规则闸、照常记账,只是不写下游。所以这一段不用碰任何开关,也不需要 ws 通道就绪。
两件容易踩的事,先说在前面。
A3 之前必须先下一条「任务命令」。 参数命令(SET_SCALE / SET_PORTFOLIO_CAP / SET_STOCK_CAP / SET_MAX_NAMES 这些)只是设约束,本来就不产生方案——页面上它们显示「0 条方案」是对的,不是坏了。要走通链路得下 B/C 组的任务命令:INCREASE_EXPOSURE(升仓 pct%,全局:垫厚票补到目标 + 候选池新票建仓)或 OPEN_TARGET(建仓某股至 x%,个股:分批 50/25/25 含一手合并)。
这里的「择时」是 PMS 内置的实现 B,不经决策系统。 设计里择时有两个实现:实现 A 是委托决策系统盘中择时(待办 #9,等 bionic 侧接口,还没接);实现 B 是 PMS 自己算(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底),已实现。所以跑 A 段不需要动决策系统的任何逻辑——它这一段本来就不参与。
别开 beat——那会让几个调度位同时动,出了岔子分不清是谁干的。Makefile 里有一组 t-* 目标,就是把那几个调度位改成手动逐跳触发,顺序与生产一致:
make t-plan # A1 拉候选池 (= 08:40 调度位)
make t-pre # A2 盘前准备 (= 08:50 调度位)
# —— 页面「命令台」下一条**任务命令**: INCREASE_EXPOSURE 或 OPEN_TARGET ——
make t-cmd # A3 命令轮询: 新命令 → 方案生成
make t-plans # A4 看方案: 买什么、多少股、分几批 (还没成指令)
make t-mat # A5 方案 → 指令 (先记账后动作)
make t-dry # A6 择时试算 (dry_run: 规则闸判什么、今天该出多少, 一股都不发)
make t-tick # A7 真出手 (shadow 下 = 置 DISPATCHED 但不写下游)
make t-ins # A8 看指令与子单
make t-book # A9 看账本
make t-gate # 随时: 规则闸/研判闸拒了什么、为什么
行业占比闸:占比超 + 绝对敞口够大,两条同时成立才拦
sizer.check_caps 的 SECTOR_RATIO 原本只看「该行业占组合的比例」。这在建仓期是结构性不可满足的:空账本买第一只票,它按定义就是组合的 100%,必然超任何小于 100% 的上限;而它被拒之后组合市值不推进,第二只第三只面对的还是 100%——哪怕候选分属十个不同行业也全军覆没。数学上,上限 40% 时至少要 3 只不同行业的票同时在组合里才可能满足,而建仓是逐只累加的,所以调松阈值解决不了。
2026-07-31 实机撞上:清账后空账本 + 10 只强传导候选 + 一条 60% 升仓命令 → 一条方案都出不来,报出来却是「候选与补仓空间不足,缺口 1,200,000 元」。这个洞一直藏着,是因为行业源当天才通(此前 sector 恒为 None、整段判据被跳过),恰好又赶上账本清空。
改成两条判据同时成立才拦:该行业占组合 > PMS_SECTOR_MAX_RATIO,且该行业占总规模 > PMS_SECTOR_MAX_RATIO × PMS_PORTFOLIO_CAP(默认 40%×60% = 24%)。道理是集中度是风险的放大器而不是风险本身——敞口只有规模 6% 的时候,它 100% 集中在一个行业也谈不上风险。绝对线这样取还有个性质:组合在总仓上限以内时,「占规模超绝对线」必然蕴含「占组合超上限」,所以这条判据只会放宽建仓初期、不会额外拦人,组合建起来之后口径与原来完全一致(有单测锁住这条性质)。
同一行业的只数上限(PMS_SECTOR_MAX_NAMES,默认 4)不受影响,照常生效。
一手取整的记账:planned_amount 必须是取整后的实际值
三批各自向下取整到一手,所以实际买到的必然少于目标——票越贵差得越多。plan_increase_exposure 的「候选池新票」那支原本按理论目标累计,而同一个函数里「补既有持仓」那支按取整后的实际金额累计,隔二十行两种写法。
2026-07-31 实机撞上:命令进度写着 planned_amount=600,000 / gap=0(读起来是「完全满足」),而 15 条方案的金额合计只有 573,883——少买 26,117 元、账面上完全看不出来,其中 136.73 元一股的 002353.SZ 一只就差了 10,528。除了报数不实,还有两个连带后果:ctx 里的组合市值虚高,后面几只票的上限校验按一个不存在的仓位算;循环也会提前收敛。
现在一律按实际金额记账,并把缺口拆成两种——混在一起会得出错误结论:
shortfall:没找到足够的票(候选不够、被闸拒了)。这才是「命令没满足」,ok只看它。rounding_gap:一手取整的零头。A 股买不了零股,这不是失败。
否则任何一次升仓都会因为几千元的取整零头被判成失败,真正的「候选不够」反而淹没在里面。plan_open_target(个股建仓)原来把 gap 写死 0,同一个毛病的另一面,一并修了。
零方案的任务命令置 CANCELLED,不是 DONE
一条该产出方案的任务命令一条都没产出,那不是「完成」,是「没发生」。原来它和开关类命令(HALT_BUY 这种下达即完成的)共用 DONE,后果是页面显示已完成、cancel() 又因为 DONE 不在 ACTIVE_TASK_STATES 里而拒绝撤销——用户既看不出没执行成,也退不回来,日报还会把它算进完成的命令。现在这种情况置 CANCELLED 并把原因写进 note。
同时命令进度里加了 reject_summary:把 planner 的 rejects 聚合成一行人话(SECTOR_RATIO 10 只 (600000.SH…); STOCK_CAP 2 只)。planner 原来的 note 只会说「候选与补仓空间不足」,那读起来像「没票可买」,而真相往往是有一堆候选、全被同一道闸拒了——「拒了 10 只」和「没有候选」是两件完全不同的事,长得却一样。
make t-gate 是联测时最该盯的一张表。 方案里的票被拦掉时,它是唯一能回答「为什么这只没进去」的地方——单看方案和指令只能看到「少了几只」,看不到是撞了单股上限、一手不可行、行业集中度,还是不追高。
自主提议的分流(设计 §6 / §7)
动作引擎每分钟扫一遍持仓,产出 FILL / ADD / DCA / TRIM 四类候选,然后依次过三道:
- 规则闸(一级,纯代码)——不过就拒,未通过项落评审账本。自主动作受组合刹车约束(命令驱动不受)。
- 研判闸(二级,仅补足/加仓/补仓,委托决策系统)——
PMS_JUDGE_API_BASE为空即视为未接通,按设计自动降级为人工确认并记 ERROR,绝不把「研判拿不到」当成「研判通过」。 - 按自主档位分流——
full执行、propose_only入队(一期默认)、off不扫描。
两条无条件覆盖档位的规矩:减持方向不设确认门槛(TRIM 保垫减仓任何档位都直接落指令);−15% 及更深的补仓永远需用户确认(即便档位是 full 也强制入队)。同一只票的同一动作若已有在途提议或在途指令,不重复提。
榜单变化提示(只提示,不产生指令)
上游 /plan 应答里的 changes 字段至今恒为 null,所以这件事改成 PMS 自己算:每次拉计划落一份名册快照(pms_plan_snapshot),跟上一份比出新进榜 / 掉榜 / 档位升降 / 券商覆盖翻转 / 名次跳变。页面在「上游计划」抽屉里,持仓票单独一段——上游把一只持仓票摘出榜或降了档,是「还该不该继续拿着」的直接信号,混在几十条新进榜里会被淹掉。
它和上面的自主提议不是一回事,别混。 自主提议会落指令或入确认队列;榜单变化只是提示,从头到尾不碰账本、不进规则闸、不产生任何指令。要减要清仍走命令台或提议确认。
两个容易踩的点,都已经处理:掉榜不能直接算「上一版有、这一版没有」——榜是按 top=300 截断过的,从 rank 250 掉到 310 只是被截掉,不是上游摘了它;新进榜是对称的同一个问题。同一个 date 的计划会变(/plan 是实时算的,应答里没有版本戳),所以「同一计划日的新版本」单独算一类并挂黄色横幅——它意味着此前拿到的候选池与现在不是同一份。口径与取舍详见 UPSTREAM_PLAN_API.md §9。
已实现 / 待开发
已实现:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;上游选股计划接口接入(/plan 取候选池、交易日龄硬校验、theme 灌行业映射表、页面预览抽屉与不可用横幅);榜单变化提示(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);ws 直连通道的连接层(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);单测 401 例。
静默失败专项(2026-07-31,八条已修)
联测走到「记账并下发」这一步时,接连撞上几处「命令说完成了、实际什么都没发生」,于是停下来专门查了一轮。查出来的不是八个孤立的 bug,是同一种病:
本系统里大量写入函数失败时不抛异常,只回
{"ok": False, ...}或影响 0 行。返回值一丢,写入没发生,而调用方照常往下走、照常回ok=True、页面照常显示「已完成」。失败长得像成功。
八条实例,按「一旦发生会怎样」排序:
| # | 位置 | 一旦发生 |
|---|---|---|
| 1 | portfolio.positions_view 取不到现价时把安全垫按 0 记 |
一只实亏 50% 的票被读成「不赚不亏」;更糟的是峰值回吐判据凭空成立,触发保垫减仓真的卖出去——而减持方向不设确认门槛 |
| 2 | proposal_service 给规则闸的 day 是硬编码空壳(day_chg_from_open: None) |
「不追高(当日涨幅)」这道闸对每一笔自主买入从来没真正跑过。executor 那条路一直取的是真快照,只有这里漏了 |
| 3 | plan_liquidate_all 悄悄漏掉取不到现价的票 |
一键清仓是 danger 级命令,语义是「全都卖掉」。少卖一只就是留了个敞口,而 notes 写的是「全部 N 只」——那个 N 数的是下单条数不是持仓只数 |
| 4 | 参数表读不到时 PMS_GLOBAL_BUY_HALT 按 False 放行 |
数据库一抖,全局暂停买入自己失效。安全开关 fail-open 是方向性错误 |
| 5 | PMS_RECON_STREAK_YMD 漏在可写白名单外 |
「按交易日推进」的修复被 set_param 静默拒写打回原形,计数退化成按次累加。单测全绿——因为单测只测纯函数 |
| 6 | plan_sector_cap 算错分母 |
卖了 18 万,行业占比仍然 51.2% > 40%,命令报 DONE |
| 7 | 命令撤在途指令只改本端状态,dispatcher.cancel 在命令这条路上一次都没被调用过 |
切 ws 之后,「全局暂停买入」回一句「已撤销 N 条」,而 QMT 侧子单原封不动继续成交 |
| 8 | signal_service 在落库之前就把当日去重键 seen.add 掉 |
落库失败后,这条风控卖出信号当天再也不会被消化——页面只多一行 error,该卖的票就那么留着 |
修完之后加了一条防复发的纪律,因为八条里至少五条是同一个手势造成的:
关键路径禁止丢弃返回值。
set_param/dispatcher.cancel/dispatcher.dispatch/executor.cancel_instruction/bump_once_guards/save_neg_streak/qmt_repo.update_order这类「失败不抛异常、只把失败写在返回值里」的函数,调用点必须接住并处理。
这条纪律由 test_batch10_units.py 的 [A] 组守着——它是全套单测里唯一一条静态用例:AST 扫 app/ 全库,凡是「整条语句就是一次这类调用、返回值没被任何人接住」的写法直接判失败(await _db(qmt_repo.update_order, ...) 这种线程池间接调用也认)。名单在 SOFT_FAIL,确实可以丢的写进 ALLOWED 并必须带理由。[A2] 反过来守着扫描器本身:造一个丢返回值的调用,扫不出来就算失败——守卫写错会让 [A1] 永远绿,那比没有守卫更糟。
组合刹车的高水位改成按总资产算(2026-07-31)。 原来拿 portfolio_mv 当基准,那量的是「仓位有多大」而不是「我值多少钱」,于是每一次主动减仓都会被当成回撤:一条降仓 40% 的命令执行完,回撤算出 40% ≥ 5%,自主增持立刻被刹停 3 个交易日——用户按纪律减了仓,系统当他亏了钱。清仓、止盈卖出、清账重来三条路全中。「自高水位回撤」在任何交易语境里都是权益回撤,减仓时钱从市值挪到现金、总资产不动,这才是它该有的样子。
配套两条:资金快照不可信时整轮跳过刹车结算,不拿 cash_est 硬判(那是 scale − 市值 的虚数,拿它当权益等于换个地方犯同一个错),但跳过这件事挂在 warnings 上说出来;口径切换不需要迁移,旧的 PMS_HIGH_WATER 是市值量级必然小于总资产,第一次跑就被抬上来,只会往上走不会凭空造出一段回撤。
实机踩到这条的路径是清账:账本里躺过 1100 股(市值 10461)→ 高水位记成 10461 → 清空账本 → 市值归 0 → 回撤 100%。改按总资产之后钱还在账户里,回撤是 0。另外 PMS_HIGH_WATER 本来就是账本的派生量,reset_ledger 现在把 PMS_RECON_STREAK / PMS_RECON_STREAK_YMD / PMS_HIGH_WATER / PMS_BRAKE_UNTIL 四个一起归零(RESET_PARAMS),[D3]~[D4] 四条单测钉住。
另一头是「没事长得像出事」,同一种病的反面,三处:
make t-cmd 返回 {"planned": [], "failed": []} 读起来像「排方案失败了」,实际绝大多数时候是根本没有命令可排。这一步只是把你已下达的命令排成方案,它自己不产生命令;而 reset_ledger 默认连 pms_command 一起清,清完账就真的一条命令都没有了。现在多了 scanned / note / commands_by_status,没命令时直接告诉你去下一条。顺带把 Makefile 里那对害人的名字拆开了:t-pool(原 t-plan,单数)= 拉候选池「能买哪些」,t-plans(复数)= 看 PMS 排的方案「买哪只、多少股、分几批」——差一个字母,是两个完全不同的东西,候选池是命令的原料、方案是命令的产物,中间必须有一条命令。新增 t-issue / t-cmds / t-catalog 三个目标,命令行就能下命令、看命令、查命令目录。
make t-mat 三个列表全空时读起来像「一条都没转成」,实际多半是「早就转完了」——它只吃 PENDING 状态的方案,已转指令的是 EXECUTING、等解锁的批是 GATED。现在输出多了 scanned 与 note,把当前方案分布直接写出来。
make rebuild 在「下游一只持仓都没有」时印 [FAIL] 并退出码 2,make 报 Error 2——可账户空 + 账本空 = 两边一致,这是终态不是故障,压根不需要重建这一步。预检现在给出 verdict:READY / EMPTY / NOT_READY / NO_SOURCE,EMPTY 退 0 并明说「没有需要接管的持仓,重建这一步跳过」。只有一种 EMPTY 仍然要拦:账户空而账本非空,那是顺序反了,得先 reset_ledger 清账本,别让对账拿空集去核销已有持仓。
第四种变体:测试桩比真依赖宽松,于是单测替真依赖打掩护。 2026-08-03 切 ws 当天撞到:executor.run_tick 给 pms_repo.update_instruction 多传了一个 limit_price=None,真 repo 没这个形参 → TypeError → 被外层 except Exception 吞成 out["errors"] 里的一条。后果是单子已经发到 QMT 了,而本端一个字没记——父指令停在 PROPOSED、children 空、dispatch_ref 空,当日配额恒按 0 算,下一跳会拿同一个子单号 _D01 再发一次(出口表唯一索引拦住之后就彻底卡死)。
单测全程没看见,因为 FakeRepo.update_instruction 写的是 def update_instruction(self, iid, **kw)——什么关键字都收。桩比真依赖宽松,等于单测在替真依赖打掩护。 test_batch10_units.py 的 [M] 组守这一条:从源码(AST)读 pms_repo 的真实签名,逐个比对 FakeRepo 的同名方法,桩收 **kw 而真 repo 是固定签名就判失败。注意必须从源码读——install_fakes 已经把 pms_repo 的函数替换成桩的绑定方法了,getattr(pms_repo, name) 拿到的是桩自己,那样这条守卫会永远绿(第一版就是这么写错的,守卫本身也会静默失效)。make watch 里另有一段常驻交叉检查:出口表有委托而父指令还停在 PROPOSED、或 children 条数跟出口表对不上,直接顶在屏幕上。
第三种变体最阴,是「没跑长得像通过」。 源码是打进镜像的,docker compose run pms-web python scripts/run_tests.py 跑的是镜像里那份。于是有这么一条路径:
git pull → make test → ALL SUITES PASS
这个 PASS 是旧代码的 PASS。新增的那批测试文件根本不在镜像里,run_tests.py 只会印一行「跳过 xxx(文件不存在)」然后照样 ALL SUITES PASS——一条没跑,结论却是全过。2026-07-31 实机就撞上了:make t-pre 的返回里少了新加的字段,才反推出容器跑的是旧镜像。
两道锁:run_tests.py 现在把缺失的测试文件单列成 SUITE MISSING 并退非零码(「没跑」不许算「通过」),同时打印当前代码指纹;make test 跑之前先执行 make stale,把容器里的指纹和工作树的指纹对一遍,对不上就横幅提示先 make deploy。指纹逻辑在 scripts/code_fingerprint.py,只覆盖 app/ scripts/ config/ 下的 .py。
顺带修出来的第九条,是本轮改动自己引入的:positions_view 改成「取不到现价时拿摊薄成本顶住 price」之后,planner._usable 光看 price > 0 就漏了——拿成本价算出来的市值会被当成真市值去凑「释放 20 万」,凑够了报 DONE 而实际卖出金额对不上。现在 _usable 一并排掉 price_ok is False 的票。
下一步(按可动工顺序)
| # | 事项 | 状态 |
|---|---|---|
| 1 | ✅ 2026-07-29 | |
| 2 | /plan) |
✅ 2026-07-31:候选池独占来源、交易日龄硬校验、盘前昨收兜底、ST 剔除、PMS 侧主题限额、页面抽屉与探活脚本。口径与实测记录见 UPSTREAM_PLAN_API.md |
| 3 | ✅ 2026-07-31:PMS_SECTOR_SOURCE=gp_hybk(199 库,三级 884*,每票只认 bk_code 最小的主行业),实测 ready=true、代码写法 prefix。**这是 PMS 第一次有可用的行业源。**待账本重建后回看 PMS_SECTOR_MAX_RATIO=40% 对三级粒度是否偏松(参考项目三级用 20%)。详见 UPSTREAM_PLAN_API.md §8 |
|
| 4 | 账本重建:预检/执行/判收已就绪(make rebuild),首次建账加了成本价硬闸 |
阻塞:等 QMT 侧按真实成本价重建模拟持仓(QMT_SIDE_S3_CLOSEOUT.md §5)。对端一装好就 make rebuild 看预检,过了再 make rebuild GO=1 |
| 5 | ws 通道联调收尾(协议 §9 S3) | 阻塞:trade_no 格式不合 §5.5(QMT_SIDE_S3_CLOSEOUT.md §1,这条挡住切 ws) |
| 6 | 上游计划的剩余待确认口径 | 等上游:UPSTREAM_PLAN_API.md §4 剩 5 条 + §7.3 新增两条(同一 date 计划不幂等、档位规模与文档不符) |
| 7 | ✅ 2026-07-31:上游 changes 恒为 null,所以改成 PMS 自己算——每次拉计划落一份名册快照(第 15 张表),差异由纯逻辑比。顺带补上「同一 date 多版本、下游无从分辨手上是哪一版」那个洞。详见 UPSTREAM_PLAN_API.md §9 |
|
| 8 | T0 做T(二期) | 可做,设计已有,无外部依赖。建议排在账本重建之后:它要拿真实持仓验触发与 14:50 平回,账本空着只能跑单测 |
| 9 | 择时实现 A(委托决策系统盘中择时) | 阻塞:等 bionic 侧接口 |
| 10 | 研判闸接通 | 阻塞:等 bionic 侧 process_intraday_audit 新增 PMS direction。客户端已就位,接口好了在页面填 PMS_JUDGE_API_BASE 即通 |
| 11 | trading_buy_plan 退场:PMS 已不读它 |
待上游确认无其他消费方(UPSTREAM_PLAN_API.md Q10) |
| 12 | make deploy 一句话完成 build + up + 各 profile |
✅ 2026-07-31:Makefile(make help 看全部)。deploy 包 scripts/deploy.sh;另有 test / initdb / check / probe / changes / industry / ws-status。一次性命令统一带 --no-deps,免得跑个单测把 beat/worker 也拉起来 |
| 13 | ✅ 2026-07-31:见上一节。新增 test_batch10_units.py 27 例,其中 [A] 组是静态扫描守卫 |
盯盘:用 make watch,别拿 t-* 当监控
t-ins / t-gate 这类目标回的是原始 JSON——一条指令刷四十行,中文还被 json.tool 转义成 \uXXXX,而那些被转义的 reason 恰恰是最该看的东西。它们是排查用的(要看全字段时才用),不是盯盘用的。
盯盘时要回答的其实只有五个问题,分散在五个接口里:通道通不通、单发出去没有、出手或没出手为什么、成交回来没有、账本认领对不对。make watch 把这五问拍成一屏:
make watch # 看一眼
make watch N=10 # 每 10 秒刷一次,Ctrl-C 退出
make watch WIDE=1 # 连方案分布与评审账本一起显示
只读,跑多少次都不影响状态。两个判读要点写在表头上:「没出手」和「出手失败」长得不一样(前者是指令停在 PROPOSED、出口队列空、裁决栏写着 WAIT/STOP 及原因;后者是裁决 FIRE 但指令没进 DISPATCHED,或出口表有行卡在 QUEUED/SEND_FAILED),以及 WAIT 与 STOP 是两回事(WAIT 是"条件没到待会儿再看",STOP 是"本日不再出手",比如触发不追高闸——那只票今天不会再有动静,别一直等)。
顺带把 Makefile 里的 json.tool 加了 --no-ensure-ascii,所有 t-* 目标的中文现在都是中文了。
运行态注意(2026-07-31 收尾时的状态)
pms-beat停着——08:40 拉计划、盘中执行等调度位都不会自动跑,开发期用页面「上游计划 → 强刷」与「运维」抽屉手动触发。重开 beat 前先确认账本已重建,否则信号消化会把信号 ACK 掉(IGNORE 也照样 ACK,跨日拿不回来)。- 账本已清空(
reset_ledger --purge-channel --reset-ws --drop-reports),ws seq 水位归零 → pms-ws 一重连对端会从 seq 1 全量补发约 1.2 万条。账本空的时候不要点「成交回放」。 - 宿主机放行 docker 网段到 8300 的规则若用
iptables -I加的,重启就没了,需netfilter-persistent save或改 firewalld permanent。不持久化的话服务器重启后候选池会静默变空。 - 自主档位仍是
propose_only,下发通道仍是shadow。 PMS_TOTAL_SCALE与账户实际资金要对得上。 两个数本来就是不同的东西(scale 是「我打算投多少」的命令参数,账户总资产是「现在有多少钱」),允许不等,但差太远会产出一堆执行不了的方案:规划器只认 scale,按 200 万 × 总仓上限排出 140 万的买入,而账户只有 98 万,多出来的会在规则闸被一条条判INSUFFICIENT_CASH——方案排得出来、下不出去,每一步看着都正常。2026-07-31 实测就是这组数。盘前准备(make t-pre)现在会把两个数摆在一起,差太多就在scale_check里说破。- 静默失败专项修完之后,几处「以前默默过去」的地方现在会明着报失败,这是设计如此:开关类命令(
HALT_BUY/RESUME_ALL等)写不进参数就置CANCELLED并回ok=False,不再显示「已完成」;daily_settle在对账被拦(爆炸半径 / 两源无应答 / 成本价体检不过)或连续不一致升到 ERROR 时ok=False;premarket在「该踩刹车却没踩上」时把它记进errors而不是 warnings。看到这些报错先别急着改代码——多半是数据库或下游真的出问题了,以前只是没人告诉你。 - 新增第 15 张表
pms_plan_snapshot(名册快照),部署后要跑一次make initdb,否则榜单变化那段会一直报「读名册快照失败:表不存在」——候选池不受影响。第一份快照落下之前不报任何变化(kind=first),这是设计如此不是坏了。
ws 通道实现清单
协议 QMT_WS_PROTOCOL.md V1.0 已定稿,端点 ws://192.168.16.98:8080,明文 ws + Ed25519 双向签名。
- ✅ 常驻连接进程 ——
app/ws/runner.py+ compose 的pms-ws(--profile ws,绝不可扩副本,协议 §1 只准一条连接)。四个协程:存活心跳 / 收帧落库 / 5 秒 ping / 0.5 秒出口轮询。SIGTERM 优雅退出(停取新单 → 刷水位 → 最后一次 ack → 关连接 → 置 STOPPED 并清心跳),stop_grace_period: 25s。所有 DB 调用走to_thread,不阻塞事件循环。 - ✅
dispatcher._ws()—— 落pms_qmt_order出口表置 QUEUED 即返回,签名与发送由 ws 进程做。参数不合协议(非整百、限价缺失、代码非点式)在本地就拦下。撤单把父指令名下所有在途子单标cancel_state=REQUESTED。 - ✅ 上行消费与 seq 水位 ——
pms_qmt_inbox落库(seq 主键 +trade_no唯一索引 = 协议 §6.1/§5.5 的双层去重),pms_ws_state存连续水位,按 20 条 / 2 秒发ack_seq。落库失败绝不 ack——落不了库就主动断线,让对端从last_seq+1重发,用协议自带的补发机制而不是自攒重试队列。 - ✅
recon改为吃trade消息入账 ——ledger_service.consume_ws_trades()从pms_qmt_inbox消费processed=0的 trade 行,挂在replay_fills里(成交回放调度位从 5 分钟改为 1 分钟——ws 的成交是推过来的,没必要再等)。trade 自带instruction_id,是精确认领而不是 FIFO 猜:影子期只能拿下游成交往在途指令上「凑」,同一只票挂着两条在途指令时凑错了根本看不出来,批次类型一错、卖出核销次序(T0→ADD→DCA→FILL→BASE)跟着错。ws 通了这层不确定性直接消失。trading_order那条路退化为只兜外部/人工成交。 - ✅ 手续费口径 —— 新增
pms_cash_flow(第 14 张表)。逐笔fee记一条 FEE 流水(负数=流出),绝不进 action、不摊成本;日终calibrate_fees()用资金快照反推真实费用写一条 CALIBRATE 平掉估算误差,只调现金账不回溯改成本。影子期没有资金快照,那就只登记「待校准」不硬凑——费用既然进不了成本,晚校准没有风险。 - ✅
CANCELLED/EXPIRED自记 —— 通道层:pms_qmt_order.cancel_state记本地是否发过撤单,与对端回的status不一致时告警;终态不可被后到的消息覆盖。账本侧:未成交部分由qty − exec_qty自然释放,窗口收口照旧。
部署前另有一份检查清单在协议 §10.1.1(白名单、公钥交换、Redis 持久化、密钥只走 .env)。
切 ws 通道的操作顺序
# 1. 生成本端密钥对: seed 填进 .env 的 PMS_QMT_SIGN_SEED_HEX
docker compose run --rm pms-web python scripts/gen_keys.py
# 公钥交给 QMT 侧要 PEM 形态 (他们用 load_pem_public_key 读文件):
docker compose run --rm pms-web python scripts/gen_keys.py --pem > pms_public.pem
# 2. 把 QMT 侧公钥填进 .env 的 PMS_QMT_PEER_PUBKEY_B64; 把本机内网 IP 报给对方加白
# 3. 密钥自检 (含协议 §2.1.1 测试向量, 双方各跑一次对上再联调)
docker compose run --rm pms-web python scripts/gen_keys.py --check
# 4. 建新表 (幂等) 并自检
docker compose run --rm pms-web python scripts/init_db.py --yes
docker compose run --rm pms-web python scripts/check_db.py # [6] 段看通道状态
# 5. 起进程 (此时 PMS_DISPATCH_MODE 仍是 shadow, 只连不发)
docker compose --profile ws up -d pms-ws && docker compose logs -f pms-ws
# 6. 页面把 PMS_QMT_WS_ENABLED 打开, 观察 /api/ws-channel 的 conn_state 与 seq 水位
# 7. S2/S3 联调通过后, 页面把 PMS_DISPATCH_MODE 改成 ws
scripts/gen_keys.py还有--pubkey(打印当前私钥对应的公钥两种形态,不打印私钥)。别用python -c加 shell 续行\拼这段:续行会把下一行的行首缩进一起带进 Python 源码字符串,直接IndentationError: unexpected indent。
公钥的两种形态(2026-07-28 交换密钥时踩到,两边要的不是同一种):
| 谁 | 要什么 | 长什么样 |
|---|---|---|
PMS 的 .env(PMS_QMT_PEER_PUBKEY_B64) |
一行字符串 | 裸 32 字节 base64 Oh3Cd30l...;也接受 PEM 正文那一行、PEM 全文、64 位 hex |
QMT 侧的 crypto.py(load_pem_public_key) |
一个 .pem 文件 |
-----BEGIN PUBLIC KEY----- 三行 |
两者装的是同一把钥匙:PEM 正文解出来是 44 字节 = 12 字节 SPKI 固定头 + 32 字节裸公钥。协议 §2.1.1 正文用的是裸 base64,所以把 PEM 正文原样粘进 .env 会报「44 字节,长度不对」。ws_codec.normalize_pubkey() 现在四种形态全收并自动归一——对方发来什么直接粘上去即可,gen_keys.py --check 会告诉你它识别成了哪种。
任一步不放心都可以退回去:把 PMS_DISPATCH_MODE 改回 shadow 即恢复人工执行,pms-ws 停掉也只是让指令拒发(保持原状),不会产生半截状态。
联调(协议 §9 的 S1/S2/S3)
两个「影子模式」不是一回事,别混。 协议 §9 的 S2「影子模式」指 QMT 侧只回报不下单;PMS 的
PMS_DISPATCH_MODE=shadow指 PMS 侧压根不发。要跑 S2,PMS 必须真发——但为此切PMS_DISPATCH_MODE=ws会把整条真实指令流(命令方案、自主提议、信号消化)一起放上通道,你没法控制「这一刻只发这一张」。
所以联调用 scripts/ws_smoke.py:它直接往 pms_qmt_order 出口表塞委托,绕开 dispatch_mode 开关,但完整走 ws_codec 的参数校验与 pms-ws 的签名下发路径——消息格式、签名、幂等、回报入账全是真的,只有「发什么」由你说了算。生产开关自始至终不动。
| 阶段 | 做什么 | 通过标准 |
|---|---|---|
| S1 握手心跳 | 起 pms-ws,ws_smoke.py watch 盯着;拔网线 60 秒再插回 |
conn_state 回到 ONLINE,seq 水位无缺口,日志里退避是 1→2→5→10→30 |
| S2 消息互通 | 先跟 QMT 侧确认「只回报不下单」;place --yes 发一张 100 股、限价远离市价的单,走完 ack → order_update → trade → 终态 |
ws_smoke.py inbox 看到全套上行;pms_qmt_order 状态推进正确;pms_lot 入账、pms_cash_flow 有费用行 |
| S3 小额实盘 | 单笔 ≤100 股,覆盖五类:全成 / 部分成交 / 主动撤单(cancel)/ 到期过期(--ttl 1 等它自己撤)/ 各类拒绝(挂涨停价触发 LIMIT_UP) |
五类各至少一次;日终对账零差异 |
| 切换 | 页面把 PMS_DISPATCH_MODE 改 ws |
真实指令流开始走通道 |
place 默认 100 股,且建议限价故意给远离市价的值(卖单挂高、买单挂低)——挂不上才是预期,验的是消息链路而不是成交。
待外部协商(剩余):QMT_INTERFACE_REQUIREMENTS.md 的 A/C/D 各项。已闭环的有:A2 状态枚举与成交均价、A3 资金快照、A4 逐笔成交、Q1~Q11 与 R1/R2(详见协议 §10)。已知存疑项:trading_log.extra_data 里的 total_filled 口径与示例数据矛盾,PMS 事后核对绕开该列,只读 traded_volume / traded_price / traded_amount(协议 §10.2)。
开发约定
- 开发机与服务器经 git 同步代码;构建与运行统一走 Docker(
docker compose build→ 容器内python scripts/run_tests.py→up -d),测试结果回传后迭代。 - 153 代理侧数据库严格单表访问;该纪律已落到
app/db/session.py的静态守卫,违规 SQL 在执行前抛MultiTableSQL。持仓系统内部代码统一 Tushare 点式(600000.SH),读决策系统结论表时转前缀式。2026-07-28 修了守卫本身的一个误判:
ON DUPLICATE KEY UPDATE后面跟的是列名不是表名,原来会被当成第二张表,于是所有 upsert 一执行就抛MultiTableSQL——页面改参数、ensure_position、日报落库、行业映射导入四条路径全中。单测走内存桩不经过该函数,所以一直没暴露。回归用例在test_batch6_units.py的 [G] 组。 - 配置分两层:基础设施连接串在
.env(服务器手工维护,不入库,模板见.env.example);业务参数在config/settings.py只是初值,上线后经管理页面修改并持久化到pms_runtime_param表。业务代码禁止直接读 settings 取业务参数,一律走services/param_store.py。例外是密钥:
PMS_QMT_SIGN_SEED_HEX/PMS_QMT_PEER_PUBKEY_B64只走.env(协议 §10.1.1),已列入param_store.SECRET_KEYS——页面读不到、改不了、快照里也不出现。新增密钥类配置记得同步加进那个元组,否则它会被当成普通业务参数显示在参数设置页上。 - 新增纯逻辑一律进
app/core/(零外部依赖 + 配套单测);需要连库的编排进app/services/,并保证连库失败时降级而非崩页。ws 通道的协议编解码在app/core/ws_codec.py,改动后必须先跑通协议 §2.1.1 的测试向量(test_batch6_units.py的 [A] 组)——规范化串两边写不一致的话,联调只会告诉你"签名验不过",看不出差在哪一段。 - 关键路径禁止丢弃返回值。 本系统里大量写入函数失败时不抛异常,只回
{"ok": False, ...}或影响 0 行——param_store.set_param、dispatcher.dispatch/cancel、executor.cancel_instruction、proposal_service.bump_once_guards、portfolio.save_neg_streak、qmt_repo.update_order都是。写这类调用时返回值必须接住并处理,否则写入没发生而调用方照常报成功。test_batch10_units.py的 [A] 组 AST 扫全库守着这条;新加一个软失败函数,记得同时加进它的SOFT_FAIL名单。 - 里程碑(设计定稿、建表、各期上线)及时 git 提交。
- 接手前先读三份:本文件 →
POSITION_MGMT_DESIGN.md(V0.4 定稿,勿改设计)→QMT_WS_PROTOCOL.md(V1.0 定稿,下发通道的唯一依据)。三份读完即可开工,不需要额外的口头背景。