16 KiB
方案:给动作引擎加「新建仓」动作
状态:待拍板,2026-08-06 收盘后出。全部依据来自实际代码,不引用设计文档的结论。 涉及系统:tradingSystem(PMS,主要改动)、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 行的注释已经把职责边界钉死了 ——「上限/一手/冻结等硬约束不在
这里重复判,统一由规则闸终检(职责单一,口径唯一)」。所以新建仓求值器只回答三个问题:
- 还能开几只:
min(最大持仓数 − 当前持仓数, 每日新开上限 − 今日已开)。 - 还有多少钱可投:
总仓上限 × 总规模 − 当前组合市值,与单只的目标金额取小。 - 这一批买多少股:按
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。
八、两个已知的交互,先摆出来
-
命令驱动的 OPEN 拒绝记录会挡住自主 OPEN。
command_service._ledger_rejects写的是action="OPEN"、arbiter="rule"、verdict="REJECT",而proposal_service._rejected_today_keys读的正是这张表的(ts_code, action)。所以某只票今天因为一条升仓命令在规划期被上限拦过, 自主新建仓当天就不会再提它。多数情况下这是对的(同一组约束),但两边算的目标金额 不一样,一手不足这类拒绝可能口径不同。先记下来,观察一天再看要不要分开。 -
升仓命令的候选没有排除已有在途方案的票。
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 行 |
十、需要你拍板的四件事(其余我按上面的建议做)
- 档位:给新建仓单独一个
PMS_OPEN_AUTONOMY(建议),还是直接用全局PMS_AUTONOMY? - 每日节流:一天最多新开几只?(建议 1)
- 研判闸:这一轮 PMS 侧留口子、决策系统侧下一轮再做(建议),还是这一轮双侧一起改?
- 漂移防护:做首答锁定加偏离即停(建议),还是这一轮先不做、继续靠「盘中不跑 push-pool」的纪律?