跳过原因分得开: skip 改成带原因的字典 (在途提议/在途指令/规则闸/研判闸四种处境), 兼容集合口径

This commit is contained in:
zlt 2026-08-06 15:57:52 +08:00
parent a14db0c833
commit d8829daa6b
7 changed files with 168 additions and 30 deletions

View File

@ -172,6 +172,54 @@ PMS_UNAVAILABLE 三个值,而 `pms_advisor._today_exit_verdict` 第 102 行明
---
## 2026-08-06 · 新建仓盘中真跑了 40 分钟,第一份实测;跳过原因改成分得开
**这一条是实测记录,不是设计。** 新建仓在今天下午约 14:07 起真跑了四十分钟
(部署时间比原计划提前,是在盘中做的)。
**研判判据生效,方向对。** 评审账本里 11 条 `arbiter=judge、action=OPEN、verdict=REJECT`
理由**每一条都在谈「开新仓」**——「建仓缺乏资金支持」「开新仓风险过高」「新建仓证据不足」,
没有一条把它当成加仓在谈;而且确实用上了决策系统自动补的实时资金分布(主力净流占比、
主散背离)去对照「把这只票挑出来的驱动到今天还成不成立」。判收第一步通过。
**当日去重立竿见影。** 驳回集中在 14:07 到 14:38 这 31 分钟,扫完一遍候选池之后就静默了。
没有这道去重,这 22 只会每分钟重送一次研判,一个下午上千次大模型调用与上千行账本。
研判速度也对得上预期31 分钟十几只,正是 150 秒预算下一跳一到两条的节奏。
**今天一股没建,驳回率 100%。** 多条理由提到「宏观空头环境」「市场空头相位」「熊市共振」
「下降通道」,今天本来就是空头,全驳回大概率是合理判断,不是判据写窄。
**但埋了一处要盯**OPEN 判据末尾那句「这是开新仓不是加仓,没有既有仓位做缓冲,建错了
代价全额承担,标准应当比加仓类更严,不确定就 REJECT」加上边界声明里的「宁可错杀不可
错放」,**是双重保守**。若接下来两三天在正常或偏多的市场里仍接近全驳回,就是这句话把模型
推过头了,要调轻。一天的数据不足以判断。
**这份实测暴露的可读性问题,已改**22 条跳过全写着同一句「已有在途提议或指令, 或今日已被
闸门拒过」,把四种完全不同的处境揉成一句——有在途提议、有在途指令、今日被规则闸拒过、
今日被研判闸驳回过。前两种明天照样被挡,后两种日切就重新评估,而它们长得一模一样,
看的人判断不了这只票明天还会不会再被评估。这违反了 `scan` 入口自己立的那条规矩:
「什么都没发生」和「明着跳过了」必须是两回事——现在虽然明着跳过了,却说不清为什么。
改法:`skip` 从集合改成 `{(代码, 动作): 原因}` 字典。`in` 的语义不变,
新增 `action_engine.skip_why()` 取原因,**传集合进来仍然工作**(退回一句兜底话,
既有单测与临时调用不受影响)。三个来源各写各的原因,在途的排在最后覆盖被拒的——
一只票既有在途指令又今天被拒过时,「有在途」更贴近它此刻的状态。
在途那两条还带上提议号、指令号与状态,让人从这一行就能接着往下查。
**动了哪些文件**`app/core/action_engine.py``skip_why` 与两个扫描入口)、
`app/services/proposal_service.py`(三个来源改成带原因的字典);
测试 `scripts/test_batch12_units.py` 加 3 例,`scripts/test_batch4_units.py` 与
`scripts/test_wiring.py` 各改一处断言(原本钉着那句笼统话的字面量)。
全量单测 **ALL SUITES PASS467 例**
**一条要更正的承诺**:先前说过「既有 `scan()` 一个字不动」,这次动了它——为的是让跳过留痕
说得清。`in` 的语义与向后兼容都保住了,但这个承诺本身要如实更正,不该含糊过去。
**我在这一轮犯的一个误判**:账本里 14:38 有两条对没持仓票的 `PMS_REJECT`,我据此推断
「新建仓还没上线,所以不可能,应该是探活脚本」——实际是新建仓已经部署并在跑。
**推理没落地就当结论**,与 07 月那次 `audit_date` 的教训同型。该先查账本再下判断。
---
## 2026-08-06 · 收盘后四条只读诊断,外加一个由它们暴露出来的窗口末日追高
**四条诊断的结论**

View File

