From e21b73b46da5e0361354ab5412677c1d265cb555 Mon Sep 17 00:00:00 2001 From: zlt Date: Mon, 14 Sep 2026 10:32:54 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=9B=BA=E5=8C=85=EF=BC=9A=E5=8E=BB?= =?UTF-8?q?=E9=87=8D=E9=9B=86=E6=B8=85=E9=99=A4=E6=9D=A1=E4=BB=B6=E3=80=81?= =?UTF-8?q?=E6=8C=81=E4=BB=93=E8=A1=8C=E8=BD=AC=E5=A4=9A=E7=95=99=E7=97=95?= =?UTF-8?q?=E3=80=81=E7=99=BB=E8=AE=B0=E5=BC=B1=E8=A1=A8=E6=80=81=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E3=80=81=E6=97=B6=E9=92=9F=E7=94=A8=E4=BE=8B=E6=94=B9?= =?UTF-8?q?=E7=9B=B8=E5=AF=B9=E6=97=B6=E5=88=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审第五节四处小加固,每处开关关掉或默认值即旧行为: - 转空去重集只在 SAR 明确为多时才清除(原来「不是空就清」把缺读数也当翻多, 会重复减仓)。抽出纯函数 _keep_tech_exit_done,第三十批加三例。 - 持仓行的择时票带上当日盘中转多留痕:decorate_positions 从买入信号同一来源 取 flip_map,昨夜中性当日转多时择时票升看多。第二十八批加一例。 - 登记 PMS_CONSENSUS_WEAK_CONFIRM(默认开):合议方向看多但只有一方表态时 交人,关掉则放行。consensus.decide 加 weak_confirm 参数。第二十八批加两例。 - 第二十四批时钟用例改相对当前时刻构造陈旧告警,任何时刻跑都过(原来写死 今天09:35,开发机09:36前跑年龄算成负数)。 开发机全量单测846例 ALL SUITES PASS。未在155部署(收盘后随观察读数包一起)。 Co-Authored-By: Claude Opus 4.8 --- app/core/consensus.py | 16 +++++++++++----- app/services/consensus_service.py | 4 +++- app/services/param_store.py | 2 ++ app/services/proposal_service.py | 23 ++++++++++++++++++++--- scripts/run_tests.py | 9 +++++---- scripts/test_batch24_units.py | 8 ++++++-- scripts/test_batch28_units.py | 26 ++++++++++++++++++++++++++ scripts/test_batch30_units.py | 20 ++++++++++++++++++++ 8 files changed, 93 insertions(+), 15 deletions(-) diff --git a/app/core/consensus.py b/app/core/consensus.py index b6566ef..a6a0e4d 100644 --- a/app/core/consensus.py +++ b/app/core/consensus.py @@ -16,7 +16,7 @@ def _reason(fund, tech, timing, direction, strength): return head + " → " + direction + (("·" + strength) if strength else "") -def _route(fund, tech, timing, direction, tech_phase, fund_required): +def _route(fund, tech, timing, direction, tech_phase, fund_required, weak_confirm=True): """附录丙路由表, 从上到下第一个命中。返回 (路由, 原因)。路由取值: 放行/观察/交人/跳过。""" if fund_required and fund == _NA: return "跳过", "没有买方评析" @@ -33,16 +33,21 @@ def _route(fund, tech, timing, direction, tech_phase, fund_required): if timing == _BEAR: # 异议在择时系统 return "交人", "方向看多但择时系统有异议" if len(voiced) <= 1: # 只有一方表态 - return "交人", "方向看多但只有一方表态" + # 弱表态: weak_confirm 开着交人, 关掉放行 (2026-09-14 加固, 登记 PMS_CONSENSUS_WEAK_CONFIRM) + if weak_confirm: + return "交人", "方向看多但只有一方表态" + return "放行", "方向看多但只有一方表态(弱表态放行)" return "放行", "方向看多" # 看多强, 或异议只在技术面 if fund == _NA and tech == _NA and timing == _NA: return "放行", "三方都无读数,机械方案" return "观察", "方向中性" -def decide(fund, tech, timing, *, tech_phase=None, fund_required=True) -> dict: +def decide(fund, tech, timing, *, tech_phase=None, fund_required=True, weak_confirm=True) -> dict: """fund/tech/timing 是三个立场字符串 (看多/看空/中性/无读数)。tech_phase 是技术面相位 - (收口等待 等), 路由用。fund_required: 无买方评析是否拦 (开关 PMS_FUND_REQUIRED)。""" + (收口等待 等), 路由用。fund_required: 无买方评析是否拦 (开关 PMS_FUND_REQUIRED)。 + weak_confirm: 方向看多但只有一方表态时是否交人 (开关 PMS_CONSENSUS_WEAK_CONFIRM, 默认真; + 关掉则放行)。""" votes = {"fund": fund, "tech": tech, "timing": timing} voiced = [v for v in (fund, tech, timing) if v in (_BULL, _BEAR)] bulls = sum(1 for v in voiced if v == _BULL) @@ -66,7 +71,8 @@ def decide(fund, tech, timing, *, tech_phase=None, fund_required=True) -> dict: else: strength = "中" - route, route_reason = _route(fund, tech, timing, direction, tech_phase, fund_required) + route, route_reason = _route(fund, tech, timing, direction, tech_phase, fund_required, + weak_confirm) return {"direction": direction, "votes": votes, "strength": strength, "reason": _reason(fund, tech, timing, direction, strength), "route": route, "route_reason": route_reason} diff --git a/app/services/consensus_service.py b/app/services/consensus_service.py index a8a2204..f5d7e40 100644 --- a/app/services/consensus_service.py +++ b/app/services/consensus_service.py @@ -27,6 +27,7 @@ def _params() -> dict: "fund_stale_days": gi("PMS_FUND_STALE_DAYS", 120), "fund_consensus_good_min": gi("PMS_FUND_CONSENSUS_GOOD_MIN", 8), "consensus_route": gb("PMS_CONSENSUS_ROUTE", True), + "weak_confirm": gb("PMS_CONSENSUS_WEAK_CONFIRM", True), "timing_stale_tdays": gi("PMS_REF_STALE_TDAYS", 3), } @@ -72,7 +73,8 @@ def assemble(row, *, nightly=None, tech_states=None, flip_at=None, params=None) # 合议参与分流的开关关掉时, 基本面无读数不由合议这层拦 (回到只按判决分流) con = consensus.decide(fund["stance"], tech.get("stance") or "无读数", tm["stance"], tech_phase=tech.get("phase"), - fund_required=(p["fund_required"] and p["consensus_route"])) + fund_required=(p["fund_required"] and p["consensus_route"]), + weak_confirm=p.get("weak_confirm", True)) return {"fund": fund, "tech": tech, "timing": tm, "consensus": con} diff --git a/app/services/param_store.py b/app/services/param_store.py index 702f055..c3a3df5 100644 --- a/app/services/param_store.py +++ b/app/services/param_store.py @@ -112,6 +112,8 @@ RUNTIME_EXTRA = { "PMS_FUND_STALE_DAYS": (120, int, "买方评析报告超过这么多天算无读数"), "PMS_FUND_CONSENSUS_GOOD_MIN": (8, int, "十视角好达这么多个且估值贵 → 打共识已定价标记, 让首批推迟"), "PMS_CONSENSUS_ROUTE": (True, bool, "三源合议参与新建仓分流 (拍板: 上线即开; 关掉即回到只按选股判决分流)"), + # 2026-09-14 加固包 (方案第六节承诺、评审发现五): 方向看多但只有一方表态时是否交人。 + "PMS_CONSENSUS_WEAK_CONFIRM": (True, bool, "合议方向看多但只有一方表态时交人确认 (默认开; 关掉则弱表态也放行)"), # ── 2026-09-11 三源合议工作包二·接入 (台账 006/009): 持仓增持门与候选按质地排序。 "PMS_TECH_GATE_INCREASE": (True, bool, "持仓增持门: 合议看空停回踩补足/盈利加仓/补仓, 技术面看空停回踩补足与盈利加仓, 基本面看空停补仓 (减持不受影响; 关掉即回到不设门)"), "PMS_PLAN_RANK_BY_QUALITY": (True, bool, "候选先按基本面立场分桶 (质地看多的桶排前) 再按分数, 在主题限额与截断之前生效; 技术面不参与排序 (关掉即回到只按分数排)"), diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py index 1f5d262..e983990 100644 --- a/app/services/proposal_service.py +++ b/app/services/proposal_service.py @@ -135,7 +135,14 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict: _lmap = _lss.state_map() _crs = {code: (st or {}).get("company_review") for code, st in (_lmap or {}).items()} - consensus_service.decorate_positions(view["held"], company_reviews=_crs) + # 持仓行的择时票也带上当日盘中转多留痕 (2026-09-14 加固, 评审发现四): 来源与候选 + # 相同 —— 决策系统判过盘中转多的留痕 (_buy_signals_today)。没这留痕的行照旧只按 + # 昨夜定性。持仓行昨夜中性而当日转多时, 择时票升为看多, 增持门的方向判断才跟得上。 + _sig = _buy_signals_today() + _flip = {code: (v or {}).get("at") for code, v in (_sig or {}).items() + if (v or {}).get("at")} + consensus_service.decorate_positions(view["held"], company_reviews=_crs, + flip_map=_flip) except Exception as e: # noqa: BLE001 logger.warning("[合议] 持仓增持门装配失败, 本轮按无合议 (不设门): %s", e) # 技术面转空自动离场 (2026-09-11 工作包三): 给持仓行挂技术面状态供 eval_tech_exit 读, @@ -147,8 +154,7 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict: _tech_states = _ts.state_map() _ts.attach(view["held"], _tech_states) _done = _load_tech_exit_done() - params["tech_exit_done"] = {c for c in _done - if (_tech_states.get(c) or {}).get("sar_side") == "空"} + params["tech_exit_done"] = _keep_tech_exit_done(_done, _tech_states) except Exception as e: # noqa: BLE001 logger.warning("[技术面离场] 挂技术面状态失败, 本轮不评转空离场: %s", e) params["tech_exit_done"] = set() @@ -242,6 +248,17 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict: _TECH_EXIT_DONE_KEY = "PMS_TECH_EXIT_DONE" +def _keep_tech_exit_done(done, tech_states) -> set: + """本次翻空去重集里, 本轮该保留 (不清除) 的代码集。 + + 2026-09-14 加固 (评审发现三): 原来是「SAR 方向是空才保留」, 而「不是空」把「映射里没有 + 这只票」也算了进去 —— 停牌或决策系统那晚漏算的持仓票缺读数, 会被当成翻多而清出去重集; + 若同一轮另有别的票处理了转空离场, 清过的集合会被写回, 第二天读数回来、翻空仍在两天内就 + 再减一次 (多减三分之一)。改成**只在 SAR 方向明确为多时才清除**: 翻空与缺读数一律保留。""" + return {c for c in (done or set()) + if (tech_states.get(c) or {}).get("sar_side") != "多"} + + def _load_tech_exit_done() -> set: """本次翻空已处理过的代码集 (跨轮去重, 不动表结构; 与 tech_state_map 同一手法用运行参数承载)。 读不到或坏了返回空集。清除 (SAR 翻多=新一轮) 在 scan_and_route 扫描前按当轮技术面映射做。""" diff --git a/scripts/run_tests.py b/scripts/run_tests.py index ad98bb1..0e60ec9 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -75,7 +75,7 @@ 读数陈旧按无读数/参数登记与交易日差/upsert 用 VALUES 与按 matched 分页两个真机哨兵 (29 例) test_batch28_units.py 三源合议·工作包二纯逻辑 (2026-09-11): 基本面质地档映射与共识已定价/择时定性 映射与盘中转多升级/合议多数决与强弱/附录丙路由表逐条/装配服务凑齐四块与六键/ - advise_v2 基本面×技术面仓位矩阵逐格 (37 例) + advise_v2 基本面×技术面仓位矩阵逐格 + 加固包弱表态开关两例与持仓行转多留痕一例 (40 例) test_batch29_units.py 三源合议·工作包二接入下单链路 (2026-09-11): scan_open 合议分流(跳过/观察带 wait_tech/交人强制确认/放行/缺合议退旧路/开关关掉逐字回旧)/定档改用 advise_v2 且六键进硬数字而不进送研判白名单/持仓增持门矩阵与 scan 端到端(减持不受影响、 @@ -85,7 +85,8 @@ 夹紧/开关关掉逐字回旧)/卖出优先级目标价<研究走弱<技术面转空<保垫减仓/scan 端到端/ 策略票只看目标价按函数判(技术面转空对策略票不评)/跟踪止盈盘中 SAR 止损线 (09:45 后跌破缓冲全清、当日一次、没刷进不判、开关关掉不触发)/SAR 线刷新腿只动 - 自动跟踪止盈且无读数撤旧线/弱基本面试探仓紧止盈自动挂载(入场标记识别、回撤3%硬目标8%带SAR线、不占名额、冷却让路)/参数登记 (33 例) + 自动跟踪止盈且无读数撤旧线/弱基本面试探仓紧止盈自动挂载(入场标记识别、回撤3%硬目标8%带SAR线、不占名额、冷却让路)/参数登记 + + 加固包去重集清除条件三例 (缺读数/明确多/明确空) (36 例) test_batch31_units.py 观察读数包 (2026-09-14 台账 013): 动作引擎给合议跳过项打标签 (跳过分 fund/bear、观察 wait_tech、增持门 gate_block 带 gate_kind, 开关关掉不打标签)/record_round 归类落表 (检查点写路由与装配两类、 @@ -98,10 +99,10 @@ test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) + 目标价到价必定入队 (档位 full 也不自动卖) + 用户设的止损价与目标价单独成列显示 (70 例) - 共 840 例 + 共 846 例 (总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条 减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 到 654; 又加了页面文案守卫一例, 到 655; 09-07 审查修复加了跨轮减持等五例, 到 660; 第二件低把握驳回交人一例, 到 661; 第三件逻辑状态接入第二十二批十五例, 到 676; 第四件安全边际整句透传一例, 到 677; 参考目标价一例, 到 678; 催化事件与定价状态透传一例, 到 679; 两个期限的头一例, 现为 680; 09-10 建议档位对齐第二十六批十七例到 697; 09-11 技术面接入工作包一第二十七批二十七例到 724; 三源合议工作包二纯逻辑第二十八批三十七例到 763; 接入下单链路第二十九批二十四例到 787; 工作包三离场纪律第三十批十八例到 805; part3 盘中 SAR 止损线补九例到 814; part4 弱基本面紧止盈补六例到 820; - 09-14 观察读数包第三十一批二十例, 现为 840) + 09-14 观察读数包第三十一批二十例到 840; 加固包给第二十八批加三例、第三十批加三例, 现为 846) 任一子集失败即整体失败 (退出码 1)。 哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了): diff --git a/scripts/test_batch24_units.py b/scripts/test_batch24_units.py index b43f441..1897d66 100644 --- a/scripts/test_batch24_units.py +++ b/scripts/test_batch24_units.py @@ -130,7 +130,11 @@ def test_parse_sources(): assert rows[1]["src"] == "decision" and rows[1]["time"] == "10:04" and rows[1]["confidence"] == 0.95, rows[1] assert rows[1]["cat_label"] == "建议买入" and rows[1]["price"] == 43.15 - old = _today_ms(9, 35) + # 陈旧告警的时刻**相对当前时刻构造** (2026-09-14 加固): 取十分钟前, 不写死 09:35 —— + # 服务器收盘后跑测试碰不上, 但开发机在 09:35 之前跑, "今天 09:35" 落在未来、年龄算成负数, + # 陈旧判定与 age_min>=0 都会失败。十分钟前在任何时刻都已过 60 秒的陈旧线, 且年龄恒非负。 + old = int((time.time() - 600) * 1000) + old_hhmm = time.strftime("%H:%M", time.localtime(old / 1000)) with _Patch() as p: p(us, "_alert_max_age_sec", lambda: 60) al = [("1-0", {"ts_code": "601126.SH", "source": "fund_flow_strength", "level": "HIGH", @@ -139,7 +143,7 @@ def test_parse_sources(): "trigger_time": int(time.time() * 1000), "metadata": '{"direction": "down"}'})] arows, an, _ = us._bycode_alerts(_FakeRedis({"intraday_alerts:%s" % ymd: al}), ymd, "SH601126", 5000) assert an == 2 and len(arows) == 2 - stale = [r for r in arows if r["time"] == "09:35"][0] + stale = [r for r in arows if r["time"] == old_hhmm][0] assert stale["stale"] is True and stale["cat"] and stale["age_min"] >= 0, stale unknown = [r for r in arows if not r["stale"]][0] assert unknown["cat"] == "other" and unknown["direction"] == "看跌", unknown diff --git a/scripts/test_batch28_units.py b/scripts/test_batch28_units.py index b2c82ca..85a99e1 100644 --- a/scripts/test_batch28_units.py +++ b/scripts/test_batch28_units.py @@ -283,6 +283,32 @@ def _(): assert _av2("看多", "看多", ls="逻辑存疑")["tier"] == "不建" +# ================================================================ F 加固包 (2026-09-14) +@case("C 路由·弱表态开着 (weak_confirm 真): 方向看多但只有一方表态 → 交人") +def _(): + r = cons.decide("看多", "无读数", "无读数", weak_confirm=True) + assert r["direction"] == "看多" and r["route"] == "交人" + + +@case("C 路由·弱表态关掉 (weak_confirm 假): 方向看多但只有一方表态 → 放行") +def _(): + r = cons.decide("看多", "无读数", "无读数", weak_confirm=False) + assert r["direction"] == "看多" and r["route"] == "放行" + + +@case("D 装配·持仓行昨夜中性当日转多 → 择时票升看多 (decorate_positions 带 flip_map)") +def _(): + from app.services import consensus_service as csv + from app.core import tradedays as _td + rows = [{"ts_code": "600000.SH"}] + csv.decorate_positions( + rows, nightly={"600000.SH": {"verdict": "WATCH", "trade_date": _td.ymd()}}, + tech_states={}, company_reviews={"600000.SH": _cr(overall="好")}, + flip_map={"600000.SH": "10:30"}, params=_ASM_P) + tmb = rows[0]["consensus_blocks"]["timing"] + assert tmb["stance"] == "看多" and tmb["intraday_flip_at"] == "10:30" + + def main(): ok = 0 for name, fn in RESULTS: diff --git a/scripts/test_batch30_units.py b/scripts/test_batch30_units.py index 19b7350..ed6f0d5 100644 --- a/scripts/test_batch30_units.py +++ b/scripts/test_batch30_units.py @@ -411,6 +411,26 @@ def _(): assert abs(ps.RUNTIME_EXTRA["PMS_TECH_TIGHT_TRAIL_TARGET"][0] - 0.08) < 1e-9 +# ================================================================ I 去重集清除条件 (2026-09-14 加固) +# 评审发现三: 原来「SAR 不是空就清」把缺读数也当翻多清掉, 会重复减仓。改成「明确为多才清」。 +@case("I 去重集·映射里没这只票则保留 (缺读数不当翻多)") +def _(): + from app.services import proposal_service as ps + assert ps._keep_tech_exit_done({"600000.SH"}, {}) == {"600000.SH"} + + +@case("I 去重集·SAR 明确为多则清除 (新一轮翻向)") +def _(): + from app.services import proposal_service as ps + assert ps._keep_tech_exit_done({"600000.SH"}, {"600000.SH": {"sar_side": "多"}}) == set() + + +@case("I 去重集·SAR 明确为空则保留 (同一翻空不重复处理)") +def _(): + from app.services import proposal_service as ps + assert ps._keep_tech_exit_done({"600000.SH"}, {"600000.SH": {"sar_side": "空"}}) == {"600000.SH"} + + def main(): ok = 0 for name, fn in RESULTS: