diff --git a/DEVLOG.md b/DEVLOG.md index 8a87e3e..2f1e55a 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -172,6 +172,70 @@ PMS_UNAVAILABLE 三个值,而 `pms_advisor._today_exit_verdict` 第 102 行明 --- +## 2026-08-06 · 决策系统的买入信号一直被 PMS 丢在门口,已接上 + +**做了什么** +用户问「新建仓能不能并入决策系统原有的信号链路」。查下来先纠正了一个前提,又撞出一个洞。 + +**先纠正的前提**:决策系统的**建仓侧本来就是轮询**,不是订阅。 +`workers/celery_app.py:94-97` 的 `entry-gate-poll` 是 `crontab(minute="*", hour="9-11,13-14")`, +每分钟去 `trading_buy_plan` 捞 `is_active=7`。真正事件驱动的是告警链 +(`scripts/intraday_watcher.py` 常驻进程 `xreadgroup` 读上游告警流)与卖出链。 +所以「PMS 只能轮询不合理」对既有架构不成立——候选池是日频静态清单,没有事件可订阅, +轮询是它唯一的读法,而且与决策系统自己的建仓入口同构。 + +**撞出来的洞(与 08-05 那次同型)**:决策系统盘中判出 `REVERSAL_BUY` 会往 db2 的 +`intraday_signals:{日期}` 广播(`workers/tasks_intraday.py:783-785`),**PMS 一直订阅得到** +(`signal_service.streams()` 第一条就是它,每分钟拉一批),但走到 +`signal_rules.digest`(第 106 到 108 行)被归进 `ACT_RECORD`,而 +`signal_service._handle` 的 RECORD 分支**只给持仓票写账本**。 +于是「决策系统今天看多了某只没持仓的票」——**正是新建仓关心的那一批**——PMS 收到了、 +计了个数,然后一个字都不留:账本查不到、页面看不见,事后复盘问「那天系统看见了吗」答不上来。 +当年那句注释的理由(「买什么买多少由动作引擎决定」)在动作引擎没有新建仓动作时成立, +现在失效了。 + +**改法(口径:信号只做加速器,不改资格)** +- `signal_rules.py`:新增 `ACT_NOTE_BUY`。BUY 信号**仍然不产生任何买入动作**, + 但持没持仓都要留痕。HOLD 等其余类型口径一个字未动。 +- `signal_service.py`:新增 `ACT_NOTE_BUY` 分支,落 `action=SIGNAL_BUY`、`verdict=NOTE` + 的账本行,按(日期,来源,股票,动作)当日去重。用 `NOTE` 不用 `PASS`—— + 「记下来」和「放行」在账本里必须分得开。 +- `pms_repo.buy_signals_today`:只读,今天有转多留痕的票。 +- `proposal_service._scan_open` → `action_engine.scan_open`:有信号的候选**排最前**。 + **只影响先后,不影响资格**——不在候选池里的票不会因为有信号就被建仓, + 候选层那一整套过滤(ST、黑名单、分数下限、主题限额、预期空间)一道都不绕。 +- `judge.OPEN_JUDGE_KEYS` 加两个键,把「你自己今天判过这只票转多」送回决策系统, + 让它拿自己的结论对照一次。这是定性材料不是仓位数字,符合那条白名单的立意。 +- 新参数 `PMS_OPEN_SIGNAL_PRIORITY`(默认开),关掉即退回纯分数排序。 + +**为什么走账本传递而不是让 signal_service 直接调建仓** +两边各管各的一件事:信号消化管「收到了、记下来」,动作引擎管「买不买、买多少」, +中间靠账本这个既有事实源接。不新增跨模块调用、不复制闸门逻辑。 +代价是最多差一分钟(两个调度位各自每分钟一跳),而后面还要等择时区间,这点延迟无所谓。 + +**插队为什么是实打实的增量**:候选按分数降序取,名额只剩两个时第 25 名永远轮不上, +哪怕决策系统刚刚判它转多。分数是昨夜算的静态排名,「此刻转多」是盘中才有的新信息, +两者不同量纲,折算成分数得凭空定系数;插队直接表达了这件事。 + +**动了哪些文件** +`app/core/signal_rules.py`、`app/services/signal_service.py`、`app/repo/pms_repo.py`、 +`app/services/proposal_service.py`、`app/core/action_engine.py`、`app/services/judge.py`、 +`config/settings.py`、`app/services/param_store.py`;测试 `scripts/test_batch12_units.py` +加 7 例,`scripts/test_batch5_units.py` 与 `scripts/test_wiring.py` 各改一例 +(那两例原本钉着 BUY→`ACT_RECORD` 的旧行为,「不买」这条口径没变,变的是留痕范围)。 +全量单测 **ALL SUITES PASS,458 例**。 + +**还欠着什么** +1. `trading_buy_plan` 四个状态都有行,说明老的建仓链路还活着。**关掉 ENTRY_GATE 之前 + 必须先确认那些行是不是今天的**——若老链路今天仍在挂单,而 PMS 同时开始建仓, + 就是两套系统都在买。查法见交接说明。 +2. 关 ENTRY_GATE 是往决策系统 `.env` 写 `ENTRY_GATE_ENABLED=False`, + **这是新增 env 值,必须 `docker compose up -d --force-recreate`,`restart` 不重读 env。** +3. 口径三(信号票在择时上放宽一档)本轮没做,要碰「不追高、接受买不上」那条纪律, + 等积累一段实证再谈。 + +--- + ## 2026-08-06 · 新建仓动作:两侧代码写完,容器内全量单测通过,待实机部署 **做了什么** diff --git a/app/core/action_engine.py b/app/core/action_engine.py index a7da0c8..8e6008e 100644 --- a/app/core/action_engine.py +++ b/app/core/action_engine.py @@ -305,6 +305,11 @@ def eval_open(c: dict, params: dict, caps: dict, room_amt: float): "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.OPEN_JUDGE_KEYS) —— 等于当面告诉它「你自己今天判过这只票转多」, + # 它可以拿自己的结论去对照; 没有信号时这两项是 None, 不误导。 + "intraday_buy_signal": bool(c.get("sig_buy")), + "intraday_buy_reason": (c.get("sig_buy") or {}).get("reason"), # ---- 仓位口径: 只进评审账本做判分锚。judge.py 送研判时会把这几项过滤掉, # 理由见那边的 OPEN_JUDGE_KEYS —— 决策系统本来就不管仓位, 别送过去带偏它。 "target_pct": target_pct, "target_amount": round(full_amt, 2), @@ -314,9 +319,10 @@ def eval_open(c: dict, params: dict, caps: dict, room_amt: float): "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} 元)") + f"{', 主题 ' + str(c.get('theme')) if c.get('theme') else ''})" + + ("; **决策系统今天盘中判过该股转多**" if c.get("sig_buy") 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 @@ -359,10 +365,19 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float, ctx = dict(caps) sector_on = bool(ctx.get("sector_source_ready", True)) - # 按分数降序、同分按榜内名次 —— 与 plan_feed.select_candidates 的次序一致。 - # 上游给过来本来就是排好的, 这里再排一次只是防调用方乱序, 不改变正常路径的结果。 + # 排序: 今天被决策系统判过「盘中转多」的票排最前, 其余按分数降序、同分按榜内名次 + # (与 plan_feed.select_candidates 的次序一致)。 + # + # 为什么让信号插队而不是加分: 分数是昨夜算的静态排名, 「此刻转多」是盘中才有的新信息, + # 两者不同量纲, 折算成分数就得凭空定一个系数。而名额是稀缺的 —— 只剩两个名额时, + # 候选池第 25 名按分数永远轮不上, 哪怕决策系统刚刚判它转多。插队直接表达了这件事。 + # **只影响先后, 不影响资格**: 不在候选池里的票根本不会走到这里 (调用方传进来的就是 + # 候选池的产物), 候选层那一整套过滤一道都不绕。 + # PMS_OPEN_SIGNAL_PRIORITY 关掉即退回纯分数排序, 页面可改, 不用改码。 + sig_first = bool(params.get("open_signal_priority", True)) for c in sorted(candidates or [], - key=lambda x: (-_f(x.get("score")), _f(x.get("rank"), 10 ** 9), + key=lambda x: (0 if (sig_first and x.get("sig_buy")) else 1, + -_f(x.get("score")), _f(x.get("rank"), 10 ** 9), str(x.get("ts_code") or ""))): code = c.get("ts_code") if not code: diff --git a/app/core/signal_rules.py b/app/core/signal_rules.py index 2865f3f..175cbe6 100644 --- a/app/core/signal_rules.py +++ b/app/core/signal_rules.py @@ -20,8 +20,22 @@ 消化口径 (PMS 侧): * SELL 信号 —— 只对**持有的票**有意义。置信度够高即转卖出动作 (减持方向不设确认门槛, 与保垫减仓同一口径); 置信度中等则落提议队列等用户裁决。 - * BUY / HOLD 信号 —— **不产生买入动作**。买什么、买多少是 PMS 自己的命令与动作引擎说了算 - (设计: 持仓系统管「做什么、多少」)。这类信号只作为择时参考落痕, 不越权。 + * BUY 信号 —— **本模块仍然不产生买入动作**, 但 2026-08-06 起**无论持没持仓都要留痕**。 + * HOLD 等其余 —— 只给持仓票留痕 (口径未变)。 + +2026-08-06 补的那条留痕, 由来值得写下来 +------------------------------------------ +决策系统盘中判出 REVERSAL_BUY 时会往 db2 这条流广播 (action=BUY / signal_type=ENTRY), +PMS 一直订阅得到, 但走到 digest 就被归进 ACT_RECORD, 而 signal_service 的 RECORD 分支 +**只给持仓票写账本**。于是「决策系统今天看多了某只没持仓的票」这件事, PMS 收到了、计了个数, +然后一个字都不留 —— 账本查不到、页面看不见, 事后复盘问「那天系统看见了吗」答不上来。 +这与 2026-08-05 那次「同一条结论一边当判决一边当摆设」是同一个形状, 只是换了个入口。 + +当时那句注释的理由 (「买什么买多少由 PMS 的命令与动作引擎决定」) 在动作引擎**没有新建仓 +动作**的时候是成立的 —— PMS 确实没有能力消化一个买入信号。动作引擎补上 OPEN 之后这条理由 +就失效了, 所以先把留痕补上: 本模块只负责「记下来」, 要不要据此建仓由动作引擎那条路决定 +(proposal_service 读账本里这些痕迹, 给候选排序时优先, 见那边的说明)。 +**分工没有变**: 信号消化不下买单, 买什么买多少仍然归动作引擎。 """ from __future__ import annotations @@ -29,6 +43,8 @@ import json SRC_INTRADAY, SRC_RISK_SELL = "intraday", "risk_sell" ACT_EXIT, ACT_PROPOSE, ACT_RECORD, ACT_IGNORE = "EXIT", "PROPOSE", "RECORD", "IGNORE" +# 买入信号留痕: 不产生任何买入动作, 但**持没持仓都要写账本** (与 ACT_RECORD 的差别就在这) +ACT_NOTE_BUY = "NOTE_BUY" def _num(v, d=0.0): @@ -99,12 +115,26 @@ def digest(signal: dict, position: dict, params: dict) -> dict: conf = _num((signal or {}).get("confidence")) hard = {"source": signal.get("source"), "confidence": round(conf, 4), "dominant_signal": signal.get("dominant_signal"), - "minute_qrs": signal.get("minute_qrs")} + "minute_qrs": signal.get("minute_qrs"), + "suggested_price": signal.get("suggested_price")} if not code: return _r(ACT_IGNORE, 0, "信号缺少股票代码", hard) + if act == "BUY": + # 买入信号**仍然不产生买入动作** —— 买什么买多少归动作引擎。 + # 但无论持没持仓都要留痕: 未持仓的票正是新建仓关心的那一批, 从前它们连账本都没有。 + held_now = int((position or {}).get("total_qty") or 0) + hard["held"] = held_now + return _r(ACT_NOTE_BUY, 0, + f"决策系统盘中判该股转多 (置信度 {conf:.0%}" + + (f", 建议价 {signal.get('suggested_price')}" if signal.get("suggested_price") + else "") + + f"){'; 该股当前有持仓' if held_now > 0 else '; 该股当前无持仓'} —— " + f"只留痕, 买不买由动作引擎按闸门决定: " + + (signal.get("reason") or signal.get("dominant_signal") or "未给理由"), + hard) if act != "SELL": - # 买入/持有类信号不产生动作 —— 买什么买多少由 PMS 的命令与动作引擎决定 + # HOLD 等其余类型: 口径未变, 只给持仓票留痕 return _r(ACT_RECORD, 0, f"{act or '未知'} 信号仅作择时参考留痕, PMS 不据此买入", hard) held = int((position or {}).get("total_qty") or 0) diff --git a/app/repo/pms_repo.py b/app/repo/pms_repo.py index 4a7c10c..e606a6f 100644 --- a/app/repo/pms_repo.py +++ b/app/repo/pms_repo.py @@ -541,6 +541,31 @@ def judge_rejected_today(since) -> set: return {(r["ts_code"], r["action"]) for r in rows} +def buy_signals_today(since) -> dict: + """今天决策系统判过「盘中转多」的票 → {代码: {reason, price, at}}。 + + 读的是 signal_service 落下的那些留痕行 (action='SIGNAL_BUY', verdict='NOTE')。 + 动作引擎那条路拿它给候选**排序**用: 同样在候选池里的票, 今天被决策系统盘中判过转多的 + 排在前面。**只影响先后, 不影响资格** —— 不在候选池里的票不会因为有信号就被建仓, + 候选层那一整套过滤 (ST、黑名单、分数下限、主题限额、预期空间) 一道都不绕。 + + 为什么走账本而不是让 signal_service 直接调建仓: 两边各管各的一件事 —— + 信号消化管「收到了、记下来」, 动作引擎管「买不买、买多少」, 中间靠账本这个既有的事实源 + 传递, 不新增跨模块调用、不复制闸门逻辑。代价是最多差一分钟 (两个调度位各自每分钟一跳), + 而后面还要等择时区间, 这点延迟无所谓。 + """ + rows = fetch_all( + "SELECT ts_code, MAX(decided_at) AS at, " + " SUBSTRING_INDEX(GROUP_CONCAT(reason ORDER BY decided_at DESC SEPARATOR '\\n'), " + " '\\n', 1) AS reason, " + " MAX(price_at) AS price " + "FROM pms_action_ledger " + "WHERE action = 'SIGNAL_BUY' AND decided_at >= :d GROUP BY ts_code", + {"d": since}) + return {r["ts_code"]: {"reason": r.get("reason"), "at": str(r.get("at") or ""), + "price": float(r.get("price") or 0)} for r in rows} + + def opened_names_today(since) -> list: """今天已经落了新建仓指令、或还在提议队列里等确认的票 (去重后的代码列表)。 diff --git a/app/services/judge.py b/app/services/judge.py index 89547c2..7e54965 100644 --- a/app/services/judge.py +++ b/app/services/judge.py @@ -36,7 +36,10 @@ PASS, REJECT, UNAVAILABLE = "PASS", "REJECT", "UNAVAILABLE" # **只对 OPEN 生效。** FILL/ADD/DCA 的硬数字 (安全垫、评估档位、底仓量) 本身就是它们的判据, # 一个都不能删: 那几类动作是在**已有持仓**上做加减, 谈的就是这个仓位, 与新建仓不是一回事。 OPEN_JUDGE_KEYS = ("price", "score", "theme", "tier", "upside", "heat", - "plan_rank", "plan_bucket", "plan_src", "sector") + "plan_rank", "plan_bucket", "plan_src", "sector", + # 决策系统今天盘中判没判过这只票转多。这是它自己的结论, 不是仓位数字, + # 送回去等于当面提醒它「你今天判过」, 让它拿自己的结论对照一次。 + "intraday_buy_signal", "intraday_buy_reason") def _judge_hard_numbers(action: str, hard: dict) -> dict: diff --git a/app/services/param_store.py b/app/services/param_store.py index c2703be..466826f 100644 --- a/app/services/param_store.py +++ b/app/services/param_store.py @@ -125,6 +125,7 @@ DESC = { "PMS_OPEN_AUTONOMY": "自主新建仓档位兼总开关 full / propose_only / off " "(与 PMS_AUTONOMY 分开: 新建仓要不要人点头是另一个决定)", "PMS_OPEN_REQUIRE_WS_CASH": "拿不到 ws 资金快照时不自动新建仓 (只影响新建仓, 其余动作照旧)", + "PMS_OPEN_SIGNAL_PRIORITY": "今天被决策系统判过盘中转多的候选票排最前 (只影响先后, 不影响资格)", "PMS_OPEN_REF_DRIFT_MAX": "参考位盘中被改写超此幅度 → 该票当日暂停新建仓", "PMS_JUDGE_TICK_BUDGET_SEC": "单轮提议扫描用于研判的时间预算 (秒), 用尽则剩下的候选留到下一跳", "PMS_JUDGE_ENABLED": "研判闸开关", "PMS_JUDGE_ACTIONS": "需过研判闸的动作", diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py index c14be2c..d325254 100644 --- a/app/services/proposal_service.py +++ b/app/services/proposal_service.py @@ -152,8 +152,16 @@ def _scan_open(view, params, stock_params, skip, mkt, out) -> list: # (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] + # 今天被决策系统盘中判过转多的票 (signal_service 落的留痕)。**只用来排序, 不改资格**: + # 不在候选池里的票不会因为有信号就被建仓, 候选层那一整套过滤一道都不绕。 + # 它的增量是实打实的 —— 候选按分数降序取, 名额只剩两个时第 25 名永远轮不上; + # 而「此刻转多」是盘中才有的新信息, 昨夜算出来的分数与买入区间都表达不了它。 + sig_buy = _buy_signals_today() + cands = [{**x, "price": prices.get(x["ts_code"]), "sector": sectors.get(x["ts_code"]), + "sig_buy": sig_buy.get(x["ts_code"])} for x in items] + hit = [c["ts_code"] for c in cands if c.get("sig_buy")] + if hit: + logger.info("[新建仓] 候选池里今天被决策系统判过转多的: %s", hit) t = view["totals"] p = view["params"] @@ -406,6 +414,7 @@ def _scan_params(view: dict) -> dict: "buy_halt_dayup": param_store.get_float("PMS_BUY_HALT_DAYUP", 0.05), "build_window_tdays": param_store.get_int("PMS_BUILD_WINDOW_TDAYS", 10), "fill_max_loss": param_store.get_float("PMS_FILL_MAX_LOSS", -0.03), + "open_signal_priority": param_store.get_bool("PMS_OPEN_SIGNAL_PRIORITY", True), }) return p @@ -455,6 +464,17 @@ def _rejected_today_keys() -> set: return set() +def _buy_signals_today() -> dict: + """今天的盘中转多留痕。读不到就返回空 —— 它只影响候选的先后, 不影响资格, + 所以读失败时降级成「按分数排」就够了, 不该因此让整轮新建仓停摆。""" + try: + return pms_repo.buy_signals_today(datetime.now().replace( + hour=0, minute=0, second=0, microsecond=0)) + except Exception as e: + logger.warning("[新建仓] 读当日转多留痕失败 (本轮按纯分数排序): %s", e) + return {} + + def _judge_rejected_open_keys() -> set: """今天已被研判闸驳回的**新建仓** (代码, 动作)。读不到就返回空集 (同上: 去重是降噪)。 diff --git a/app/services/signal_service.py b/app/services/signal_service.py index 89277d8..b9d7a67 100644 --- a/app/services/signal_service.py +++ b/app/services/signal_service.py @@ -30,6 +30,13 @@ from app.services import executor, param_store, portfolio logger = logging.getLogger("pms.signal") SEEN_KEY = "PMS_SIGNAL_SEEN" # 当日去重集合 (JSON), 日切自动作废 + +# 买入信号留痕在评审账本里用的动作名 (2026-08-06)。 +# **刻意不叫 OPEN**: 账本里 action=OPEN 的行是真的建仓评审 (放行或拒绝), 两者混在一起, +# `make t-gate` 就分不清「决策系统说这只票转多了」和「PMS 决定建这只票」。 +# 动作引擎那条路读的就是这个动作名 —— 见 pms_repo.buy_signals_today。 +SIGNAL_BUY_ACTION = "SIGNAL_BUY" + _clients = {} @@ -145,6 +152,32 @@ def _handle(sig, view, prm, seen, ymd, dry_run, out): out["recorded"] += 1 return + if act == sr.ACT_NOTE_BUY: + # 买入信号留痕: **持没持仓都写**。未持仓的票正是新建仓关心的那一批, 从前它们 + # 连一行账本都没有 —— 「决策系统今天看多了哪几只」查不到, 事后没法复盘。 + # + # 三条防冲垮的口径 (与上面 RECORD 那句「否则全市场广播会把评审账本冲垮」同一个顾虑): + # 1. 只认 BUY, HOLD 等仍走 RECORD 的老口径; + # 2. 按 (日期, 来源, 股票, 动作) 当日去重 —— 同一只票一天最多一行; + # 3. 上游那侧本来就有节流: watcher 对同股看多研判有两小时防抖锁, 昨夜已看多的票 + # 直接免疫不派单, 所以 REVERSAL_BUY 是几十条的量级, 不是全市场广播。 + # verdict 用 NOTE 不用 PASS —— 这是「记下来」不是「放行」, 账本里必须分得开 + # (watch.py 对不认识的 verdict 有兜底符号, 不会显示异常)。 + key = sr.dedup_key(sig, ymd) + if key in seen: + out["ignored"] += 1 + return + out.setdefault("buy_notes", []).append({"ts_code": code, "reason": d["reason"]}) + if dry_run: + return + px = float((pos or {}).get("price") or d["hard_numbers"].get("suggested_price") or 0) + pms_repo.insert_ledger( + ts_code=code, action=SIGNAL_BUY_ACTION, arbiter="rule", verdict="NOTE", + price_at=px, hard_numbers={**d["hard_numbers"], "msg_id": sig.get("msg_id")}, + reason=d["reason"][:500]) + seen.add(key) + return + key = sr.dedup_key(sig, ymd) if key in seen: out["ignored"] += 1 diff --git a/config/settings.py b/config/settings.py index 68a14a4..7a1e640 100644 --- a/config/settings.py +++ b/config/settings.py @@ -84,6 +84,12 @@ class Settings(BaseSettings): # 规则闸那条「拿不到真实资金只告警不拦」是为**已经排好的命令**设计的 —— 通道故障 # 不该升级成业务停摆。但无人值守地从零建仓是可以等的, 所以这里比规则闸严一档: # 07-30 教训 (scale 200 万 / 账户实际 98 万, 方案一路放行到下游才被拒) 不能重演。 + PMS_OPEN_SIGNAL_PRIORITY: bool = True # 今天被决策系统判过盘中转多的候选票排最前 + # 决策系统判出 REVERSAL_BUY 时会往 db2 的 intraday_signals 流广播, PMS 一直订阅得到, + # 但 2026-08-06 之前 signal_rules 把买入类信号一律归进"只留痕", 而留痕又只给持仓票写 —— + # 于是「决策系统今天看多了某只没持仓的票」这件事, PMS 收到了却一个字都不留。 + # 现在补上了留痕 (action=SIGNAL_BUY, verdict=NOTE), 动作引擎读它给候选排序。 + # **只影响先后, 不影响资格**: 不在候选池里的票不会因为有信号就被建仓。 PMS_OPEN_REF_DRIFT_MAX: float = 0.03 # 参考位盘中被改写超此幅度 → 该票当日暂停新建仓 # 择时读的 strategy_daily_results 会被盘中补扫就地改写 (实证: 002335 支撑 31.27→29.00, # 差 7.3%)。已有持仓有摊薄成本与安全垫做锚, 漂一点不翻转判断; 而新建仓的买入区间 diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 8e23f29..8d4a1a7 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -16,9 +16,10 @@ test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (49 例) test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (48 例) test_batch11_units.py 择时实现A: 本地检查等价/应答折算/缓存冷却/退B (24 例) - test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算 (26 例) + test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算/ + 转多信号插队与留痕 (33 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例) - 共 451 例 + 共 458 例 任一子集失败即整体失败 (退出码 1)。 """ import os diff --git a/scripts/test_batch12_units.py b/scripts/test_batch12_units.py index f3296c3..0271ca3 100644 --- a/scripts/test_batch12_units.py +++ b/scripts/test_batch12_units.py @@ -232,6 +232,89 @@ def _(): assert "评估异常" in whys and "RuntimeError" in whys, whys +@case("转多信号·插队排最前: 分数低但有信号的票抢在前面") +def _(): + cs = [cand("600000.SH", score=300, rank=1), + cand("600001.SH", score=200, rank=2), + dict(cand("600002.SH", score=100, rank=3), sig_buy={"reason": "盘中判转多"})] + r = ae.scan_open(candidates=cs, params=params(), caps=caps(names_count=19), + room_amt=1_400_000, slots=1) + assert [c["ts_code"] for c in r["candidates"]] == ["600002.SH"], r["candidates"] + c = r["candidates"][0] + assert c["hard_numbers"]["intraday_buy_signal"] is True, c["hard_numbers"] + assert c["hard_numbers"]["intraday_buy_reason"] == "盘中判转多" + assert "转多" in c["reason"], c["reason"] + + +@case("转多信号·开关关掉就退回纯分数排序") +def _(): + cs = [cand("600000.SH", score=300, rank=1), + dict(cand("600002.SH", score=100, rank=3), sig_buy={"reason": "盘中判转多"})] + r = ae.scan_open(candidates=cs, params=params(open_signal_priority=False), + caps=caps(names_count=19), room_amt=1_400_000, slots=1) + assert [c["ts_code"] for c in r["candidates"]] == ["600000.SH"], r["candidates"] + + +@case("转多信号·只改先后不改资格: 没信号的票照样能建, 有信号也过同样的闸") +def _(): + # 有信号但组合没名额 —— 一样建不了, 信号不是免死金牌 + r = ae.scan_open(candidates=[dict(cand("600002.SH"), sig_buy={"reason": "转多"})], + params=params(), caps=caps(names_count=20), room_amt=1_400_000, slots=0) + assert r["candidates"] == [], r + # 没信号的票照常建 + r2 = ae.scan_open(candidates=[cand("600000.SH")], params=params(), caps=caps(), + room_amt=1_400_000, slots=20) + assert len(r2["candidates"]) == 1 + assert r2["candidates"][0]["hard_numbers"]["intraday_buy_signal"] is False + + +@case("信号消化·未持仓票的买入信号也要留痕 (从前它连账本都没有)") +def _(): + from app.core import signal_rules as sr + sig = {"source": "intraday", "ts_code": "600002.SH", "action": "BUY", + "confidence": 0.95, "suggested_price": 12.3, "reason": "放量突破"} + d = sr.digest(sig, None, {"sell_conf_min": 0.75, "auto_exit_conf": 0.85}) + assert d["action"] == sr.ACT_NOTE_BUY, d + assert d["qty"] == 0, "留痕不产生任何数量" + assert "无持仓" in d["reason"] and "只留痕" in d["reason"], d["reason"] + assert d["hard_numbers"]["suggested_price"] == 12.3 + + +@case("信号消化·持仓票的买入信号同样走留痕, 且标明有持仓") +def _(): + from app.core import signal_rules as sr + sig = {"source": "intraday", "ts_code": "600000.SH", "action": "BUY", "confidence": 0.95} + d = sr.digest(sig, {"total_qty": 1000}, {}) + assert d["action"] == sr.ACT_NOTE_BUY and d["hard_numbers"]["held"] == 1000, d + assert "有持仓" in d["reason"], d["reason"] + + +@case("信号消化·卖出与 HOLD 的口径一个字没变") +def _(): + from app.core import signal_rules as sr + prm = {"sell_conf_min": 0.75, "auto_exit_conf": 0.85, "trim_ratio": 1 / 3} + # HOLD 仍走老的 RECORD + d = sr.digest({"source": "intraday", "ts_code": "600000.SH", "action": "HOLD"}, None, prm) + assert d["action"] == sr.ACT_RECORD, d + # 高置信卖出仍是清仓 + d = sr.digest({"source": "risk_sell", "ts_code": "600000.SH", "action": "SELL", + "confidence": 0.9}, {"total_qty": 1000, "avail_qty": 1000}, prm) + assert d["action"] == sr.ACT_EXIT and d["qty"] == 1000, d + # 未持有的卖出信号仍然忽略 + d = sr.digest({"source": "risk_sell", "ts_code": "600000.SH", "action": "SELL", + "confidence": 0.9}, None, prm) + assert d["action"] == sr.ACT_IGNORE, d + + +@case("研判请求·转多信号是定性材料, 要送过去; 仓位数字照旧不送") +def _(): + hard = {"price": 10.0, "score": 300.0, "intraday_buy_signal": True, + "intraday_buy_reason": "放量突破", "target_amount": 120000.0, "names_before": 3} + got = jd._judge_hard_numbers("OPEN", hard) + assert got["intraday_buy_signal"] is True and got["intraday_buy_reason"] == "放量突破" + assert "target_amount" not in got and "names_before" not in got, got + + @case("回归·既有四类动作的判据与研判范围没被动过") def _(): assert ae.JUDGE_ACTIONS == {"FILL", "ADD", "DCA", "OPEN"}, ae.JUDGE_ACTIONS @@ -253,7 +336,8 @@ def _(): "batch_scheme": "0.5,0.25,0.25", "names_before": 3, "max_names": 20, "room_amt_before": 1_400_000.0} got = jd._judge_hard_numbers("OPEN", hard) - assert set(got) == set(jd.OPEN_JUDGE_KEYS), got + assert set(got) <= set(jd.OPEN_JUDGE_KEYS), got + assert set(got) == {k for k in hard if k in jd.OPEN_JUDGE_KEYS}, got for k in ("target_amount", "names_before", "max_names", "room_amt_before", "batch_scheme", "base_amount", "target_pct"): assert k not in got, f"{k} 不该送到决策系统" diff --git a/scripts/test_batch5_units.py b/scripts/test_batch5_units.py index 7ddcd4e..5dd3acc 100644 --- a/scripts/test_batch5_units.py +++ b/scripts/test_batch5_units.py @@ -94,12 +94,16 @@ def _(): @case("消化·BUY/HOLD 信号只留痕不买 (买什么买多少由 PMS 自己决定)") def _(): + # 2026-08-06: BUY 从 ACT_RECORD 改判 ACT_NOTE_BUY。**「不买」这条口径一个字没变** —— + # 变的只是留痕范围: RECORD 只给持仓票写账本, 而未持仓的票正是新建仓关心的那一批, + # 从前它们连一行痕迹都没有。NOTE_BUY 持没持仓都写。HOLD 等其余类型仍走 RECORD。 b = sr.parse_intraday({"ts_code": "600000.SH", "action": "BUY", "confidence": "0.95"}) d = sr.digest(b, POS, PRM) - assert d["action"] == sr.ACT_RECORD and d["qty"] == 0, d - assert "不据此买入" in d["reason"], d + assert d["action"] == sr.ACT_NOTE_BUY and d["qty"] == 0, d + assert "只留痕" in d["reason"] and "买不买由动作引擎" in d["reason"], d h = sr.parse_intraday({"ts_code": "600000.SH", "action": "HOLD", "confidence": "0.99"}) assert sr.digest(h, POS, PRM)["action"] == sr.ACT_RECORD + assert "不据此买入" in sr.digest(h, POS, PRM)["reason"] @case("消化·盘中流里的 SELL 也照样消化 (两条流同一套口径)") diff --git a/scripts/test_wiring.py b/scripts/test_wiring.py index cc86be9..1503f49 100644 --- a/scripts/test_wiring.py +++ b/scripts/test_wiring.py @@ -1698,9 +1698,12 @@ def _(): _install_signal_fakes(fake, intraday_msgs=[ ("1-1", {"ts_code": "600000.SH", "action": "BUY", "confidence": "0.95"})]) r = ss.consume() - assert r["recorded"] == 1 and not r["exits"], r - assert not any(i["side"] == "buy" for i in fake.instructions.values()) - assert any(x["action"] == "SIGNAL" for x in fake.ledger), fake.ledger + # 2026-08-06: BUY 改走 NOTE_BUY —— 仍然一股都不买, 但账本必须留下一行, 而且 + # **未持仓的票也要留** (从前 RECORD 只给持仓票写, 没持仓的票一个字都没有, 事后无从复盘)。 + assert r.get("buy_notes") and not r["exits"], r + assert not any(i["side"] == "buy" for i in fake.instructions.values()), "买入信号绝不下买单" + notes = [x for x in fake.ledger if x["action"] == ss.SIGNAL_BUY_ACTION] + assert notes and notes[0]["verdict"] == "NOTE", fake.ledger fake2 = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000", "PMS_SIGNAL_ENABLED": "false"},