# 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）**|
| `BIONIC_PMS_INTERFACE.md` | **PMS ↔ 决策系统 (bionic) 接口契约 V1.1**：研判闸 `pms_judge`（大模型仲裁，走 brain_queue）与盘中择时 `pms_exec`（按凌晨结论给执行区间，秒回）两个接口的请求/应答、各种拿不到结论时两侧各自怎么降级、bionic 侧部署与联调步骤。**待办 #9/#10 的实现依据** |
| `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         研判闸客户端 (决策系统未接通时自动降级为人工确认)
    exec_advisor.py  择时实现A客户端: 本地检查先行 → 问决策系统「现价在不在执行区间」
                     (应答有缓存与失败冷却) → 不可用退实现B (默认 PMS_EXEC_IMPL=B 整个短路)
    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_batch11_units.py 择时实现A: 本地检查等价/应答折算/缓存冷却/退B 24 例
  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 才落库, 那是它唯一的写操作)
  probe_bionic.py      决策系统两接口实机探活 (择时研判 pms_exec + 研判闸 pms_judge,
                       只读不落表; 待办 #9/#10 的接通自证)
  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，**代码已接完、默认关**——页面把 `PMS_EXEC_IMPL` 改成 A 才生效，且不可用自动退 B）；实现 B 是 PMS 自己算（分日配额、分笔、VWAP/回踩/不追高、14:45 兜底）。`PMS_EXEC_IMPL=B`（默认）时决策系统完全不参与，跑 A 段行为与接通前一字不差。接口契约与切换步骤见 `BIONIC_PMS_INTERFACE.md`。

**别开 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 水位与累积确认，见下）；**择时实现 A 客户端**（`exec_advisor.py`：本地检查先行 → 问决策系统「现价在不在执行区间」→ 应答缓存与失败冷却 → 不可用退实现 B，默认 `PMS_EXEC_IMPL=B` 整个短路）；**单测 425 例**。

### 静默失败专项（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 | ~~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（委托决策系统盘中择时） | **代码就绪待联调**（2026-08-03，当日按用户原则重做过一版）：原则是**提前计算为主、盘中监控为辅、不另做盘中判断、可以接受买不上**。bionic 侧 `/api/intraday/pms_exec` 用凌晨已算好的支撑/压力推出买入、卖出执行区间，盘中只回答「现价在不在区间内」并核对当天监控有没有离场结论，秒回；区间内出手时**限价挂区间边缘**（今天委托全部到期的病根——限价贴着现价×1.002 太窄——在实现 A 下由此解决）。PMS 侧 `exec_advisor.py`（本地检查先行、应答缓存、失败冷却、不可用退 B）。凌晨用 LLM 明示产出区间是下一个迭代。**默认 `PMS_EXEC_IMPL=B` 不生效**；联调步骤见 `BIONIC_PMS_INTERFACE.md` §6，探活 `probe_bionic.py` |
| 10 | 研判闸接通 | **代码就绪待联调**（2026-08-03）：bionic 侧 `process_intraday_audit` 已加 `PMS_JUDGE` direction——通过、驳回、研判不可用三种结果严格分开（解析失败绝不折成驳回），同股同动作 30 分钟内直接回上次结论；留痕只写 `strategy_audit_log`，**不写 `decision_ledger`**（每晚判分是全表扫描，写进去会混入判分与建仓仲裁的历史）。API `/api/intraday/pms_judge` 同步应答（走 brain_queue，可配独立队列）。PMS 客户端原本就位——**bionic 部署后在页面填 `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`），这是设计如此不是坏了。
- （2026-08-03 补）**决策系统两接口的接入代码已进库，全部默认不生效**：研判闸要页面填 `PMS_JUDGE_API_BASE` 才接通（空=沿用「降级人工确认」现状）；择时实现 A 要页面把 `PMS_EXEC_IMPL` 改 A 才启用（默认 B，行为与接通前一字不差）。两个都不需要重启。接通前先跑 `probe_bionic.py` 探活，判读口径见 `BIONIC_PMS_INTERFACE.md` §6。

### 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 侧压根不发**。要跑 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 定稿，下发通道的唯一依据）。三份读完即可开工，不需要额外的口头背景。
