# 开发节点记录（三系统量化交易）

> 这份文件的用途只有一个：**让下一次交接不必重新查一遍**。
> 每个重要节点写一条，写完就补，不攒到最后。一条节点包含五样东西：
> 做了什么、动了哪些文件、部署方式、真机判收结果、还欠着什么。
>
> 三个系统的部署方式（改错等于白改）：
> - **tradingSystem（PMS）** 跑在 `factor@factorevaluation`，源码打进镜像，
>   改码必须 `make deploy`，跑完 `make test` 见 ALL SUITES PASS，盯盘 `make watch`。
>   容器时钟是北京时间。
> - **bionic_trader（决策系统）** 跑在 `192.168.16.188:38000`，挂载卷，
>   `git pull` 后 `docker compose restart backend-api worker-brain` 即生效；
>   **新增 env 必须 `docker compose up -d --force-recreate`**，restart 不重读 env。
> - **akg-factor-bridge（因子桥）** 在 `factorevaluation`，容器名 `akg_factor_bridge`，
>   API 在 8300，常驻 API 改码要 compose restart。
>
> **时区（2026-08-06 查实、2026-08-07 再证一次，这里写的以实测为准）**：
> 不能按「哪个系统」一刀切，要按「哪种时间」分：
> - **容器时钟与容器日志正文** —— 北京时间，**不要加八小时**。
>   证据：bionic 容器内 `date` 为 `15:37 CST`。
> - **数据库的时间列**（`strategy_audit_log.created_at`、
>   `strategy_daily_results.updated_at` 等）—— **UTC，要加八小时**。
>   证据：08-07 北京 09:03 时该库 `SELECT NOW()` = `2026-08-07 01:03:32`。
> - **日期列**（`audit_date`、`trade_date`）—— 北京日期，不换算。
> - `docker logs -t` 自己加的那个时间戳前缀是 UTC，跟正文里 Python logging
>   打的时间不是一个口径，别混着读。
>
> 记录纪律：判收状态只写「真机见过」或「未判收」两种，不写「应该没问题」。
> 时间一律写北京时间，引用数据库时间列时先加八小时并标注已换算。

---

## 2026-08-18 · 宏观择时层上线：股汇对冲指数管整体仓位，外加个股宏观闸

**背景与依据**
方案与全部拍板记录在 MACRO_TIMING_PLAN.md（V3 可读版）。参数初值来自当天在桥机实跑的标定，报告存档在 MACRO_CALIB_2026-08-18.md。模拟仓拍板：默认总开关开、档位全自动、不设观察期；转实盘前要把这两个默认改回保守档。

**做了什么**
给持仓系统加了一个宏观择时层。全部改动都在 tradingSystem，其他四个系统零改动。行为分四句话说。每个交易日九点三十五分算一次股汇对冲指数。指数进偏冷区（低于负二十）当天，像用户一样在命令台下升仓命令。指数进偏热区（高于正二十五）当天，先落宏观闸：自主增持四类动作和策略的买入开腿暂停，卖出与平回照常。指数从极值回落穿出正十五时，下降仓命令，幅度按本轮峰值深度取对数映射（S0=0.049、k=1.6、单周期封顶两成、降仓地板是总仓位两成）。

护栏与铁律的落点。它动仓位的唯一出口是 command_service.issue，署名 macro。与用户在途命令冲突就放弃并留痕，绝不带强制标志。升仓让位于全局暂停买入和组合刹车。数据取不到就判不可用，不动作、不落闸。信号每日一行落新表 pms_macro_signal（第 18 张表）。闸状态放运行参数 PMS_MACRO_GATE_STATE，由扫描写入，提议层与策略层只读，读不到一律当不生效——闸的安全方向是不额外拦。三张数据源表全在 153 代理，是标定实测钉死的（zs_day_data、gp_fx_daily 的 USDCNH.FXCM、gp_shibor 的 rate_1m）。

**动了哪些文件**
新增五个：
- app/core/macro_rules.py —— 纯逻辑：四步计算与 as-of 对齐、区域迟滞、周期状态、对数映射决策
- app/repo/macro_repo.py —— 三张源表单表取数 + pms_macro_signal 读写
- app/services/macro_service.py —— 编排、信号注册表、闸状态、建议采纳
- scripts/test_batch14_units.py —— 25 例（计算对照手算/迟滞/周期映射/让路冷却/闸/采纳幂等）
- （scripts/calibrate_macro_signal.py 此前已交付并实跑，保留做月度复盘）

修改十二处，全部只加行、不改既有行为：
- config/settings.py —— PMS_MACRO_* 参数一段（含模拟仓默认与转实盘提醒）
- app/services/param_store.py —— 说明/范围/合法值校验；GATE_STATE 运行键；PMS_MACRO_ENABLED 进 FAIL_CLOSED（文件初值是开，参数表读不到时按关处理）
- app/scheduler.py —— macro_scan 调度位（交易日 09:35，带守卫）
- app/services/proposal_service.py —— 两处扫描入口加宏观闸检查（拦买入侧、保垫减仓照常）
- app/services/strategy_runner.py —— 买开腿判定点加闸条件（与 buypause 同点，平回天然不拦）
- app/web/main.py —— 三个接口：/api/macro/status、/api/ops/macro-scan、/api/macro/adopt
- app/web/static/index.html —— 宏观择时面板 + 偏热闸与数据不可用两条横幅
- ddl_pms_v1.sql —— 第 18 张表 pms_macro_signal
- scripts/check_db.py —— 表清单加 pms_macro_signal（顺带记录：pms_strategy 与 pms_op_log 本就不在清单里，属既有欠账，按不夹带纪律未补，单独提）
- scripts/run_tests.py —— 登记 batch14，总数改为 498 例
- scripts/test_batch6_units.py —— DDL 表数哨兵 17 改 18（哨兵注释写明本次加表）
- scripts/test_wiring.py —— 调度表哨兵集合加 macro_scan

**部署方式**
桥机 factorevaluation 上 make deploy（源码打进镜像，必须重建容器），跑 make initdb 建新表（幂等），make test 要见 ALL SUITES PASS。**务必收盘后部署**：默认就是全自动档，部署完成后的下一个交易日 09:35 就是第一次真实扫描。参数全是新键，不存在页面旧值覆盖的问题。

**真机判收**
未判收。开发容器里已过两道：全量单测 ALL SUITES PASS（498 例，含新增 25 例与两处哨兵更新）；页面无头渲染判收八项全过、零页面错误（面板、指数值、区域标签、建议卡、采纳按钮、闸横幅、最近命令、二十根迷你条）。桥机判收按 MACRO_TIMING_PLAN.md 第六节走：make test → make check 见 18 表 → dry_run 试算并与标定脚本同日值对照 → 次日 09:35 看第一跳与面板。

**还欠着什么**
1. 桥机部署与第六节判收（含冲突演练那一步）。
2. 第一个极值触发日的表现（模拟仓，让它自然发生）。
3. 月度复盘：数据多一段就重跑标定脚本，校准阈值与 S0/k。
4. check_db 表清单缺 pms_strategy 与 pms_op_log 的既有欠账，单独提。

---

## 2026-08-18 · 决策系统卖出采纳复核：降门槛+全留痕 / 确认即加速 / 清仓完成清场