@ -200,16 +200,39 @@ def eval_trim(p: dict, params: dict, mkt: dict = None):
EVALUATORS = ((A_TRIM, eval_trim), (A_ADD, eval_add), (A_FILL, eval_fill), (A_DCA, eval_dca))
# ================================================================ 跳过原因
SKIP_WHY_FALLBACK = "已有在途提议或指令, 或今日已被闸门拒过"
def skip_why(skip, key):
"""这个 (代码, 动作) 为什么被跳过。不在跳过集合里返回 None。
skip 可以是 `{(代码, 动作): 原因}` 字典, 也可以是只有键的集合 (旧口径)
集合形式退回一句笼统的兜底话 这是 2026-08-06 之前的样子, 留着是为了兼容
既有单测与临时调用, 生产路径由 proposal_service 传字典进来
为什么值得专门改这一处: 那句兜底话把四种处境揉成了一句 有在途提议有在途指令
今天被规则闸拒过今天被研判闸驳回过前两种明天照样会被挡, 后两种日切就重新评估,
而它们在页面上长得一模一样, 看的人判断不了这只票明天还会不会再被评估
明着跳过了不该等于说不清为什么跳过
"""
if not skip or key not in skip:
return None
if isinstance(skip, dict):
return skip.get(key) or SKIP_WHY_FALLBACK
return SKIP_WHY_FALLBACK
# ================================================================ 扫描入口
def scan(*, positions: list, params: dict, market: dict, skip: set = None) -> dict:
def scan(*, positions: list, params: dict, market: dict, skip=None) -> dict:
"""扫描全部持仓, 产出候选动作。
positions: portfolio.positions_view()["held"] 的口径
market: {ts_code: {ma5, high5, tdays_since_open, tdays_since_last_add}}
skip: 已有在途提议/指令的 (ts_code, action) 集合 不重复提
skip: 不再评估的 (代码, 动作) 字典时值是跳过原因, 集合时退回兜底话
返回 {"candidates": [...], "skipped": [...]}
"""
skip = skip or set()
skip = skip or {}
out, skipped = [], []
for p in positions or []:
code = p.get("ts_code")
@ -228,8 +251,9 @@ def scan(*, positions: list, params: dict, market: dict, skip: set = None) -> di
mkt = (market or {}).get(code) or {}
frozen = (p.get("frozen_reason") or "NONE") != "NONE"
for action, fn in EVALUATORS:
if (code, action) in skip:
skipped.append({"ts_code": code, "action": action, "why": "已有在途提议/指令"})
why = skip_why(skip, (code, action))
if why:
skipped.append({"ts_code": code, "action": action, "why": why})
continue
# 冻结只禁增持, 减仓照评 (与规则闸同一口径, 这里先剪枝少算一遍)
if frozen and action != A_TRIM:
@ -334,7 +358,7 @@ def eval_open(c: dict, params: dict, caps: dict, room_amt: float):
def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float,
slots: int, skip: set = None) -> dict:
slots: int, skip=None) -> dict:
"""从上游候选池挑新票建仓。名额与金额**边走边扣**, 所以产出的这一组候选彼此不冲突。
为什么滚动必须在这里做, 而不是交给规则闸: `_route_one` 给每条候选调 `caps_ctx` ,
@ -348,9 +372,10 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float,
slots: 还能开几只新仓 = 最大持仓数 当前持仓数 (由调用方算好)
room_amt: 还有多少钱可投, 已取总仓上限×总规模 组合市值真实可用资金的小者
skip: 不再评估的 (代码, 动作) 集合 在途提议/指令当日已被规则闸或研判闸拒过的
skip: 不再评估的 (代码, 动作) 字典时值是跳过原因 (在途提议 / 在途指令 /
今日被规则闸拒过 / 今日被研判闸驳回过, 这四种处境完全不同, 必须分得开)
"""
skip = skip or set()
skip = skip or {}
out, skipped = [], []
slots = int(slots or 0)
left = _f(room_amt)
@ -390,9 +415,9 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float,
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": "已有在途提议或指令, 或今日已被闸门拒过"})
why_skip = skip_why(skip, (code, A_OPEN))
if why_skip:
skipped.append({"ts_code": code, "action": A_OPEN, "why": why_skip})
continue
try:
cand, why = eval_open(c, params, ctx, left)

View File

