三源合议工作包二(一):基本面/择时/合议三个纯逻辑模块

方案第四节的三个纯逻辑判定,零外部依赖好单测:
- fund_rules 基本面立场:质地好看多/质地中两好零差看多/差或硬疑点看空/缺或超120天无读数/
  十视角好≥8且估值贵打共识已定价标记
- timing_rules 择时立场:昨夜 BUY/WATCH/SELL 映射 + 盘中转多升级
- consensus 合议与路由:多数决方向、强弱、附录丙路由表逐条
batch28 二十六例。装配服务 consensus_service 与 advise_v2、扫描接入、增持门、页面合议行随后。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-11 14:17:04 +08:00
parent 5fa68817dc
commit 84207d53b8
5 changed files with 389 additions and 2 deletions

72
app/core/consensus.py Normal file
View File

@ -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}

67
app/core/fund_rules.py Normal file
View File

@ -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}")

31
app/core/timing_rules.py Normal file
View File

@ -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)}

View File

@ -72,14 +72,16 @@
提议卡重排要紧的在前证据收进展开/建议档位与实下股数不一致标出 (17 ) 提议卡重排要紧的在前证据收进展开/建议档位与实下股数不一致标出 (17 )
test_batch27_units.py 技术面接入·工作包一 (2026-09-11): 相位合成九格与四种无读数/震荡市抑制翻向/ test_batch27_units.py 技术面接入·工作包一 (2026-09-11): 相位合成九格与四种无读数/震荡市抑制翻向/
取数解析与代码归一/分页拼接与状态非 OK 不落表/映射只覆盖有当天读数的相关票/ 取数解析与代码归一/分页拼接与状态非 OK 不落表/映射只覆盖有当天读数的相关票/
读数陈旧按无读数/参数登记与交易日差 (27 ) 读数陈旧按无读数/参数登记与交易日差/upsert VALUES 与按 matched 分页两个真机哨兵 (29 )
test_batch28_units.py 三源合议·工作包二纯逻辑 (2026-09-11): 基本面质地档映射与共识已定价/择时定性
映射与盘中转多升级/合议多数决与强弱/附录丙路由表逐条 (26 )
test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许 test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许
直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象 直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象
打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 ) 打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) + test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) +
目标价到价必定入队 (档位 full 也不自动卖) + 目标价到价必定入队 (档位 full 也不自动卖) +
用户设的止损价与目标价单独成列显示 (70 ) 用户设的止损价与目标价单独成列显示 (70 )
724 752
(总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条 (总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条
减持与研究理由两键各一例, 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 654; 又加了页面文案守卫一例, 655; 09-07 审查修复加了跨轮减持等五例, 660; 第二件低把握驳回交人一例, 661; 第三件逻辑状态接入第二十二批十五例, 676; 第四件安全边际整句透传一例, 677; 参考目标价一例, 678; 催化事件与定价状态透传一例, 679; 两个期限的头一例, 现为 680; 09-10 建议档位对齐第二十六批十七例到 697; 09-11 技术面接入工作包一第二十七批二十七例到 724) 减持与研究理由两键各一例, 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 654; 又加了页面文案守卫一例, 655; 09-07 审查修复加了跨轮减持等五例, 660; 第二件低把握驳回交人一例, 661; 第三件逻辑状态接入第二十二批十五例, 676; 第四件安全边际整句透传一例, 677; 参考目标价一例, 678; 催化事件与定价状态透传一例, 679; 两个期限的头一例, 现为 680; 09-10 建议档位对齐第二十六批十七例到 697; 09-11 技术面接入工作包一第二十七批二十七例到 724)
任一子集失败即整体失败 (退出码 1) 任一子集失败即整体失败 (退出码 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_batch24_units.py", "test_batch25_units.py",
"test_batch26_units.py", "test_batch26_units.py",
"test_batch27_units.py", "test_batch27_units.py",
"test_batch28_units.py",
"test_page_enum_guard.py", "test_page_enum_guard.py",
"test_page_wiring_guard.py", "test_page_wiring_guard.py",
"test_wiring.py"] "test_wiring.py"]

View File

@ -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())