**背景**
用户观测"系统似乎不采纳决策系统的卖出"，复核整条链（bionic 生产端 → PMS 消费端）。结论：
接线、代码格式（都是 Tushare 点式，能对上持仓）、置信度量纲（风控 0~100 归一到 0~1）都没
问题；"不采纳"是门槛标定 + 挂策略路由所致。真机取数印证：bionic 对持仓票的卖出置信度几乎
全落在 60~84（002015 60/70/70、300474 65、300433/300354 72、688256 70…），而原门槛是
低于 75 忽略、75~84 落提议、≥85 才自动清仓——于是持仓票的卖出大多被静默忽略或排队等拍板。

**做了什么（三件，用户经 AskUserQuestion 拍板）**
一，降门槛 + 全留痕。`PMS_SIGNAL_SELL_CONF_MIN` 默认 0.75 → 0.60（页面可再调），60~84 一律
浮上来变提议、可见可一键采纳，≥85 仍自动清仓；并在 signal_service 对**持仓票**的"未采纳
卖出"（低于门槛的 IGNORE）也留痕（action=SIGNAL / verdict=NOTE，当日按来源+股票+动作去重
一条），不再让一只连日被喊卖的持仓票在账本里查不到。
二，确认即加速。决策系统卖出命中一只**已有在途清仓指令**的票时，不再简单跳过，而是把该股
在途的卖出**指令**升级为紧急直通（progress.urgent=True，下一跳 run_tick 按现价直卖、不等
均价不分桶），每条升级账本留痕。仅挂着提议（还没成指令）的不动。人工清仓 + 风控独立确认
= 最强离场信号，走最快的路。
三，清仓完成清场。持仓归零（status=CLOSED 且 qty≤0）后，自动撤下该股 策略（网格/做T/跟踪）、
在途建仓方案与买单、挂着的相关提议；每只闭仓票靠标记（PMS_EXIT_CLEANUP_DONE）**只清一次**，
避免把闭仓后新下的建仓命令误撤（重开的票掉出标记，下次闭仓再清）。挂在 command_poll 每分钟
一跳、幂等。解决"清仓离场后网格还挂着 ACTIVE、一旦持仓重建又逢跌买入"的隐患。

**动了哪些文件**
config/settings.py（门槛 0.60）；app/services/signal_service.py（持仓票未采纳留痕 +
`_escalate_inflight_sells` 确认加速）；app/services/command_service.py（`cleanup_exited_positions`
+ 纯逻辑 `_cleanup_todo` 标记）；app/scheduler.py（command_poll 挂清场）；
scripts/test_batch13_units.py（新增 4 例：门槛分档 / 清场标记 / 确认加速 / 闭仓清场，打桩 repo）
+ scripts/run_tests.py（登记 batch13）。

**部署与判收**
factorevaluation `make deploy`（动了后端与配置，要重建容器），`make test` 见 ALL SUITES PASS
（已含 batch13）。门槛降了默认值，若表里 pms_runtime_param 已覆盖过该键，需在页面把
PMS_SIGNAL_SELL_CONF_MIN 也改成 0.60 才生效。真机待看：① 持仓票被喊卖时账本出现"未采纳"
留痕或提议；② 人工清仓一只票后决策系统再喊卖，该清仓指令 progress.urgent 变 True、当即出手；
③ 清仓走完后该股策略变 CANCELLED、在途建仓/提议被撤、账本有 CLEANUP 行。

**还欠着什么**
当日去重仍是"先到先赢"（同股同动作一天只消化第一条够格的卖出）——先到的若落成提议、把去重键
烧掉，后到本可自动清仓的高置信卖出会被当重复丢；本轮未动它（避免过度扩面），若实盘发现高置信
卖出被前面的弱卖出挡住，再加"更强的卖出可升级已有提议为清仓"。自动清仓线 0.85 未动，按用户
选择保留，可后续按盘中体感调。

---

## 2026-08-18 · 上游信号面板改版：删下线框 + 按重要度分档 + 资金异动 z 可读化 + 待触发条

**做了什么**
重排管理页面「上游信号」面板（app/web/static/index.html 单页，纯前端，不动后端）。四件事。
一，删掉「放量异动」框：该源（volume_capitulation/breakout）6/15 已被资金分布取代，
生产端（intraday_timing orchestrator 方向源清单）不再发它，框永远只显示历史残留键。
二，卡片按重要度分三档、大小不一：行动级（决策买卖 c-wide、择时入场、风控卖出、多源升级）
有数据才出大卡；判读级（资金异动、QRS、资金分布、资金流强度）中卡两列；参考级（股价异动
小卡、实况分榜宽卡）。用 12 列网格 + c-wide/c-half/c-third 跨列实现。
三，资金异动「异动z」换成净额领读 + 强度带：净额折成万/亿带正负号打头，强度按 z_dd 绝对值
分档（显著 2–3 / 强 3–5 / 极强 ≥5）用小方块条 + 中文标签表达，原始 z 收进悬停提示。
四，空的「在用」信号收进一条「在用·今日待触发」横条（armed 待命，不再各占空框）——这是
本次核心：此前四个空框和一个死框摆一起，看不出「活着但安静」和「已下线」的区别，用户误以为
下线了。逐个追到生产端核实：只有放量异动真下线，多源升级/择时层BUY/风控卖出/资金流强度都是
在用但罕见或条件触发。「其他」桶只在非空时出现（空=所有源已登记=健康）。

**动了哪些文件**
app/web/static/index.html：新增 CSS（sig-tier/sig-grid/c-*/intens/ibar/armed 等）；新增 JS
（moneyCn、zBand、zBars、dirUp、SIG 组装、armed 待触发、CARDS_B、tierHasA/B/C 计算属性，
并加入 setup return）；替换整个 up-grid 模板为三档 + 待触发条 + 其他桶。旧的 alertGridFixed
等计算属性留着未删（无害，减少改动面）。

**部署方式**
factorevaluation 上 `make deploy`（前端资源打进镜像）。纯前端改动，不动后端与取数口径。
回滚只需还原 index.html 一个文件。

**真机判收**
开发机已做无头渲染判收（Playwright 加载真页面 + 造数）：三档正确渲染、待触发条正确收纳空信号、
强度带与万/亿格式生效、放量异动消失、无任何 Vue 编译/运行告警；node 语法检查与括号平衡通过；
全页 Vue 模板编译无错。真机待看：盘中有真数据时各档卡片与截图一致，且级别筛选条仍联动。

**还欠着什么**
待触发条目前只标「今日待触发」，未显示「最近一次触发时间」（那需要后端跨日扫历史键，会给每次
刷新加延迟，暂不做；用户认可先不加）。强度分档三个阈值（2–3/3–5/≥5）可按盘中手感再调。

