tradingSystem/NEW_POSITION_ACTION_PLAN.md

17 KiB
Raw Blame History

方案(定稿):给动作引擎加「新建仓」动作

V22026-08-06。四条取舍已拍板决策系统侧已评估完毕。待你点头即可动手。 全部依据来自实际代码,不引用设计文档的结论。 部署PMS 源码打进镜像,改码必须 make deploy,跑完 make test 见 ALL SUITES PASS 决策系统挂载卷,git pulldocker compose restart backend-api worker-brain 即可。


、拍板结果V1 的四问)

# 问题 决定
1 档位 单独一个开关,默认开启。新建仓不跟随全局档位,自己一个参数,默认就是自动执行
2 每日节流 不做。逻辑进逻辑出,不设人为上限,由既有的上限、资金、候选池与择时自己收敛
3 研判闸 这一轮一并加上,双侧改到位
4 漂移防护 ,首答锁定加偏离即停

一、现状:管道已经铺好,缺的只有一段

OPEN 这个动作名在 PMS 里已经存在,从方案到下单的整条管道是通的: planner.py:31 定义 A_OPENexecutor.py:41BUY_ACTIONS 已含 OPENcommand_service.py:647 已经在用 action="OPEN" 写评审账本。

缺的只有两处:action_engine.py:27 的自主动作词表里没有 OPEN action_engine.scan() 第 197 行是 for p in positions or [],输入是持仓列表, 结构上就不可能引入新标的

所以这次做的事是:补一个以候选池为输入的求值器,产出 OPEN 候选,并进现有的 「规则闸 → 研判闸 → 档位分流」那条路。下游一行不用改。


二、决策系统侧的评估结论(第 3 条拍板的前置)

读了 workers/tasks_intraday.pyapp/api/main.pyapp/services/pms_advisor.pyconfig/settings.py。三条结论:

1. 择时那一侧,一个字都不用改

pms_advisor._advise(第 163 到 226 行)从头到尾没有读过 action 字段 它只用 ts_codesideday.price 三样。买入区间由昨夜结论的支撑压力推出来, 跟这笔买单是建新仓还是加老仓无关。所以新建仓的择时天然就能用,零改动。

2. 研判那一侧,没有任何白名单,但兜底文案是给加仓写的

从 HTTP 入口到大模型提示词,全链路没有一处校验 action 的取值 app/api/main.py:323 的路由是裸 dict,只查总开关和 ts_code 非空; worker 侧 tasks_intraday.py:512

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. 部署方式:只要不动 .envrestart 就够

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_filleval_add 自然接管。这条路上一条 GATED 方案都不产生。

价格必须是实时价。 market.plan_price 拿不到实时价会回落昨收, 它自己的注释写着「拿昨收当现价去做不追高这类判断会出错,只给规划期定量用」。 自主建仓要真下单,所以走 market.get_price,取不到就整只跳过并写进 skipped

不节流的一个自然收敛点:择时的买入区间本身就是过滤器。 只有现价正好落在 [支撑×0.99, 支撑+(压力−支撑)×0.3] 里的票才会真的出手, 高于上沿一律「不追,接受买不上」。所以每天实际能建成几只,取决于当天有几只票 落在自己的区间内——这正是「让系统自己判断」。第一周要盯的就是这个数。


五、要动的文件与改法

PMS 侧(factor@factorevaluation,改完 make deploy

  1. app/core/action_engine.pyA_OPEN = "OPEN",加进 JUDGE_ACTIONS 新增 eval_open()scan_open()既有 scan() 一个字不动,既有单测继续全绿。

  2. app/services/proposal_service.py

    • scan_and_routeae.scan(...) 之后追加取候选池、算名额与可投金额、调 scan_open 两批候选合并走同一个 _route_one
    • 候选池取数失败(PlanFeedError)时不产生任何新建仓候选, 原因写进 skipped;已有持仓的四类动作照常扫描,不受影响。
    • 研判时间预算:_route_one 之外记一个本轮起始时刻,送研判前判预算。
    • _route_oneOPEN 三处特判:现价取候选自带的(_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.pyconfig/settings.py 新增参数(第七节),同步补 DESC 中文说明、_RANGES_range_check 校验、 以及 FAIL_CLOSEDparam_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

  1. 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_ACTIONSFILL,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=proposalaction=OPENis_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 一跳的耗时分布——预算够不够,要不要开研判独立队列。