@ -70,7 +70,12 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
# 一次, 一天往评审账本灌几千行一模一样的记录。闸门结论当天基本不会变, 记一次就够。
# ③ 只挡新建仓: 加仓类对研判驳回**有意**不去重 (契约里那句「研判结论会变」), 那条
# 纪律不动; 而候选池是几十只的量级, 不挡的话 ② 那个洞会原样从研判闸重来一遍。
skip = _inflight_keys() | _rejected_today_keys() | _judge_rejected_open_keys()
#
# 三份合并成 `{(代码, 动作): 原因}`, **在途的排在最后, 覆盖被拒的** (2026-08-06):
# 这四种处境完全不同 —— 在途的明天照样被挡, 被拒的日切就重新评估。从前它们在
# skipped 里长得一模一样, 看的人判断不了这只票明天还会不会再被评估。
# 一只票既有在途指令又今天被拒过时, 显示"有在途"更贴近它此刻的实际状态。
skip = {**_judge_rejected_open_keys(), **_rejected_today_keys(), **_inflight_keys()}
scanned = ae.scan(positions=view["held"], params=params, market=mkt, skip=skip)
except Exception as e:
logger.exception("提议扫描失败")
@ -453,15 +458,16 @@ def _tdays_between(start, today):
return None
def _rejected_today_keys() -> set:
"""今天已被规则闸拒过的 (代码, 动作)。读不到就返回空 —— 去重是降噪, 不是纪律,
def _rejected_today_keys() -> dict:
"""今天已被规则闸拒过的 {(代码, 动作): 原因}。读不到就返回空 —— 去重是降噪, 不是纪律,
读失败时宁可多记几行日志, 也不能因此漏扫一个本该评估的动作"""
try:
return pms_repo.rule_rejected_today(datetime.now().replace(
keys = pms_repo.rule_rejected_today(datetime.now().replace(
hour=0, minute=0, second=0, microsecond=0))
except Exception as e:
logger.warning("读当日规则闸拒绝记录失败 (按未拒过继续扫描): %s", e)
return set()
return {}
return {k: "今日已被规则闸拒过 (日切后重新评估; 拒因看 make t-gate)" for k in keys}
def _buy_signals_today() -> dict:
@ -475,8 +481,8 @@ def _buy_signals_today() -> dict:
return {}
def _judge_rejected_open_keys() -> set:
"""今天已被研判闸驳回的**新建仓** (代码, 动作)。读不到就返回空 (同上: 去重是降噪)。
def _judge_rejected_open_keys() -> dict:
"""今天已被研判闸驳回的**新建仓** {(代码, 动作): 原因}。读不到就返回空 (同上: 去重是降噪)。
只取 OPEN 那些键加仓类对研判驳回**有意**不做当日去重 契约里那句研判结论会变,
节流责任放在决策系统那侧的半小时缓存上, 这条纪律一个字不动
@ -486,21 +492,29 @@ def _judge_rejected_open_keys() -> set:
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}
return {}
return {(code, act): "今日已被研判闸驳回 (日切后重新评估; 驳回理由看 make t-gate)"
for code, act in keys if act == ae.A_OPEN}
def _inflight_keys() -> set:
"""已有在途提议或在途指令的 (代码, 动作) —— 同一件事不重复提。"""
keys = set()
def _inflight_keys() -> dict:
"""已有在途提议或在途指令的 {(代码, 动作): 原因} —— 同一件事不重复提。
提议与指令分开写原因, 并且**指令写在后面覆盖提议**: 一条提议被采纳之后会变成指令,
这时候有在途指令有提议在等确认更贴近它此刻的状态原因里带上单号与状态,
是为了让人从 skipped 那一行就能接着往下查, 不必再去翻两张表
"""
keys = {}
try:
for p in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=200):
keys.add((p["ts_code"], p["action"]))
keys[(p["ts_code"], p["action"])] = \
f"已有在途提议 {p.get('proposal_id')} 在等人确认"
except Exception as e:
logger.warning("读提议队列失败: %s", e)
try:
for i in pms_repo.list_instructions(statuses=list(executor.LIVE), limit=300):
keys.add((i["ts_code"], i.get("action")))
keys[(i["ts_code"], i.get("action"))] = \
f"已有在途指令 {i.get('instruction_id')} ({i.get('status')})"
except Exception as e:
logger.warning("读在途指令失败: %s", e)
return keys

View File

@ -17,9 +17,9 @@
test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (48 )
test_batch11_units.py 择时实现A: 本地检查等价/应答折算/缓存冷却/退B (24 )
test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算/
转多信号插队与留痕/窗口末日只对命令强制完成 (39 )
转多信号插队与留痕/窗口末日只对命令/跳过原因分得开 (42 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 )
464
467
任一子集失败即整体失败 (退出码 1)
"""
import os

View File