**同日修订（改版二，用户反馈：分档布局太乱、要固定成对）**
把"按重要度分三档 + 卡片宽度随内容变(c-wide/c-third)"改成**固定成对网格**，位置稳定不再随
数据重排，整体更对称干净。三对（用户指定）各占一行、两两并排等宽等高：① mtf 资金异动 ｜
mtf 实况分榜（这对通常列表长，单独拉高到 min-height 340，两边等高并排）；② 决策系统买卖 ｜
日QRS对称；③ 资金分布 ｜ 股价异动。这六个常用框**常驻**（空了框内显示"暂无"，不再消失/重排）。
四个稀有或条件触发的信号（风控卖出、多源升级、择时层BUY、资金流强度）归入下方「事件信号」区：
触发了才出卡，没触发的收进一条「在用·今日待触发」；「其他/未登记源」桶也并进这条、非空才显示。
去掉了三档标题与 c-wide/c-half/c-third 变宽卡，新增 .sig2(成对行)/.sig2.tall/.sig-ev(事件卡)；
删掉不再用的 tierHasA/B/C、CARDS_B、armed 计算属性。判收：无头渲染六框全在、事件卡与待触发条
各自正确、零 Vue 告警。旧的"分档版"未曾部署，改版二是最终形态。

**同日再修（风控卖出显示为空，真机暴露）**
部署后风控卖出卡出现 40 行、每行只有一个"SELL"、无股票无理由无时间。根因是后端
`upstream_signals._read_sell_actions` 直读流的顶层字段，而 `bionic:signals:llm_sell_actions`
的真实载荷整包在 `data`(JSON 字符串)里、顶层只有 data 一个字段——这正是第一份审查报告
F3 标过的疑点，今天真机确认。修法：`_read_sell_actions` 按 data 解包（与消化端
`signal_rules.parse_risk_sell` 同口径），字段名对齐 bionic 写入端——理由 `llm_reason`、
时间 `timestamp`(epoch 毫秒)、`dominant_signal` 区分风控止损与止盈(take_profit)；顶层
字段留作兜底兼容未来扁平化。前端卡片相应不再硬写"SELL"，改显示 股票名 + 风控卖出/止盈
标签 + 置信 + 时间 + 理由。判收：后端纯逻辑单测（data 包裹 + 扁平兜底两形态各解出
ts_code/理由/时间/主导信号）；前端无头渲染确认卡片显示完整字段、止盈与风控区分正确、零告警。
动了 app/services/upstream_signals.py 与 app/web/static/index.html 两个文件。

---

## 2026-08-17 · 清仓择时改造：紧急直通 + 卖出分桶收口 + 自主卖出窗口不作废

**做了什么**
清仓的执行节奏按用户拍板改造，三件事一次落地。一，紧急直通：一键清仓（LIQUIDATE_ALL）
与高置信风控清仓（信号消化 ≥ PMS_SIGNAL_AUTO_EXIT_CONF 转出的 EXIT）带 urgent 标志，
择时不再避开开盘半小时、不再等均价，有价在时段有配额即出手，限价用更激进的
PMS_URGENT_SELL_DISCOUNT（默认 0.995），过了兜底时点自动转 forced 挂到收盘。此前
一键清仓的方案注记写着「紧急, 不做择时优化」而执行层不认识这个语义，文案与行为不一致。
二，卖出分桶收口：日内按 PMS_SELL_BUCKET_TIMES（默认 11:30,14:00，加兜底共三桶）
给每桶分一份当日配额，桶内照旧择价（价好就多卖），桶收口时累计应出量没跟上就无条件
补齐差额（限价 = 现价×0.998，不置 forced，TTL 到期撤掉按新价重下）。动机：
「现价≥均价才卖」是只在强势时放行的条件，下跌日全天不满足，全部数量堆到 14:45
一笔打出。分桶后跌日的强制完成分散到多个时点。参数留空 = 不分桶，回到旧行为。
三，自主卖出窗口耗尽不作废：window_verdict 增加 side 参数，自主类卖出（信号清仓、
保垫减仓）窗口耗尽置 PARTIAL 保持在途、按末日节奏继续出手，不再 EXPIRED——与
hard_gate 里「宁可买不上, 不能卖不掉」对齐口径。自主买入到期作废的口径不变。

**动了哪些文件**
app/core/exec_timing.py（parse_bucket_times / _bucket_due 新增；hard_gate、decide 加
urgent；window_verdict 加 side）；app/services/executor.py（materialize_plans 给
LIQUIDATE_ALL 指令写 urgent；run_tick 传 urgent 与两个新参数；sweep_windows 传 side）；
app/services/exec_advisor.py（decide 加 urgent 透传，紧急与分桶都在 hard_gate 消化，
实现A/B 一律生效）；app/services/signal_service.py（_make_exit 写 urgent）；
config/settings.py 与 app/services/param_store.py（PMS_SELL_BUCKET_TIMES /
PMS_URGENT_SELL_DISCOUNT 两个新参数与页面描述）；scripts/test_batch3_units.py
（新增三组用例：紧急直通、分桶收口、自主卖出不作废）。

**部署方式**
factorevaluation 上 `make deploy`（源码打进镜像，restart 无效），跑 `make test`
见 ALL SUITES PASS。两个新参数页面可调、即时生效；PMS_SELL_BUCKET_TIMES 清空
即整体退回旧行为，不用回滚代码。

**真机判收**
未判收。开发机全量单测 ALL SUITES PASS（含新增三组用例）。待真机看三样：
① 下一条紧急清仓的指令 progress.last_decision.reason 出现「紧急卖出直通」；
② 下跌日的普通清仓在 11:30 后、14:00 后各有一笔「分桶收口」子单；
③ 窗口耗尽的信号清仓不再变 EXPIRED，progress.window_verdict.note 带「不作废」。

**还欠着什么**
分桶份额目前按桶数等分，未按成交量 U 形加权；紧急直通没有跳过跌停一字板的顺延
（封板时挂单也成交不了，维持顺延是有意的，记录在此防止误会）。

---

## 2026-08-06 · 新建仓动作：方案已出，待拍板

**做了什么**
读完 PMS 的动作引擎主链代码，出了《给动作引擎加「新建仓」动作》的方案，等待拍板。
一行代码都还没改。

**读代码得到的、与既有文档不一致的事实（以代码为准）**

1. `OPEN` 这个动作名在 PMS 里**已经存在**，而且从方案到下单的整条管道是通的：
   `planner.py:31` 定义 `A_OPEN`、`executor.py:41` 的 `BUY_ACTIONS` 已含 `OPEN`、
   `command_service.py:647` 已经在用 `action="OPEN"` 写评审账本。
   缺的只有自主提议这一段：`action_engine.py:27` 的动作词表没有 OPEN，
   `action_engine.scan()` 的输入是持仓列表，结构上不可能引入新标的。
2. `caps_ctx(view, ts_code=...)` 对**从未持仓过的票**不会带出行业名，
   不显式传 `sector` 的话，行业集中度那道硬拦截会整段静默跳过。
3. 从未持仓过的票在 `pms_position` 里没有行，`update_position` 会影响 0 行。
   建行要显式调 `ensure_position`，它只写 `ts_code / status='PLANNED' / updated_at`。
4. `command_spec.py` 里那个 `"planner": "..."` 字段**全代码库没有任何一处读它**，
   真正的分派是 `command_service._dispatch_planner` 里一串硬编码的 `if cmd_type ==`。
5. 参考位取数（`ledger_service` 盘前那一跳）只对 `only_open=True` 的持仓取，
   候选票没有支撑压力。这对择时实现 A 无影响（它用决策系统自己库里的结论），
   但实现 B 的「回踩带」判据对新票是空的。

