tradingSystem/NEW_POSITION_ACTION_PLAN.md

261 lines
16 KiB
Markdown
Raw Normal View History

2026-08-06 12:34:57 +08:00
# 方案:给动作引擎加「新建仓」动作
> 状态:**待拍板**2026-08-06 收盘后出。全部依据来自实际代码,不引用设计文档的结论。
> 涉及系统tradingSystemPMS主要改动、bionic_trader决策系统可选的第二步
> 部署方式PMS 源码打进镜像,改码必须 `make deploy`,跑完 `make test` 见到 ALL SUITES PASS。
---
## 一、先说清现状:管道其实已经铺好了,缺的只有一段
读代码之后,一件事和交接信里的判断略有出入,而且是好消息:**`OPEN` 这个动作名在
PMS 里已经存在,而且从方案一路到下单的整条管道都是通的**,缺的只是「自主提议」这一段。
已经存在的部分:
- `app/core/planner.py` 第 31 行 `A_OPEN, A_FILL, A_ADD, A_DCA = "OPEN", "FILL", "ADD", "DCA"`
建仓命令拆出来的底仓批产出的就是 `OPEN`
- `app/services/executor.py` 第 41 行 `BUY_ACTIONS = {"OPEN", "FILL", "ADD", "DCA"}`
动作是 `OPEN` 的指令已经能自动转成买入方向、走择时、过规则闸、下发。
- `app/services/command_service.py` 第 647 行在候选被规划期拦下时,已经在往评审账本里
`action="OPEN"` 的记录。
- 择时接口 `exec_advisor._advice``action` 原样透传给决策系统,动作名叫什么它都不挑。
缺的部分,只有两处:
- `app/core/action_engine.py` 第 27 行的自主动作词表 `A_FILL, A_ADD, A_DCA, A_TRIM`
里没有 `OPEN`,第 182 行的 `EVALUATORS` 也没有对应的求值器。
- `action_engine.scan()` 第 197 行是 `for p in positions or []`,输入是
`portfolio.positions_view()["held"]` —— **只遍历已有持仓,结构上就不可能引入新标的**
所以这次要做的事可以说得很准确:**给动作引擎补一个以候选池为输入的求值器,让它产出
`OPEN` 候选,然后并进现有的那条「规则闸 → 研判闸 → 档位分流」的路。** 下游一行都不用动。
---
## 二、选票规则:动作引擎只管名额、钱、批次,不管时机
候选池那边已经过完一整套筛选(`plan_feed.select_candidates`):按分数降序、档位白名单、
分数与来源数与预期空间的下限、同主题限额、剔除 ST、剔除已持仓、剔除黑名单。
排在它后面的规则闸又会管:一手整百、冻结、全局暂停买入、黑名单、决策系统昨夜定性
`BAD_SIGNAL`、涨停不追、当日涨幅不追高、距 MA5 不追高、组合与单股与持仓数与行业集中度
的上限、预留现金、真实可用资金。再后面的择时又会管:现价在不在买入区间内。
`action_engine.py` 第 17 行的注释已经把职责边界钉死了 ——「上限/一手/冻结等硬约束**不在
这里重复判**,统一由规则闸终检(职责单一,口径唯一)」。所以新建仓求值器只回答三个问题:
1. **还能开几只**`min(最大持仓数 当前持仓数, 每日新开上限 今日已开)`。
2. **还有多少钱可投**`总仓上限 × 总规模 当前组合市值`,与单只的目标金额取小。
3. **这一批买多少股**:按 `PMS_BATCH_SPLIT` 拆批,**只提底仓那一批**(默认 50%
数量 `lot_qty(金额, 现价)`,不足一手就不提。
**只提底仓批**这一条是刻意的:后续的回踩补足与盈利加仓,等持仓真的建立起来之后,由已有的
`eval_fill``eval_add` 自然接管——它们本来就是干这个的。这样不需要命令驱动那套
`GATED` 方案的解锁机制,新建仓这条路上一条 GATED 都不产生。
**入场时机不在这里判。** 现价合不合适是择时的活;定性好不好是规则闸 `BAD_SIGNAL` 的活。
动作引擎在这里另加一套判断,就违背了「提前计算为主、盘中监控为辅」,而且会和已有的闸重复。
**价格必须是实时价。** `market.plan_price` 在拿不到实时价时会回落昨收,它自己的注释写着
「拿昨收当现价去做不追高这类判断会出错,只给规划期定量用」。自主建仓是要真下单的,
所以走 `market.get_price`,取不到就整只跳过并写进 `skipped`——「什么都没发生」和
「明着跳过了」在页面上必须是两回事,这是 `scan()` 入口处已经立过的规矩。
---
## 三、要动的文件与改法PMS 侧,全部是加法)
### 1. `app/core/action_engine.py`
- 加常量 `A_OPEN = "OPEN"`,把它加进 `JUDGE_ACTIONS`
- 新增 `eval_open(c, params, mkt)`:输入是一条候选(代码、现价、分数、行业),
输出与现有四个求值器同构的候选结构。
- 新增 `scan_open(*, candidates, params, market, room, slots, skip)`:遍历候选,
名额与金额是全局的,所以边遍历边扣减;每一条不产出候选都要写进 `skipped` 并说明原因。
- **现有的 `scan()` 一个字不动**,既有单测继续全绿。
### 2. `app/services/proposal_service.py`
- `scan_and_route``ae.scan(...)` 之后追加一段:取候选池、取名额与可投金额、
`ae.scan_open(...)`,把两批候选拼起来走同一个 `_route_one`
- 候选池取数失败(`PlanFeedError`)时**不产生任何新建仓候选,并把原因写进 `skipped`**。
已有持仓的四类动作不受影响,照常扫描——一条外部接口的故障不该让整轮扫描停摆。
- `_route_one` 里给 `OPEN` 三处特判:
- 现价取候选自带的,不能取 `_pos_of` 的(没持仓的票那里没有 `price`,会是 0
规则闸直接 `PRICE_MISSING` 拒掉)。
- 组合上下文改成 `portfolio.caps_ctx(view, ts_code=code, is_new_name=True,
sector=industry.get(code))`。**必须显式传行业**`caps_ctx` 只在
`view["positions"]` 里找得到该票时才会带出行业,新票找不到,不传的话行业集中度
整段静默跳过(`sizer.check_caps` 对 `sector` 为空是直接跳过的)。
- 落指令时先 `pms_repo.ensure_position(code)` 建一行 `status='PLANNED'` 的持仓行,
再写上 `target_pct`。不建这行的话 `bump_once_guards` 那类更新会影响 0 行。
建了也不会污染上限计算——`positions_view` 的 `held` 过滤 `total_qty > 0`
- 档位分流:见第四节。
### 3. `app/services/exec_advisor.py` 与 `app/services/executor.py`(漂移防护)
见第五节。
### 4. `app/repo/pms_repo.py`
- 新增一个只读函数,统计今日已经开过或正在提议中的新仓票数(`pms_instruction` 与
`pms_proposal` 里今天 `action='OPEN'` 的 distinct 股票数)。每日节流要用。
### 5. `app/services/param_store.py` 与 `config/settings.py`
新增参数(见第六节),并同步补 `DESC` 中文说明与 `_RANGES` 取值校验。
`param_store.py` 第 46 到 48 行有一条血的教训写在那里:加参数表时不同步加白名单,
`set_param` 只回 `ok=False` 不抛异常,调用方又把返回值丢了,于是那条纪律静默失效。
这次一并照做。
### 6. `scripts/test_batch3_units.py`(或新增一个测试文件)
至少覆盖:名额已满、组合钱不够、买不足一手、取不到实时价、每日节流已用完、
候选池取数失败、参考位漂移超阈值。**这些用例都是纯逻辑,不连库。**
---
## 四、档位分流:这是要你拍板的第一件事
现有分流在 `proposal_service._route_one` 第 132 行:
```
auto_exec = (side == "sell") or (autonomy == AUTONOMY_FULL and not force_queue)
```
新建仓是买入方向,所以在当前的 `propose_only` 档位下,它必然落进待确认队列。
这意味着人力从「下命令 + 排方案 + 核方案」降到「点一下确认」,但仍然不是无人值守。
三条路:
- **甲:不动档位。** 新建仓入队待确认。改动最小,但没达到「不要每天靠人」的目标。
- **乙:把 `PMS_AUTONOMY` 调成 `full`。** 新建仓自动了,但补足底仓、盈利加仓、
浅档补仓也一起自动了——那是三个不同的决定,不该被一个开关捆在一起。
- **丙(我的建议):给新建仓单独一个档位参数 `PMS_OPEN_AUTONOMY`。**
取值 `inherit`(跟随全局,默认)/ `full`(自动执行)/ `propose_only`(待确认)。
`auto_exec` 的计算里加一个针对 `OPEN` 的分支,不动既有那行的语义。
这样可以做到「新建仓自动、其余仍待人确认」,也可以随时单独关掉新建仓而不影响别的。
深档补仓的强制确认(`needs_user_confirm`)与研判不可用时的降级入队仍然无条件优先。
---
## 五、盘中输入漂移:这是要你拍板的第二件事
你点名的约束:择时读的 `strategy_daily_results` 在盘中跑 push-pool 触发补扫时会被就地改写。
实证是 000035 的压力从 5.2 变 5.15、002335 的支撑从 31.27 变 29.00(差 7.3%)。
对新建仓的具体危害比对已有持仓大得多。已有持仓有摊薄成本和安全垫做锚,支撑压力漂一点,
补仓补足的判断不会翻转;**而新建仓的买入区间完全由支撑压力推出来**,支撑变 7.3%
区间整体平移,一只上午判为「现价高于上沿、不追」的票,下午可能变成「在区间内、出手」——
而这时候没有人在看。
两条路:
- **甲:这一轮不做防护,靠「盘中不跑 push-pool」的纪律。**
改动最小,但把一条口头纪律放进了无人值守的关键路径。哪天忘了,或者哪天 XXL 例行调度
真的自动跑起来了,没有任何东西会拦。
- **乙(我的建议):首答锁定 + 偏离即停,只作用于新建仓。**
一条 `OPEN` 指令当天第一次拿到择时应答时,把应答 `observed` 里的支撑、压力、买入区间
存进 `progress.ref_lock`。之后每次应答都跟它比,偏离超过 `PMS_OPEN_REF_DRIFT_MAX`
(默认 3%)就把本轮判定改成等待,理由写明「昨夜结论盘中被改写:支撑 31.27 → 29.00
偏离 7.3%,该票新建仓当日暂停」,并在评审账本落一条 `WARN`
三个好处:纯加法、只在 PMS 侧、不需要动桥和决策系统;不需要先拍板悬项二那个
「要不要给 `fetch_yesterday_strategy` 加日期过滤」的取舍;而且**它顺带把漂移变成了
可统计的数据**——漂了几次、每次漂多少,都会留在账本里,将来真要改上游时有实证依据。
代价是 `exec_advisor``executor` 各要动几行,而这两个文件是买入侧新闸刚改过的地方。
---
## 六、新增参数(全部有默认值,页面可调,默认值等于「不改变现有行为」)
| 键 | 默认 | 说明 |
|---|---|---|
| `PMS_OPEN_ENABLED` | `False` | 自主新建仓总开关。**默认关**,部署后行为与今天一字不差 |
| `PMS_OPEN_AUTONOMY` | `inherit` | 新建仓单独档位inherit 跟随全局 / full 自动执行 / propose_only 待确认 |
| `PMS_OPEN_MAX_PER_DAY` | `1` | 每个交易日最多自动新开几只 |
| `PMS_OPEN_MAX_NAMES` | `0` | 自主新建仓能占的持仓数上限0 = 不额外限制(仍受 `PMS_MAX_NAMES` 约束) |
| `PMS_OPEN_TARGET_PCT` | `0` | 自主新建仓的单股目标仓位0 = 用 `PMS_STOCK_TARGET_DEFAULT` |
| `PMS_OPEN_MIN_SCORE` | `0` | 在候选池已有过滤之上自主建仓额外的分数下限0 = 不额外过滤 |
| `PMS_OPEN_REF_DRIFT_MAX` | `0.03` | 参考位盘中改写的容忍幅度,超过则该票当日暂停新建仓(选了第五节的乙才需要) |
`PMS_OPEN_ENABLED` 建议同时加进 `param_store.FAIL_CLOSED`,取值 `False`——参数表读不到时
安全方向是不建仓。
**每日只开一只**这个默认值需要你确认。理由:候选池 top 30、持仓上限 20 只,如果不限,
第一天就会把组合一次性建满,所有仓位同一天建立、同涨同跌,而且撞上的还是交接信里
「仓位单向增长」的另一个版本。一天一只,二十个交易日建满,节奏上更像人在做。
---
## 七、研判闸:这是要你拍板的第三件事
`judge.request` 的第一行是:动作不在 `PMS_JUDGE_ACTIONS`(页面可调,默认
`FILL,ADD,DCA,SWITCH`)里就直接回 `PASS`。而决策系统那侧按动作类型给定性判据的那张表,
只有 FILL、ADD、DCA、SWITCH 四行,没有 `OPEN`。契约写明「越界与解析失败一律回
`UNAVAILABLE`」,而 PMS 收到 `UNAVAILABLE``degraded=True` → 强制入队待人确认。
**也就是说,如果现在就把 OPEN 送研判,新建仓会 100% 落回人工确认——正好把这次要解决的
问题原样退回去。**
两条路:
- **甲(我的建议):这一轮 PMS 侧把口子留好,实际先不送研判。**
`action_engine.JUDGE_ACTIONS` 加上 `A_OPEN`(让它有资格),但页面参数
`PMS_JUDGE_ACTIONS` 不加 `OPEN`(实际不送)。下一轮决策系统侧补上 OPEN 的判据、
联调通过之后,在页面参数里加一个词就生效,**零部署、随时可退**。
这一轮新建仓只过规则闸——规则闸已经有 `BAD_SIGNAL` 那道定性闸,加上候选池本身就是
决策系统每晚推理覆盖过的池子,不是裸奔。
- **乙:这一轮双侧一起改。** 决策系统 `workers/tasks_intraday.py``PMS_JUDGE` 分支加
OPEN 的判据(核心问题大致是「现在从零建这只票,逻辑成立吗」),
`app/services/pms_advisor.py` 与判据表同步。跨机改动,部署方式是挂载卷 `git pull`
`docker compose restart backend-api worker-brain`;如果加了新的 env 就必须
`docker compose up -d --force-recreate`
---
## 八、两个已知的交互,先摆出来
1. **命令驱动的 OPEN 拒绝记录会挡住自主 OPEN。**
`command_service._ledger_rejects` 写的是 `action="OPEN"`、`arbiter="rule"`、
`verdict="REJECT"`,而 `proposal_service._rejected_today_keys` 读的正是这张表的
`(ts_code, action)`。所以某只票今天因为一条升仓命令在规划期被上限拦过,
自主新建仓当天就不会再提它。**多数情况下这是对的**(同一组约束),但两边算的目标金额
不一样,一手不足这类拒绝可能口径不同。先记下来,观察一天再看要不要分开。
2. **升仓命令的候选没有排除已有在途方案的票。**
`command_service._dispatch_planner` 算出了 `exclude = _codes_with_live_plans()`
但只传给了降仓分支,升仓分支没传。这是既有的现象,不属于本次改动范围,
但自主新建仓上线后两条路会同时挑票,撞车概率上升。
自主这条路有 `_inflight_keys` 兜底(同票同动作有在途提议或指令就不重复提),
命令那条路没有。**建议这次不动它,先观察**。
---
## 九、分步上线与判收
判收纪律照旧:代码就绪不等于接通,接通不等于判收,标准是真机各见一次。
| 步 | 动作 | 判收标准 |
|---|---|---|
| 1 | `make deploy` + `make test``PMS_OPEN_ENABLED=False` | ALL SUITES PASS且盘中行为与今天一字不差`make watch` 看不出变化) |
| 2 | 页面开 `PMS_OPEN_ENABLED=True`,档位 `propose_only`,每日 1 只 | 提议队列出现一条 OPEN理由读得懂硬数字里有名额、可投金额、批次与分数 |
| 3 | 人为把 `PMS_MAX_NAMES` 调到当前持仓数 | 评审账本出现 `MAX_NAMES` 的 OPEN 拒绝行(`make t-gate` 看得到) |
| 4 | 档位改 `full` | 一条 OPEN 提议直接落指令,`make t-ins` 看到 `origin_type=proposal`、`action=OPEN`、`is_command=false` |
| 5 | 盘中观察那条指令 | 现价在买入区间内时 FIRE`last_decision.source=A`,限价等于区间上沿 |
| 6 | 成交之后 | `pms_position` 出现该票、`opened_date` 有值、`status=HOLDING`;次日起 `eval_fill` 能看见它 |
| 7 | 漂移防护(若选了第五节的乙) | **建议只靠单测判收,不在生产人为制造**:盘中跑 push-pool 触发补扫风险太大。生产侧只观察账本里有没有 `REF_DRIFT` 的 WARN 行 |
---
## 十、需要你拍板的四件事(其余我按上面的建议做)
1. **档位**:给新建仓单独一个 `PMS_OPEN_AUTONOMY`(建议),还是直接用全局 `PMS_AUTONOMY`
2. **每日节流**:一天最多新开几只?(建议 1
3. **研判闸**:这一轮 PMS 侧留口子、决策系统侧下一轮再做(建议),还是这一轮双侧一起改?
4. **漂移防护**:做首答锁定加偏离即停(建议),还是这一轮先不做、继续靠「盘中不跑
push-pool」的纪律