tradingSystem/NEW_POSITION_ACTION_PLAN.md

16 KiB
Raw Blame History

方案:给动作引擎加「新建仓」动作

状态:待拍板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._adviceaction 原样透传给决策系统,动作名叫什么它都不挑。

缺的部分,只有两处:

  • 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_filleval_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_routeae.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_capssector 为空是直接跳过的)。
    • 落指令时先 pms_repo.ensure_position(code) 建一行 status='PLANNED' 的持仓行, 再写上 target_pct。不建这行的话 bump_once_guards 那类更新会影响 0 行。 建了也不会污染上限计算——positions_viewheld 过滤 total_qty > 0
  • 档位分流:见第四节。

3. app/services/exec_advisor.pyapp/services/executor.py(漂移防护)

见第五节。

4. app/repo/pms_repo.py

  • 新增一个只读函数,统计今日已经开过或正在提议中的新仓票数(pms_instructionpms_proposal 里今天 action='OPEN' 的 distinct 股票数)。每日节流要用。

5. app/services/param_store.pyconfig/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_advisorexecutor 各要动几行,而这两个文件是买入侧新闸刚改过的地方。


六、新增参数(全部有默认值,页面可调,默认值等于「不改变现有行为」)

默认 说明
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 收到 UNAVAILABLEdegraded=True → 强制入队待人确认。

也就是说,如果现在就把 OPEN 送研判,新建仓会 100% 落回人工确认——正好把这次要解决的 问题原样退回去。

两条路:

  • 甲(我的建议):这一轮 PMS 侧把口子留好,实际先不送研判。 action_engine.JUDGE_ACTIONS 加上 A_OPEN(让它有资格),但页面参数 PMS_JUDGE_ACTIONS 不加 OPEN(实际不送)。下一轮决策系统侧补上 OPEN 的判据、 联调通过之后,在页面参数里加一个词就生效,零部署、随时可退。 这一轮新建仓只过规则闸——规则闸已经有 BAD_SIGNAL 那道定性闸,加上候选池本身就是 决策系统每晚推理覆盖过的池子,不是裸奔。

  • 乙:这一轮双侧一起改。 决策系统 workers/tasks_intraday.pyPMS_JUDGE 分支加 OPEN 的判据(核心问题大致是「现在从零建这只票,逻辑成立吗」), app/services/pms_advisor.py 与判据表同步。跨机改动,部署方式是挂载卷 git pulldocker 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 testPMS_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=proposalaction=OPENis_command=false
5 盘中观察那条指令 现价在买入区间内时 FIRElast_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」的纪律