**动了哪些文件**
无。方案文件 `NEW_POSITION_ACTION_PLAN.md` 已放进 tradingSystem 项目目录。

**部署方式**
不涉及。

**真机判收**
未判收（尚未动码）。

**还欠着什么**
方案第十节的四件待拍板：新建仓的档位怎么给、每天最多新开几只、研判闸这一轮做不做、
盘中参考位漂移要不要加防护。拍板之后才动手。

---

## 2026-08-06 · 新建仓动作：四条取舍已拍板，决策系统侧评估完毕，方案定稿

**做了什么**
四条取舍拍板：新建仓单独一个档位参数且默认开启；不设每日开仓上限，由既有上限、资金、
候选池与择时自己收敛；研判闸这一轮双侧一并加上；参考位漂移做首答锁定加偏离即停。
按第三条读了决策系统的相关代码，方案更新为定稿版 V2。代码仍然一行未改。

**读决策系统代码得到的三条事实（以代码为准）**

1. **择时那一侧一个字都不用改。** `app/services/pms_advisor.py` 的 `_advise`
   （163 到 226 行）从头到尾没有读过 `action` 字段，只用 `ts_code`、`side`、`day.price`。
   买入区间由昨夜支撑压力推出，与这笔买单是建新仓还是加老仓无关。
2. **研判那一侧没有任何 action 白名单。** `app/api/main.py:323` 的路由是裸 dict，
   只校验总开关与 `ts_code` 非空；worker 侧 `tasks_intraday.py:512` 是
   `_task_by_action.get(_act, 兜底文案)`。所以今天发 `action="OPEN"` 就能跑通，
   但拿到的是给加仓写的兜底文案，后面还跟着一句「仓位纪律不归你管」。
   要改的是三处，都在 `workers/tasks_intraday.py`：加一条 OPEN 判据、
   OPEN 且现价缺失时显式回 UNAVAILABLE、边界声明按动作分岔。
3. **新票的现价链路最脆弱。** `tasks_intraday.py` 449 到 456 行，新票没有持仓快照，
   `fetch_realtime_close` 读不到分钟线时返回 `0.0` 被 `or None` 变成 None，
   提示词渲染成「当前现价 未知 元」。加仓类还有摊薄成本可依，建仓判断没有现价锚不成立。

**新撞出来的一条硬约束（工程事实，不是取舍）**
`app/scheduler.py:45` 给所有调度任务设了 `task_soft_time_limit=240, task_time_limit=300`，
而 `judge.request` 是同步阻塞、单次超时上限 90 秒。**三只票送研判就是 270 秒，已经超过
软超时。** 今天没出事是因为送研判的只有三只持仓票且多数轮次被去重挡掉。新建仓不节流、
候选池默认取前 30 只，第一跳就会捅穿。
处理办法是给单轮扫描一个研判时间预算 `PMS_JUDGE_TICK_BUDGET_SEC`（默认 150 秒），
用尽就把剩下的候选留到下一分钟的心跳，并写进 skipped 留痕。
**这不是节流：一条候选都没丢，也没有按天计的上限**，只是让一次心跳做得完。

**动了哪些文件**
无代码改动。`NEW_POSITION_ACTION_PLAN.md` 更新为定稿版 V2，已放进项目目录。

**部署方式**
不涉及。定稿版里写明：决策系统这次只改 worker 代码、不动 `.env`，
所以 `git pull` 后 `docker compose restart backend-api worker-brain` 即可；
哪天真要往 `.env` 写值（比如切研判独立队列）才必须 `up -d --force-recreate`。
PMS 侧因为新建仓档位默认就是自动执行，**建议收盘后 `make deploy`**，
让第一次真实建仓发生在次日开盘、人在场的时候。

**真机判收**
未判收（尚未动码）。

**还欠着什么**
等点头后开工。开工顺序：决策系统侧先上并用 `pms_smoke.py` 手工发一条
`action=OPEN` 的研判请求验证，再回来做 PMS 侧。

---

## 2026-08-06 · 新立一条规矩：动 tradingSystem 以外的系统之前先答三题

**做了什么**
用户指出决策系统本来就不管仓位（它是聚合大量信号的判断系统），往里面塞仓位信息
会把别的在跑的逻辑带偏。据此立了一条通用规矩，并按它把方案重过一遍，改成 V3。

**规矩（改 tradingSystem 以外的系统，动手前先答，答不上就别动）**

1. 这段代码是不是只有我这条路径会走到？共用的提示词模板、裁决解析、上下文组装、
   配置项，都不能在里面加只对我有意义的分支。
2. 我加进去的字段，会不会被别的逻辑读到并当真？尤其是写共享表、共享缓存、
   往共享 context 里塞键。
3. 我的东西全删掉，原系统能不能一字不差地回到今天？答不上「能」就不是加法。

**按规矩核过的结论**
决策系统这三处改动全部落在两段 `if direction == "PMS_JUDGE":` 里
（`workers/tasks_intraday.py` 439 到 523 行、632 到 656 行），不碰共用的提示词模板
（557 到 576 行）、共用的裁决解析（579 到 590 行）、资金与板块注入（530 到 555 行）；
不写 `decision_ledger`；不加 settings 字段；留痕仍只有 PMS_PASS / PMS_REJECT /
PMS_UNAVAILABLE 三个值，而 `pms_advisor._today_exit_verdict` 第 102 行明确跳过
`PMS_` 开头的行，所以写进去的东西不会反过来影响择时。第三题答案是能，`git revert` 一次。

**额外补的一条：仓位数字不进研判请求**
`judge.request` 现在把 `hard_numbers` 整个塞进请求体，决策系统会逐键渲染进提示词。
新建仓的 hard_numbers 里有名额、可投金额、批次比例——等于当面请一个不该管仓位的系统
去看仓位。所以在 PMS 侧加键白名单，只对 OPEN 生效：送现价、分数、主题、档位、
预期空间、热度、名次；不送名额、金额、批次、组合占比。账本照旧存全量。

**重过一遍还改掉与补上的（详见方案 V3 第三节）**
改掉两处：去掉 `PMS_OPEN_TARGET_PCT` 与建持仓行（改由首次成交时既有的
`ensure_position` 自然建行，省掉僵尸空行的清理）；去掉 `PMS_OPEN_MIN_SCORE`
（与候选池的 `PMS_PLAN_MIN_SCORE` 重复）。
补上六处，其中三条是真问题：
- **候选之间要滚动扣减**。`_route_one` 每条各自用本轮开始时的旧快照算上限，
  一跳内五条各自不超、加起来超，规则闸拦不住。命令那条路有 `_ctx_after` 滚动，
  自主这条没有。滚动放进 `scan_open`，复用 `planner.check_all_caps`。
- **研判驳回要做当日去重（只对 OPEN）**。`_rejected_today_keys` 只读 arbiter='rule'，
  研判驳回不在里面，`_inflight_keys` 也认不出，于是被驳回的候选每分钟往账本写一行
  一模一样的记录——正是 07-29 那个把判分锚淹掉的教训。
- **可投金额要用真实可用资金封顶**，并加 `PMS_OPEN_REQUIRE_WS_CASH`：
  拿不到资金快照就不自动新建仓。规则闸那条「拿不到资金只告警不拦」是为已排好的命令
  设计的，不适合无人值守地从零建仓。
