添加自动建仓逻辑

This commit is contained in:
zlt 2026-08-06 13:55:27 +08:00
parent 996a2f358b
commit 50714c1c6a
11 changed files with 916 additions and 176 deletions

118
DEVLOG.md
View File

@ -108,6 +108,124 @@ PMS 侧因为新建仓档位默认就是自动执行,**建议收盘后 `make d
---
## 2026-08-06 · 新立一条规矩:动 tradingSystem 以外的系统之前先答三题
**做了什么**
用户指出决策系统本来就不管仓位(它是聚合大量信号的判断系统),往里面塞仓位信息
会把别的在跑的逻辑带偏。据此立了一条通用规矩,并按它把方案重过一遍,改成 V3。
**规矩(改 tradingSystem 以外的系统,动手前先答,答不上就别动)**
1. 这段代码是不是只有我这条路径会走到?共用的提示词模板、裁决解析、上下文组装、
配置项,都不能在里面加只对我有意义的分支。
2. 我加进去的字段,会不会被别的逻辑读到并当真?尤其是写共享表、共享缓存、
往共享 context 里塞键。
3. 我的东西全删掉,原系统能不能一字不差地回到今天?答不上「能」就不是加法。
**按规矩核过的结论**
决策系统这三处改动全部落在两段 `if direction == "PMS_JUDGE":`
`workers/tasks_intraday.py` 439 到 523 行、632 到 656 行),不碰共用的提示词模板
557 到 576 行、共用的裁决解析579 到 590 行、资金与板块注入530 到 555 行);
不写 `decision_ledger`;不加 settings 字段;留痕仍只有 PMS_PASS / PMS_REJECT /
PMS_UNAVAILABLE 三个值,而 `pms_advisor._today_exit_verdict` 第 102 行明确跳过
`PMS_` 开头的行,所以写进去的东西不会反过来影响择时。第三题答案是能,`git revert` 一次。
**额外补的一条:仓位数字不进研判请求**
`judge.request` 现在把 `hard_numbers` 整个塞进请求体,决策系统会逐键渲染进提示词。
新建仓的 hard_numbers 里有名额、可投金额、批次比例——等于当面请一个不该管仓位的系统
去看仓位。所以在 PMS 侧加键白名单,只对 OPEN 生效:送现价、分数、主题、档位、
预期空间、热度、名次;不送名额、金额、批次、组合占比。账本照旧存全量。
**重过一遍还改掉与补上的(详见方案 V3 第三节)**
改掉两处:去掉 `PMS_OPEN_TARGET_PCT` 与建持仓行(改由首次成交时既有的
`ensure_position` 自然建行,省掉僵尸空行的清理);去掉 `PMS_OPEN_MIN_SCORE`
(与候选池的 `PMS_PLAN_MIN_SCORE` 重复)。
补上六处,其中三条是真问题:
- **候选之间要滚动扣减**。`_route_one` 每条各自用本轮开始时的旧快照算上限,
一跳内五条各自不超、加起来超,规则闸拦不住。命令那条路有 `_ctx_after` 滚动,
自主这条没有。滚动放进 `scan_open`,复用 `planner.check_all_caps`
- **研判驳回要做当日去重(只对 OPEN**。`_rejected_today_keys` 只读 arbiter='rule'
研判驳回不在里面,`_inflight_keys` 也认不出,于是被驳回的候选每分钟往账本写一行
一模一样的记录——正是 07-29 那个把判分锚淹掉的教训。
- **可投金额要用真实可用资金封顶**,并加 `PMS_OPEN_REQUIRE_WS_CASH`
拿不到资金快照就不自动新建仓。规则闸那条「拿不到资金只告警不拦」是为已排好的命令
设计的,不适合无人值守地从零建仓。
另外三条:行业名一次批量取本轮复用;研判预算用尽的候选整条跳过不入人工队列;
新建仓退实现B 时择时判据只剩均价一条(已知降级,要在理由里写明)。
**一条顺带发现、但这次不夹带的**
`industry.get_many``gp_stock_category` 时是逐只查库、没有缓存,
可以照 `_hybk_many` 加按日缓存。收益是每跳少几十次查询,但它改的是既有函数的时序行为,
按上面第 1 题的规矩不夹带,要做单独一条、单独拍板。
**动了哪些文件**
无代码改动。`NEW_POSITION_ACTION_PLAN.md` 更新为 V3。
**部署方式**
不涉及。
**真机判收**
未判收(尚未动码)。
**还欠着什么**
同上,等点头开工。
---
## 2026-08-06 · 新建仓动作:两侧代码写完,容器内全量单测通过,待实机部署
**做了什么**
按 V3 方案把两侧代码写完了。容器里拼了一份可运行的副本跑全量单测:**ALL SUITES PASS
451 例**(新增的第十二批 26 例 + 既有 425 例,含装配自检 58 例)。**尚未在实机部署。**
**动了哪些文件**
决策系统(`bionic_trader`一个文件48 增 4 删,三处全在 `if direction == "PMS_JUDGE":` 块内:
- `workers/tasks_intraday.py``_task_by_action` 加 OPEN 建仓判据(含必答「把这只票挑出来的
驱动到今天还成不成立」OPEN 且现价缺失时块内早退回 `UNAVAILABLE`(加仓类有摊薄成本可依,
建仓没有现价锚就是盲判);边界声明按动作分岔(原句里的「配额」对新建仓不成立)。
持仓系统(`tradingSystem`)九个文件:
- `app/core/action_engine.py`:加 `A_OPEN` 并进 `JUDGE_ACTIONS`;新增 `eval_open`
`scan_open`。**既有 `scan()` 一个字未动。**
- `app/services/proposal_service.py`:新增 `_scan_open`(候选池取数、批量取行业、
真实资金封顶);`_route_one` 加新建仓特判与档位分支;研判时间预算。
- `app/services/judge.py``OPEN_JUDGE_KEYS` 键白名单,新建仓只送定性材料。
- `app/services/exec_advisor.py`:存 `observed` 的支撑压力区间;`_check_ref_drift` 首答锁定、
偏离即停。
- `app/services/executor.py`:等待分支看到 `ref_drift` 落一条账本 WARN**一天一条**。
- `app/repo/pms_repo.py`:新增只读 `judge_rejected_today``opened_names_today`
- `app/services/param_store.py`:四个新参数的说明、校验、`FAIL_CLOSED`。
- `config/settings.py`:四个新参数;`PMS_JUDGE_ACTIONS` 默认值加 `OPEN`
- `scripts/test_batch12_units.py`26 例)与 `scripts/run_tests.py`(登记新批次)。
**写代码过程中改掉的一条设计(单测逼出来的)**
原方案的取数口径抄的是命令驱动那条路:`want = min(单股目标, 剩余额度)`。单测发现它会在
可投金额只剩一万时开出一只 0.5% 的零头仓位——**拿一个持仓名额换一个永远补不到目标的半截仓,
还挡住了后面真正建得起来的票**。改成「钱不够一整只就不开」,并把与命令那条路的差别写在注释里:
命令是用户明确下了「投这么多」,最后一只缩水是命令的收尾;自主建仓没有这层意思。
**部署方式**
- 决策系统:不加配置、不动 `.env``git pull``docker compose restart backend-api worker-brain`
- 持仓系统:源码打进镜像 → `make deploy`,跑完 `make test` 要见 ALL SUITES PASS。
**新建仓档位默认就是 full自动执行`make deploy` 一跑完下一个整分钟的心跳就可能真建仓,
所以务必收盘后部署**,让第一次真实运行发生在次日开盘、人在场的时候。
**真机判收**
未判收。判收步骤见 `NEW_POSITION_ACTION_PLAN.md` 第十节,顺序是决策系统先上并手工发一条
`action=OPEN` 的研判请求,确认理由是在谈从零建仓而不是加仓、且请求体里没有仓位数字。
**还欠着什么**
1. 实机部署与八步判收。
2. 第一周要盯的四个数:每天有几只候选落在买入区间内、研判驳回率、`intraday_exec` 一跳的
耗时分布、评审账本每天的行数。
3. 未夹带的一条:给 `industry.get_many``gp_stock_category` 分支加按日缓存
(每跳省几十次库查询,但改的是既有函数的时序行为,单独提、单独拍板)。
4. 文档补账仍欠着:`WS_INTEGRATION_STATUS.md` 停在 07-30`BIONIC_PMS_INTERFACE.md`
要补新建仓这一路(研判闸第五类动作、硬数字裁剪、择时侧零改动)。
---
<!--
下一条节点从这里往下写,格式照抄上面:
## YYYY-MM-DD · 一句话标题

View File

@ -1,123 +1,193 @@
# 方案(定稿):给动作引擎加「新建仓」动作
> V22026-08-06。四条取舍已拍板决策系统侧已评估完毕。**待你点头即可动手。**
> V32026-08-06。四条取舍已拍板决策系统侧已评估按「不影响原有业务系统」这条原则
> 重新过了一遍,改掉与补上的地方见第三节。**待你点头即可动手。**
> 全部依据来自实际代码,不引用设计文档的结论。
> 部署PMS 源码打进镜像,改码必须 `make deploy`,跑完 `make test` 见 ALL SUITES PASS
> 决策系统挂载卷,`git pull` 后 `docker compose restart backend-api worker-brain` 即可。
---
## 〇、拍板结果V1 的四问)
## 〇、拍板结果
| # | 问题 | 决定 |
|---|---|---|
| 1 | 档位 | **单独一个开关,默认开启**。新建仓不跟随全局档位,自己一个参数,默认就是自动执行 |
| 2 | 每日节流 | **不做**。逻辑进逻辑出,不设人为上限,由既有的上限、资金、候选池与择时自己收敛 |
| 1 | 档位 | **单独一个开关,默认开启**。新建仓不跟随全局档位,自己一个参数,默认自动执行 |
| 2 | 每日节流 | **不做**。逻辑进逻辑出,由既有的上限、资金、候选池与择时自己收敛 |
| 3 | 研判闸 | **这一轮一并加上**,双侧改到位 |
| 4 | 漂移防护 | **做**,首答锁定加偏离即停 |
---
## 一、现状:管道已经铺好,缺的只有一段
## 一、动别的系统之前先问的三件事(新立的规矩)
`OPEN` 这个动作名在 PMS 里已经存在,从方案到下单的整条管道是通的:
`planner.py:31` 定义 `A_OPEN`、`executor.py:41` 的 `BUY_ACTIONS` 已含 `OPEN`
`command_service.py:647` 已经在用 `action="OPEN"` 写评审账本。
决策系统本来就不管仓位——它是一套聚合了大量信号的判断系统,仓位是 PMS 的活。
往它里面塞仓位信息,最容易出的事不是这次的功能不好用,而是**把别的、已经在跑的逻辑
带偏**。所以凡是改 tradingSystem 以外的系统,动手前先答这三题,答不上就先别动:
缺的只有两处:`action_engine.py:27` 的自主动作词表里没有 `OPEN`
`action_engine.scan()` 第 197 行是 `for p in positions or []`,输入是持仓列表,
**结构上就不可能引入新标的**。
1. **这段代码是不是只有我这条路径会走到?** 如果是共用的(提示词模板、裁决解析、
上下文组装、配置项),就不能在里面加只对我有意义的分支。
2. **我加进去的字段,会不会被别的逻辑读到并当真?** 尤其是写共享表、写共享缓存、
往共享 context 里塞键。
3. **我的东西全删掉,原系统能不能一字不差地回到今天?** 答不上「能」,就说明它不是加法。
所以这次做的事是:补一个以候选池为输入的求值器,产出 `OPEN` 候选,并进现有的
「规则闸 → 研判闸 → 档位分流」那条路。下游一行不用改。
按这三题重新核过这次的决策系统改动,见下一节。
---
## 二、决策系统侧的评估结论(第 3 条拍板的前置)
## 二、决策系统侧:三处改动全部落在 PMS_JUDGE 块内
读了 `workers/tasks_intraday.py`、`app/api/main.py`、`app/services/pms_advisor.py`、
`config/settings.py`。三条结论:
### 结论先说
### 1. 择时那一侧,一个字都不用改
`workers/tasks_intraday.py``process_intraday_audit` 是一个按 `direction` 分流的大函数。
PMS 相关的代码本来就已经被圈在两段 `if direction == "PMS_JUDGE":` 里面
(第 439 到 523 行的入口与素材段,第 632 到 656 行的收口与出口段)。
**我要改的三处,全部在这两段之内,一行都不碰共用代码。**
`pms_advisor._advise`(第 163 到 226 行)从头到尾**没有读过 `action` 字段**
它只用 `ts_code`、`side`、`day.price` 三样。买入区间由昨夜结论的支撑压力推出来,
跟这笔买单是建新仓还是加老仓无关。所以新建仓的择时天然就能用,零改动。
| 改什么 | 在哪 | 是不是共用 |
|---|---|---|
| 加一条 OPEN 建仓判据 | 第 495 到 511 行的 `_task_by_action` 字典 | **不是**。这个字典整个定义在 PMS_JUDGE 块内,别的 direction 看不见它 |
| OPEN 且现价缺失时回不可用 | 第 449 到 456 行现价链路之后,块内早退 | **不是**。早退返回的结构与既有 UNAVAILABLE 出口同形 |
| 边界声明按动作分岔 | 第 513 到 514 行的 `core_task +=` | **不是**。`core_task` 只在 PMS_JUDGE 块内被赋值 |
### 2. 研判那一侧,没有任何白名单,但兜底文案是给加仓写的
不碰的东西,逐条点名:第 557 到 576 行那个所有 direction 共用的提示词模板不动;
第 579 到 590 行共用的裁决解析不动;第 530 到 555 行的资金分布与板块动量注入不动
PMS_JUDGE 本来就在它们的白名单里);`decision_ledger` 一个字不写
(每晚判分是全表扫描,写进去会污染既有功能,这条第一版就核实过);
`config/settings.py` 不加任何字段。
从 HTTP 入口到大模型提示词,全链路**没有一处校验 `action` 的取值**
`app/api/main.py:323` 的路由是裸 `dict`,只查总开关和 `ts_code` 非空;
worker 侧 `tasks_intraday.py:512`
留痕仍然只写 `strategy_audit_log`verdict 列仍然只有 `PMS_PASS` / `PMS_REJECT` /
`PMS_UNAVAILABLE` 三个值——不新增第四种。而 `pms_advisor._today_exit_verdict`
读这张表时明确跳过 `PMS_` 开头的行(第 102 行),所以我们写进去的东西不会反过来
影响择时应答。
```python
core_task = _task_by_action.get(_act, f"这是 PMS 的【{_act}】提议, 请按提议理由与硬数字做定性仲裁。")
```
**「全删掉能不能回到今天」这一题的答案是能**:把 `_task_by_action` 里那一项删掉、
把两段早退和分岔删掉,代码就回到今天的样子,`git revert` 一次搞定。
也就是说,**今天就把 `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 规则闸查的),但措辞要改成建仓语境,
免得模型把它读成「这是一笔加仓」。
`judge.request`PMS 侧 `app/services/judge.py` 第 65 到 72 行)现在是把
`candidate["hard_numbers"]` 整个塞进请求体,而决策系统会把它逐键渲染成
「【硬数字 (PMS 已算好, 勿重算)】」贴进提示词。如果新建仓的 `hard_numbers` 里装着
名额、可投金额、批次比例、组合占比这些东西,等于**当面请一个不该管仓位的系统去看仓位**——
哪怕提示词后面跟着一句「仓位纪律不归你管」,模型该被带偏还是会被带偏。
另外两件不用改、但要知道的事:提示词里会出现「(无持仓快照)」「(无近期评审流水)」
这两个空占位(新票本来就没有),是噪声但不误导;
`get_board_qrs(ts_code)` 那里没做代码格式转换,我核过 PMS 侧
`command_spec.normalize_code`(第 296 到 313 行)产出的是点式,
`get_board_qrs` 对点式解析正常,**这条路上不是 bug**。
所以加一条 PMS 侧的过滤:**新建仓送研判时,只送定性材料,不送仓位数字。**
### 3. 部署方式:只要不动 `.env`restart 就够
- 送:现价、候选池给的分数、主题、档位、预期空间、热度、榜内名次。
这些回答的是「这只票凭什么被选出来」,正是要它裁的问题。
- 不送:名额、可投金额、批次数量与比例、组合占比、总规模。
这些回答的是「买多少」,是 PMS 自己的活。
`config/settings.py` 第 208 到 226 行那段 P 段自己写着「全部有默认值,不新增必填 env
→ 重启 backend-api + worker-brain 即生效」。这次改的是 worker 代码,不加新配置,
实现上在 `judge.py` 里加一个显式的键白名单常量,只对 `OPEN` 生效,
并把「为什么」写在常量旁边。账本那边不受影响——`pms_action_ledger.hard_numbers_json`
照旧存全量,判分锚一个字不少。
### 必答清单放在决策系统那一侧
DCA 那条「下跌是杀逻辑还是杀情绪」现在两边都写了一份PMS 的 `must_answer` 传一次,
决策系统的判据里又写一次)。新建仓不照抄这个做法:**必答只写在决策系统的 OPEN 判据里,
PMS 侧的 `must_answer` 对 OPEN 留空。** 道理是分工——「建仓该问什么」属于仲裁哲学,
是决策系统的知识PMS 不需要懂。
### 部署方式
只改 worker 代码,不加配置、不动 `.env`。`config/settings.py` 第 208 到 226 行那段
自己写着「全部有默认值,不新增必填 env → 重启 backend-api + worker-brain 即生效」。
所以走常规路径:`git pull` 之后 `docker compose restart backend-api worker-brain`
**一旦哪天要往 `.env` 里真的写一个值(比如切研判独立队列),那才必须
**哪天要往 `.env` 里写值(比如切研判独立队列),那才必须
`docker compose up -d --force-recreate`。**
---
## 三、新发现的一条硬约束:一跳心跳里塞不下那么多次研判
## 三、重新过一遍之后改掉与补上的V2 → V3
这条是读代码时撞出来的,不是策略取舍,是工程事实,必须处理。
### 改掉的两处
`app/scheduler.py:45` 给所有调度任务设了 `task_soft_time_limit=240, task_time_limit=300`
自主提议扫描跑在每分钟一跳的 `intraday_exec` 里,而 `judge.request` 是**同步阻塞**的,
超时上限 `PMS_JUDGE_TIMEOUT` 默认 90 秒。
**1. 去掉 `PMS_OPEN_TARGET_PCT`,落指令时不再建持仓行。**
**三只票送研判就是 270 秒,已经超过 240 秒的软超时。** 今天之所以没出事,
是因为送研判的只有已有持仓的三只票,而且多数轮次被在途去重和当日去重挡掉了。
新建仓不节流、候选池默认取前 30 只,第一跳就会把这个洞捅穿——任务被中途杀掉,
而且是在已经落了一部分表之后
V2 说落指令前先 `ensure_position` 建一行再写 `target_pct`。重想之后这条不划算:
`target_pct` 只有在它与 `PMS_STOCK_TARGET_DEFAULT` 不同时才有意义,而那种差异化
本来就该由建仓命令去表达;为它一个参数,要引入建行、写列、以及**窗口耗尽作废后清理
僵尸空持仓行**三处新逻辑
**处理办法:给单轮扫描一个研判时间预算,而不是给每天一个开仓上限。**
改成:自主新建仓一律用 `PMS_STOCK_TARGET_DEFAULT`,与后续的回踩补足、盈利加仓天然同口径,
落指令时**不建行**。持仓行由首次成交时 `ledger_service._apply_action` 里既有的
`ensure_position` 自然建出来——这条路径命令驱动建仓已经走通并判收过。
少一个参数、少三处逻辑、不产生僵尸行。
新增 `PMS_JUDGE_TICK_BUDGET_SEC`(默认 150 秒)。每次要调研判前先看这一轮已经花了多久,
如果「已花 + 单次超时上限」会顶破预算,本轮就不再送后面的候选,
把它们写进 `skipped`,理由写「本轮研判时间预算用尽,下一跳继续」。
**2. 去掉 `PMS_OPEN_MIN_SCORE`。**
这不是节流:**一条候选都没有被丢掉,也没有任何按天计的上限**
只是把一次心跳做不完的活挪到下一分钟。候选是按分数降序排的,
所以预算用尽时先做完的一定是分数最高的那些。决策系统那侧的裁决缓存是按
`日期 + 股票 + 动作` 存半小时的,所以同一批候选在半小时内的重复扫描都是秒回,
真正慢的只有缓存过期后的第一跳。
它和候选池已有的 `PMS_PLAN_MIN_SCORE` 是同一件事。两个旋钮管一件事,
将来一定会有一次调错地方。
顺带一提,决策系统那侧还预埋了研判独立队列(接口文档 §5.2)。
现在不用开——那是给 `brain_queue` 拥堵准备的第二档,而我们这里的瓶颈是
PMS 自己的心跳时长,不是对端排队。真出现频繁超时再开,而且开它要改 `.env`
必须 `force-recreate`
### 补上的六处
**3. 候选之间要滚动扣减,否则一跳内几条加起来会超上限。**
这是个真问题。`_route_one` 里每条候选各自调 `caps_ctx(view, ...)`,而 `view`
本轮开始时取的快照,**不会因为前面几条已经落了指令而更新**。后果是一次心跳产出五条新建仓,
每条单独看都不超上限,五条加起来超了,而规则闸拦不住——它每次看到的都是同一个旧快照。
命令那条路没有这个问题,因为 `plan_increase_exposure``_ctx_after` 在循环里滚动更新。
自主这条路要照做:**滚动放在 `scan_open` 里**,边遍历边扣名额、扣可投金额、累加行业市值,
并直接复用 `planner.check_all_caps`(规则闸用的就是它,口径天然一致)。
`rule_gate` 已经在 `from app.core.planner import check_all_caps`core 层内部互相引用是既有模式。
**4. 候选数的上界是剩余名额,不是候选池大小——这修正了 V2 对研判负载的估计。**
因为名额和金额在 `scan_open` 里边走边扣,产出的候选数**天生不会超过剩余名额**。
组合已建满就一条都不产、一行账本都不写;还剩三个名额就最多三条。
冷启动那天是上界:名额 20 只,但组合上限 70% × 200 万 = 140 万,单只 6% = 12 万,
**金额先约束到 11 只左右**。11 只 × 最多 90 秒研判 ≈ 990 秒,靠第五节的时间预算
摊到七八跳,也就是七八分钟做完。之后每天只会有个位数。
**5. 研判驳回要做当日去重,只对新建仓生效。**
`_rejected_today_keys` 只读 `arbiter='rule'` 的拒绝,**研判驳回不在里面**
`_inflight_keys` 只认在途提议与在途指令,被研判驳回的候选两样都不产生。
于是一条被研判驳回的新建仓候选会**每分钟重来一次**:决策系统那侧有半小时缓存,
所以不烧大模型,但 PMS 这边每分钟往 `pms_action_ledger` 写一行一模一样的驳回记录。
半小时三十行,几只票就是几百行——**这正是代码注释里记着的 2026-07-29 那个教训**
(十六只深亏票的补仓候选每分钟被拒一次,一天几千行,把判分锚淹了)。
处理:新增一个只读函数 `judge_rejected_today`(读 `arbiter='judge'` 的当日驳回),
组装跳过集合时**只把其中动作为 `OPEN` 的键并进去**。既有四类动作的行为一个字不变——
它们「研判结论会变、有意不做当日去重」那条设计保持原样。
新建仓不一样:「这只票今天不该从零建仓」这个结论当天基本不会翻转。
**6. 可投金额要用真实可用资金封顶。**
V2 只按仓位口径算可投金额(`总仓上限 × 总规模 组合市值`)。但 2026-07-30 那次教训
就是这么来的:总规模两百万而账户实际只有九十八万,方案一路排出来,规则闸一路放行,
要等下游回资金不足才被拒,而拒了不自动重发。
所以 `scan_open` 的可投金额要在仓位口径与 `cash_avail` 之间取小,
并且新增 `PMS_OPEN_REQUIRE_WS_CASH`(默认开):**拿不到真实资金快照时不自动新建仓,
写进 skipped 留痕**。这不是节流,是和「必须实时价」同一类的输入质量闸——
建新仓是可以等的,而规则闸那条「拿不到资金只告警不拦」的降级口径,
是为已经排好的命令设计的,不适合无人值守地从零建仓。
**7. 行业名一次批量取,本轮复用。**
滚动扣减要算行业集中度,所以得先有行业名。`industry.get_many` 走
`gp_stock_category` 时是**逐只查库**(第 231 到 237 行),没有缓存。
所以在 `proposal_service` 里对候选代码**一次性取一遍**,塞进候选,
`scan_open``_route_one` 都用这一份,不重复查。
顺带提一条**建议但需要你单独拍板的**:给 `industry.get_many``gp_stock_category`
分支加按日缓存,与 `_hybk_many` 同一口径(行业归属本来就是日频的)。
收益是每跳少几十次库查询。但它改的是既有函数的时序行为,按第一节的规矩,
**这次不夹带,要做就单独一条**。
**8. 研判预算用尽的候选整条跳过,不入人工队列。**
预算用尽时,已经过了规则闸但还没送研判的候选,处理方式是**整条跳过并写 skipped**
下一跳重来。不把它们当成「研判不可用」入人工确认队列——那会在自动档位下凭空造出
一个人工队列,与这次要解决的问题正好相反。规则闸白跑一次的成本是毫秒级。
---
@ -125,14 +195,15 @@ PMS 自己的心跳时长,不是对端排队。真出现频繁超时再开,
候选池那边已经筛过(分数降序、档位白名单、分数与来源数与预期空间下限、同主题限额、
剔除 ST、剔除已持仓、剔除黑名单。规则闸后面还会管一手整百、冻结、全局暂停买入、
黑名单、昨夜定性 `BAD_SIGNAL`、涨停不追、当日涨幅不追高、距 MA5 不追高、
组合与单股与持仓数与行业集中度上限、预留现金、真实可用资金。择时再管现价在不在买入区间。
黑名单、昨夜定性、涨停不追、当日涨幅不追高、距 MA5 不追高、组合与单股与持仓数与
行业集中度上限、预留现金、真实可用资金。择时再管现价在不在买入区间。
`action_engine.py:17` 的注释已经把边界钉死了——上限、一手、冻结这些不在动作引擎重复判。
所以新建仓求值器只回答三个问题:
`action_engine.py` 第 17 行的注释把边界钉死了——上限、一手、冻结不在动作引擎重复判。
所以新建仓求值器只回答三个问题,且**边算边扣**
1. **还能开几只**`最大持仓数 当前持仓数`。没有额外的每日上限。
2. **还有多少钱可投**`总仓上限 × 总规模 当前组合市值`,与单只目标金额取小。
1. **还能开几只**`最大持仓数 当前持仓数`,每产出一条减一。
2. **还有多少钱可投**`min(总仓上限 × 总规模 组合市值, 真实可用资金)`
每产出一条减去该条的实际金额。
3. **这一批买多少股**:按 `PMS_BATCH_SPLIT` 拆批,**只提底仓那一批**(默认 50%
`lot_qty(金额, 现价)`,不足一手就不提。
@ -141,98 +212,123 @@ PMS 自己的心跳时长,不是对端排队。真出现频繁超时再开,
**价格必须是实时价。** `market.plan_price` 拿不到实时价会回落昨收,
它自己的注释写着「拿昨收当现价去做不追高这类判断会出错,只给规划期定量用」。
自主建仓要真下单,所以走 `market.get_price`,取不到就整只跳过并写进 `skipped`
自主建仓要真下单,所以走 `market.get_price`,取不到就整只跳过并写进 skipped。
**不节流的一个自然收敛点**:择时的买入区间本身就是过滤器。
只有现价正好落在 `[支撑×0.99, 支撑+(压力−支撑)×0.3]` 里的票才会真的出手,
高于上沿一律「不追,接受买不上」。所以每天实际能建成几只,取决于当天有几只票
落在自己的区间内——这正是「让系统自己判断」。第一周要盯的就是这个数。
**不节流的自然收敛点有两个**:一是名额与金额在求值器里边走边扣,候选数天生有界;
二是择时的买入区间——只有现价正好落在 `[支撑×0.99, 支撑+(压力−支撑)×0.3]` 里的票
才会真出手,高于上沿一律「不追,接受买不上」。每天实际建成几只,取决于当天有几只
落在自己的区间内。这正是「让系统自己判断」,第一周主要盯的就是这个数。
**一条已知的降级**新建仓在退实现B时`day.support` 是空的(新票没有参考位取数),
内置择时的「回踩带」判据用不上,只剩「现价不高于当日均价」一条,比已有持仓弱。
这是可接受的降级,但要在决策理由里写明白,别让人以为两条判据都过了。
---
## 五、要动的文件与改法
## 五、研判的时间预算(不是节流)
`app/scheduler.py` 第 45 行给所有调度任务设了软超时 240 秒、硬超时 300 秒,
`judge.request` 是同步阻塞、单次超时上限 90 秒。**三只票送研判就是 270 秒,
已经顶破软超时。** 今天没出事,是因为送研判的只有三只持仓票且多数轮次被去重挡掉。
冷启动那天新建仓会有十来条,第一跳就会捅穿——任务被中途杀掉,
而且是在已经落了一部分表之后。
新增 `PMS_JUDGE_TICK_BUDGET_SEC`(默认 150 秒)。每次要调研判前先看这一轮花了多久,
如果「已花 + 单次超时上限」会顶破预算,本轮不再送后面的候选,写进 skipped
理由写「本轮研判时间预算用尽,下一跳继续」。
**这不是节流**:一条候选都没丢,没有任何按天计的上限,只是把一次心跳做不完的活
挪到下一分钟。候选按分数降序排,预算用尽时先做完的一定是分数最高的那些。
决策系统那侧的裁决缓存是按「日期 + 股票 + 动作」存半小时的,
所以同一批候选在半小时内的重复扫描都是秒回,真正慢的只有缓存过期后的第一跳。
决策系统那侧还预埋了研判独立队列(接口文档 §5.2)。现在不用开——
那是给 `brain_queue` 拥堵准备的,而我们的瓶颈是 PMS 自己的心跳时长,不是对端排队。
真出现频繁超时再开,而且开它要改 `.env`,必须 `force-recreate`
---
## 六、要动的文件与改法
### PMS 侧(`factor@factorevaluation`,改完 `make deploy`
1. **`app/core/action_engine.py`**
`A_OPEN = "OPEN"`,加进 `JUDGE_ACTIONS`
新增 `eval_open()``scan_open()`**既有 `scan()` 一个字不动**,既有单测继续全绿。
`A_OPEN = "OPEN"`,加进 `JUDGE_ACTIONS`;新增 `eval_open()``scan_open()`
(含名额、金额、行业的滚动扣减,复用 `planner.check_all_caps`
**既有 `scan()` 一个字不动**,既有单测继续全绿。
2. **`app/services/proposal_service.py`**
- `scan_and_route``ae.scan(...)` 之后追加取候选池、算名额与可投金额、调 `scan_open`
- `scan_and_route` 追加取候选池、批量取行业、算名额与可投金额、调 `scan_open`
两批候选合并走同一个 `_route_one`
- 候选池取数失败(`PlanFeedError`)时不产生任何新建仓候选,
原因写进 `skipped`;已有持仓的四类动作照常扫描,不受影响。
- 研判时间预算:`_route_one` 之外记一个本轮起始时刻,送研判前判预算。
- `_route_one``OPEN` 三处特判:现价取候选自带的(`_pos_of` 对新票没有 `price`
- 候选池取数失败(`PlanFeedError`)时不产生新建仓候选,原因写进 skipped
已有持仓的四类动作照常扫描。
- 跳过集合并入 `judge_rejected_today` 里动作为 `OPEN` 的键。
- 研判时间预算:记本轮起始时刻,送研判前判预算,用尽则跳过并留痕。
- `_route_one``OPEN` 两处特判:现价取候选自带的(`_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 分支(见第六节)。
`caps_ctx(view, ts_code=code, is_new_name=True, sector=<批量取到的行业>)`
——**必须显式传行业**,否则行业集中度那道硬拦截会静默跳过。
- 档位分流加 OPEN 分支(见第七节)。
3. **`app/services/exec_advisor.py`**
`_advice` 把应答 `observed` 里的支撑、压力、买入区间一并存进缓存;
当日首答把这三样锁进 `prog["ref_lock"]`;之后每答比对,
偏离超过 `PMS_OPEN_REF_DRIFT_MAX` 就把本轮判定改成等待,
并在返回的决策里带上 `ref_drift` 说明。**只对 `action == "OPEN"` 生效。**
3. **`app/services/judge.py`**
加 OPEN 专用的 `hard_numbers` 键白名单,只送定性材料;`must_answer` 对 OPEN 留空。
4. **`app/services/executor.py`**
4. **`app/services/exec_advisor.py`**
`_advice` 把应答 `observed` 里的支撑、压力、买入区间存进缓存;当日首答锁进
`prog["ref_lock"]`;之后每答比对,偏离超过 `PMS_OPEN_REF_DRIFT_MAX` 就把本轮判定
改成等待,返回的决策里带 `ref_drift` 说明。**只对 `action == "OPEN"` 生效。**
5. **`app/services/executor.py`**
`run_tick` 的等待分支里,看到 `d.get("ref_drift")` 就额外落一条评审账本 `WARN`
(放这里而不放 `exec_advisor`,是为了不给后者引入落表副作用。)
放这里而不放 `exec_advisor`,是为了不给后者引入落表副作用。
5. **`app/repo/pms_repo.py`**
新增只读函数,统计当日已开与在提的新仓票数(供留痕与页面显示;
不做上限,只是让人看得见今天开了几只)。
6. **`app/repo/pms_repo.py`**
新增只读函数 `judge_rejected_today`;新增只读的当日新开票数统计(供页面显示,不做上限)。
6. **`app/services/param_store.py``config/settings.py`**
新增参数(第七节),同步补 `DESC` 中文说明、`_RANGES` 或 `_range_check` 校验、
以及 `FAIL_CLOSED`。`param_store.py:46` 那条注释记着一次血的教训——
7. **`app/services/param_store.py``config/settings.py`**
新增四个参数(第八节),同步补 `DESC` 中文说明、`_range_check` 或 `_RANGES` 校验、
以及 `FAIL_CLOSED`。`param_store.py` 第 46 行那条注释记着一次教训——
加参数不同步加白名单,`set_param` 只回 `ok=False` 不抛异常,调用方又把返回值丢了,
于是那条纪律静默失效。这次一并照做。
另外把 `PMS_JUDGE_ACTIONS` 的默认值从 `FILL,ADD,DCA,SWITCH` 改成
`FILL,ADD,DCA,SWITCH,OPEN`
那条纪律就静默失效。这次一并照做。
另把 `PMS_JUDGE_ACTIONS` 默认值改成 `FILL,ADD,DCA,SWITCH,OPEN`
7. **测试脚本**
纯逻辑用例,不连库:名额已满、组合钱不够、买不足一手、取不到实时价、
候选池取数失败、研判预算用尽、参考位漂移超阈值、档位分流的三种取值。
8. **测试脚本**(纯逻辑,不连库)
名额已满、组合钱不够、真实资金不足、拿不到资金快照、买不足一手、取不到实时价、
候选池取数失败、**多条候选的滚动扣减**、研判预算用尽、参考位漂移超阈值、
档位分流的三种取值、研判驳回当日去重。
### 决策系统侧(`192.168.16.188`改完 `git pull` + `restart`
### 决策系统侧(`192.168.16.188``git pull` + `restart backend-api worker-brain`
8. **`workers/tasks_intraday.py`**
`_task_by_action` 加 OPEN 判据;OPEN 且现价缺失时回 `UNAVAILABLE`
边界声明按动作分岔。**不动 `.env`不加新配置restart 即生效。**
9. **`workers/tasks_intraday.py`**`_task_by_action` 加 OPEN 判据(含必答);
OPEN 且现价缺失时块内早退`UNAVAILABLE`边界声明按动作分岔。
**三处全在 `if direction == "PMS_JUDGE":` 块内,不动共用代码,不动 `.env`。**
---
## 、档位:一个开关,默认开启
## 、档位:一个开关,默认开启
新增 `PMS_OPEN_AUTONOMY`,取值 `full` / `propose_only` / `off`**默认 `full`**。
它同时充当总开关,不再单设第二个开关`off` 就是不扫描新建仓。
它同时充当总开关,不再单设第二个:`off` 就是不扫描新建仓。
`_route_one` 第 132 行那行不动,在它之外给 OPEN 加一个分支:
`full` 过完两道闸直接落指令;`propose_only` 落待确认队列;`off` 根本不扫描。
- `full` → 过完两道闸就直接落指令,无人值守。
- `propose_only` → 落待确认队列。
- `off` → 根本不扫描,一条候选都不产生
三条无条件优先的规矩不变:减持方向永远自动执行;深档补仓永远要人点头;
**研判不可用时一律入队待确认**——最后这条对新建仓尤其重要,
它是「拿不到意见绝不当成通过」这条纪律在新链路上的落点
三条无条件优先的规矩保持不变:减持方向永远自动执行;
深档补仓永远要人点头;**研判不可用(`degraded`)时一律入队待确认**——
最后这条对新建仓尤其重要,它是「拿不到意见绝不当成通过」这条纪律在新链路上的落点。
**一条部署上的提醒**:默认 `full` 意味着 `make deploy` 一跑完,
下一个整分钟的心跳就可能真的建仓。建议**收盘后部署**
**部署时机提醒**:默认 `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_REQUIRE_WS_CASH` | `True` | 拿不到真实资金快照时不自动新建仓(写 skipped 留痕) |
| `PMS_OPEN_REF_DRIFT_MAX` | `0.03` | 参考位盘中被改写的容忍幅度,超过则该票当日暂停新建仓 |
| `PMS_JUDGE_TICK_BUDGET_SEC` | `150` | 单轮提议扫描用于研判的时间预算(秒),用尽则剩下的候选留到下一跳 |
@ -244,48 +340,48 @@ PMS 自己的心跳时长,不是对端排队。真出现频繁超时再开,
---
## 、两个已知的交互
## 、两个已知的交互
1. **命令驱动的 OPEN 拒绝记录会挡住自主 OPEN。**
`command_service._ledger_rejects` 写的是 `action="OPEN"`、`arbiter="rule"`、
`verdict="REJECT"`,而 `_rejected_today_keys` 读的正是这张表的 `(股票, 动作)`
`verdict="REJECT"`,而 `_rejected_today_keys` 读的正是这张表的(股票,动作)
所以某只票今天因为一条升仓命令在规划期被上限拦过,自主新建仓当天不会再提它。
多数情况下这是对的(同一组约束),但两边算的目标金额不同,
一手不足这类拒绝口径可能不一样。先记下来,观察一天再看要不要分开
一手不足这类拒绝口径可能不一样。先记下来,观察一天。
2. **升仓命令的候选没有排除已有在途方案的票。**
`command_service._dispatch_planner` 算出了 `exclude = _codes_with_live_plans()`
但只传给了降仓分支。这是既有现象,不属本次范围;
自主这条路有 `_inflight_keys` 兜底,命令那条路没有。**这次不动,先观察。**
`_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理由读得出是在谈建仓而不是加仓 |
| 1 | 决策系统侧先上:`git pull` + `restart backend-api worker-brain` | 手工发一条 `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 的行 |
| 3 | 次日盘前`PMS_OPEN_AUTONOMY` 设成 `propose_only` 看一轮 | 提议队列出现 OPEN理由读得懂`make t-gate` 看到 arbiter=judge 的行;多条候选时确认名额与金额是滚动扣减的 |
| 4 | 确认无误后改成 `full` | 一条 OPEN 提议直接落指令,`make t-ins` 看到 `origin_type=proposal`、`action=OPEN`、`is_command=false` |
| 5 | 盘中观察 | 现价在买入区间内时出手,`last_decision.source=A`,限价等于区间上沿`make watch` 里那条指令的进度在推进 |
| 5 | 盘中观察 | 现价在买入区间内时出手,`last_decision.source=A`,限价等于区间上沿 |
| 6 | 成交之后 | `pms_position` 出现该票、`opened_date` 有值、`status=HOLDING`;次日起 `eval_fill` 能看见它 |
| 7 | 研判预算 | 日志里出现过「本轮研判时间预算用尽」且下一跳接上了;`intraday_exec` 一次都没触发软超时 |
| 7 | 研判预算与账本 | 日志里出现过「本轮研判时间预算用尽」且下一跳接上了;`intraday_exec` 一次都没触发软超时**评审账本没有被同一条驳回刷屏** |
| 8 | 漂移防护 | **只靠单测判收,不在生产人为制造**(盘中跑 push-pool 触发补扫风险太大)。生产侧只观察账本里有没有 `REF_DRIFT` 的 WARN 行 |
第三步那个 `propose_only` 看一轮,是我加的一道自保:默认虽然是 `full`
但第一次真跑之前先用一轮眼睛确认提议本身是对的,代价只有一个交易日。
你要是想直接上 `full`,把第三步跳过即可,判收标准从第四步开始。
第三步那轮 `propose_only` 是我加的一道自保,代价一个交易日;想直接上 `full` 就跳过,
判收从第四步开始。
---
## 十、第一周要盯的三个数
## 十一、第一周要盯的四个数
不设人为上限之后,这三个数替代了「每天开几只」这个旋钮,用来判断规则要不要调
不设人为上限之后,这四个数替代了「每天开几只」这个旋钮
1. **每天有几只候选落在自己的买入区间内**——这是实际建仓速度,
1. **每天有几只候选落在自己的买入区间内**——实际建仓速度,
也是「让系统自己判断」到底判成什么样的直接答案。
2. **研判驳回率**——驳得太狠说明 OPEN 判据写窄了,一条不驳说明写宽了。
2. **研判驳回率**——驳得太狠说明 OPEN 判据写窄了,一条不驳说明写宽了。
3. **`intraday_exec` 一跳的耗时分布**——预算够不够,要不要开研判独立队列。
4. **评审账本每天的行数**——第三节第 5 条那个去重有没有真的生效。

View File

@ -10,25 +10,43 @@
| ADD 盈利加仓 | 安全垫 +3% 且创 5 日新高或站上压力位 | 距上次 2 交易日; 单股上限; MA5 <+6% |
| DCA 补仓 | 浮亏触及 8%/15% 评估档 (各评估一次, 执行终身一次) | 底仓 50%; 15% 及更深永远需用户确认 |
| TRIM 保垫减仓 | 安全垫峰值 6% 且回吐过半 1/3 锁盈 | 纯规则自动执行 (减持方向不设确认门槛) |
| OPEN 新建仓 | 上游候选池里的新票, 且还有持仓名额与可投金额 | 名额与金额边走边扣; 只提底仓批 |
本模块只回答该不该动动多少为什么, 不查库不下发:
* 交易日相关的输入 (建仓天数距上次加仓天数) services 层用交易日历算好传进来,
避免把日历依赖塞进纯逻辑
* 上限/一手/冻结等硬约束**不在这里重复判**, 统一由规则闸终检 (职责单一, 口径唯一)
这里只做引擎自身的触发条件批次额度计算
**唯一的例外是新建仓**: 它一轮能产出多条候选, 而规则闸每条拿到的都是同一份本轮开始时
的组合快照, 于是"每条单独看都不超上限、加起来超了"这种情况它拦不住所以那条路的上限
校验必须在这里就滚动算一遍 用的仍然是规则闸那个 check_all_caps, 口径没有第二份
输出候选统一结构, proposal_service 规则闸 研判闸 按自主档位分流
扫描入口有两个, 输入不同, 互不影响:
scan() 输入是**已有持仓**, 产出 FILL/ADD/DCA/TRIM (2026-08-06 之前就有的四类)
scan_open() 输入是**上游候选池**, 产出 OPEN (2026-08-06 新增)
候选池取不到时 scan() 照常跑, 反之亦然 一条外部接口的故障不该让整轮扫描停摆
"""
from __future__ import annotations
from app.core.cushion import dca_stage, trim_trigger
from app.core.sizer import LOT, lot_qty
from app.core.sizer import LOT, lot_qty, split_batches
# 新建仓要在这里滚动校验上限, 用的必须是规则闸那一份 check_all_caps, 不能另写一套。
# _new_name_ctx / _ctx_after 是命令驱动建仓 (planner.plan_increase_exposure) 滚动更新组合
# 快照用的同两个函数, 一起借过来 —— 为的是让「自主建仓」与「命令建仓」的上限口径逐字一致。
# 带下划线的名字跨模块引用不好看, 但比复制一份口径出来强: 口径有两份, 迟早会分叉。
from app.core.planner import check_all_caps, _ctx_after, _new_name_ctx
A_FILL, A_ADD, A_DCA, A_TRIM = "FILL", "ADD", "DCA", "TRIM"
A_OPEN = "OPEN" # 新建仓 (与 planner.A_OPEN、executor.BUY_ACTIONS 同名同义)
BUY, SELL = "buy", "sell"
# 需要送研判闸的动作 (设计 §7: 仅自主提议的补足/加仓/补仓/调仓)
JUDGE_ACTIONS = {A_FILL, A_ADD, A_DCA}
# **有资格**送研判闸的动作 (设计 §7: 自主提议的补足/加仓/补仓/调仓; 2026-08-06 加入新建仓)。
# 注意只是"有资格"——真正送不送由页面参数 PMS_JUDGE_ACTIONS 决定 (judge.request 第一行就按它
# 过滤)。两道门分开是有用的: 决策系统那侧的 OPEN 判据万一要退回去, 页面上摘掉一个词就行,
# 不改码、不部署、不重启。
JUDGE_ACTIONS = {A_FILL, A_ADD, A_DCA, A_OPEN}
def _f(v, d=0.0):
@ -227,3 +245,153 @@ def scan(*, positions: list, params: dict, market: dict, skip: set = None) -> di
if c:
out.append(c)
return {"candidates": out, "skipped": skipped}
# ================================================================ 新建仓 (OPEN)
def eval_open(c: dict, params: dict, caps: dict, room_amt: float):
"""一条候选票能不能从零建仓、建多少股。
返回 `(候选, None)` `(None, 跳过原因)` 与上面四个求值器只回 None 不同,
这里**必须给得出原因**: 候选池里明明有这只票却没被提, 页面上要看得出是名额满了
钱不够买不足一手, 还是被上限拦了只是"没出现"等于什么都没说
c: {ts_code, price, score, sector, theme, tier, upside, heat, rank, bucket, src}
price 必须是**实时价**规划用的昨收不能拿来下单 ( market.plan_price 的注释)
caps: 滚动中的组合上下文 (portfolio.caps_ctx 的产出, scan_open 边走边更新)
room_amt: 本轮还剩多少钱可投 (已取过仓位口径真实可用资金的小者)
"""
code = c.get("ts_code")
price = _f(c.get("price"))
if price <= 0:
return None, "取不到实时价, 不建仓 (规划用的昨收不能拿来下单)"
scale = _f(params.get("scale"))
if scale <= 0:
return None, "总规模未设置"
target_pct = _f(params.get("stock_target_default"), 0.06)
want = target_pct * scale
if want <= 0:
return None, "单股目标仓位为 0"
# **钱不够一整只就不开。** 这一条与命令驱动建仓有意不同: 那边是
# `want = min(单股目标, 命令剩余额度)`, 允许最后一只按剩下的钱缩水买 —— 那是用户
# 明确下了一条「投这么多」的命令, 缩水的那只是命令的收尾。
# 自主建仓没有这层意思: 开一只新仓要占掉一个持仓名额, 拿一个名额去换一只 0.5% 的
# 零头仓位是亏的 —— 它永远补不到目标, 却挡住了后面真正建得起来的票。
if want > room_amt:
return None, (f"剩余可投金额 {room_amt:,.0f} 元不足一只目标仓位 "
f"{want:,.0f} 元 ({target_pct:.0%}), 不开半截新仓")
sp = split_batches(want, price, splits=params.get("batch_split"),
merge=bool(params.get("min_lot_merge", True)))
if not sp["ok"]:
return None, sp["reason"]
# 上限校验按**这只票的整只目标金额**算, 不是只按这次要买的底仓批。
# 决定「要不要开这只新仓」的时候就该把它将来要占的位置留出来 —— 只按底仓批算的话,
# 一跳能开出一堆将来永远补不满的半仓。这也与命令驱动建仓 (plan_increase_exposure
# 里的 `actual = sum(b["qty"] * price ...)`) 是同一个口径。
# 一句要说破的话: 这份预留只在**本轮**有效, 下一跳的组合快照是按实际市值重新取的。
full_amt = sum(b["qty"] * price for b in sp["batches"])
bad = check_all_caps(ts_code=code, add_amount=full_amt, ctx=_new_name_ctx(caps, c))
if bad:
return None, "; ".join(bad)
base = sp["batches"][0]
qty = int(base["qty"])
if qty < LOT:
return None, f"底仓批 {qty} 股不足一手"
hard = {
# ---- 定性材料: 这只票凭什么被选出来。研判闸要看的就是这几项 ----
"price": price, "score": c.get("score"), "theme": c.get("theme"),
"tier": c.get("tier"), "upside": c.get("upside"), "heat": c.get("heat"),
"plan_rank": c.get("rank"), "plan_bucket": c.get("bucket"),
"plan_src": c.get("src"), "sector": c.get("sector"),
# ---- 仓位口径: 只进评审账本做判分锚。judge.py 送研判时会把这几项过滤掉,
# 理由见那边的 OPEN_JUDGE_KEYS —— 决策系统本来就不管仓位, 别送过去带偏它。
"target_pct": target_pct, "target_amount": round(full_amt, 2),
"base_amount": round(qty * price, 2),
"batch_scheme": ",".join(str(x) for x in (sp.get("scheme") or ())),
"names_before": caps.get("names_count"), "max_names": caps.get("max_names"),
"room_amt_before": round(_f(room_amt), 2),
}
reason = (f"新建仓: 候选池第 {c.get('rank') or ''} 名 (分数 {c.get('score') or ''}"
f"{', 主题 ' + str(c.get('theme')) if c.get('theme') else ''}), "
f"现价 {price}, 目标仓位 {target_pct:.0%}{full_amt:,.0f} 元, "
f"先建底仓 {qty} 股 (约 {qty * price:,.0f} 元)")
cand = _cand(c, A_OPEN, BUY, qty, reason, hard)
# 这两项是 OPEN 独有的, 供 proposal_service 用:
# price —— 新票在账本里没有行, _pos_of 拿不到现价, 取它会是 0 而被规则闸判 PRICE_MISSING
# sector —— caps_ctx 对新票带不出行业名, 不显式传的话行业集中度那道硬拦截会静默跳过
cand["price"] = price
cand["sector"] = c.get("sector")
cand["target_amount"] = round(full_amt, 2)
return cand, None
def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float,
slots: int, skip: set = None) -> dict:
"""从上游候选池挑新票建仓。名额与金额**边走边扣**, 所以产出的这一组候选彼此不冲突。
为什么滚动必须在这里做, 而不是交给规则闸: `_route_one` 给每条候选调 `caps_ctx` ,
拿的是**本轮开始时**的那一份组合快照, 不会因为前面几条已经落了指令而更新于是一跳产出
五条新建仓, 每条单独看都不超上限五条加起来超了, 规则闸一条也拦不住 它每次看到的
都是同一个旧快照命令驱动那条路没这个问题, 因为 planner 在循环里用 `_ctx_after` 滚动
自主这条路照做
**刻意没有每日开仓上限** 上界由三层自己收敛: 这里的名额与金额边走边扣;
后面规则闸的资金与上限终检; 再后面择时的买入区间 (现价不在区间内一律等待不追)
slots: 还能开几只新仓 = 最大持仓数 当前持仓数 (由调用方算好)
room_amt: 还有多少钱可投, 已取总仓上限×总规模 组合市值真实可用资金的小者
skip: 不再评估的 (代码, 动作) 集合 在途提议/指令当日已被规则闸或研判闸拒过的
"""
skip = skip or set()
out, skipped = [], []
slots = int(slots or 0)
left = _f(room_amt)
if slots <= 0:
return {"candidates": [], "skipped": [
{"ts_code": "*", "action": A_OPEN,
"why": f"持仓数已达上限 {caps.get('max_names')}, 没有新仓名额"}]}
if left <= 0:
return {"candidates": [], "skipped": [
{"ts_code": "*", "action": A_OPEN,
"why": "组合已到总仓上限, 或可用资金为零 —— 没有可投金额"}]}
ctx = dict(caps)
sector_on = bool(ctx.get("sector_source_ready", True))
# 按分数降序、同分按榜内名次 —— 与 plan_feed.select_candidates 的次序一致。
# 上游给过来本来就是排好的, 这里再排一次只是防调用方乱序, 不改变正常路径的结果。
for c in sorted(candidates or [],
key=lambda x: (-_f(x.get("score")), _f(x.get("rank"), 10 ** 9),
str(x.get("ts_code") or ""))):
code = c.get("ts_code")
if not code:
continue
if slots <= 0:
skipped.append({"ts_code": code, "action": A_OPEN,
"why": "本轮新仓名额已用完 (下一跳按最新持仓数重算)"})
continue
if left <= 0:
skipped.append({"ts_code": code, "action": A_OPEN,
"why": "本轮可投金额已用完 (下一跳按最新组合市值重算)"})
continue
if (code, A_OPEN) in skip:
skipped.append({"ts_code": code, "action": A_OPEN,
"why": "已有在途提议或指令, 或今日已被闸门拒过"})
continue
try:
cand, why = eval_open(c, params, ctx, left)
except Exception as e: # 单票异常不能拖垮整轮扫描 (与 scan() 同口径)
skipped.append({"ts_code": code, "action": A_OPEN,
"why": f"评估异常 {type(e).__name__}: {e}"})
continue
if not cand:
skipped.append({"ts_code": code, "action": A_OPEN, "why": why or "未产出候选"})
continue
out.append(cand)
used = _f(cand.get("target_amount"))
left -= used
slots -= 1
ctx = _ctx_after(ctx, used, is_new_name=True,
sector=(c.get("sector") if sector_on else None))
return {"candidates": out, "skipped": skipped}

View File

@ -521,6 +521,42 @@ def rule_rejected_today(since) -> set:
return {(r["ts_code"], r["action"]) for r in rows}
def judge_rejected_today(since) -> set:
"""今天已被**研判闸**驳回的 (代码, 动作)。
与上面那条是刻意分开的两个函数, 因为**只有新建仓会用它**
加仓类 (FILL/ADD/DCA) 对研判驳回**有意**不做当日去重 契约里那句研判结论会变,
节流责任放在决策系统那侧的半小时缓存上这条纪律不动
新建仓不一样, 有两点不同:
1. 这只票今天不该从零建仓这个结论当天基本不会翻转, 每分钟重问没有新信息;
2. 候选池是几十只的量级, 而加仓类的分母只有持仓那几只不去重的话, 决策系统那侧
虽然靠缓存不烧大模型, PMS 这边却会每分钟往评审账本写一行一模一样的驳回记录
半小时三十行, 几只票就是几百行**这正是 2026-07-29 那次把判分锚淹掉的教训**
(十六只深亏票的补仓候选每分钟被拒一次, 一天几千行), 只是换了个入口重来一遍
"""
rows = fetch_all("SELECT DISTINCT ts_code, action FROM pms_action_ledger "
"WHERE verdict = 'REJECT' AND arbiter = 'judge' AND decided_at >= :d",
{"d": since})
return {(r["ts_code"], r["action"]) for r in rows}
def opened_names_today(since) -> list:
"""今天已经落了新建仓指令、或还在提议队列里等确认的票 (去重后的代码列表)。
**只用来显示与留痕, 不做任何上限** 每天开几只由名额资金上限与择时区间自己收敛,
不由一个人为的数字决定 今天开了哪几只得让人一眼看得见, 否则无人值守就成了黑箱
"""
rows = fetch_all(
"SELECT DISTINCT ts_code FROM pms_instruction "
"WHERE action = 'OPEN' AND created_at >= :d "
"UNION "
"SELECT DISTINCT ts_code FROM pms_proposal "
"WHERE action = 'OPEN' AND created_at >= :d",
{"d": since})
return [r["ts_code"] for r in rows]
def list_ledger(*, ts_code=None, limit: int = 200) -> list:
sql = "SELECT * FROM pms_action_ledger"
p = {"n": int(limit)}

View File

@ -108,6 +108,10 @@ def decide(*, side: str, action: str, ts_code: str, now, day: dict, params: dict
# 把昨夜定性顺着决策一起带回去, 规则闸终检要用 (见 rule_gate.BAD_Y_SIGNALS)。
# 缓存命中的那条 advice 里也存着它, 所以十个交易分钟的缓存期内一样有值。
d["y_signal"] = advice.get("y_signal")
# 参考位盘中被改写的说明 (只有新建仓会有)。executor 看到它就在评审账本落一条
# WARN —— 这一路的留痕不能只写在指令的 progress 里, 账本才是判分事实源。
if advice.get("ref_drift"):
d["ref_drift"] = advice["ref_drift"]
return d
note = f"研判动作无法识别: {advice.get('verdict')!r}"
@ -178,16 +182,78 @@ def _advice(*, ts_code, side, action, now, day, pos, left, is_last_day, tdays_le
except (TypeError, ValueError):
valid_min = ttl
valid_min = max(1, min(valid_min, ttl)) # 对端只能缩短有效期, 不能放长
obs = data.get("observed") or {}
adv = {"ymd": today, "verdict": verdict,
"limit_price": data.get("limit_price"),
"reason": str(data.get("reason") or "")[:200],
"confidence": data.get("confidence"),
# 应答 observed 里的昨夜定性: 存进缓存, 让规则闸在缓存期内也拿得到
"y_signal": (data.get("observed") or {}).get("y_signal"),
"y_signal": obs.get("y_signal"),
# 支撑/压力/买入区间也一并存下来 —— 新建仓的漂移比对要用 (见 _check_ref_drift)
"support": obs.get("support"), "pressure": obs.get("pressure"),
"buy_band": obs.get("buy_band"),
"valid_until_min": et.add_trade_minutes(now_min, valid_min),
"consulted_at": et._fmt(now_min)}
drift = _check_ref_drift(action=action, prog=prog, adv=adv, today=today)
if drift:
# 输入在盘中被改写了 —— 本轮改判等待, 并把原因写在明面上。
# 不是拒绝、也不是退实现B: 这是一次「输入不可信, 先不动」的显式等待。
adv = {**adv, "verdict": WAIT, "limit_price": None, "reason": drift[:200],
"ref_drift": drift}
if prog is not None:
prog["exec_advice"] = adv
a = dict(adv)
a["_source"] = "A"
return a, ""
def _f(v):
try:
x = float(v)
return x if x > 0 else 0.0
except (TypeError, ValueError):
return 0.0
def _check_ref_drift(*, action, prog, adv, today) -> str:
"""新建仓的参考位「当日首答锁定, 之后偏离即停」。返回漂移说明, 没漂就返回空串。
要防的是这件事: 择时读的 `strategy_daily_results` 会被盘中跑 push-pool 触发的补扫
**就地改写**实证过两只 000035 的压力 5.2 5.15, 002335 的支撑 31.27 29.00
( 7.3%)已有持仓有摊薄成本与安全垫做锚, 支撑压力漂一点不会让判断翻转; 而新建仓的
买入区间**完全由支撑压力推出来**, 支撑一变, 区间整体平移 上午判现价高于上沿
不追的票, 下午可能变成在区间内出手, 而这时候没有人在看
所以只对 OPEN 生效: 当天第一次拿到应答时把支撑/压力/区间锁进指令的 progress.ref_lock,
之后每次应答都跟它比, 偏离超过 PMS_OPEN_REF_DRIFT_MAX 就改判等待
锁按重置 次日的昨夜结论本来就该是新的一份, 那不叫漂移
顺带一个副作用是特意要的: 漂移一旦发生就在账本里留下痕迹, 漂了几次每次多少都能统计
将来真要决定 fetch_yesterday_strategy 加日期过滤那件事时, 手里有实证而不是印象
"""
if str(action or "").upper() != "OPEN" or prog is None:
return ""
sup, pre = _f(adv.get("support")), _f(adv.get("pressure"))
lock = dict(prog.get("ref_lock") or {})
if int(lock.get("ymd") or 0) != int(today):
if sup or pre: # 当日首答: 锁定, 不比对
prog["ref_lock"] = {"ymd": int(today), "support": sup, "pressure": pre,
"buy_band": adv.get("buy_band"),
"at": adv.get("consulted_at")}
return ""
max_drift = param_store.get_float("PMS_OPEN_REF_DRIFT_MAX", 0.03)
if max_drift <= 0:
return ""
moved = []
for label, key, now_v in (("支撑", "support", sup), ("压力", "pressure", pre)):
old = _f(lock.get(key))
if old <= 0 or now_v <= 0:
continue
gap = abs(now_v / old - 1)
if gap > max_drift:
moved.append(f"{label} {old}{now_v} (偏离 {gap:.1%})")
if not moved:
return ""
return (f"昨夜结论盘中被改写: {'; '.join(moved)}, 超过容忍幅度 {max_drift:.0%} —— "
f"该票新建仓当日暂停。无人值守的建仓不能建在会漂移的输入上 "
f"(锁定于 {lock.get('at') or '当日首答'})")

View File

@ -170,6 +170,24 @@ def run_tick(*, now=None, dry_run: bool = False) -> dict:
if d["action"] != et.ACT_FIRE:
prog["last_decision"] = {"at": now.strftime("%H:%M"), **d}
# 参考位漂移 (只有新建仓会有) 在评审账本里记**一天一条**。
# 漂移一旦发生通常持续整天, 而这一跳是每分钟一次 —— 不去重的话一天能往账本
# 灌几百行一模一样的告警, 把有信息量的行淹掉 (2026-07-29 那个教训)。
# 想看当下状态去指令的 progress.last_decision, 那里每跳都有; 账本这一条是
# 留给事后判分的「这一天该票因为输入漂移没建成仓」。
if (not dry_run and d.get("ref_drift")
and int(prog.get("ref_drift_logged") or 0) != ymd_today):
prog["ref_drift_logged"] = ymd_today
try:
pms_repo.insert_ledger(
ts_code=code, action=ins.get("action"), arbiter="rule",
verdict="WARN", price_at=day_ctx.get("price") or 0,
hard_numbers={"ref_lock": prog.get("ref_lock"),
"advice": (prog.get("exec_advice") or {})},
ref_id=ins["instruction_id"], reason=d["ref_drift"])
except Exception as e:
logger.error("[参考位漂移] 留痕写入失败 %s: %s —— "
"本次漂移只剩指令 progress 里那一份, 账本查不到", code, e)
if not dry_run:
pms_repo.update_instruction(ins["instruction_id"], progress=prog)
out["waited"].append({"instruction_id": ins["instruction_id"], "code": code,

View File

@ -23,6 +23,28 @@ logger = logging.getLogger("pms.judge")
PASS, REJECT, UNAVAILABLE = "PASS", "REJECT", "UNAVAILABLE"
# 新建仓送研判时, hard_numbers 里**只送这几个键**。
#
# 决策系统本来就不管仓位 —— 它是一套聚合了大量信号的判断系统, 「买多少」从来不是它的活。
# 而它收到 hard_numbers 之后是逐键渲染成「【硬数字 (PMS 已算好, 勿重算)】」贴进提示词的,
# 所以把名额、可投金额、批次比例、组合占比原样送过去, 等于当面请一个不该管仓位的系统去看
# 仓位 —— 提示词后面那句「仓位纪律不归你管」压不住已经摆在它眼前的一串数字。
#
# 这里只送「这只票凭什么被选出来」: 现价 + 候选池给的定性材料, 那正是要它裁的问题。
# 账本不受影响 —— pms_action_ledger.hard_numbers_json 照旧存全量, 判分锚一个字不少。
#
# **只对 OPEN 生效。** FILL/ADD/DCA 的硬数字 (安全垫、评估档位、底仓量) 本身就是它们的判据,
# 一个都不能删: 那几类动作是在**已有持仓**上做加减, 谈的就是这个仓位, 与新建仓不是一回事。
OPEN_JUDGE_KEYS = ("price", "score", "theme", "tier", "upside", "heat",
"plan_rank", "plan_bucket", "plan_src", "sector")
def _judge_hard_numbers(action: str, hard: dict) -> dict:
"""按动作裁剪送出去的硬数字。非 OPEN 一律原样送 (行为与 2026-08-06 之前一字不差)。"""
if str(action or "").upper() != "OPEN":
return dict(hard or {})
return {k: v for k, v in (hard or {}).items() if k in OPEN_JUDGE_KEYS}
def enabled() -> bool:
return param_store.get_bool("PMS_JUDGE_ENABLED", True)
@ -65,9 +87,12 @@ def request(candidate: dict, context: dict = None, *, timeout: int = None) -> di
payload = {
"direction": "PMS_JUDGE", "action": action, "ts_code": candidate.get("ts_code"),
"qty": candidate.get("qty"), "reason": candidate.get("reason"),
"hard_numbers": candidate.get("hard_numbers") or {},
# 硬数字按动作裁剪: 新建仓只送定性材料, 不送仓位数字 (见 OPEN_JUDGE_KEYS 的说明)
"hard_numbers": _judge_hard_numbers(action, candidate.get("hard_numbers")),
"context": context or {},
# 设计要求: 补仓类研判必须回答「下跌是杀逻辑还是杀情绪」
# 设计要求: 补仓类研判必须回答「下跌是杀逻辑还是杀情绪」。
# 新建仓的必答 (「把这只票挑出来的驱动到今天还成不成立」) **只写在决策系统的判据里**,
# 这边不传 —— 「建仓该问什么」属于仲裁哲学, 是它的知识, PMS 不需要懂。
"must_answer": (["下跌是杀逻辑还是杀情绪"] if action == "DCA" else []),
}
to = int(timeout or param_store.get_int("PMS_JUDGE_TIMEOUT", 90))

View File

@ -57,6 +57,10 @@ RUNTIME_EXTRA = {
FAIL_CLOSED = {
"PMS_GLOBAL_BUY_HALT": True, # 读不到 → 当作已暂停买入
"PMS_GLOBAL_EXEC_HALT": True, # 读不到 → 当作已暂停执行
# 自主新建仓的默认值是 full (无人值守地开新仓), 所以它读不到时的安全方向格外要紧:
# 参数表抖一下就按 full 走, 等于在读不到任何约束的情况下自己去买没买过的票。
# 读不到 → off (本轮不扫描新建仓), 已有持仓的四类动作不受影响。
"PMS_OPEN_AUTONOMY": "off",
}
# 页面展示用的中文说明 (settings.py 用行尾注释, pydantic 取不到, 故在此集中维护)
@ -118,6 +122,11 @@ DESC = {
"PMS_BRAKE_DRAWDOWN": "组合刹车: 自高水位回撤", "PMS_BRAKE_DAYS": "刹车持续交易日",
"PMS_STOP_ATR_MULT": "自算止损参考: 成本 N×ATR",
"PMS_REF_STALE_TDAYS": "决策系统结论日龄超此转自算兜底",
"PMS_OPEN_AUTONOMY": "自主新建仓档位兼总开关 full / propose_only / off "
"(与 PMS_AUTONOMY 分开: 新建仓要不要人点头是另一个决定)",
"PMS_OPEN_REQUIRE_WS_CASH": "拿不到 ws 资金快照时不自动新建仓 (只影响新建仓, 其余动作照旧)",
"PMS_OPEN_REF_DRIFT_MAX": "参考位盘中被改写超此幅度 → 该票当日暂停新建仓",
"PMS_JUDGE_TICK_BUDGET_SEC": "单轮提议扫描用于研判的时间预算 (秒), 用尽则剩下的候选留到下一跳",
"PMS_JUDGE_ENABLED": "研判闸开关", "PMS_JUDGE_ACTIONS": "需过研判闸的动作",
"PMS_JUDGE_TIMEOUT": "研判超时 (秒) → 降级 propose_only",
"PMS_JUDGE_API_BASE": "决策系统 PMS 研判接口根地址; 留空=未接通, 自动降级人工确认",
@ -302,12 +311,17 @@ _RANGES = {
"PMS_PLAN_MIN_SOURCES": (0, 100), "PMS_PLAN_TOP": (0, 5000),
"PMS_PLAN_OBS_TOP": (0, 5000), "PMS_PLAN_THEME_CAP": (0, 5000),
"PMS_PLAN_THEME_CAP_LOCAL": (0, 1000),
"PMS_OPEN_REF_DRIFT_MAX": (0, 0.5), "PMS_JUDGE_TICK_BUDGET_SEC": (0, 240),
# 上限 240 = scheduler 给调度任务设的软超时。填得比它还大, 预算就形同虚设,
# 任务会先被 celery 打死 (而且是在已经落了一部分表之后)。
}
def _range_check(key, v):
if key == "PMS_AUTONOMY" and v not in ("full", "propose_only", "off"):
return "PMS_AUTONOMY 只能是 full / propose_only / off"
if key == "PMS_OPEN_AUTONOMY" and v not in ("full", "propose_only", "off"):
return "PMS_OPEN_AUTONOMY 只能是 full / propose_only / off"
if key == "PMS_EXEC_IMPL" and str(v).strip().upper() not in ("A", "B"):
return "PMS_EXEC_IMPL 只能是 A (委托决策系统) / B (内置保守择时)"
if key == "PMS_SECTOR_SOURCE" and v not in ("", "gp_hybk", "custom_table",

View File

@ -13,10 +13,20 @@
研判闸不可用时 (决策系统未接通/超时), 按设计自动降级为 propose_only + ERROR 告警,
**绝不把研判拿不到当成研判通过**
2026-08-06 加了第五类动作新建仓 OPEN: 除了管已有持仓, 也从上游候选池里挑新票建底仓,
走的是同一条 规则闸 研判闸 档位分流三处与已有四类不同的地方, 都在本文件里:
* 档位走自己的 `PMS_OPEN_AUTONOMY` (默认 full), 不跟随 `PMS_AUTONOMY` 新建仓要不要
人点头加仓要不要人点头是两个决定 `PMS_AUTONOMY=off` 仍然是总闸, 它一关,
连新建仓一起停: off 的意思就是自主动作全部停下, 不该有子开关能绕过它
* 研判驳回对新建仓做当日去重 (加仓类**有意**不去重, 那条纪律不动), 理由见
`pms_repo.judge_rejected_today`
* 单轮扫描给研判一个时间预算, 用尽的候选留到下一跳 `_judge_budget_left` 的说明
"""
from __future__ import annotations
import logging
import time
from datetime import datetime, timedelta
from app.core import action_engine as ae
@ -24,7 +34,8 @@ from app.core import command_spec as cs
from app.core import rule_gate
from app.core import tradedays as td
from app.repo import pms_repo
from app.services import (command_service, executor, judge, market, param_store, portfolio)
from app.services import (command_service, executor, industry, judge, market, param_store,
plan_feed, portfolio)
logger = logging.getLogger("pms.proposal")
@ -34,27 +45,32 @@ AUTONOMY_FULL, AUTONOMY_PROPOSE, AUTONOMY_OFF = "full", "propose_only", "off"
def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
"""自主提议扫描一轮。dry_run=True 只出候选与判定, 不落任何表。"""
now = now or datetime.now()
out = {"ok": True, "autonomy": None, "candidates": 0, "executed": [], "queued": [],
out = {"ok": True, "autonomy": None, "open_autonomy": None, "candidates": 0,
"open_candidates": 0, "executed": [], "queued": [],
"rejected": [], "skipped": [], "errors": [], "degraded": False, "dry_run": dry_run}
autonomy = param_store.get("PMS_AUTONOMY", AUTONOMY_PROPOSE)
out["autonomy"] = autonomy
if autonomy == AUTONOMY_OFF:
out["skipped"].append({"why": "自主档位 off, 不扫描"})
out["skipped"].append({"why": "自主档位 off, 不扫描 (新建仓一并停 —— off 是总闸)"})
return out
if param_store.get_bool("PMS_GLOBAL_EXEC_HALT", False):
out["skipped"].append({"why": "全局暂停执行 (休假模式)"})
return out
open_autonomy = param_store.get("PMS_OPEN_AUTONOMY", AUTONOMY_FULL)
out["open_autonomy"] = open_autonomy
try:
view = portfolio.positions_view()
params = _scan_params(view)
mkt = _market_ctx(view["held"], now)
params["_mkt"] = mkt # 规则闸要用同一份 MA5, 不再重取
# 跳过类: ①已有在途提议或指令的 ②今天已被规则闸拒过的。
# 后者是 2026-07-29 的教训 —— 组合已超总仓上限时, 16 只深亏票的补仓候选每分钟被拒
# 跳过类: ①已有在途提议或指令的 ②今天已被规则闸拒过的 ③今天已被研判闸驳回的新建仓
# 是 2026-07-29 的教训 —— 组合已超总仓上限时, 16 只深亏票的补仓候选每分钟被拒
# 一次, 一天往评审账本灌几千行一模一样的记录。闸门结论当天基本不会变, 记一次就够。
skip = _inflight_keys() | _rejected_today_keys()
# ③ 只挡新建仓: 加仓类对研判驳回**有意**不去重 (契约里那句「研判结论会变」), 那条
# 纪律不动; 而候选池是几十只的量级, 不挡的话 ② 那个洞会原样从研判闸重来一遍。
skip = _inflight_keys() | _rejected_today_keys() | _judge_rejected_open_keys()
scanned = ae.scan(positions=view["held"], params=params, market=mkt, skip=skip)
except Exception as e:
logger.exception("提议扫描失败")
@ -65,9 +81,27 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
stock_params = command_service.effective_stock_params()
brake_active = td.ymd() < param_store.get_int("PMS_BRAKE_UNTIL", 0)
for c in scanned["candidates"]:
# ---- 新建仓: 候选池那一路 (取不到候选池不影响上面四类, 反之亦然) ----
open_cands = []
if open_autonomy == AUTONOMY_OFF:
out["skipped"].append({"action": ae.A_OPEN, "why": "新建仓档位 off, 不扫描候选池"})
else:
try:
_route_one(c, view, params, stock_params, brake_active, now, dry_run, out)
open_cands = _scan_open(view, params, stock_params, skip, mkt, out)
except Exception as e:
logger.exception("新建仓扫描失败")
out["errors"].append(f"新建仓扫描失败: {type(e).__name__}: {e}")
out["open_candidates"] = len(open_cands)
# 研判的时间预算从这一刻起算。**先跑已有持仓的四类, 再跑新建仓** —— 预算真用尽时,
# 被推到下一跳的一定是新建仓, 已有持仓的动作行为与 2026-08-06 之前一致。
deadline = time.monotonic() + max(
0, param_store.get_int("PMS_JUDGE_TICK_BUDGET_SEC", 150))
for c in list(scanned["candidates"]) + open_cands:
try:
_route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
deadline=deadline)
except Exception as e:
logger.exception("提议分流失败 %s", c.get("ts_code"))
out["errors"].append(f"{c.get('ts_code')} {c.get('action')}: "
@ -76,10 +110,93 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
return out
def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out):
# ================================================================ 新建仓的取数与筛选
def _scan_open(view, params, stock_params, skip, mkt, out) -> list:
"""候选池 → 新建仓候选。产出的候选数天生不超过剩余名额 (名额与金额在纯逻辑里边走边扣)。
与命令驱动那条路 (command_service._candidates) 有意不同的两点:
1. **只认上游计划接口这一个来源** buy_plan 那张旧表在目标架构下没有明确的写入方,
白名单是给人下命令用的 无人值守地建仓, 事实源必须单一
2. **价格只认实时价** 那边用 market.plan_price, 拿不到实时价会回落昨收; 它自己的
注释就写着拿昨收当现价去做不追高这类判断会出错, 只给规划期定量用
这条路是要真下单的, 所以走 market.get_prices, 取不到就整只跳过并留痕
"""
src = (param_store.get("PMS_CANDIDATE_SOURCE", plan_feed.SRC_PLAN_API)
or plan_feed.SRC_PLAN_API).strip()
if src == plan_feed.SRC_BUY_PLAN:
out["skipped"].append({"action": ae.A_OPEN,
"why": f"候选池来源是 {src}, 自主新建仓只认上游计划接口"})
return []
black = {c for c, d in (stock_params or {}).items() if d.get("black")}
held = [x["ts_code"] for x in view["held"]]
try:
sel = plan_feed.candidates(held=held, black=black)
except plan_feed.PlanFeedError as e:
# 拿不到 ≠ 今天没票可买。显式留痕, 本轮不产新建仓候选, 已有持仓的四类照常。
logger.error("[新建仓] 候选池取数失败, 本轮不建仓: %s", e)
out["skipped"].append({"action": ae.A_OPEN, "why": f"候选池取不到, 本轮不建仓: {e}"})
return []
items = list(sel.get("items") or [])
if not items:
out["skipped"].append({"action": ae.A_OPEN,
"why": f"候选池过滤后为空 (考察 {sel.get('considered')} 只, "
f"落选明细 {sel.get('dropped')})"})
return []
codes = [x["ts_code"] for x in items]
prices = market.get_prices(codes)
# 行业名一次批量取, 本轮复用。滚动扣减要算行业集中度, 所以必须先有它 ——
# caps_ctx 对**从没持仓过**的票带不出行业名, 不显式传的话 sizer.check_caps 遇到
# sector 为空会整段跳过, 行业集中度那道硬拦截就静默失效了。
# (industry.get_many 走 gp_stock_category 时是逐只查库、没有缓存; 给它加按日缓存能
# 省掉这几十次往返, 但那会改到既有函数的时序行为, 单独提、单独拍板, 这次不夹带。)
sectors = industry.get_many(codes) if codes else {}
cands = [{**x, "price": prices.get(x["ts_code"]), "sector": sectors.get(x["ts_code"])}
for x in items]
t = view["totals"]
p = view["params"]
slots = int(p["max_names"] or 0) - int(t["names_count"] or 0)
room = float(p["portfolio_cap"] or 0) * float(p["scale"] or 0) - float(t["portfolio_mv"] or 0)
# 真实可用资金封顶。**这一条比规则闸严一档, 是刻意的**: 规则闸那条「拿不到 ws 资金快照
# 只告警不拦」是为**已经排好的命令**设计的 —— 通道故障不该升级成业务停摆。而无人值守地
# 从零建仓完全可以等一等, 07-30 那次 (scale 200 万 / 账户实际 98 万, 方案一路放行到
# 下游才被拒, 而拒了不自动重发) 不该换个入口重演。
if str(t.get("cash_source") or "") == portfolio.CASH_WS and t.get("cash_avail") is not None:
room = min(room, float(t["cash_avail"]))
elif param_store.get_bool("PMS_OPEN_REQUIRE_WS_CASH", True):
out["skipped"].append({"action": ae.A_OPEN,
"why": f"拿不到 ws 资金快照 ({t.get('cash_why') or '原因未知'}), "
f"本轮不自动新建仓 (PMS_OPEN_REQUIRE_WS_CASH=True)"})
return []
res = ae.scan_open(candidates=cands, params=params, caps=portfolio.caps_ctx(view),
room_amt=room, slots=slots, skip=skip)
out["skipped"].extend(res["skipped"])
# 当日行情快照只对**真的产出了候选**的票取 —— 候选数已被名额与金额扣到很小,
# 不必为整池几十只票各拉一遍分钟线。规则闸的「不追高」靠的就是这一份。
for c in res["candidates"]:
code = c["ts_code"]
d = {"ma5": market.get_ma5(code)}
try:
d["day"] = market.day_snapshot(code) or {}
except Exception as e:
logger.warning("[新建仓] 取当日快照失败 %s: %s", code, e)
d["day"] = {}
mkt[code] = d
return res["candidates"]
def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
*, deadline=None):
code, action, side = c["ts_code"], c["action"], c["side"]
is_open = (action == ae.A_OPEN)
pos = _pos_of(view, code)
price = float(pos.get("price") or 0)
# 新建仓的现价取候选自带的那份。**不能取 pos 的**: 从没持仓过的票在账本里没有行,
# `_pos_of` 回的是空壳、没有 price, 取它会是 0, 规则闸一上来就判 PRICE_MISSING。
price = float(c.get("price") or 0) if is_open else float(pos.get("price") or 0)
# ---- 一级: 规则闸 (自主动作受刹车约束, is_command=False) ----
gate = rule_gate.check(
@ -95,7 +212,13 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out):
"params": {"no_chase_ma5": params.get("no_chase_ma5"),
"buy_halt_dayup": params.get("buy_halt_dayup"),
"sector_source_ready": view["sector_ready"]},
"caps": portfolio.caps_ctx(view, ts_code=code) if side == "buy" else None,
# 新建仓必须**显式**把 is_new_name 与行业名传进去。caps_ctx 只在
# view["positions"] 里找得到该票时才带得出行业, 而新票根本不在里面 ——
# 不传的话 sizer.check_caps 遇到 sector 为空会整段跳过, 行业集中度那道硬拦截
# 就静默失效了 (不报错、不少数据, 就是不生效)。
"caps": (portfolio.caps_ctx(view, ts_code=code, is_new_name=True,
sector=c.get("sector")) if is_open
else (portfolio.caps_ctx(view, ts_code=code) if side == "buy" else None)),
"flags": {"buy_halt": params.get("buy_halt"), "exec_halt": params.get("exec_halt"),
"brake_active": brake_active,
"blacklisted": bool(stock_params.get(code, {}).get("black")),
@ -109,9 +232,18 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out):
failed_checks=gate["failed"], reason="自主提议未过规则闸")
return
# ---- 二级: 研判闸 (补足/加仓/补仓; 减持不送研判) ----
# ---- 二级: 研判闸 (补足/加仓/补仓/新建仓; 减持不送研判) ----
verdict = {"verdict": judge.PASS, "reason": "", "degraded": False}
if c.get("judge_required"):
if not _judge_budget_left(deadline):
# 本轮研判时间用尽 —— 整条跳过, 下一跳重来。
# **不当成「研判不可用」入人工队列**: 那会在自动档位下凭空造出一个人工确认队列,
# 与「不用每天靠人」这件事正好相反。规则闸白跑一次的成本是毫秒级。
out["skipped"].append({"ts_code": code, "action": action,
"why": "本轮研判时间预算用尽, 下一跳继续 "
f"(PMS_JUDGE_TICK_BUDGET_SEC="
f"{param_store.get_int('PMS_JUDGE_TICK_BUDGET_SEC', 150)}s)"})
return
verdict = judge.request(c, context={"position": _judge_ctx(pos),
"recent_ledger": _recent_ledger(code)})
if verdict["verdict"] == judge.REJECT:
@ -127,7 +259,11 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out):
out["degraded"] = True
# ---- 三级: 按档位分流 ----
autonomy = out["autonomy"]
# 新建仓走自己的档位 (PMS_OPEN_AUTONOMY), 不跟随全局 —— 「新建仓要不要人点头」和
# 「加仓要不要人点头」是两个不同的决定。全局 off 已经在 scan_and_route 入口拦掉了,
# 走到这里说明总闸是开的。三条无条件覆盖档位的规矩对新建仓一样有效: 减持自动、
# 深档补仓强制确认、研判不可用一律入队。
autonomy = (out.get("open_autonomy") or AUTONOMY_FULL) if is_open else out["autonomy"]
force_queue = bool(c.get("needs_user_confirm")) or verdict.get("degraded")
auto_exec = (side == "sell") or (autonomy == AUTONOMY_FULL and not force_queue)
@ -145,15 +281,35 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out):
ref_id=iid,
reason=(verdict.get("reason") or c["reason"])[:500])
out["executed"].append({**_brief(c), "instruction_id": iid,
"why": "减持方向自动执行" if side == "sell" else "档位 full"})
"why": ("减持方向自动执行" if side == "sell"
else ("新建仓档位 full" if is_open else "档位 full"))})
else:
pid = _make_proposal(c, price, verdict)
why = ("深档补仓强制确认" if c.get("needs_user_confirm")
else ("研判不可用, 降级人工确认" if verdict.get("degraded")
else "档位 propose_only"))
else ("新建仓档位 propose_only" if is_open else "档位 propose_only")))
out["queued"].append({**_brief(c), "proposal_id": pid, "why": why})
def _judge_budget_left(deadline) -> bool:
"""这一轮还够不够再送一次研判。
**这不是节流, 是让一次心跳做得完** judge.request 是同步阻塞的, 单次上限
PMS_JUDGE_TIMEOUT (默认 90 ), scheduler 给所有调度任务设的软超时是 240
三只票送研判就顶破了, 任务被 celery 打死在中途, 而且是在已经落了一部分表之后
以前送研判的只有已有持仓那几只多数轮次还被去重挡掉, 所以一直没撞上; 新建仓上线后
冷启动那天会有十来条候选, 第一跳就会捅穿
预算不够时调用方整条跳过并留痕, 下一分钟的心跳接着做 一条候选都不丢, 也没有任何
按天计的上限决策系统那侧的裁决按日期+股票+动作缓存半小时, 所以真正慢的只有
缓存过期后的第一跳, 之后同一批候选都是秒回
"""
if deadline is None:
return True
need = max(1, param_store.get_int("PMS_JUDGE_TIMEOUT", 90))
return (time.monotonic() + need) <= deadline
# ================================================================ 落表
def _make_instruction(c, price, now) -> str:
ymd = td.ymd(now)
@ -299,6 +455,21 @@ def _rejected_today_keys() -> set:
return set()
def _judge_rejected_open_keys() -> set:
"""今天已被研判闸驳回的**新建仓** (代码, 动作)。读不到就返回空集 (同上: 去重是降噪)。
只取 OPEN 那些键加仓类对研判驳回**有意**不做当日去重 契约里那句研判结论会变,
节流责任放在决策系统那侧的半小时缓存上, 这条纪律一个字不动
"""
try:
keys = pms_repo.judge_rejected_today(datetime.now().replace(
hour=0, minute=0, second=0, microsecond=0))
except Exception as e:
logger.warning("读当日研判驳回记录失败 (按未驳回过继续扫描): %s", e)
return set()
return {(code, act) for code, act in keys if act == ae.A_OPEN}
def _inflight_keys() -> set:
"""已有在途提议或在途指令的 (代码, 动作) —— 同一件事不重复提。"""
keys = set()

View File

@ -71,6 +71,25 @@ class Settings(BaseSettings):
PMS_WEAK_NEG_DAYS: int = 5 # 降仓「清弱票」判定: 安全垫连续为负 N 日 (设计 §3.2)
PMS_PROPOSAL_TTL_HOURS: int = 24 # 自主提议待确认有效期 (超时置 EXPIRED)
# --- 自主新建仓 (动作引擎第五类动作 OPEN, 2026-08-06) ---
# 让自主提议除了管已有持仓, 也能从上游候选池里挑新票建底仓, 走同一条
# 规则闸 → 研判闸 → 档位分流。要解决的是「每天靠人下一条升仓命令」这件事。
# **刻意不设每日开仓上限**: 名额与金额在 action_engine.scan_open 里边走边扣,
# 候选数天生不超过剩余名额; 再往后还有择时的买入区间做天然过滤 (现价不在区间内
# 一律等待、不追)。每天实际建成几只由这几层自己收敛, 不由一个人为的数字决定。
PMS_OPEN_AUTONOMY: str = "full" # 新建仓档位兼总开关: full / propose_only / off
# 与 PMS_AUTONOMY 分开的理由: 「新建仓要不要人点头」和「加仓要不要人点头」是两个
# 不同的决定, 不该被一个开关捆住。off = 根本不扫描新建仓, 行为回到 2026-08-06 之前。
PMS_OPEN_REQUIRE_WS_CASH: bool = True # 拿不到 ws 资金快照时不自动新建仓 (写 skipped 留痕)
# 规则闸那条「拿不到真实资金只告警不拦」是为**已经排好的命令**设计的 —— 通道故障
# 不该升级成业务停摆。但无人值守地从零建仓是可以等的, 所以这里比规则闸严一档:
# 07-30 教训 (scale 200 万 / 账户实际 98 万, 方案一路放行到下游才被拒) 不能重演。
PMS_OPEN_REF_DRIFT_MAX: float = 0.03 # 参考位盘中被改写超此幅度 → 该票当日暂停新建仓
# 择时读的 strategy_daily_results 会被盘中补扫就地改写 (实证: 002335 支撑 31.27→29.00,
# 差 7.3%)。已有持仓有摊薄成本与安全垫做锚, 漂一点不翻转判断; 而新建仓的买入区间
# 完全由支撑压力推出来, 区间一平移, 上午「不追」的票下午可能变成「出手」, 而无人在看。
# 处置: 当日首答把 observed 的支撑/压力/区间锁进指令 progress.ref_lock, 之后每答比对。
# --- 上游选股计划接口 (候选池的事实源; 口径与待确认项见 UPSTREAM_PLAN_API.md) ---
# 上游只回答「买什么、排第几」, 不给价格金额 —— 买多少/什么价是 PMS 自己算。
# 接口拿不到一律显式失败 (候选池为空 + ERROR), 绝不静默回退旧表。
@ -180,8 +199,15 @@ class Settings(BaseSettings):
# --- 研判闸 (委托决策系统) ---
PMS_JUDGE_ENABLED: bool = True
PMS_JUDGE_ACTIONS: str = "FILL,ADD,DCA,SWITCH"
PMS_JUDGE_ACTIONS: str = "FILL,ADD,DCA,SWITCH,OPEN"
PMS_JUDGE_TIMEOUT: int = 90 # 超时 → 降级 propose_only
PMS_JUDGE_TICK_BUDGET_SEC: int = 150 # 单轮提议扫描用于研判的时间预算 (秒)
# **这不是节流, 是让一次心跳做得完。** judge.request 是同步阻塞的, 单次上限 90 秒,
# 而 scheduler 给所有调度任务设的软超时是 240 秒 —— 三只票送研判就顶破了。
# 以前送研判的只有三只持仓票且多数轮次被去重挡掉, 所以没撞上; 新建仓上线后
# 冷启动那天会有十来条, 第一跳就会把任务打死在中途 (而且是已经落了一部分表之后)。
# 预算用尽时本轮剩下的候选整条跳过并写 skipped, 下一分钟的心跳接着做 —— 一条都不丢,
# 也没有任何按天计的上限。候选按分数降序, 先做完的一定是分数最高的那些。
PMS_JUDGE_API_BASE: str = "" # 决策系统 PMS 研判接口根地址; 空=未接通(自动降级人工确认)
PMS_JUDGE_PATH: str = "/api/intraday/pms_judge" # 研判接口路径 (bionic 侧配套改造后确定)

View File

@ -16,8 +16,9 @@
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (49 )
test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (48 )
test_batch11_units.py 择时实现A: 本地检查等价/应答折算/缓存冷却/退B (24 )
test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算 (26 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 )
425
451
任一子集失败即整体失败 (退出码 1)
"""
import os
@ -29,7 +30,8 @@ ROOT = os.path.dirname(HERE)
SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
"test_batch4_units.py", "test_batch5_units.py", "test_batch6_units.py",
"test_batch7_units.py", "test_batch8_units.py", "test_batch9_units.py",
"test_batch10_units.py", "test_batch11_units.py", "test_wiring.py"]
"test_batch10_units.py", "test_batch11_units.py", "test_batch12_units.py",
"test_wiring.py"]
def main():