tradingSystem/NEW_POSITION_ACTION_PLAN.md

292 lines
17 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.

# 方案(定稿):给动作引擎加「新建仓」动作
> V22026-08-06。四条取舍已拍板决策系统侧已评估完毕。**待你点头即可动手。**
> 全部依据来自实际代码,不引用设计文档的结论。
> 部署PMS 源码打进镜像,改码必须 `make deploy`,跑完 `make test` 见 ALL SUITES PASS
> 决策系统挂载卷,`git pull` 后 `docker compose restart backend-api worker-brain` 即可。
---
## 、拍板结果V1 的四问)
| # | 问题 | 决定 |
|---|---|---|
| 1 | 档位 | **单独一个开关,默认开启**。新建仓不跟随全局档位,自己一个参数,默认就是自动执行 |
| 2 | 每日节流 | **不做**。逻辑进逻辑出,不设人为上限,由既有的上限、资金、候选池与择时自己收敛 |
| 3 | 研判闸 | **这一轮一并加上**,双侧改到位 |
| 4 | 漂移防护 | **做**,首答锁定加偏离即停 |
---
## 一、现状:管道已经铺好,缺的只有一段
`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()` 第 197 行是 `for p in positions or []`,输入是持仓列表,
**结构上就不可能引入新标的**
所以这次做的事是:补一个以候选池为输入的求值器,产出 `OPEN` 候选,并进现有的
「规则闸 → 研判闸 → 档位分流」那条路。下游一行不用改。
---
## 二、决策系统侧的评估结论(第 3 条拍板的前置)
读了 `workers/tasks_intraday.py`、`app/api/main.py`、`app/services/pms_advisor.py`、
`config/settings.py`。三条结论:
### 1. 择时那一侧,一个字都不用改
`pms_advisor._advise`(第 163 到 226 行)从头到尾**没有读过 `action` 字段**
它只用 `ts_code`、`side`、`day.price` 三样。买入区间由昨夜结论的支撑压力推出来,
跟这笔买单是建新仓还是加老仓无关。所以新建仓的择时天然就能用,零改动。
### 2. 研判那一侧,没有任何白名单,但兜底文案是给加仓写的
从 HTTP 入口到大模型提示词,全链路**没有一处校验 `action` 的取值**
`app/api/main.py:323` 的路由是裸 `dict`,只查总开关和 `ts_code` 非空;
worker 侧 `tasks_intraday.py:512`
```python
core_task = _task_by_action.get(_act, f"这是 PMS 的【{_act}】提议, 请按提议理由与硬数字做定性仲裁。")
```
也就是说,**今天就把 `action="OPEN"` 发过去,它不会报错、不会回不可用,会照常打大模型
并按 PASS/REJECT 收口**——但拿到的是一段没有针对性判据的兜底文案,
后面还无条件跟着一句「仓位纪律 PMS 的规则闸已经查过,不归你管」,那是给加仓写的语境。
让它就这么跑,等于用一个含糊的问题去问一个昂贵的模型,答出来的东西没法信。
所以「一并加上」要改的是三处,都在 `workers/tasks_intraday.py`
- **加一条 OPEN 判据**(第 495 到 511 行的 `_task_by_action` 字典里加一项)。
核心问题写成:这是一只从零开始建仓的新票,当初把它选进候选池的驱动,
到今天是否仍然成立、有没有被证伪;证据不足以支持从零建仓时选驳回。
- **现价缺失时显式回不可用**。第 449 到 456 行的现价链路:新票没有持仓快照,
`_pos.get("price")` 拿不到,退到 `fetch_realtime_close`,而那个函数读不到分钟线时
返回 `0.0`,被 `or None` 变成 `None`,提示词最终渲染成「当前现价 未知 元」。
加仓类还有摊薄成本可依,**建仓判断没有现价锚基本不成立**
所以要给 OPEN 加一条:现价拿不到直接回 `UNAVAILABLE`,不让模型盲判。
- **边界声明按动作分岔**。那句「仓位纪律不归你管」对 OPEN 仍然成立
(名额、行业集中度、资金确实是 PMS 规则闸查的),但措辞要改成建仓语境,
免得模型把它读成「这是一笔加仓」。
另外两件不用改、但要知道的事:提示词里会出现「(无持仓快照)」「(无近期评审流水)」
这两个空占位(新票本来就没有),是噪声但不误导;
`get_board_qrs(ts_code)` 那里没做代码格式转换,我核过 PMS 侧
`command_spec.normalize_code`(第 296 到 313 行)产出的是点式,
`get_board_qrs` 对点式解析正常,**这条路上不是 bug**。
### 3. 部署方式:只要不动 `.env`restart 就够
`config/settings.py` 第 208 到 226 行那段 P 段自己写着「全部有默认值,不新增必填 env
→ 重启 backend-api + worker-brain 即生效」。这次改的是 worker 代码,不加新配置,
所以走常规路径:`git pull` 之后 `docker compose restart backend-api worker-brain`
**一旦哪天要往 `.env` 里真的写一个值(比如切研判独立队列),那才必须
`docker compose up -d --force-recreate`。**
---
## 三、新发现的一条硬约束:一跳心跳里塞不下那么多次研判
这条是读代码时撞出来的,不是策略取舍,是工程事实,必须处理。
`app/scheduler.py:45` 给所有调度任务设了 `task_soft_time_limit=240, task_time_limit=300`
自主提议扫描跑在每分钟一跳的 `intraday_exec` 里,而 `judge.request` 是**同步阻塞**的,
超时上限 `PMS_JUDGE_TIMEOUT` 默认 90 秒。
**三只票送研判就是 270 秒,已经超过 240 秒的软超时。** 今天之所以没出事,
是因为送研判的只有已有持仓的三只票,而且多数轮次被在途去重和当日去重挡掉了。
新建仓不节流、候选池默认取前 30 只,第一跳就会把这个洞捅穿——任务被中途杀掉,
而且是在已经落了一部分表之后。
**处理办法:给单轮扫描一个研判时间预算,而不是给每天一个开仓上限。**
新增 `PMS_JUDGE_TICK_BUDGET_SEC`(默认 150 秒)。每次要调研判前先看这一轮已经花了多久,
如果「已花 + 单次超时上限」会顶破预算,本轮就不再送后面的候选,
把它们写进 `skipped`,理由写「本轮研判时间预算用尽,下一跳继续」。
这不是节流:**一条候选都没有被丢掉,也没有任何按天计的上限**
只是把一次心跳做不完的活挪到下一分钟。候选是按分数降序排的,
所以预算用尽时先做完的一定是分数最高的那些。决策系统那侧的裁决缓存是按
`日期 + 股票 + 动作` 存半小时的,所以同一批候选在半小时内的重复扫描都是秒回,
真正慢的只有缓存过期后的第一跳。
顺带一提,决策系统那侧还预埋了研判独立队列(接口文档 §5.2)。
现在不用开——那是给 `brain_queue` 拥堵准备的第二档,而我们这里的瓶颈是
PMS 自己的心跳时长,不是对端排队。真出现频繁超时再开,而且开它要改 `.env`
必须 `force-recreate`
---
## 四、选票规则:动作引擎只管名额、钱、批次
候选池那边已经筛过(分数降序、档位白名单、分数与来源数与预期空间下限、同主题限额、
剔除 ST、剔除已持仓、剔除黑名单。规则闸后面还会管一手整百、冻结、全局暂停买入、
黑名单、昨夜定性 `BAD_SIGNAL`、涨停不追、当日涨幅不追高、距 MA5 不追高、
组合与单股与持仓数与行业集中度上限、预留现金、真实可用资金。择时再管现价在不在买入区间。
`action_engine.py:17` 的注释已经把边界钉死了——上限、一手、冻结这些不在动作引擎重复判。
所以新建仓求值器只回答三个问题:
1. **还能开几只**`最大持仓数 当前持仓数`。没有额外的每日上限。
2. **还有多少钱可投**`总仓上限 × 总规模 当前组合市值`,与单只目标金额取小。
3. **这一批买多少股**:按 `PMS_BATCH_SPLIT` 拆批,**只提底仓那一批**(默认 50%
`lot_qty(金额, 现价)`,不足一手就不提。
**只提底仓批**是刻意的:后续的回踩补足与盈利加仓,等持仓真建立起来之后由已有的
`eval_fill``eval_add` 自然接管。这条路上一条 `GATED` 方案都不产生。
**价格必须是实时价。** `market.plan_price` 拿不到实时价会回落昨收,
它自己的注释写着「拿昨收当现价去做不追高这类判断会出错,只给规划期定量用」。
自主建仓要真下单,所以走 `market.get_price`,取不到就整只跳过并写进 `skipped`
**不节流的一个自然收敛点**:择时的买入区间本身就是过滤器。
只有现价正好落在 `[支撑×0.99, 支撑+(压力−支撑)×0.3]` 里的票才会真的出手,
高于上沿一律「不追,接受买不上」。所以每天实际能建成几只,取决于当天有几只票
落在自己的区间内——这正是「让系统自己判断」。第一周要盯的就是这个数。
---
## 五、要动的文件与改法
### PMS 侧(`factor@factorevaluation`,改完 `make deploy`
1. **`app/core/action_engine.py`**
`A_OPEN = "OPEN"`,加进 `JUDGE_ACTIONS`
新增 `eval_open()``scan_open()`**既有 `scan()` 一个字不动**,既有单测继续全绿。
2. **`app/services/proposal_service.py`**
- `scan_and_route``ae.scan(...)` 之后追加取候选池、算名额与可投金额、调 `scan_open`
两批候选合并走同一个 `_route_one`
- 候选池取数失败(`PlanFeedError`)时不产生任何新建仓候选,
原因写进 `skipped`;已有持仓的四类动作照常扫描,不受影响。
- 研判时间预算:`_route_one` 之外记一个本轮起始时刻,送研判前判预算。
- `_route_one``OPEN` 三处特判:现价取候选自带的(`_pos_of` 对新票没有 `price`
取它会是 0规则闸直接 `PRICE_MISSING`);组合上下文改成
`caps_ctx(view, ts_code=code, is_new_name=True, sector=industry.get(code))`
——**必须显式传行业**,否则行业集中度那道硬拦截会静默跳过;
落指令前先 `pms_repo.ensure_position(code)` 建行再写 `target_pct`
- 档位分流加 OPEN 分支(见第六节)。
3. **`app/services/exec_advisor.py`**
`_advice` 把应答 `observed` 里的支撑、压力、买入区间一并存进缓存;
当日首答把这三样锁进 `prog["ref_lock"]`;之后每答比对,
偏离超过 `PMS_OPEN_REF_DRIFT_MAX` 就把本轮判定改成等待,
并在返回的决策里带上 `ref_drift` 说明。**只对 `action == "OPEN"` 生效。**
4. **`app/services/executor.py`**
`run_tick` 的等待分支里,看到 `d.get("ref_drift")` 就额外落一条评审账本 `WARN`
(放这里而不放 `exec_advisor`,是为了不给后者引入落表副作用。)
5. **`app/repo/pms_repo.py`**
新增只读函数,统计当日已开与在提的新仓票数(供留痕与页面显示;
不做上限,只是让人看得见今天开了几只)。
6. **`app/services/param_store.py``config/settings.py`**
新增参数(第七节),同步补 `DESC` 中文说明、`_RANGES` 或 `_range_check` 校验、
以及 `FAIL_CLOSED`。`param_store.py:46` 那条注释记着一次血的教训——
加参数不同步加白名单,`set_param` 只回 `ok=False` 不抛异常,调用方又把返回值丢了,
于是那条纪律静默失效。这次一并照做。
另外把 `PMS_JUDGE_ACTIONS` 的默认值从 `FILL,ADD,DCA,SWITCH` 改成
`FILL,ADD,DCA,SWITCH,OPEN`
7. **测试脚本**
纯逻辑用例,不连库:名额已满、组合钱不够、买不足一手、取不到实时价、
候选池取数失败、研判预算用尽、参考位漂移超阈值、档位分流的三种取值。
### 决策系统侧(`192.168.16.188`,改完 `git pull` + `restart`
8. **`workers/tasks_intraday.py`**
`_task_by_action` 加 OPEN 判据OPEN 且现价缺失时回 `UNAVAILABLE`
边界声明按动作分岔。**不动 `.env`不加新配置restart 即生效。**
---
## 六、档位:一个开关,默认开启
新增 `PMS_OPEN_AUTONOMY`,取值 `full` / `propose_only` / `off`**默认 `full`**。
它同时充当总开关,不再单设第二个开关:`off` 就是不扫描新建仓。
`_route_one` 第 132 行那行不动,在它之外给 OPEN 加一个分支:
- `full` → 过完两道闸就直接落指令,无人值守。
- `propose_only` → 落待确认队列。
- `off` → 根本不扫描,一条候选都不产生。
三条无条件优先的规矩保持不变:减持方向永远自动执行;
深档补仓永远要人点头;**研判不可用(`degraded`)时一律入队待确认**——
最后这条对新建仓尤其重要,它是「拿不到意见绝不当成通过」这条纪律在新链路上的落点。
**一条部署上的提醒**:默认 `full` 意味着 `make deploy` 一跑完,
下一个整分钟的心跳就可能真的建仓。建议**收盘后部署**
让第一次真实运行发生在次日开盘、你在场的时候。
---
## 七、新增参数
| 键 | 默认 | 说明 |
|---|---|---|
| `PMS_OPEN_AUTONOMY` | `full` | 新建仓档位兼总开关full 自动执行 / propose_only 待确认 / off 不扫描 |
| `PMS_OPEN_TARGET_PCT` | `0` | 自主新建仓的单股目标仓位0 = 用 `PMS_STOCK_TARGET_DEFAULT` |
| `PMS_OPEN_MIN_SCORE` | `0` | 在候选池已有过滤之上额外的分数下限0 = 不额外过滤 |
| `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. **升仓命令的候选没有排除已有在途方案的票。**
`command_service._dispatch_planner` 算出了 `exclude = _codes_with_live_plans()`
但只传给了降仓分支。这是既有现象,不属于本次范围;
自主这条路有 `_inflight_keys` 兜底,命令那条路没有。**这次不动,先观察。**
---
## 九、上线与判收
判收标准是真机各见一次。
| 步 | 动作 | 判收标准 |
|---|---|---|
| 1 | 决策系统侧先上:`git pull` + `restart backend-api worker-brain` | `docker compose exec backend-api python scripts/pms_smoke.py` 里手工发一条 `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`,限价等于区间上沿;`make watch` 里那条指令的进度在推进 |
| 6 | 成交之后 | `pms_position` 出现该票、`opened_date` 有值、`status=HOLDING`;次日起 `eval_fill` 能看见它 |
| 7 | 研判预算 | 日志里出现过「本轮研判时间预算用尽」且下一跳接上了;`intraday_exec` 一次都没有触发软超时 |
| 8 | 漂移防护 | **只靠单测判收,不在生产人为制造**(盘中跑 push-pool 触发补扫风险太大)。生产侧只观察账本里有没有 `REF_DRIFT` 的 WARN 行 |
第三步那个 `propose_only` 看一轮,是我加的一道自保:默认虽然是 `full`
但第一次真跑之前先用一轮眼睛确认提议本身是对的,代价只有一个交易日。
你要是想直接上 `full`,把第三步跳过即可,判收标准从第四步开始。
---
## 十、第一周要盯的三个数
不设人为上限之后,这三个数替代了「每天开几只」这个旋钮,用来判断规则要不要调:
1. **每天有几只候选落在自己的买入区间内**——这是实际的建仓速度,
也是「让系统自己判断」到底判成什么样的直接答案。
2. **研判的驳回率**——驳得太狠说明 OPEN 判据写窄了,一条不驳说明写宽了。
3. **`intraday_exec` 一跳的耗时分布**——预算够不够,要不要开研判独立队列。