另外三条：行业名一次批量取本轮复用；研判预算用尽的候选整条跳过不入人工队列；
新建仓退实现B 时择时判据只剩均价一条（已知降级，要在理由里写明）。

**一条顺带发现、但这次不夹带的**
`industry.get_many` 走 `gp_stock_category` 时是逐只查库、没有缓存，
可以照 `_hybk_many` 加按日缓存。收益是每跳少几十次查询，但它改的是既有函数的时序行为，
按上面第 1 题的规矩不夹带，要做单独一条、单独拍板。

**动了哪些文件**
无代码改动。`NEW_POSITION_ACTION_PLAN.md` 更新为 V3。

**部署方式**
不涉及。

**真机判收**
未判收（尚未动码）。

**还欠着什么**
同上，等点头开工。

---

## 2026-08-06 · 研判闸第一次真发请求就全军覆没：请求体里的 Decimal

**这是今天最要紧的一条。**

三条新建仓提议进了人工确认队列，档位明明是 `full`。查提议的研判理由，三条一模一样：

```
研判: UNAVAILABLE | 研判请求失败: TypeError: Object of type Decimal is not JSON serializable
```

**源头**：`proposal_service._recent_ledger()` 把评审账本流水塞进研判请求的 context，
其中 `"price": r.get("price_at")` —— `pms_action_ledger.price_at` 是数据库的 DECIMAL 列，
读出来是 Python 的 `Decimal`，`requests.post(json=payload)` 序列化不了，直接抛 TypeError。

**危害是渐进的，这才是它可怕的地方**：只要一只票在评审账本里有过**任何一行**，
它的研判请求就必然失败；而账本只会越积越多，于是过几天几乎所有票的研判都会失败，
全部降级成人工确认——「不用每天靠人」这件事会一点点失效，
而每一条看起来都只是「研判不可用，降级人工确认」这种系统里天天都有的正常降级，
不会有人觉得不对。今天只撞上三只，是因为多数候选还是第一次出现在账本里。

**为什么今天才暴露**：研判闸此前从没真正接通过——`PMS_JUDGE_API_BASE` 一直是空的，
`judge.available()` 为假就直接返回不可用，请求体根本没构造过。
今天是它第一次真发请求，第一次就撞上。这也意味着接口契约 §6 里 #10 那条判收
（「评审账本出现 arbiter=judge 的行」）今天才第一次真正达成。

**降级逻辑救了场**：错误没有被吞掉——报错、回 UNAVAILABLE、入人工队列、理由写进提议，
一路留痕，所以一条查询就看见了。这正是「拿不到意见绝不当成通过」那条纪律的价值。

**改法**：`judge.jsonable()` 递归把 Decimal 换成 float、日期换成字符串，
在请求出口统一拦一道。修在出口而不是修 `_recent_ledger` 一处，是因为请求体的任何一层
将来都可能再冒出数据库类型——一次拦住比每加一个字段就想一次靠谱。
`_recent_ledger` 那边也顺手把 `price` 转成 float，让日志与留痕里的数字也干净。
择时那条路（`exec_advisor._advice`）目前不受影响：它的 refs 与 position 都取自
`positions_view()`，那里每个数值都过了 `float()`/`round()`；哪天它也报同样的错，
照 `jsonable` 的写法在它的 `_post` 前包一层即可。

单测里放了一条**复现用例**：用 Decimal 与 date 造出当天的现场，转换后必须真的
`json.dumps` 得出来。另加一条源码检查，钉住 `_recent_ledger` 的 float 转换。
全量单测 **ALL SUITES PASS，469 例**。

**顺带一条结构性发现，还没处置**：`300570.SZ` 是今天唯一一只既在候选池、
又被决策系统判过盘中转多的票，它倒在规则闸的 `NO_CHASE_MA5` 上（距 MA5 12.22%，上限 6%）。
而那 67 条转多理由里满是「放量突破」「量比 8.55」「站上关键位」——**按定义就是脱离均线的形态**。
所以「信号只做加速器、其余闸门一道不放宽」这个口径，在结构上就注定收效甚微：
就算把候选池调大、重合率提上去，信号票也会成片倒在不追高那道闸上。
要让信号真正有增量，只能是口径三（信号票在不追高或择时上放宽一档），
而那要碰「不追高、接受买不上」这条纪律，必须先拍板。**本轮不动。**

---

## 2026-08-06 · 新建仓盘中真跑了 40 分钟，第一份实测；跳过原因改成分得开

**这一条是实测记录，不是设计。** 新建仓在今天下午约 14:07 起真跑了四十分钟
（部署时间比原计划提前，是在盘中做的）。

**研判判据生效，方向对。** 评审账本里 11 条 `arbiter=judge、action=OPEN、verdict=REJECT`，
理由**每一条都在谈「开新仓」**——「建仓缺乏资金支持」「开新仓风险过高」「新建仓证据不足」，
没有一条把它当成加仓在谈；而且确实用上了决策系统自动补的实时资金分布（主力净流占比、
主散背离）去对照「把这只票挑出来的驱动到今天还成不成立」。判收第一步通过。

**当日去重立竿见影。** 驳回集中在 14:07 到 14:38 这 31 分钟，扫完一遍候选池之后就静默了。
没有这道去重，这 22 只会每分钟重送一次研判，一个下午上千次大模型调用与上千行账本。
研判速度也对得上预期：31 分钟十几只，正是 150 秒预算下一跳一到两条的节奏。

**今天一股没建，驳回率 100%。** 多条理由提到「宏观空头环境」「市场空头相位」「熊市共振」
「下降通道」，今天本来就是空头，全驳回大概率是合理判断，不是判据写窄。
**但埋了一处要盯**：OPEN 判据末尾那句「这是开新仓不是加仓，没有既有仓位做缓冲，建错了
代价全额承担，标准应当比加仓类更严，不确定就 REJECT」，加上边界声明里的「宁可错杀不可
错放」，**是双重保守**。若接下来两三天在正常或偏多的市场里仍接近全驳回，就是这句话把模型
推过头了，要调轻。一天的数据不足以判断。

**这份实测暴露的可读性问题，已改**：22 条跳过全写着同一句「已有在途提议或指令, 或今日已被
闸门拒过」，把四种完全不同的处境揉成一句——有在途提议、有在途指令、今日被规则闸拒过、
今日被研判闸驳回过。前两种明天照样被挡，后两种日切就重新评估，而它们长得一模一样，
看的人判断不了这只票明天还会不会再被评估。这违反了 `scan` 入口自己立的那条规矩：
「什么都没发生」和「明着跳过了」必须是两回事——现在虽然明着跳过了，却说不清为什么。

改法：`skip` 从集合改成 `{(代码, 动作): 原因}` 字典。`in` 的语义不变，
新增 `action_engine.skip_why()` 取原因，**传集合进来仍然工作**（退回一句兜底话，
既有单测与临时调用不受影响）。三个来源各写各的原因，在途的排在最后覆盖被拒的——
一只票既有在途指令又今天被拒过时，「有在途」更贴近它此刻的状态。
在途那两条还带上提议号、指令号与状态，让人从这一行就能接着往下查。

