tradingSystem/NEW_POSITION_ACTION_PLAN.md

261 lines
16 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.

# 方案:给动作引擎加「新建仓」动作
> 状态:**待拍板**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」的纪律