@ -211,6 +211,49 @@ def _():
assert "在途" in r["skipped"][0]["why"], r["skipped"]
@case("跳过原因·字典形式把四种处境分得开 (在途 / 规则闸 / 研判闸)")
def _():
skip = {
("600000.SH", "OPEN"): "已有在途指令 INS_20260806_600000SH_OPEN_001 (DISPATCHED)",
("600001.SH", "OPEN"): "今日已被规则闸拒过 (日切后重新评估; 拒因看 make t-gate)",
("600002.SH", "OPEN"): "今日已被研判闸驳回 (日切后重新评估; 驳回理由看 make t-gate)",
}
cs = [cand("600000.SH", score=300, rank=1), cand("600001.SH", score=200, rank=2),
cand("600002.SH", score=100, rank=3)]
r = ae.scan_open(candidates=cs, params=params(), caps=caps(),
room_amt=1_400_000, slots=20, skip=skip)
assert r["candidates"] == [], r
got = {s["ts_code"]: s["why"] for s in r["skipped"]}
assert "在途指令" in got["600000.SH"] and "DISPATCHED" in got["600000.SH"], got
assert "规则闸" in got["600001.SH"] and "日切后重新评估" in got["600001.SH"], got
assert "研判闸" in got["600002.SH"] and "日切后重新评估" in got["600002.SH"], got
# 「明天还会不会再评估」这个问题, 光看这一行就该答得出来
assert "日切后重新评估" not in got["600000.SH"], "在途的明天照样被挡, 别写成会重来"
@case("跳过原因·传集合进来仍然工作 (旧口径的向后兼容)")
def _():
r = ae.scan_open(candidates=[cand("600000.SH")], params=params(), caps=caps(),
room_amt=1_400_000, slots=20, skip={("600000.SH", "OPEN")})
assert r["skipped"][0]["why"] == ae.SKIP_WHY_FALLBACK, r["skipped"]
assert ae.skip_why({("A", "OPEN")}, ("A", "OPEN")) == ae.SKIP_WHY_FALLBACK
assert ae.skip_why({("A", "OPEN")}, ("B", "OPEN")) is None
assert ae.skip_why(None, ("A", "OPEN")) is None
# 字典里值是空的也不能当成"没被跳过"
assert ae.skip_why({("A", "OPEN"): ""}, ("A", "OPEN")) == ae.SKIP_WHY_FALLBACK
@case("跳过原因·已有四类动作也跟着分得开 (scan 与 scan_open 同一套)")
def _():
pos = [{"ts_code": "600000.SH", "total_qty": 1000, "avail_qty": 1000,
"price": 10.0, "price_ok": True, "cushion_pct": 0.05, "cushion_peak": 0.10,
"frozen_reason": "NONE", "base_qty": 1000}]
skip = {("600000.SH", "TRIM"): "已有在途指令 INS_x (DISPATCHED)"}
r = ae.scan(positions=pos, params=params(), market={}, skip=skip)
trim = [s for s in r["skipped"] if s["action"] == "TRIM"]
assert trim and "在途指令" in trim[0]["why"], r["skipped"]
@case("新建仓·单票评估异常不拖垮整轮, 后面的票照常评")
def _():
orig = ae.eval_open

View File

@ -161,7 +161,10 @@ def _():
"600519.SH": mkt()},
skip={("000001.SZ", "ADD")})
assert ("000001.SZ", "ADD") not in {(c["ts_code"], c["action"]) for c in r2["candidates"]}
assert any(s.get("why") == "已有在途提议/指令" for s in r2["skipped"]), r2["skipped"]
# 2026-08-06: skip 改成 {(代码, 动作): 原因} 的字典, 好让 skipped 说得出是哪一种跳过
# (在途提议 / 在途指令 / 今日被规则闸拒过 / 今日被研判闸驳回过 —— 前两种明天照样被挡,
# 后两种日切就重新评估)。这里传的仍是**集合**, 走的是向后兼容那条路, 所以拿到兜底话。
assert any(s.get("why") == ae.SKIP_WHY_FALLBACK for s in r2["skipped"]), r2["skipped"]
# 空仓票直接跳过, 不进候选
r3 = ae.scan(positions=[pos(ts_code="300750.SZ", total_qty=0)], params=PARAMS, market={})

View File

@ -1507,10 +1507,15 @@ def _():
add_prop = [p for p in fake.proposals.values() if p["action"] == "ADD"]
assert add_prop and add_prop[0]["qty"] == 2700, add_prop
assert add_prop[0]["hard_numbers"]["price"] == 11.0
# 再扫一轮不重复提 (在途去重)
# 再扫一轮不重复提 (在途去重)。
# 2026-08-06: 跳过原因从一句笼统的「已有在途提议/指令」改成分得开的四种 ——
# 提议还是指令、单号是哪个、是当日被闸拒过 (日切重来) 还是在途 (明天照样挡)。
# 这里同时钉住两条: 提议那条要报出提议号, 指令那条要报出指令号与状态。
r2 = ps.scan_and_route()
assert not r2["executed"] and not r2["queued"], r2
assert any(s.get("why") == "已有在途提议/指令" for s in r2["skipped"]), r2["skipped"]
whys = [s.get("why") or "" for s in r2["skipped"]]
assert any("在途提议" in w and "PRP_" in w for w in whys), r2["skipped"]
assert any("在途指令" in w and "INS_" in w for w in whys), r2["skipped"]
@case("自主提议·full + 研判通过: 增持直接落指令; 深档补仓仍强制确认")