**动了哪些文件**：`app/core/action_engine.py`（`skip_why` 与两个扫描入口）、
`app/services/proposal_service.py`（三个来源改成带原因的字典）；
测试 `scripts/test_batch12_units.py` 加 3 例，`scripts/test_batch4_units.py` 与
`scripts/test_wiring.py` 各改一处断言（原本钉着那句笼统话的字面量）。
全量单测 **ALL SUITES PASS，467 例**。

**一条要更正的承诺**：先前说过「既有 `scan()` 一个字不动」，这次动了它——为的是让跳过留痕
说得清。`in` 的语义与向后兼容都保住了，但这个承诺本身要如实更正，不该含糊过去。

**我在这一轮犯的一个误判**：账本里 14:38 有两条对没持仓票的 `PMS_REJECT`，我据此推断
「新建仓还没上线，所以不可能，应该是探活脚本」——实际是新建仓已经部署并在跑。
**推理没落地就当结论**，与 07 月那次 `audit_date` 的教训同型。该先查账本再下判断。

---

## 2026-08-06 · 收盘后四条只读诊断，外加一个由它们暴露出来的窗口末日追高

**四条诊断的结论**

1. **买入信号 67 条，落在候选池里的只有 1 只**（`300570.SZ`）。
   `intraday_signals:2026-08-06` 共 73 条，动作分布 BUY 67 / SELL 6。
   说明**决策系统的盘中转多判断与上游选股计划的排序是两套几乎不相干的视角**——
   互补而不是互相验证。所以「必须同时在候选池里」这条口径在 `PMS_PLAN_TOP_N=30`
   的当前参数下，插队排序的增量只有 1/67，基本无效。
   顺带日志说破另一件事：`主榜正好吃满 top=300 (打分池 440)`，上游还有 140 只没拿到。
   两条改法（调大 `PMS_PLAN_TOP_N`，或让只被 top_n 纯排序截断切掉的信号票凭信号捞回来）
   记在下面「还欠着什么」里，等明天有真实留痕再定。

2. **`trading_buy_plan` 最新一行是 07-30，七天没动。** 四个状态全是旧数据 →
   老的建仓链路是死腿，**不存在两套系统同时买的风险**，ENTRY_GATE 关不关都行。

3. **悬项四定案：不是口径差。** 差额 564（08-05）→ 51（08-06 盘中）→ **4.00 元**（收盘后），
   递减到接近零，就是取数时点差加收盘价精度差（4 元对 177,561 的持仓市值是 0.002%）。
   **但「`cash_avail` 里 `+ sell_return_today` 双算」这个假设今天没被验证，只是没被触发**
   ——今天零卖出成交，那个加法加的是 0。真正的验证要等有卖出成交的那天。

4. **悬项八有结论：`trade_no` 是随机串**（`T-SHADOW-420911c4b8`），不是确定式，
   S3 收尾函那个阻断项**不闭环**。后果是幂等：同一笔成交若被重推，随机串识别不出来。
   得确认回放那侧的去重键用的是 `trade_no` 还是单调的 `seq`。

**由诊断暴露出来的问题：窗口末日会强制追高，而自主买入不该这样**

`ws_smoke inbox` 里 `000063.SZ` 那五笔成交是今天 **14:46**（时间戳换算），1700 股，
成交价 34.71 到 34.76。而交接信写的是「现价高于买入区间上沿，**不追**，窗口今天到期」。
判了不追，最后还是买了——走的是 `exec_timing.hard_gate` 买入分支的窗口末日强制完成，
而 `hard_gate` 在 `exec_advisor.decide` 里是**先于**咨询决策系统跑的，
14:45 之后根本不问择时，直接按 现价×1.002 追进去。

那一笔是命令驱动的（`INS_20260804_000063SZ_OPEN_003`），**强制完成是对的**：
用户下过「投这么多」的命令，到期必须完成。但同一段代码等新建仓上线就会作用在自主提议上，
那时它与「可以接受买不上」直接冲突，而且是无人值守。

更要紧的是 `window_verdict` 早就把口径写死了：`"PARTIAL" if is_command else "EXPIRED"`
——**自主类窗口耗尽直接作废**。既然到期就作废，就不该在到期当天先被强制完成一遍。
两处本来是矛盾的，这次是把它们对齐，不是新增策略。

**改法**
- `exec_timing.hard_gate` 新增 `is_command`，**必填、故意不给默认值**——漏传立刻
  `TypeError`，不许静默按某一侧走。买入的窗口末日强制完成只对命令生效，自主返回等待
  并写明「到期作废」。
- **卖出侧一个字没改**，两种来源都照常兜底。这不是漏改：买入的强制完成是「多背一份风险」，
  卖出的是「少背一份风险」；自主减仓若也到期作废，那是把该降的风险留在账上。
  宁可买不上，但不能卖不掉。
- `exec_timing.decide` 与 `exec_advisor.decide` 的 `is_command` 默认 `True`
  （命令口径 = 改动前的行为），`executor.run_tick` 从指令的 `progress.is_command` 显式传下来。
- 影响面说破：**已有的 FILL / ADD / DCA 自主买单也跟着改**——从前也会在末日强制完成，
  现在到期作废。「回踩补足」在末日追高买本身就自相矛盾，所以对它们同样是修正，
  但确实是既有行为的改变。

**动了哪些文件**
`app/core/exec_timing.py`、`app/services/exec_advisor.py`、`app/services/executor.py`；
测试 `scripts/test_batch12_units.py` 加 6 例（含一条「executor 真的把 is_command 传下去了」
的签名与源码检查，防这道分岔形同虚设），`scripts/test_batch11_units.py` 的夹具补一个参数。
全量单测 **ALL SUITES PASS，464 例**。

---

## 2026-08-06 · 决策系统的买入信号一直被 PMS 丢在门口，已接上

**做了什么**
用户问「新建仓能不能并入决策系统原有的信号链路」。查下来先纠正了一个前提，又撞出一个洞。

**先纠正的前提**：决策系统的**建仓侧本来就是轮询**，不是订阅。
`workers/celery_app.py:94-97` 的 `entry-gate-poll` 是 `crontab(minute="*", hour="9-11,13-14")`，
每分钟去 `trading_buy_plan` 捞 `is_active=7`。真正事件驱动的是告警链
（`scripts/intraday_watcher.py` 常驻进程 `xreadgroup` 读上游告警流）与卖出链。
所以「PMS 只能轮询不合理」对既有架构不成立——候选池是日频静态清单，没有事件可订阅，
轮询是它唯一的读法，而且与决策系统自己的建仓入口同构。

**撞出来的洞（与 08-05 那次同型）**：决策系统盘中判出 `REVERSAL_BUY` 会往 db2 的
`intraday_signals:{日期}` 广播（`workers/tasks_intraday.py:783-785`），**PMS 一直订阅得到**
（`signal_service.streams()` 第一条就是它，每分钟拉一批），但走到
`signal_rules.digest`（第 106 到 108 行）被归进 `ACT_RECORD`，而
`signal_service._handle` 的 RECORD 分支**只给持仓票写账本**。
于是「决策系统今天看多了某只没持仓的票」——**正是新建仓关心的那一批**——PMS 收到了、
计了个数，然后一个字都不留：账本查不到、页面看不见，事后复盘问「那天系统看见了吗」答不上来。
当年那句注释的理由（「买什么买多少由动作引擎决定」）在动作引擎没有新建仓动作时成立，
现在失效了。

**改法（口径：信号只做加速器，不改资格）**
- `signal_rules.py`：新增 `ACT_NOTE_BUY`。BUY 信号**仍然不产生任何买入动作**，
  但持没持仓都要留痕。HOLD 等其余类型口径一个字未动。
- `signal_service.py`：新增 `ACT_NOTE_BUY` 分支，落 `action=SIGNAL_BUY`、`verdict=NOTE`
  的账本行，按（日期，来源，股票，动作）当日去重。用 `NOTE` 不用 `PASS`——
  「记下来」和「放行」在账本里必须分得开。
- `pms_repo.buy_signals_today`：只读，今天有转多留痕的票。
- `proposal_service._scan_open` → `action_engine.scan_open`：有信号的候选**排最前**。
  **只影响先后，不影响资格**——不在候选池里的票不会因为有信号就被建仓，
  候选层那一整套过滤（ST、黑名单、分数下限、主题限额、预期空间）一道都不绕。
- `judge.OPEN_JUDGE_KEYS` 加两个键，把「你自己今天判过这只票转多」送回决策系统，
  让它拿自己的结论对照一次。这是定性材料不是仓位数字，符合那条白名单的立意。
- 新参数 `PMS_OPEN_SIGNAL_PRIORITY`（默认开），关掉即退回纯分数排序。

**为什么走账本传递而不是让 signal_service 直接调建仓**
两边各管各的一件事：信号消化管「收到了、记下来」，动作引擎管「买不买、买多少」，
中间靠账本这个既有事实源接。不新增跨模块调用、不复制闸门逻辑。
代价是最多差一分钟（两个调度位各自每分钟一跳），而后面还要等择时区间，这点延迟无所谓。

**插队为什么是实打实的增量**：候选按分数降序取，名额只剩两个时第 25 名永远轮不上，
哪怕决策系统刚刚判它转多。分数是昨夜算的静态排名，「此刻转多」是盘中才有的新信息，
两者不同量纲，折算成分数得凭空定系数；插队直接表达了这件事。

**动了哪些文件**
`app/core/signal_rules.py`、`app/services/signal_service.py`、`app/repo/pms_repo.py`、
`app/services/proposal_service.py`、`app/core/action_engine.py`、`app/services/judge.py`、
`config/settings.py`、`app/services/param_store.py`；测试 `scripts/test_batch12_units.py`
加 7 例，`scripts/test_batch5_units.py` 与 `scripts/test_wiring.py` 各改一例
（那两例原本钉着 BUY→`ACT_RECORD` 的旧行为，「不买」这条口径没变，变的是留痕范围）。
全量单测 **ALL SUITES PASS，458 例**。

**还欠着什么**
1. `trading_buy_plan` 四个状态都有行，说明老的建仓链路还活着。**关掉 ENTRY_GATE 之前
   必须先确认那些行是不是今天的**——若老链路今天仍在挂单，而 PMS 同时开始建仓，
   就是两套系统都在买。查法见交接说明。
2. 关 ENTRY_GATE 是往决策系统 `.env` 写 `ENTRY_GATE_ENABLED=False`，
   **这是新增 env 值，必须 `docker compose up -d --force-recreate`，`restart` 不重读 env。**
3. 口径三（信号票在择时上放宽一档）本轮没做，要碰「不追高、接受买不上」那条纪律，
   等积累一段实证再谈。

---

## 2026-08-06 · 新建仓动作：两侧代码写完，容器内全量单测通过，待实机部署

**做了什么**
按 V3 方案把两侧代码写完了。容器里拼了一份可运行的副本跑全量单测：**ALL SUITES PASS，
451 例**（新增的第十二批 26 例 + 既有 425 例，含装配自检 58 例）。**尚未在实机部署。**

**动了哪些文件**

决策系统（`bionic_trader`）一个文件，48 增 4 删，三处全在 `if direction == "PMS_JUDGE":` 块内：
- `workers/tasks_intraday.py`：`_task_by_action` 加 OPEN 建仓判据（含必答「把这只票挑出来的
  驱动到今天还成不成立」）；OPEN 且现价缺失时块内早退回 `UNAVAILABLE`（加仓类有摊薄成本可依，
  建仓没有现价锚就是盲判）；边界声明按动作分岔（原句里的「配额」对新建仓不成立）。

持仓系统（`tradingSystem`）九个文件：
- `app/core/action_engine.py`：加 `A_OPEN` 并进 `JUDGE_ACTIONS`；新增 `eval_open` 与
  `scan_open`。**既有 `scan()` 一个字未动。**
- `app/services/proposal_service.py`：新增 `_scan_open`（候选池取数、批量取行业、
  真实资金封顶）；`_route_one` 加新建仓特判与档位分支；研判时间预算。
- `app/services/judge.py`：`OPEN_JUDGE_KEYS` 键白名单，新建仓只送定性材料。
- `app/services/exec_advisor.py`：存 `observed` 的支撑压力区间；`_check_ref_drift` 首答锁定、
  偏离即停。
- `app/services/executor.py`：等待分支看到 `ref_drift` 落一条账本 WARN，**一天一条**。
- `app/repo/pms_repo.py`：新增只读 `judge_rejected_today` 与 `opened_names_today`。
- `app/services/param_store.py`：四个新参数的说明、校验、`FAIL_CLOSED`。
- `config/settings.py`：四个新参数；`PMS_JUDGE_ACTIONS` 默认值加 `OPEN`。
- `scripts/test_batch12_units.py`（新，26 例）与 `scripts/run_tests.py`（登记新批次）。

**写代码过程中改掉的一条设计（单测逼出来的）**
原方案的取数口径抄的是命令驱动那条路：`want = min(单股目标, 剩余额度)`。单测发现它会在
可投金额只剩一万时开出一只 0.5% 的零头仓位——**拿一个持仓名额换一个永远补不到目标的半截仓，
还挡住了后面真正建得起来的票**。改成「钱不够一整只就不开」，并把与命令那条路的差别写在注释里：
命令是用户明确下了「投这么多」，最后一只缩水是命令的收尾；自主建仓没有这层意思。

**部署方式**
- 决策系统：不加配置、不动 `.env` → `git pull` 后 `docker compose restart backend-api worker-brain`。
- 持仓系统：源码打进镜像 → `make deploy`，跑完 `make test` 要见 ALL SUITES PASS。
  **新建仓档位默认就是 full（自动执行），`make deploy` 一跑完下一个整分钟的心跳就可能真建仓，
  所以务必收盘后部署**，让第一次真实运行发生在次日开盘、人在场的时候。

**真机判收**
未判收。判收步骤见 `NEW_POSITION_ACTION_PLAN.md` 第十节，顺序是决策系统先上并手工发一条
`action=OPEN` 的研判请求，确认理由是在谈从零建仓而不是加仓、且请求体里没有仓位数字。

**还欠着什么**
1. 实机部署与八步判收。
2. 第一周要盯的四个数：每天有几只候选落在买入区间内、研判驳回率、`intraday_exec` 一跳的
   耗时分布、评审账本每天的行数。
3. 未夹带的一条：给 `industry.get_many` 的 `gp_stock_category` 分支加按日缓存
   （每跳省几十次库查询，但改的是既有函数的时序行为，单独提、单独拍板）。
