tradingSystem/README.md

482 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 静默失败专项: 关键路径不许丢返回值 + 八条实例 46 例
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 秒刷
```
## 三条铁律
1. **命令至上**:自动决策不得突破用户命令参数;冲突时命令优先;命令间冲突由用户裁决。
2. **分工不越权**:持仓系统管「做什么、多少」,决策系统管「该不该、何时」,下游只管执行;研判不可用时降级为保守规则 + 人工确认,不自建第二套研判。
3. **先记账后动作 + 故障即守成**:指令先落表再下发;故障不产生新指令;账本与下游定期对账,以下游为实际持仓事实源。
## 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 也照样能跑,两者等价。
```bash
# 服务器首次部署
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。
**管理页面的两个坑**(都踩过,写在这里):
1. **前端资源走 unpkg CDN**,浏览器需能访问外网。上不了外网的表现不是报错,而是**只剩一个头部、下面全空**——`el-*` 组件全部渲染不出来。离线办法:把 Vue3 / ElementPlus / axios 三个库放进 `app/web/static/vendor/`,再把 `index.html` 头部那 5 个 URL 换成 `/static/vendor/xxx`(后端已把 `static` 挂在 `/static`,页面本身无构建步骤)。
2. **模板里不能用自闭合的自定义标签**。`<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 起算。
配套一条命令,默认只预检不改账:
```bash
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-*` 目标,就是把那几个调度位改成手动逐跳触发,顺序与生产一致:
```bash
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 四类候选,然后依次过三道:
1. **规则闸**(一级,纯代码)——不过就拒,未通过项落评审账本。自主动作受组合刹车约束(命令驱动不受)。
2. **研判闸**(二级,仅补足/加仓/补仓,委托决策系统)——`PMS_JUDGE_API_BASE` 为空即视为未接通,按设计**自动降级为人工确认**并记 ERROR绝不把「研判拿不到」当成「研判通过」。
3. **按自主档位分流**——`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 水位与累积确认,见下);**单测 399 例**。
### 静默失败专项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]` 并退出码 2make 报 `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 | ~~ws 通道的账本侧改造(清单 4~6~~ | ✅ 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` 把这五问拍成一屏:
```bash
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 双向签名。
1.**常驻连接进程** —— `app/ws/runner.py` + compose 的 `pms-ws``--profile ws`**绝不可扩副本**,协议 §1 只准一条连接)。四个协程:存活心跳 / 收帧落库 / 5 秒 ping / 0.5 秒出口轮询。SIGTERM 优雅退出(停取新单 → 刷水位 → 最后一次 ack → 关连接 → 置 STOPPED 并清心跳),`stop_grace_period: 25s`。所有 DB 调用走 `to_thread`,不阻塞事件循环。
2.**`dispatcher._ws()`** —— 落 `pms_qmt_order` 出口表置 QUEUED 即返回,签名与发送由 ws 进程做。参数不合协议(非整百、限价缺失、代码非点式)在本地就拦下。撤单把父指令名下所有在途子单标 `cancel_state=REQUESTED`
3.**上行消费与 seq 水位** —— `pms_qmt_inbox` 落库seq 主键 + `trade_no` 唯一索引 = 协议 §6.1/§5.5 的双层去重),`pms_ws_state` 存连续水位,按 20 条 / 2 秒发 `ack_seq`。**落库失败绝不 ack**——落不了库就主动断线,让对端从 `last_seq+1` 重发,用协议自带的补发机制而不是自攒重试队列。
4.**`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` 那条路退化为只兜外部/人工成交。
5.**手续费口径** —— 新增 `pms_cash_flow`(第 14 张表)。逐笔 `fee` 记一条 FEE 流水(负数=流出),**绝不进 action、不摊成本**;日终 `calibrate_fees()` 用资金快照反推真实费用写一条 CALIBRATE 平掉估算误差,只调现金账不回溯改成本。影子期没有资金快照,那就只登记「待校准」不硬凑——费用既然进不了成本,晚校准没有风险。
6.**`CANCELLED` / `EXPIRED` 自记** —— 通道层:`pms_qmt_order.cancel_state` 记本地是否发过撤单,与对端回的 `status` 不一致时告警;终态不可被后到的消息覆盖。账本侧:未成交部分由 `qty exec_qty` 自然释放,窗口收口照旧。
部署前另有一份检查清单在协议 §10.1.1白名单、公钥交换、Redis 持久化、密钥只走 `.env`)。
### 切 ws 通道的操作顺序
```bash
# 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 侧压根不发**。要跑 S2PMS 必须真发——但为此切 `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 定稿,下发通道的唯一依据)。三份读完即可开工,不需要额外的口头背景。