From 84207d53b8e2054e90efc9f529d17eda7ce64e76 Mon Sep 17 00:00:00 2001 From: zlt Date: Fri, 11 Sep 2026 14:17:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=89=E6=BA=90=E5=90=88=E8=AE=AE=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=85=E4=BA=8C(=E4=B8=80)=EF=BC=9A=E5=9F=BA?= =?UTF-8?q?=E6=9C=AC=E9=9D=A2/=E6=8B=A9=E6=97=B6/=E5=90=88=E8=AE=AE?= =?UTF-8?q?=E4=B8=89=E4=B8=AA=E7=BA=AF=E9=80=BB=E8=BE=91=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 方案第四节的三个纯逻辑判定,零外部依赖好单测: - fund_rules 基本面立场:质地好看多/质地中两好零差看多/差或硬疑点看空/缺或超120天无读数/ 十视角好≥8且估值贵打共识已定价标记 - timing_rules 择时立场:昨夜 BUY/WATCH/SELL 映射 + 盘中转多升级 - consensus 合议与路由:多数决方向、强弱、附录丙路由表逐条 batch28 二十六例。装配服务 consensus_service 与 advise_v2、扫描接入、增持门、页面合议行随后。 Co-Authored-By: Claude Opus 4.8 --- app/core/consensus.py | 72 ++++++++++++ app/core/fund_rules.py | 67 +++++++++++ app/core/timing_rules.py | 31 +++++ scripts/run_tests.py | 7 +- scripts/test_batch28_units.py | 214 ++++++++++++++++++++++++++++++++++ 5 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 app/core/consensus.py create mode 100644 app/core/fund_rules.py create mode 100644 app/core/timing_rules.py create mode 100644 scripts/test_batch28_units.py diff --git a/app/core/consensus.py b/app/core/consensus.py new file mode 100644 index 0000000..b6566ef --- /dev/null +++ b/app/core/consensus.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""三源合议与路由 (2026-09-11, 纯逻辑, 零外部依赖)。 + +三票 (基本面 / 技术面 / 择时系统) 各是看多/看空/中性/无读数之一, 合成方向再定路由。 +方案第四节的合议规则 (台账 009): + 三票多数决定方向; 无读数弃权不计; 平局中性; 只有一方表态算弱; 三方一致算强。 +路由表见方案附录丙。方向词与合议词一律中文原文, 取值只有看多/看空/中性/无读数。 +""" +from __future__ import annotations + +_BULL, _BEAR, _NEUT, _NA = "看多", "看空", "中性", "无读数" + + +def _reason(fund, tech, timing, direction, strength): + head = "、".join([f"基本面{fund}", f"技术面{tech}", f"择时{timing}"]) + return head + " → " + direction + (("·" + strength) if strength else "") + + +def _route(fund, tech, timing, direction, tech_phase, fund_required): + """附录丙路由表, 从上到下第一个命中。返回 (路由, 原因)。路由取值: 放行/观察/交人/跳过。""" + if fund_required and fund == _NA: + return "跳过", "没有买方评析" + if direction == _BEAR: + return "跳过", f"方向看空(基本面{fund}、技术面{tech}、择时{timing})" + if direction == _NEUT and tech_phase == "收口等待" and fund != _BEAR: + return "观察", "等技术面开口" + if direction == _NEUT and fund == _BULL and tech == _BEAR: + return "观察", "等技术面转向" + if fund == _BEAR and tech == _BULL: + return "交人", "基本面看空、技术面看多,试探仓" + if direction == _BULL: + voiced = [v for v in (fund, tech, timing) if v in (_BULL, _BEAR)] + if timing == _BEAR: # 异议在择时系统 + return "交人", "方向看多但择时系统有异议" + if len(voiced) <= 1: # 只有一方表态 + 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: + """fund/tech/timing 是三个立场字符串 (看多/看空/中性/无读数)。tech_phase 是技术面相位 + (收口等待 等), 路由用。fund_required: 无买方评析是否拦 (开关 PMS_FUND_REQUIRED)。""" + 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) + bears = sum(1 for v in voiced if v == _BEAR) + + # ---- 方向: 多数决, 无读数弃权不计, 平局中性 ---- + if bulls > bears: + direction = _BULL + elif bears > bulls: + direction = _BEAR + else: + direction = _NEUT + + # ---- 强弱: 只有一方表态弱, 三方一致强, 其余 (两方表态) 为中 ---- + if direction == _NEUT: + strength = None + elif len(voiced) <= 1: + strength = "弱" + elif bulls == 3 or bears == 3: + strength = "强" + else: + strength = "中" + + route, route_reason = _route(fund, tech, timing, direction, tech_phase, fund_required) + return {"direction": direction, "votes": votes, "strength": strength, + "reason": _reason(fund, tech, timing, direction, strength), + "route": route, "route_reason": route_reason} diff --git a/app/core/fund_rules.py b/app/core/fund_rules.py new file mode 100644 index 0000000..dfab1bb --- /dev/null +++ b/app/core/fund_rules.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +"""基本面立场合成 (2026-09-11 三源合议, 纯逻辑, 零外部依赖)。 + +输入是选股系统的公司深度评析 (plan_feed._company_review_or_none 的结构: overall 质地档、 +valuation 估值、grade_counts 十视角计数、groups 三类、doubt_hard 硬疑点、age_days 报告日龄)。 +规则见《技术面接入与三源合议方案》第四节与台账 007: + 质地好为看多; 质地中且三类里两好零差、十视角差不超过一个也算看多 (整体方向为好就够); + 质地差或有硬疑点为看空; 其余中性; 缺报告或超 120 天为无读数。 + 十视角好达八个且估值贵时打「共识已定价」标记, 不改立场, 只让首批推迟。 +立场取值只有: 看多 / 看空 / 中性 / 无读数。无读数是弃权, 绝不折成看空 (设计原则二)。 +""" +from __future__ import annotations + +_GROUPS = ("回报与护城河", "盈余质量与财务安全", "成长与含金量") + +DEFAULTS = { + "stale_days": 120, # 评析报告超这么多天算无读数 (PMS_FUND_STALE_DAYS) + "consensus_good_min": 8, # 十视角好达这么多个且估值贵 → 共识已定价 (PMS_FUND_CONSENSUS_GOOD_MIN) +} + + +def _out(stance, *, fact=None, why=None, mark=None, priced_in=False, valuation=None, overall=None): + return {"stance": stance, "fact": fact, "mark": mark, "priced_in": priced_in, + "valuation": valuation, "overall": overall, "no_read_why": why} + + +def synthesize(company_review, *, params=None) -> dict: + p = dict(DEFAULTS) + if params: + p.update({k: params[k] for k in DEFAULTS if params.get(k) is not None}) + cr = company_review if isinstance(company_review, dict) else None + + # ---- 无读数: 缺报告或超期 (无读数是弃权) ---- + if not cr or not cr.get("overall"): + return _out("无读数", why="没有买方评析") + age = cr.get("age_days") + if age is not None and int(age) > int(p["stale_days"]): + return _out("无读数", why=f"评析报告已 {age} 天, 超过 {p['stale_days']} 天") + + overall = str(cr.get("overall") or "") + gc = cr.get("grade_counts") if isinstance(cr.get("grade_counts"), dict) else {} + groups = cr.get("groups") if isinstance(cr.get("groups"), dict) else {} + good_views = int(gc.get("好") or 0) + bad_views = int(gc.get("差") or 0) + doubt = bool(cr.get("doubt_hard")) + + # 共识已定价: 十视角好够多且估值贵 (不改立场, 只让首批推迟) + priced_in = good_views >= int(p["consensus_good_min"]) and "贵" in str(cr.get("valuation") or "") + + def out(stance, fact): + return _out(stance, fact=fact, mark=("共识已定价" if priced_in else None), + priced_in=priced_in, valuation=cr.get("valuation"), overall=overall) + + # ---- 看空: 质地差或硬疑点 ---- + if overall == "差" or doubt: + return out("看空", "质地差" if overall == "差" else "有硬疑点") + # ---- 看多: 质地好 ---- + if overall == "好": + return out("看多", "质地好") + # ---- 看多: 质地中但「整体方向为好就够」(三类两好零差, 十视角差 ≤ 1) ---- + if overall == "中": + group_good = sum(1 for k in _GROUPS if str(groups.get(k) or "") == "好") + group_bad = sum(1 for k in _GROUPS if str(groups.get(k) or "") == "差") + if group_good >= 2 and group_bad == 0 and bad_views <= 1: + return out("看多", "质地中但三类两好零差、十视角差不过一个") + # ---- 其余中性 ---- + return out("中性", f"质地{overall}") diff --git a/app/core/timing_rules.py b/app/core/timing_rules.py new file mode 100644 index 0000000..c994c28 --- /dev/null +++ b/app/core/timing_rules.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +"""择时系统立场合成 (2026-09-11 三源合议, 纯逻辑, 零外部依赖)。 + +输入是决策系统的昨夜定性 (BUY/WATCH/SELL/AVOID/DROPPED, 或超期/缺) 与当天盘中转多留痕。 +规则见《技术面接入与三源合议方案》第四节: + 昨夜 BUY 看多, WATCH 中性, SELL/AVOID/DROPPED 看空, 超期或缺无读数。 + 当天盘中转多留痕把无读数与中性升为看多, 与看空相遇归中性。 +立场取值只有: 看多 / 看空 / 中性 / 无读数。无读数是弃权, 绝不折成看空 (设计原则二)。 + +研判闸不当选票 (设计原则三): 这一票只用昨夜定性与盘中转多留痕, 不重复计入研判结论。 +""" +from __future__ import annotations + +_NIGHTLY = {"BUY": "看多", "WATCH": "中性", "SELL": "看空", "AVOID": "看空", "DROPPED": "看空"} + + +def synthesize(nightly, *, intraday_flip_at=None, fresh=True) -> dict: + """nightly: 昨夜定性字符串 (BUY 等) 或 None/空; fresh: 昨夜结论是否在有效期内 (超期传 False); + intraday_flip_at: 当天盘中转多留痕的时刻字符串, 没有传 None。""" + n = str(nightly or "").strip().upper() + base = _NIGHTLY.get(n, "中性") if (n and fresh) else "无读数" + stance = base + if intraday_flip_at: # 当天盘中转多留痕 + if base in ("无读数", "中性"): + stance = "看多" + elif base == "看空": + stance = "中性" # 盘中转多与昨夜看空相遇, 归中性 + # base 已是看多则维持看多 + return {"stance": stance, "nightly": (n or None), "fresh": bool(fresh), + "intraday_flip_at": intraday_flip_at, + "no_read_why": ("没有昨夜结论或已超期" if stance == "无读数" else None)} diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 31c7d7c..4bfbef6 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -72,14 +72,16 @@ 提议卡重排要紧的在前证据收进展开/建议档位与实下股数不一致标出 (17 例) test_batch27_units.py 技术面接入·工作包一 (2026-09-11): 相位合成九格与四种无读数/震荡市抑制翻向/ 取数解析与代码归一/分页拼接与状态非 OK 不落表/映射只覆盖有当天读数的相关票/ - 读数陈旧按无读数/参数登记与交易日差 (27 例) + 读数陈旧按无读数/参数登记与交易日差/upsert 用 VALUES 与按 matched 分页两个真机哨兵 (29 例) + test_batch28_units.py 三源合议·工作包二纯逻辑 (2026-09-11): 基本面质地档映射与共识已定价/择时定性 + 映射与盘中转多升级/合议多数决与强弱/附录丙路由表逐条 (26 例) test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许 直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象 打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) + 目标价到价必定入队 (档位 full 也不自动卖) + 用户设的止损价与目标价单独成列显示 (70 例) - 共 724 例 + 共 752 例 (总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条 减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 到 654; 又加了页面文案守卫一例, 到 655; 09-07 审查修复加了跨轮减持等五例, 到 660; 第二件低把握驳回交人一例, 到 661; 第三件逻辑状态接入第二十二批十五例, 到 676; 第四件安全边际整句透传一例, 到 677; 参考目标价一例, 到 678; 催化事件与定价状态透传一例, 到 679; 两个期限的头一例, 现为 680; 09-10 建议档位对齐第二十六批十七例到 697; 09-11 技术面接入工作包一第二十七批二十七例到 724) 任一子集失败即整体失败 (退出码 1)。 @@ -131,6 +133,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py", "test_batch24_units.py", "test_batch25_units.py", "test_batch26_units.py", "test_batch27_units.py", + "test_batch28_units.py", "test_page_enum_guard.py", "test_page_wiring_guard.py", "test_wiring.py"] diff --git a/scripts/test_batch28_units.py b/scripts/test_batch28_units.py new file mode 100644 index 0000000..0c6c16f --- /dev/null +++ b/scripts/test_batch28_units.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- +"""三源合议 · 工作包二纯逻辑 (2026-09-11 方案第四节, 台账 007/009)。全部离线, 不连库。 + A 基本面 fund_rules: 质地档映射、质地中整体为好、共识已定价、缺与超期无读数。 + B 择时 timing_rules: 昨夜定性映射、盘中转多升级、超期无读数。 + C 合议 consensus: 多数决方向、平局中性、强弱、附录丙路由表逐条。 +""" +import os +import sys +import traceback + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.core import fund_rules as fr # noqa: E402 +from app.core import timing_rules as tm # noqa: E402 +from app.core import consensus as cons # noqa: E402 + +RESULTS = [] + + +def case(name): + def deco(fn): + RESULTS.append((name, fn)) + return fn + return deco + + +def _cr(overall="好", valuation="便宜", good=8, mid=2, bad=0, groups=None, doubt=False, age=0): + return {"overall": overall, "valuation": valuation, + "grade_counts": {"好": good, "中": mid, "差": bad, "证据不足": 0}, + "groups": groups or {"回报与护城河": "好", "盈余质量与财务安全": "好", "成长与含金量": "中"}, + "doubt_hard": doubt, "age_days": age} + + +# ================================================================ A 基本面 +@case("A 基本面: 质地好为看多") +def _(): + assert fr.synthesize(_cr(overall="好"))["stance"] == "看多" + + +@case("A 基本面: 质地中但三类两好零差、十视角差≤1 → 看多(整体方向为好就够)") +def _(): + r = fr.synthesize(_cr(overall="中", bad=1, + groups={"回报与护城河": "好", "盈余质量与财务安全": "好", "成长与含金量": "中"})) + assert r["stance"] == "看多", r + + +@case("A 基本面: 质地中只有一类好 → 中性") +def _(): + r = fr.synthesize(_cr(overall="中", bad=0, + groups={"回报与护城河": "好", "盈余质量与财务安全": "中", "成长与含金量": "中"})) + assert r["stance"] == "中性", r + + +@case("A 基本面: 质地中两好但十视角差>1 → 中性") +def _(): + r = fr.synthesize(_cr(overall="中", bad=2, + groups={"回报与护城河": "好", "盈余质量与财务安全": "好", "成长与含金量": "中"})) + assert r["stance"] == "中性", r + + +@case("A 基本面: 质地差看空") +def _(): + assert fr.synthesize(_cr(overall="差"))["stance"] == "看空" + + +@case("A 基本面: 有硬疑点看空(哪怕质地不差)") +def _(): + assert fr.synthesize(_cr(overall="中", doubt=True))["stance"] == "看空" + + +@case("A 基本面: 缺评析无读数, 绝不折看空") +def _(): + assert fr.synthesize(None)["stance"] == "无读数" + assert fr.synthesize({})["stance"] == "无读数" + + +@case("A 基本面: 报告超 120 天无读数") +def _(): + assert fr.synthesize(_cr(overall="好", age=121))["stance"] == "无读数" + assert fr.synthesize(_cr(overall="好", age=120))["stance"] == "看多" + + +@case("A 基本面: 十视角好≥8且估值贵 → 共识已定价标记, 立场不变") +def _(): + r = fr.synthesize(_cr(overall="好", good=8, valuation="贵")) + assert r["stance"] == "看多" and r["mark"] == "共识已定价" and r["priced_in"] is True + + +@case("A 基本面: 好视角够但估值不贵 → 不打共识已定价") +def _(): + assert fr.synthesize(_cr(overall="好", good=9, valuation="便宜"))["mark"] is None + + +# ================================================================ B 择时 +@case("B 择时: BUY看多 / WATCH中性 / SELL·AVOID·DROPPED看空") +def _(): + assert tm.synthesize("BUY")["stance"] == "看多" + assert tm.synthesize("WATCH")["stance"] == "中性" + for v in ("SELL", "AVOID", "DROPPED"): + assert tm.synthesize(v)["stance"] == "看空", v + + +@case("B 择时: 缺或超期无读数") +def _(): + assert tm.synthesize(None)["stance"] == "无读数" + assert tm.synthesize("BUY", fresh=False)["stance"] == "无读数" + + +@case("B 择时: 盘中转多把无读数与中性升看多") +def _(): + assert tm.synthesize(None, intraday_flip_at="10:05")["stance"] == "看多" + assert tm.synthesize("WATCH", intraday_flip_at="10:05")["stance"] == "看多" + + +@case("B 择时: 盘中转多与昨夜看空相遇归中性; 昨夜看多维持看多") +def _(): + assert tm.synthesize("SELL", intraday_flip_at="10:05")["stance"] == "中性" + assert tm.synthesize("BUY", intraday_flip_at="10:05")["stance"] == "看多" + + +# ================================================================ C 合议 +@case("C 合议·方向: 多数决, 无读数弃权, 平局中性") +def _(): + assert cons.decide("看多", "看多", "看空")["direction"] == "看多" + assert cons.decide("看空", "看空", "看多")["direction"] == "看空" + assert cons.decide("看多", "看空", "无读数")["direction"] == "中性" + assert cons.decide("无读数", "无读数", "无读数", fund_required=False)["direction"] == "中性" + + +@case("C 合议·强弱: 一方表态弱, 三方一致强, 两方为中") +def _(): + assert cons.decide("看多", "无读数", "无读数")["strength"] == "弱" + assert cons.decide("看多", "看多", "看多")["strength"] == "强" + assert cons.decide("看多", "看多", "无读数")["strength"] == "中" + + +@case("C 路由: 基本面无读数且开关开 → 跳过没有买方评析") +def _(): + r = cons.decide("无读数", "看多", "看多", fund_required=True) + assert r["route"] == "跳过" and "买方评析" in r["route_reason"] + + +@case("C 路由: 关掉 fund_required 后无评析不因此跳过") +def _(): + assert cons.decide("无读数", "看多", "看多", fund_required=False)["route"] != "跳过" + + +@case("C 路由: 方向看空 → 跳过") +def _(): + assert cons.decide("看空", "看空", "中性")["route"] == "跳过" + + +@case("C 路由: 中性且技术面收口等待且基本面非看空 → 观察等开口") +def _(): + r = cons.decide("中性", "中性", "中性", tech_phase="收口等待") + assert r["route"] == "观察" and "开口" in r["route_reason"] + + +@case("C 路由: 中性且基本面看多技术面看空 → 观察等技术面转向") +def _(): + r = cons.decide("看多", "看空", "无读数") + assert r["direction"] == "中性" and r["route"] == "观察" and "转向" in r["route_reason"] + + +@case("C 路由: 基本面看空技术面看多 → 交人试探仓") +def _(): + r = cons.decide("看空", "看多", "中性") + assert r["route"] == "交人" and "试探" in r["route_reason"] + + +@case("C 路由: 看多但异议在择时系统 → 交人") +def _(): + r = cons.decide("看多", "看多", "看空") + assert r["direction"] == "看多" and r["route"] == "交人" and "择时" in r["route_reason"] + + +@case("C 路由: 看多但只有一方表态 → 交人") +def _(): + r = cons.decide("看多", "无读数", "无读数") + assert r["route"] == "交人" and "一方" in r["route_reason"] + + +@case("C 路由: 看多且多方表态、异议只在技术面 → 放行") +def _(): + r = cons.decide("看多", "看空", "看多") + assert r["direction"] == "看多" and r["route"] == "放行" + + +@case("C 路由: 三方都无读数 → 放行机械方案") +def _(): + r = cons.decide("无读数", "无读数", "无读数", fund_required=False) + assert r["route"] == "放行" and "机械" in r["route_reason"] + + +def main(): + ok = 0 + for name, fn in RESULTS: + try: + fn() + ok += 1 + print(" ok " + name) + except Exception: + print(" FAIL " + name) + traceback.print_exc() + print("-" * 60) + if ok == len(RESULTS): + print("ALL PASS (%d cases)" % ok) + return 0 + print("FAILED %d/%d" % (len(RESULTS) - ok, len(RESULTS))) + return 1 + + +if __name__ == "__main__": + sys.exit(main())