4. 文档补账仍欠着：`WS_INTEGRATION_STATUS.md` 停在 07-30；`BIONIC_PMS_INTERFACE.md`
   要补新建仓这一路（研判闸第五类动作、硬数字裁剪、择时侧零改动）。

---

## 2026-08-07 · 股票评述里冒出「【手动重跑AKG_PLAN】」：运维口径的话被当成风控质询

**做了什么**

页面上一些股票的评述里出现了「【手动重跑AKG_PLAN】」这种谁也看不懂的字段。第一反应会怀疑
是前一天新建仓那批改动，但**不是**：三个仓库全文检索「手动重跑」只有一个出处，
`bionic_trader/scripts/rerun_pool.py` 第 237–238 行。链条如下（全部是源码，不是推测）：

1. `rerun_pool.py` 下发 `analyze_strategic_plan.delay(c, reason, None, target_date)`。
   **第二个位置参数不是留痕字段，是 `reviewer_feedback`** —— 风控官质询通道。
2. `tasks_brain.IntradayClassifier.classify()` 第一道判断是 `if "【盘中" not in text: return None`，
   「【手动重跑】」不带这个前缀，返回 None。
3. 于是掉进 `elif reviewer_feedback:` 分支，被拼进提示词的
   `[📢 CRITICAL CHALLENGE FROM RISK OFFICER]` 段，后面跟着
   `You MUST address this in your analysis`。
4. 那是一句命令。大脑照办，把这句运维口径的话写进了 `analysis`。
5. `generate_chinese_report` → `save_to_database` → 覆盖进
   `strategy_daily_results.analysis_summary`，也就是页面上的评述。

**这条真正的教训不是那个字段，是它顺带证实的悬项二。** `rerun_pool` 落库是
`INSERT ... ON DUPLICATE KEY UPDATE`，把 `signal_type / support_level / pressure_level /
analysis_summary` 一起就地覆盖。查实的落库时刻（库时钟是 UTC，下面已换算成北京时间）：

| trade_date | 行数 | 落库时刻（北京，已换算） | 当时是什么时候 |
|---|---|---|---|
| 20260806 | 16 | 08-06 22:32 → 22:45 | 收盘后，安全 |
| 20260805 | 18 | 08-05 22:30 → **08-06 11:04** | **盘中**，改写的是当时正被读的昨夜结论 |
| 20260804 | 11 | **08-05 13:04** → 08-07 00:50 | **盘中**，补录历史日 |

08-06 早盘 11:04 与 08-05 午盘 13:04 各有一次盘中重跑。而这张表下游有两个人在读：
bionic 的 `pms_advisor`（支撑压力位算 PMS 买卖区间、`signal_type` 走 `BAD_Y_SIGNALS` 闸）、
桥 `pool.py` 的 `BAD_SIGNALS`（决定把哪只票请出候选池）。所以「盘中不跑 push-pool」这条纪律
不只针对桥，`rerun_pool` 同样在内 —— 这次是拿实测把它钉死了，不再是推测。

顺带记一条待查的：`SZ000800` 现在有两行相互打架的定性，`trade_date=20260806` 是 `SELL`
（08-06 22:38 写入），`trade_date=20260804` 是 `DROPPED`（08-07 00:50 写入，晚了两个多小时）。
补录历史日会写出一行「时间上更新、日期上更旧」的结论，取数方按 `trade_date` 排还是按
`updated_at` 排会拿到不同答案。两边取数口径要对一遍，这次没做。

**改法（两处，缺一不可）**

- **根治** —— `rerun_pool.py` 下发时第二个位置参数固定传 `None`；`--reason` 保留，但改成
  只印在脚本自己的输出里给人看。
- **兜底** —— `tasks_brain.py` 加 `_is_ops_note()` 前缀护栏，认
  `【手动重跑】/【运维】/【回补】`，命中就先打一行日志再把 `reviewer_feedback` 置空。
  为什么两处都要：`rerun_pool` 的用法示例第 2 条明写着鼓励人手填
  `--reason "调整X因子后重跑"`，只堵住默认值，下次手填照样中招。

护栏落点选在 `analyze_strategic_plan` 打完那行「🧠 Brain 正在介入」日志之后、
`IntradayClassifier.classify` 之前。**先打日志再丢弃**是有意的：「谁在什么时候用什么理由
重跑了这只票」这条线索要留在日志里，它只是不该进大模型的证据。拦在源头一次就够，因为
下游用到 `reviewer_feedback` 的只有 classify 和风控官质询段两处。

**已经被污染的评述救不回来**：就地覆盖，没有历史表。要洗只能改完代码后按同一个池重跑一遍
让它再覆盖一次，而那又是一次全量重算、支撑压力位会再动 —— 只能收盘后做，别在盘中洗。

**动了哪些文件**（都在 bionic_trader，PMS 侧一个字没动）
- `scripts/rerun_pool.py` —— 下发传 `None`；`--reason` 改为本地留痕；文件头补两段说明
  （「什么时候不能跑」「为什么 --reason 不再下发给大脑」）。
- `workers/tasks_brain.py` —— 新增 `_OPS_NOTE_PREFIXES` 与 `_is_ops_note()`；
  `analyze_strategic_plan` 里加四行护栏。既有两条路径（盘中事件、常规风控意见）零改动。
- `scripts/selftest_ops_note_guard.py`（新增，24 项）—— 不 import `tasks_brain`
  （它拖着 milvus/llm），靠 AST 把 `_is_ops_note` 从源码里抠出来单独执行做真实断言，
  再用 AST 校验护栏落点与 `delay` 的第二个实参。做过三次变异验证，确认它真的会失败：
  说明文字塞回去、护栏只打日志不置空、前缀写宽一个字，三次都被抓住。

**部署方式**
决策系统那台：`git pull` 后 `docker compose restart worker-brain`。没有新增 env，
不需要 `--force-recreate`。**只重启 worker-brain 就够**，`backend-api` 没动。

**真机判收**
未判收。判收两步：① 容器里跑一次
`docker exec trader_worker_brain python scripts/selftest_ops_note_guard.py` 见「全部通过」；
② 收盘后拿任意一只票跑一次 `rerun_pool.py --group AKG_PLAN --limit 1 --reason "护栏验证"`，
确认 worker 日志里有「🛡️ 运维口径说明已从提示词中剔除」这一行，且该票新落库的
`analysis_summary` 里不含「手动重跑」。

**还欠着什么**
1. 上面两步真机判收。
2. `SZ000800` 那种「补录历史日写出时间更新、日期更旧的行」，取数方按 `trade_date` 还是
   按 `updated_at` 排要对一遍口径（`pms_advisor` 与桥 `pool.py` 两处）。
3. 已污染的三批评述（16/18/11 行）要不要收盘后洗一遍，没定。
4. `rerun_pool.py` 里 `--dry-run` 的中文说明写的是「干跑」，项目里同类东西叫「试算」，
   措辞不统一。这次没改，因为跟本条改动无关，单独提。

---

<!--
下一条节点从这里往下写，格式照抄上面：
## YYYY-MM-DD · 一句话标题
**做了什么** / **动了哪些文件** / **部署方式** / **真机判收** / **还欠着什么**
-->
