tradingSystem/NEW_POSITION_ACTION_PLAN.md

388 lines
24 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.

# 方案(定稿):给动作引擎加「新建仓」动作
> V32026-08-06。四条取舍已拍板决策系统侧已评估按「不影响原有业务系统」这条原则
> 重新过了一遍,改掉与补上的地方见第三节。**待你点头即可动手。**
> 全部依据来自实际代码,不引用设计文档的结论。
> 部署PMS 源码打进镜像,改码必须 `make deploy`,跑完 `make test` 见 ALL SUITES PASS
> 决策系统挂载卷,`git pull` 后 `docker compose restart backend-api worker-brain` 即可。
---
## 〇、拍板结果
| # | 问题 | 决定 |
|---|---|---|
| 1 | 档位 | **单独一个开关,默认开启**。新建仓不跟随全局档位,自己一个参数,默认自动执行 |
| 2 | 每日节流 | **不做**。逻辑进逻辑出,由既有的上限、资金、候选池与择时自己收敛 |
| 3 | 研判闸 | **这一轮一并加上**,双侧改到位 |
| 4 | 漂移防护 | **做**,首答锁定加偏离即停 |
---
## 一、动别的系统之前先问的三件事(新立的规矩)
决策系统本来就不管仓位——它是一套聚合了大量信号的判断系统,仓位是 PMS 的活。
往它里面塞仓位信息,最容易出的事不是这次的功能不好用,而是**把别的、已经在跑的逻辑
带偏**。所以凡是改 tradingSystem 以外的系统,动手前先答这三题,答不上就先别动:
1. **这段代码是不是只有我这条路径会走到?** 如果是共用的(提示词模板、裁决解析、
上下文组装、配置项),就不能在里面加只对我有意义的分支。
2. **我加进去的字段,会不会被别的逻辑读到并当真?** 尤其是写共享表、写共享缓存、
往共享 context 里塞键。
3. **我的东西全删掉,原系统能不能一字不差地回到今天?** 答不上「能」,就说明它不是加法。
按这三题重新核过这次的决策系统改动,见下一节。
---
## 二、决策系统侧:三处改动全部落在 PMS_JUDGE 块内
### 结论先说
`workers/tasks_intraday.py``process_intraday_audit` 是一个按 `direction` 分流的大函数。
PMS 相关的代码本来就已经被圈在两段 `if direction == "PMS_JUDGE":` 里面
(第 439 到 523 行的入口与素材段,第 632 到 656 行的收口与出口段)。
**我要改的三处,全部在这两段之内,一行都不碰共用代码。**
| 改什么 | 在哪 | 是不是共用 |
|---|---|---|
| 加一条 OPEN 建仓判据 | 第 495 到 511 行的 `_task_by_action` 字典 | **不是**。这个字典整个定义在 PMS_JUDGE 块内,别的 direction 看不见它 |
| OPEN 且现价缺失时回不可用 | 第 449 到 456 行现价链路之后,块内早退 | **不是**。早退返回的结构与既有 UNAVAILABLE 出口同形 |
| 边界声明按动作分岔 | 第 513 到 514 行的 `core_task +=` | **不是**。`core_task` 只在 PMS_JUDGE 块内被赋值 |
不碰的东西,逐条点名:第 557 到 576 行那个所有 direction 共用的提示词模板不动;
第 579 到 590 行共用的裁决解析不动;第 530 到 555 行的资金分布与板块动量注入不动
PMS_JUDGE 本来就在它们的白名单里);`decision_ledger` 一个字不写
(每晚判分是全表扫描,写进去会污染既有功能,这条第一版就核实过);
`config/settings.py` 不加任何字段。
留痕仍然只写 `strategy_audit_log`verdict 列仍然只有 `PMS_PASS` / `PMS_REJECT` /
`PMS_UNAVAILABLE` 三个值——不新增第四种。而 `pms_advisor._today_exit_verdict`
读这张表时明确跳过 `PMS_` 开头的行(第 102 行),所以我们写进去的东西不会反过来
影响择时应答。
**「全删掉能不能回到今天」这一题的答案是能**:把 `_task_by_action` 里那一项删掉、
把两段早退和分岔删掉,代码就回到今天的样子,`git revert` 一次搞定。
### 送过去的东西也要干净:仓位数字不进研判请求
比改哪几行更要紧的是**别把仓位概念送过去**。
`judge.request`PMS 侧 `app/services/judge.py` 第 65 到 72 行)现在是把
`candidate["hard_numbers"]` 整个塞进请求体,而决策系统会把它逐键渲染成
「【硬数字 (PMS 已算好, 勿重算)】」贴进提示词。如果新建仓的 `hard_numbers` 里装着
名额、可投金额、批次比例、组合占比这些东西,等于**当面请一个不该管仓位的系统去看仓位**——
哪怕提示词后面跟着一句「仓位纪律不归你管」,模型该被带偏还是会被带偏。
所以加一条 PMS 侧的过滤:**新建仓送研判时,只送定性材料,不送仓位数字。**
- 送:现价、候选池给的分数、主题、档位、预期空间、热度、榜内名次。
这些回答的是「这只票凭什么被选出来」,正是要它裁的问题。
- 不送:名额、可投金额、批次数量与比例、组合占比、总规模。
这些回答的是「买多少」,是 PMS 自己的活。
实现上在 `judge.py` 里加一个显式的键白名单常量,只对 `OPEN` 生效,
并把「为什么」写在常量旁边。账本那边不受影响——`pms_action_ledger.hard_numbers_json`
照旧存全量,判分锚一个字不少。
### 必答清单放在决策系统那一侧
DCA 那条「下跌是杀逻辑还是杀情绪」现在两边都写了一份PMS 的 `must_answer` 传一次,
决策系统的判据里又写一次)。新建仓不照抄这个做法:**必答只写在决策系统的 OPEN 判据里,
PMS 侧的 `must_answer` 对 OPEN 留空。** 道理是分工——「建仓该问什么」属于仲裁哲学,
是决策系统的知识PMS 不需要懂。
### 部署方式
只改 worker 代码,不加配置、不动 `.env`。`config/settings.py` 第 208 到 226 行那段
自己写着「全部有默认值,不新增必填 env → 重启 backend-api + worker-brain 即生效」。
所以走常规路径:`git pull` 之后 `docker compose restart backend-api worker-brain`
**哪天真要往 `.env` 里写值(比如切研判独立队列),那才必须
`docker compose up -d --force-recreate`。**
---
## 三、重新过一遍之后改掉与补上的V2 → V3
### 改掉的两处
**1. 去掉 `PMS_OPEN_TARGET_PCT`,落指令时不再建持仓行。**
V2 说落指令前先 `ensure_position` 建一行再写 `target_pct`。重想之后这条不划算:
`target_pct` 只有在它与 `PMS_STOCK_TARGET_DEFAULT` 不同时才有意义,而那种差异化
本来就该由建仓命令去表达;为它一个参数,要引入建行、写列、以及**窗口耗尽作废后清理
僵尸空持仓行**三处新逻辑。
改成:自主新建仓一律用 `PMS_STOCK_TARGET_DEFAULT`,与后续的回踩补足、盈利加仓天然同口径,
落指令时**不建行**。持仓行由首次成交时 `ledger_service._apply_action` 里既有的
`ensure_position` 自然建出来——这条路径命令驱动建仓已经走通并判收过。
少一个参数、少三处逻辑、不产生僵尸行。
**2. 去掉 `PMS_OPEN_MIN_SCORE`。**
它和候选池已有的 `PMS_PLAN_MIN_SCORE` 是同一件事。两个旋钮管一件事,
将来一定会有一次调错地方。
### 补上的六处
**3. 候选之间要滚动扣减,否则一跳内几条加起来会超上限。**
这是个真问题。`_route_one` 里每条候选各自调 `caps_ctx(view, ...)`,而 `view`
本轮开始时取的快照,**不会因为前面几条已经落了指令而更新**。后果是一次心跳产出五条新建仓,
每条单独看都不超上限,五条加起来超了,而规则闸拦不住——它每次看到的都是同一个旧快照。
命令那条路没有这个问题,因为 `plan_increase_exposure``_ctx_after` 在循环里滚动更新。
自主这条路要照做:**滚动放在 `scan_open` 里**,边遍历边扣名额、扣可投金额、累加行业市值,
并直接复用 `planner.check_all_caps`(规则闸用的就是它,口径天然一致)。
`rule_gate` 已经在 `from app.core.planner import check_all_caps`core 层内部互相引用是既有模式。
**4. 候选数的上界是剩余名额,不是候选池大小——这修正了 V2 对研判负载的估计。**
因为名额和金额在 `scan_open` 里边走边扣,产出的候选数**天生不会超过剩余名额**。
组合已建满就一条都不产、一行账本都不写;还剩三个名额就最多三条。
冷启动那天是上界:名额 20 只,但组合上限 70% × 200 万 = 140 万,单只 6% = 12 万,
**金额先约束到 11 只左右**。11 只 × 最多 90 秒研判 ≈ 990 秒,靠第五节的时间预算
摊到七八跳,也就是七八分钟做完。之后每天只会有个位数。
**5. 研判驳回要做当日去重,只对新建仓生效。**
`_rejected_today_keys` 只读 `arbiter='rule'` 的拒绝,**研判驳回不在里面**
`_inflight_keys` 只认在途提议与在途指令,被研判驳回的候选两样都不产生。
于是一条被研判驳回的新建仓候选会**每分钟重来一次**:决策系统那侧有半小时缓存,
所以不烧大模型,但 PMS 这边每分钟往 `pms_action_ledger` 写一行一模一样的驳回记录。
半小时三十行,几只票就是几百行——**这正是代码注释里记着的 2026-07-29 那个教训**
(十六只深亏票的补仓候选每分钟被拒一次,一天几千行,把判分锚淹了)。
处理:新增一个只读函数 `judge_rejected_today`(读 `arbiter='judge'` 的当日驳回),
组装跳过集合时**只把其中动作为 `OPEN` 的键并进去**。既有四类动作的行为一个字不变——
它们「研判结论会变、有意不做当日去重」那条设计保持原样。
新建仓不一样:「这只票今天不该从零建仓」这个结论当天基本不会翻转。
**6. 可投金额要用真实可用资金封顶。**
V2 只按仓位口径算可投金额(`总仓上限 × 总规模 组合市值`)。但 2026-07-30 那次教训
就是这么来的:总规模两百万而账户实际只有九十八万,方案一路排出来,规则闸一路放行,
要等下游回资金不足才被拒,而拒了不自动重发。
所以 `scan_open` 的可投金额要在仓位口径与 `cash_avail` 之间取小,
并且新增 `PMS_OPEN_REQUIRE_WS_CASH`(默认开):**拿不到真实资金快照时不自动新建仓,
写进 skipped 留痕**。这不是节流,是和「必须实时价」同一类的输入质量闸——
建新仓是可以等的,而规则闸那条「拿不到资金只告警不拦」的降级口径,
是为已经排好的命令设计的,不适合无人值守地从零建仓。
**7. 行业名一次批量取,本轮复用。**
滚动扣减要算行业集中度,所以得先有行业名。`industry.get_many` 走
`gp_stock_category` 时是**逐只查库**(第 231 到 237 行),没有缓存。
所以在 `proposal_service` 里对候选代码**一次性取一遍**,塞进候选,
`scan_open``_route_one` 都用这一份,不重复查。
顺带提一条**建议但需要你单独拍板的**:给 `industry.get_many``gp_stock_category`
分支加按日缓存,与 `_hybk_many` 同一口径(行业归属本来就是日频的)。
收益是每跳少几十次库查询。但它改的是既有函数的时序行为,按第一节的规矩,
**这次不夹带,要做就单独一条**
**8. 研判预算用尽的候选整条跳过,不入人工队列。**
预算用尽时,已经过了规则闸但还没送研判的候选,处理方式是**整条跳过并写 skipped**
下一跳重来。不把它们当成「研判不可用」入人工确认队列——那会在自动档位下凭空造出
一个人工队列,与这次要解决的问题正好相反。规则闸白跑一次的成本是毫秒级。
---
## 四、选票规则:动作引擎只管名额、钱、批次
候选池那边已经筛过(分数降序、档位白名单、分数与来源数与预期空间下限、同主题限额、
剔除 ST、剔除已持仓、剔除黑名单。规则闸后面还会管一手整百、冻结、全局暂停买入、
黑名单、昨夜定性、涨停不追、当日涨幅不追高、距 MA5 不追高、组合与单股与持仓数与
行业集中度上限、预留现金、真实可用资金。择时再管现价在不在买入区间。
`action_engine.py` 第 17 行的注释把边界钉死了——上限、一手、冻结不在动作引擎重复判。
所以新建仓求值器只回答三个问题,且**边算边扣**
1. **还能开几只**`最大持仓数 当前持仓数`,每产出一条减一。
2. **还有多少钱可投**`min(总仓上限 × 总规模 组合市值, 真实可用资金)`
每产出一条减去该条的实际金额。
3. **这一批买多少股**:按 `PMS_BATCH_SPLIT` 拆批,**只提底仓那一批**(默认 50%
`lot_qty(金额, 现价)`,不足一手就不提。
**只提底仓批**是刻意的:后续的回踩补足与盈利加仓,等持仓真建立起来之后由已有的
`eval_fill``eval_add` 自然接管。这条路上一条 `GATED` 方案都不产生。
**价格必须是实时价。** `market.plan_price` 拿不到实时价会回落昨收,
它自己的注释写着「拿昨收当现价去做不追高这类判断会出错,只给规划期定量用」。
自主建仓要真下单,所以走 `market.get_price`,取不到就整只跳过并写进 skipped。
**不节流的自然收敛点有两个**:一是名额与金额在求值器里边走边扣,候选数天生有界;
二是择时的买入区间——只有现价正好落在 `[支撑×0.99, 支撑+(压力−支撑)×0.3]` 里的票
才会真出手,高于上沿一律「不追,接受买不上」。每天实际建成几只,取决于当天有几只
落在自己的区间内。这正是「让系统自己判断」,第一周主要盯的就是这个数。
**一条已知的降级**新建仓在退实现B时`day.support` 是空的(新票没有参考位取数),
内置择时的「回踩带」判据用不上,只剩「现价不高于当日均价」一条,比已有持仓弱。
这是可接受的降级,但要在决策理由里写明白,别让人以为两条判据都过了。
---
## 五、研判的时间预算(不是节流)
`app/scheduler.py` 第 45 行给所有调度任务设了软超时 240 秒、硬超时 300 秒,
`judge.request` 是同步阻塞、单次超时上限 90 秒。**三只票送研判就是 270 秒,
已经顶破软超时。** 今天没出事,是因为送研判的只有三只持仓票且多数轮次被去重挡掉。
冷启动那天新建仓会有十来条,第一跳就会捅穿——任务被中途杀掉,
而且是在已经落了一部分表之后。
新增 `PMS_JUDGE_TICK_BUDGET_SEC`(默认 150 秒)。每次要调研判前先看这一轮花了多久,
如果「已花 + 单次超时上限」会顶破预算,本轮不再送后面的候选,写进 skipped
理由写「本轮研判时间预算用尽,下一跳继续」。
**这不是节流**:一条候选都没丢,没有任何按天计的上限,只是把一次心跳做不完的活
挪到下一分钟。候选按分数降序排,预算用尽时先做完的一定是分数最高的那些。
决策系统那侧的裁决缓存是按「日期 + 股票 + 动作」存半小时的,
所以同一批候选在半小时内的重复扫描都是秒回,真正慢的只有缓存过期后的第一跳。
决策系统那侧还预埋了研判独立队列(接口文档 §5.2)。现在不用开——
那是给 `brain_queue` 拥堵准备的,而我们的瓶颈是 PMS 自己的心跳时长,不是对端排队。
真出现频繁超时再开,而且开它要改 `.env`,必须 `force-recreate`
---
## 六、要动的文件与改法
### PMS 侧(`factor@factorevaluation`,改完 `make deploy`
1. **`app/core/action_engine.py`**
`A_OPEN = "OPEN"`,加进 `JUDGE_ACTIONS`;新增 `eval_open()``scan_open()`
(含名额、金额、行业的滚动扣减,复用 `planner.check_all_caps`
**既有 `scan()` 一个字不动**,既有单测继续全绿。
2. **`app/services/proposal_service.py`**
- `scan_and_route` 追加:取候选池、批量取行业、算名额与可投金额、调 `scan_open`
两批候选合并走同一个 `_route_one`
- 候选池取数失败(`PlanFeedError`)时不产生新建仓候选,原因写进 skipped
已有持仓的四类动作照常扫描。
- 跳过集合并入 `judge_rejected_today` 里动作为 `OPEN` 的键。
- 研判时间预算:记本轮起始时刻,送研判前判预算,用尽则跳过并留痕。
- `_route_one``OPEN` 两处特判:现价取候选自带的(`_pos_of` 对新票没有 `price`
取它会是 0规则闸直接 `PRICE_MISSING`);组合上下文改成
`caps_ctx(view, ts_code=code, is_new_name=True, sector=<批量取到的行业>)`
——**必须显式传行业**,否则行业集中度那道硬拦截会静默跳过。
- 档位分流加 OPEN 分支(见第七节)。
3. **`app/services/judge.py`**
加 OPEN 专用的 `hard_numbers` 键白名单,只送定性材料;`must_answer` 对 OPEN 留空。
4. **`app/services/exec_advisor.py`**
`_advice` 把应答 `observed` 里的支撑、压力、买入区间存进缓存;当日首答锁进
`prog["ref_lock"]`;之后每答比对,偏离超过 `PMS_OPEN_REF_DRIFT_MAX` 就把本轮判定
改成等待,返回的决策里带 `ref_drift` 说明。**只对 `action == "OPEN"` 生效。**
5. **`app/services/executor.py`**
`run_tick` 的等待分支里,看到 `d.get("ref_drift")` 就额外落一条评审账本 `WARN`
放这里而不放 `exec_advisor`,是为了不给后者引入落表副作用。
6. **`app/repo/pms_repo.py`**
新增只读函数 `judge_rejected_today`;新增只读的当日新开票数统计(供页面显示,不做上限)。
7. **`app/services/param_store.py``config/settings.py`**
新增四个参数(第八节),同步补 `DESC` 中文说明、`_range_check` 或 `_RANGES` 校验、
以及 `FAIL_CLOSED`。`param_store.py` 第 46 行那条注释记着一次教训——
加参数不同步加白名单,`set_param` 只回 `ok=False` 不抛异常,调用方又把返回值丢了,
那条纪律就静默失效。这次一并照做。
另把 `PMS_JUDGE_ACTIONS` 默认值改成 `FILL,ADD,DCA,SWITCH,OPEN`
8. **测试脚本**(纯逻辑,不连库)
名额已满、组合钱不够、真实资金不足、拿不到资金快照、买不足一手、取不到实时价、
候选池取数失败、**多条候选的滚动扣减**、研判预算用尽、参考位漂移超阈值、
档位分流的三种取值、研判驳回当日去重。
### 决策系统侧(`192.168.16.188``git pull` + `restart backend-api worker-brain`
9. **`workers/tasks_intraday.py`**`_task_by_action` 加 OPEN 判据(含必答);
OPEN 且现价缺失时块内早退回 `UNAVAILABLE`;边界声明按动作分岔。
**三处全在 `if direction == "PMS_JUDGE":` 块内,不动共用代码,不动 `.env`。**
---
## 七、档位:一个开关,默认开启
新增 `PMS_OPEN_AUTONOMY`,取值 `full` / `propose_only` / `off`**默认 `full`**。
它同时充当总开关,不再单设第二个:`off` 就是不扫描新建仓。
`_route_one` 第 132 行那行不动,在它之外给 OPEN 加一个分支:
`full` 过完两道闸直接落指令;`propose_only` 落待确认队列;`off` 根本不扫描。
三条无条件优先的规矩不变:减持方向永远自动执行;深档补仓永远要人点头;
**研判不可用时一律入队待确认**——最后这条对新建仓尤其重要,
它是「拿不到意见绝不当成通过」这条纪律在新链路上的落点。
**部署时机提醒**:默认 `full` 意味着 `make deploy` 一跑完,
下一个整分钟的心跳就可能真的建仓。**建议收盘后部署**
让第一次真实运行发生在次日开盘、你在场的时候。
---
## 八、新增参数(四个)
| 键 | 默认 | 说明 |
|---|---|---|
| `PMS_OPEN_AUTONOMY` | `full` | 新建仓档位兼总开关full 自动执行 / propose_only 待确认 / off 不扫描 |
| `PMS_OPEN_REQUIRE_WS_CASH` | `True` | 拿不到真实资金快照时不自动新建仓(写 skipped 留痕) |
| `PMS_OPEN_REF_DRIFT_MAX` | `0.03` | 参考位盘中被改写的容忍幅度,超过则该票当日暂停新建仓 |
| `PMS_JUDGE_TICK_BUDGET_SEC` | `150` | 单轮提议扫描用于研判的时间预算(秒),用尽则剩下的候选留到下一跳 |
改默认值的既有参数:`PMS_JUDGE_ACTIONS` 从 `FILL,ADD,DCA,SWITCH` 改成
`FILL,ADD,DCA,SWITCH,OPEN`
`FAIL_CLOSED` 加一条 `PMS_OPEN_AUTONOMY: "off"`——参数表读不到时不自动建仓。
这与「默认开启」不冲突:默认值管正常情况,`FAIL_CLOSED` 管读不到参数表的故障情况。
---
## 九、两个已知的交互
1. **命令驱动的 OPEN 拒绝记录会挡住自主 OPEN。**
`command_service._ledger_rejects` 写的是 `action="OPEN"`、`arbiter="rule"`、
`verdict="REJECT"`,而 `_rejected_today_keys` 读的正是这张表的(股票,动作)。
所以某只票今天因为一条升仓命令在规划期被上限拦过,自主新建仓当天不会再提它。
多数情况下这是对的(同一组约束),但两边算的目标金额不同,
一手不足这类拒绝口径可能不一样。先记下来,观察一天。
2. **升仓命令的候选没有排除已有在途方案的票。**
`_dispatch_planner` 算出了 `exclude = _codes_with_live_plans()` 却只传给降仓分支。
既有现象,不属本次范围;自主这条路有 `_inflight_keys` 兜底,命令那条路没有。
**这次不动,先观察。**
---
## 十、上线与判收
判收标准是真机各见一次。
| 步 | 动作 | 判收标准 |
|---|---|---|
| 1 | 决策系统侧先上:`git pull` + `restart backend-api worker-brain` | 手工发一条 `action=OPEN` 的研判请求,回 PASS 或 REJECT理由读得出是在谈从零建仓而不是加仓请求体里确认没有仓位数字 |
| 2 | PMS 侧 `make deploy` + `make test`**收盘后做** | ALL SUITES PASS |
| 3 | 次日盘前先把 `PMS_OPEN_AUTONOMY` 设成 `propose_only` 看一轮 | 提议队列出现 OPEN理由读得懂`make t-gate` 看到 arbiter=judge 的行;多条候选时确认名额与金额是滚动扣减的 |
| 4 | 确认无误后改成 `full` | 一条 OPEN 提议直接落指令,`make t-ins` 看到 `origin_type=proposal`、`action=OPEN`、`is_command=false` |
| 5 | 盘中观察 | 现价在买入区间内时出手,`last_decision.source=A`,限价等于区间上沿 |
| 6 | 成交之后 | `pms_position` 出现该票、`opened_date` 有值、`status=HOLDING`;次日起 `eval_fill` 能看见它 |
| 7 | 研判预算与账本 | 日志里出现过「本轮研判时间预算用尽」且下一跳接上了;`intraday_exec` 一次都没触发软超时;**评审账本没有被同一条驳回刷屏** |
| 8 | 漂移防护 | **只靠单测判收,不在生产人为制造**(盘中跑 push-pool 触发补扫风险太大)。生产侧只观察账本里有没有 `REF_DRIFT` 的 WARN 行 |
第三步那轮 `propose_only` 是我加的一道自保,代价一个交易日;想直接上 `full` 就跳过,
判收从第四步开始。
---
## 十一、第一周要盯的四个数
不设人为上限之后,这四个数替代了「每天开几只」这个旋钮:
1. **每天有几只候选落在自己的买入区间内**——实际建仓速度,
也是「让系统自己判断」到底判成什么样的直接答案。
2. **研判驳回率**——驳得太狠说明 OPEN 判据写窄了,一条不驳说明写宽了。
3. **`intraday_exec` 一跳的耗时分布**——预算够不够,要不要开研判独立队列。
4. **评审账本每天的行数**——第三节第 5 条那个去重有没有真的生效。