From 07245096248a5f4a3cef9075ce114f26299076ef Mon Sep 17 00:00:00 2001 From: zlt Date: Wed, 9 Sep 2026 12:15:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=AC=E5=8F=B8=E8=B4=A8=E5=9C=B0=E4=B8=8E?= =?UTF-8?q?=E5=BB=BA=E8=AE=AE=E6=96=B9=E6=A1=88=E6=8E=A5=E5=85=A5=EF=BC=9A?= =?UTF-8?q?=E5=85=AC=E5=8F=B8=E6=B7=B1=E5=BA=A6=E4=B8=A4=E9=94=AE=E9=80=8F?= =?UTF-8?q?=E4=BC=A0=E3=80=81=E6=95=B4=E5=8F=A5=E9=80=81=E7=A0=94=E5=88=A4?= =?UTF-8?q?=E5=8F=AF=E5=85=B3=E3=80=81=E5=BB=BA=E8=AE=AE=E6=96=B9=E6=A1=88?= =?UTF-8?q?=E7=9F=A9=E9=98=B5=E4=B8=8E=E7=A0=94=E5=88=A4=E8=A1=A5=E6=A1=A3?= =?UTF-8?q?=E3=80=81=E6=8C=81=E4=BB=93=E9=A1=B5=E4=B8=80=E8=A1=8C=E3=80=81?= =?UTF-8?q?=E4=B8=89=E4=B8=AA=E8=BF=90=E8=A1=8C=E5=8F=82=E6=95=B0=E3=80=81?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=EF=BC=88=E5=8F=B0=E8=B4=A6=20053=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- BIONIC_PMS_INTERFACE.md | 5 + app/core/action_engine.py | 22 +++- app/services/advice_service.py | 137 ++++++++++++++++++++++ app/services/judge.py | 13 ++- app/services/logic_state_service.py | 17 +++ app/services/param_store.py | 4 + app/services/plan_feed.py | 30 ++++- app/services/proposal_service.py | 12 ++ app/web/static/index.html | 7 ++ scripts/run_tests.py | 4 +- scripts/test_batch23_units.py | 175 ++++++++++++++++++++++++++++ 11 files changed, 421 insertions(+), 5 deletions(-) create mode 100644 app/services/advice_service.py create mode 100644 scripts/test_batch23_units.py diff --git a/BIONIC_PMS_INTERFACE.md b/BIONIC_PMS_INTERFACE.md index d5e52d2..a0e5fe3 100644 --- a/BIONIC_PMS_INTERFACE.md +++ b/BIONIC_PMS_INTERFACE.md @@ -300,3 +300,8 @@ docker compose run --rm --no-deps pms-web python scripts/probe_bionic.py --base | V1.0 | 2026-08-03 | 首版定稿并双侧落码。择时部分是一套盘中判定规则(资金阈值、动量追买等) | | V1.1 | 2026-08-03 | 按用户审核意见重做择时:删掉 V1.0 的盘中判定规则(违反「提前计算为主、盘中监控为辅、不另做盘中判断、可以接受买不上」),改为由昨夜支撑压力推出执行区间、盘中只做区间比对与当日监控核对;研判留痕不再写 `decision_ledger`(每晚判分全表扫描,已核实);文档与代码注释清理生造词 | | V1.2 | 2026-09-08 | 量价研判链(《量价研判链吸收方案_2026-09-08》):请求侧硬数字白名单多送 `events_text`、`pricing_text` 两句整句(催化事件与定价状态,事件与量价不是产业逻辑);应答侧新建仓研判可并列多带 `pv_heads`(`h5`、`h20` 各含 `score` −1 到 1、`direction` 看多/中性/看空、`justification`),PMS 归一后进提议硬数字 `judge_pv_heads`,只显示不触发;三值 verdict 口径不变,缺 `pv_heads` 就是 None,向下兼容 | + +### 7.x 2026-09-09 公司深度整句(V1.3) +- 研判请求的硬数字新增 `company_review_text`(选股系统写好的整句:质地档与三类、十视角计数、买方论点一句、置信度、失效条件、报告链接)。 + 与 `verdict/reasons` 同一性质:上游结论送去让研判对照,研判不重判质地。PMS 侧开关 `PMS_JUDGE_COMPANY_TEXT`(默认开)关掉即不送。 +- bionic 侧不需要改:硬数字逐键渲染进提示词。应答形状不变。 diff --git a/app/core/action_engine.py b/app/core/action_engine.py index a995d6a..56b8112 100644 --- a/app/core/action_engine.py +++ b/app/core/action_engine.py @@ -697,6 +697,9 @@ def eval_open(c: dict, params: dict, caps: dict, room_amt: float): "news": c.get("news"), "news_text": c.get("news_text"), # 行业催化 (2026-09-08 台账 051): 环节级, 原值进账本与提议卡; 产业逻辑不送研判。 "industry_catalyst": c.get("industry_catalyst"), "industry_catalyst_text": c.get("industry_catalyst_text"), + # 公司深度 (2026-09-09 接入方案): 质地档与评议摘要进账本与提议卡; 整句送研判 (白名单里, 开关可关)。 + "company_review": c.get("company_review"), "company_review_text": c.get("company_review_text"), + "company_gate": c.get("company_gate"), # ---- 仓位口径: 只进评审账本做判分锚。judge.py 送研判时会把这几项过滤掉, # 理由见那边的 OPEN_JUDGE_KEYS —— 决策系统本来就不管仓位, 别送过去带偏它。 "target_pct": target_pct, "target_amount": round(full_amt, 2), @@ -799,8 +802,16 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float, if why_skip: skipped.append({"ts_code": code, "action": A_OPEN, "why": why_skip}) continue + # 建议方案 (2026-09-09 接入方案第六之二节): 先按质地、估值、赔率、逻辑状态、定价状态出仓位档与分批; + # 开关 PMS_PLAN_BY_ADVICE 开着时它就是评估参数, 关着时只并排显示。研判那一档在提议落账前补。 try: - cand, why = eval_open(c, params, ctx, left) + from app.services import advice_service as _adv + adv = _adv.advise(c, params) + p_use = _adv.params_for(adv, params) if params.get("plan_by_advice") else params + except Exception as e: # noqa: BLE001 —— 建议算不出不拦评估 + adv, p_use = {"tier": None, "text": f"建议方案算不出: {type(e).__name__}"}, params + try: + cand, why = eval_open(c, p_use, ctx, left) except Exception as e: # 单票异常不能拖垮整轮扫描 (与 scan() 同口径) skipped.append({"ts_code": code, "action": A_OPEN, "why": f"评估异常 {type(e).__name__}: {e}"}) @@ -808,6 +819,15 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float, if not cand: skipped.append({"ts_code": code, "action": A_OPEN, "why": why or "未产出候选"}) continue + try: + cand.setdefault("hard_numbers", {})["advice"] = adv + cand["hard_numbers"]["advice_text"] = adv.get("text") if isinstance(adv, dict) else None + cand["hard_numbers"]["plan_by_advice"] = bool(params.get("plan_by_advice")) + if isinstance(adv, dict) and adv.get("tier") == "试探仓": + cand["needs_user_confirm"] = True + cand["confirm_why"] = (cand.get("confirm_why") or "") + "建议方案为试探仓,只在人工确认后下;" + except Exception: # noqa: BLE001 + pass if route: confirm_why = verdict_confirm_why(c.get("verdict")) if confirm_why: diff --git a/app/services/advice_service.py b/app/services/advice_service.py new file mode 100644 index 0000000..544871c --- /dev/null +++ b/app/services/advice_service.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +"""建议建仓方案 (2026-09-09 接入方案第六之二节, 台账 053)。纯函数, 不读库不调模型。 + +输入六样: 公司质地 (好/中/差, 来自数据基座代码合成)、估值标签 (贵/中/便宜)、赔率 (安全边际三情景)、 +逻辑状态 (逻辑成立/强化/存疑/无法判断)、研判结论与把握度 (提议落账前才有)、定价状态。 +输出一份「建议方案」: 仓位档、分批、附加条件、持有期限标签、一句话。矩阵一次定死, 只按复盘读数换档位: + + 质地好 + 估值中或便宜 + 逻辑成立或强化 → 标准仓, 5/2.5/2.5 成, 第二批要求二十日头不看空 + 质地好 + 估值贵 → 减半仓, 两批各半, 第二批等下一期财报失效条件未触发 + 质地中 → 标准仓, 第一批 3 成、后两批各 3.5 成, 后两批要求二十日头看多且非高位兑现 + 研判把握度低或不可用、赔率低于 1 → 试探仓 (1%), 一批, 只在人工确认后下 + 定价状态高位兑现 → 档位不变, 首批推迟到回撤至支撑 + 质地差、逻辑存疑 → 不建 (判决与分流已经拦住; 这里只写原因) + 没有质地读数 (证据不足 / 没报告) → 沿用机械方案, 只标「质地未知」 + +开关 PMS_PLAN_BY_ADVICE (模拟侧默认开): 开着时建议方案直接作为提议的仓位档与分批; 关着时只并排显示。 +止损定性与风控卖出不动; 择时决策系统照旧决定每一批的下单时点。 +""" +from __future__ import annotations + +from typing import Any, Optional + +TIER_STD, TIER_HALF, TIER_TRIAL, TIER_NONE = "标准仓", "减半仓", "试探仓", "不建" +DEFAULT_SPLIT = (0.5, 0.25, 0.25) + + +def _f(v, d=None): + try: + return float(v) + except (TypeError, ValueError): + return d + + +def odds_of(valuation: Optional[dict]) -> Optional[float]: + """赔率 = 中性上行 ÷ 悲观下行 (选股系统安全边际三情景的口径)。算不出返回 None。""" + if not isinstance(valuation, dict): + return None + neut, pess, price = _f(valuation.get("neut")), _f(valuation.get("pess")), _f(valuation.get("price")) + if not neut or not pess or not price or price <= 0: + return None + up, down = neut / price - 1.0, pess / price - 1.0 + if down >= 0: + return 9.99 # 悲观情景都在现价之上: 赔率极好, 封顶 + return round(up / abs(down), 2) if up > 0 else 0.0 + + +def advise(c: dict, params: dict) -> dict[str, Any]: + """候选产出前的建议 (没有研判结论)。返回的 tier / factor / splits 由 params_for 变成评估参数。""" + cr = c.get("company_review") if isinstance(c.get("company_review"), dict) else {} + overall = cr.get("overall") + valuation_label = cr.get("valuation") + ls = (c.get("logic_state") or {}).get("state") if isinstance(c.get("logic_state"), dict) else None + ps = (c.get("pricing_state") or {}).get("state") if isinstance(c.get("pricing_state"), dict) else None + odds = odds_of(c.get("valuation")) + base_pct = _f(params.get("stock_target_default"), 0.06) + base_split = tuple(params.get("batch_split") or DEFAULT_SPLIT) + adv: dict[str, Any] = {"tier": TIER_STD, "factor": 1.0, "splits": base_split, "conditions": [], "why": [], + "hold_type": "待定", "delay_first": False, "quality": overall, "valuation_label": valuation_label, + "logic_state": ls, "pricing_state": ps, "odds": odds, + "mechanical": {"target_pct": base_pct, "splits": base_split}, "version": "1"} + if overall == "差" or ls == "逻辑存疑": + adv.update(tier=TIER_NONE, factor=0.0, splits=(), hold_type="不适用") + adv["why"].append("质地差或逻辑存疑:判决与分流已经拦住,不建") + elif overall == "好" and valuation_label in ("中", "便宜") and ls in ("逻辑成立", "逻辑强化", None): + adv.update(tier=TIER_STD, factor=1.0, splits=(0.5, 0.25, 0.25), hold_type="长期持有类") + adv["conditions"].append("第二批要求二十日头不看空") + adv["why"].append(f"质地好、估值{valuation_label}、逻辑{ls or '未读到'}") + elif overall == "好" and valuation_label == "贵": + adv.update(tier=TIER_HALF, factor=0.5, splits=(0.5, 0.5), hold_type="长期持有类") + adv["conditions"].append("第二批等下一期财报失效条件未触发再加") + adv["why"].append("质地好但估值贵") + elif overall == "中": + adv.update(tier=TIER_STD, factor=1.0, splits=(0.3, 0.35, 0.35), hold_type="事件驱动类") + adv["conditions"].append("后两批要求二十日头看多且定价状态不是高位兑现") + adv["why"].append(f"质地中、估值{valuation_label or '无'}") + elif overall == "好": + adv.update(tier=TIER_STD, factor=1.0, splits=(0.5, 0.25, 0.25), hold_type="长期持有类") + adv["conditions"].append("第二批要求二十日头不看空") + adv["why"].append(f"质地好、估值{valuation_label or '无'}、逻辑{ls or '未读到'}") + else: + adv["why"].append("没有质地读数(证据不足或没报告),沿用机械方案") + if odds is not None and odds < 1.0 and adv["tier"] not in (TIER_NONE,): + adv.update(tier=TIER_TRIAL, factor=1.0 / 6.0, splits=(1.0,)) + adv["conditions"].append("只在人工确认后下") + adv["why"].append(f"赔率 {odds:.2f} 低于 1") + if ps == "高位兑现" and adv["tier"] != TIER_NONE: + adv["delay_first"] = True + adv["conditions"].append("定价状态高位兑现:首批推迟,等回撤到支撑再下") + adv["target_pct"] = round(base_pct * adv["factor"], 4) + adv["text"] = text(adv) + return adv + + +def apply_judge(adv: Optional[dict], judge: Optional[dict], conf_min: int = 60) -> Optional[dict]: + """提议落账前补研判一档: 把握度低于 conf_min 或结论不可用 → 试探仓, 只在人工确认后下。不改数量, 数量交人。""" + if not isinstance(adv, dict) or not isinstance(judge, dict): + return adv + verdict = str(judge.get("verdict") or "").upper() + conf = _f(judge.get("confidence")) + low = (verdict in ("UNAVAILABLE", "不可用")) or (conf is not None and conf < conf_min) + if low and adv.get("tier") not in (TIER_NONE, TIER_TRIAL): + adv = dict(adv) + adv.update(tier=TIER_TRIAL, factor=1.0 / 6.0, splits=(1.0,)) + adv["conditions"] = list(adv.get("conditions") or []) + ["只在人工确认后下(研判把握度低或不可用)"] + adv["why"] = list(adv.get("why") or []) + [f"研判 {verdict or '无结论'}、把握度 {conf if conf is not None else '无'}"] + adv["target_pct"] = round(_f((adv.get("mechanical") or {}).get("target_pct"), 0.06) * adv["factor"], 4) + adv["needs_user_confirm"] = True + adv["text"] = text(adv) + return adv + + +def params_for(adv: dict, params: dict) -> dict: + """把建议方案变成评估参数 (开关开着时用): 目标仓位乘档位系数, 分批按建议。不建的返回原参数 (判决已拦)。""" + if not isinstance(adv, dict) or adv.get("tier") in (None, TIER_NONE): + return params + out = dict(params) + out["stock_target_default"] = adv.get("target_pct") or params.get("stock_target_default") + if adv.get("splits"): + out["batch_split"] = tuple(adv["splits"]) + return out + + +def text(adv: dict) -> str: + if not isinstance(adv, dict): + return "" + if adv.get("tier") == TIER_NONE: + return "建议方案:不建(" + ";".join(adv.get("why") or []) + ")" + sp = "/".join(f"{x:.0%}" for x in (adv.get("splits") or ())) + mech = adv.get("mechanical") or {} + s = (f"建议方案:{adv.get('tier')}(目标 {adv.get('target_pct', 0):.1%},分批 {sp or '一批'})" + f";机械方案 {_f(mech.get('target_pct'), 0):.1%} 分批 {'/'.join(f'{x:.0%}' for x in (mech.get('splits') or ()))}") + if adv.get("conditions"): + s += ";条件:" + ";".join(adv["conditions"]) + if adv.get("delay_first"): + s += ";首批推迟" + s += f";{adv.get('hold_type')};依据:" + ";".join(adv.get("why") or []) + return s diff --git a/app/services/judge.py b/app/services/judge.py index 6c1d832..0b16682 100644 --- a/app/services/judge.py +++ b/app/services/judge.py @@ -51,7 +51,10 @@ OPEN_JUDGE_KEYS = ("price", "score", "theme", "tier", "upside", "heat", "events_text", "pricing_text", # 相关快讯整句 (2026-09-08 台账 050): 财联社电报近 3 天点名这只票的条目。它是市场 # 反应与公司快讯, 不是产业逻辑; 正负不由 PMS 判, 交给研判连同事件上下文一起看。 - "news_text") + "news_text", + # 公司深度整句 (2026-09-09 接入方案第四节): 质地档、买方论点一句、置信度、失效条件。照 09-02 送判决 + # 与理由的先例——让研判拿自己的量价结论对照; 质地本身由选股系统判, 研判不重判。开关 PMS_JUDGE_COMPANY_TEXT。 + "company_review_text") # 新建仓送研判时的必答题 (2026-09-03)。候选卡的理由随硬数字送过去了, 研判要逐条回答 # 「这条理由到今天还成不成立」—— 这是把上游的静态判决与决策系统的当日观点接起来的那一句。 @@ -62,7 +65,13 @@ def _judge_hard_numbers(action: str, hard: dict) -> dict: """按动作裁剪送出去的硬数字。非 OPEN 一律原样送 (行为与 2026-08-06 之前一字不差)。""" if str(action or "").upper() != "OPEN": return dict(hard or {}) - return {k: v for k, v in (hard or {}).items() if k in OPEN_JUDGE_KEYS} + keys = set(OPEN_JUDGE_KEYS) + try: + if not param_store.get_bool("PMS_JUDGE_COMPANY_TEXT", True): + keys.discard("company_review_text") + except Exception: # noqa: BLE001 —— 参数读不到按默认送 + pass + return {k: v for k, v in (hard or {}).items() if k in keys} def jsonable(v): diff --git a/app/services/logic_state_service.py b/app/services/logic_state_service.py index 7a36ea1..54c7dd8 100644 --- a/app/services/logic_state_service.py +++ b/app/services/logic_state_service.py @@ -176,6 +176,19 @@ def now_view(st) -> dict: "text": (reasons[0] if reasons else (st.get("why") or ""))[:160]} +def company_view(st): + """持仓页「公司质地 · 失效条件」一行 (2026-09-09 接入方案): 质地档、三类、置信度、失效条件、报告链接。没读数返回 None。""" + cr = (st or {}).get("company_review") if isinstance(st, dict) else None + if not isinstance(cr, dict) or not cr.get("overall"): + return None + g = cr.get("groups") or {} + return {"overall": cr.get("overall"), "valuation": cr.get("valuation"), "period": cr.get("period"), + "line": f"质地{cr.get('overall')}(回报{g.get('回报与护城河') or '缺'}、盈余{g.get('盈余质量与财务安全') or '缺'}、成长{g.get('成长与含金量') or '缺'})" + f",估值{cr.get('valuation') or '无'}" + (f",置信{cr['confidence']}" if cr.get("confidence") else ""), + "invalidation": cr.get("invalidation"), "doubt_hard": bool(cr.get("doubt_hard")), + "report_url": cr.get("report_url"), "text": (st or {}).get("company_review_text")} + + def ref_target_view(st): """参考目标价: 第四件的中性情景估值 (中位每股收益乘中位市盈率), 2026-09-07 拍板在持仓页显示, 只显示不触发, 不替代用户手设的目标价 (到价提议只认命令表里那个)。算不出就不给。""" @@ -243,6 +256,10 @@ def decorate_positions(rows: list, states: dict | None = None) -> list: r["ref_target"] = ref_target_view(states.get(code)) except Exception: # noqa: BLE001 r["ref_target"] = None + try: + r["company"] = company_view(states.get(code)) + except Exception: # noqa: BLE001 + r["company"] = None if int(r.get("total_qty") or 0) <= 0: r["entry"] = None continue diff --git a/app/services/param_store.py b/app/services/param_store.py index 6c9c27d..ffe1387 100644 --- a/app/services/param_store.py +++ b/app/services/param_store.py @@ -72,6 +72,10 @@ RUNTIME_EXTRA = { "PMS_LOGIC_STATE_ROUTE": (True, bool, "按选股系统的逻辑状态分流: 逻辑存疑的持仓停增持侧自主动作并暂停策略买入腿, 判决候选而逻辑存疑的新建仓强制人工确认 (默认开; 关掉即回到只按判决)"), "PMS_LOGIC_DOUBT_TRIM": (False, bool, "逻辑存疑的持仓出减持提议 (必定交人裁决, 绝不自动卖; 默认关, 先跑停增持一个月看误伤再开)"), "PMS_LOGIC_DOUBT_TRIM_RATIO": (0.3333, float, "逻辑存疑减持提议的比例 (占总持仓; 默认与保垫减仓相同的三分之一; 一次定死, 不按复盘读数回调)"), + # 2026-09-09 接入方案 (台账 053): 公司深度与建议方案的三个开关。 + "PMS_JUDGE_COMPANY_TEXT": (True, bool, "新建仓研判送公司深度整句 (质地档、买方论点、置信度、失效条件); 关掉只给人看"), + "PMS_PLAN_BY_ADVICE": (True, bool, "建议方案直接作为新建仓提议的仓位档与分批 (模拟侧默认开; 关掉只并排显示, 提议照机械方案)"), + "PMS_ADVICE_TRIAL_CONF_MIN": (60, int, "研判把握度低于此值 (或不可用) 时建议方案降为试探仓, 只在人工确认后下"), "PMS_LOGIC_STATE_MAP": ("", str, "在持票的逻辑状态映射 (plan_pull 每早写入的 JSON: 时刻/日期/逐票状态), 扫描层与页面只读, 勿手改"), } diff --git a/app/services/plan_feed.py b/app/services/plan_feed.py index 445658c..fcfcaeb 100644 --- a/app/services/plan_feed.py +++ b/app/services/plan_feed.py @@ -189,6 +189,24 @@ def _news_or_none(v, limit=5): return {"count": count, "latest": _text_or_none(v.get("latest")), "items": items} +def _company_review_or_none(v): + """公司深度 (2026-09-09 接入方案): 数据基座个股深度评析的摘要。固定键, 缺质地档归 None。 + 质地进逻辑状态第五路与判决收敛 (都在选股系统算完); PMS 这里只透传给提议卡、持仓页、建议方案与研判整句。""" + if not isinstance(v, dict) or not v.get("overall"): + return None + gc = v.get("grade_counts") if isinstance(v.get("grade_counts"), dict) else {} + groups = v.get("groups") if isinstance(v.get("groups"), dict) else {} + return {"period": _text_or_none(v.get("period")), "overall": _text_or_none(v.get("overall")), + "valuation": _text_or_none(v.get("valuation")), "doubt_hard": bool(v.get("doubt_hard")), + "groups": {k: _text_or_none(groups.get(k)) for k in ("回报与护城河", "盈余质量与财务安全", "成长与含金量")}, + "grade_counts": {k: _int_or_none(gc.get(k)) or 0 for k in ("好", "中", "差", "证据不足")}, + "thesis": _text_or_none(v.get("thesis")), "confidence": _text_or_none(v.get("confidence")), + "invalidation": _text_or_none(v.get("invalidation")), "antithesis": _text_or_none(v.get("antithesis")), + "catalyst": _text_or_none(v.get("catalyst")), "brain_status": _text_or_none(v.get("brain_status")), + "ran_at": _text_or_none(v.get("ran_at")), "age_days": _int_or_none(v.get("age_days")), + "report_url": _text_or_none(v.get("report_url"))} + + def _industry_catalyst_or_none(v, limit=3): """行业催化 (2026-09-08 台账 051): 上游按环节标出的行业级催化, 只留最多 limit 条固定键。 产业逻辑不送研判 (judge.OPEN_JUDGE_KEYS 不收), 只进硬数字给人看与复盘。""" @@ -309,6 +327,11 @@ def _rows(raw, bucket: str) -> list: # 行业催化 (2026-09-08 台账 051): 环节级, 只给人看与复盘, 不送研判、不进判定。 "industry_catalyst": _industry_catalyst_or_none(it.get("industry_catalyst")), "industry_catalyst_text": _text_or_none(it.get("industry_catalyst_text")), + # 公司深度 (2026-09-09 接入方案): 质地档与评议摘要。整句送研判 (judge.OPEN_JUDGE_KEYS 收 company_review_text, + # 开关 PMS_JUDGE_COMPANY_TEXT); 原值进硬数字给提议卡、持仓页与建议方案。质地本身的判定在选股系统已经做完。 + "company_review": _company_review_or_none(it.get("company_review")), + "company_review_text": _text_or_none(it.get("company_review_text")), + "company_gate": _text_or_none(it.get("company_gate")), }) return out @@ -506,7 +529,9 @@ def select_candidates(plan: dict, *, held=(), black=(), top_n: int = 30, tiers=N "pricing_state": r.get("pricing_state"), "pricing_text": r.get("pricing_text"), "news": r.get("news"), "news_text": r.get("news_text"), "industry_catalyst": r.get("industry_catalyst"), - "industry_catalyst_text": r.get("industry_catalyst_text")} + "industry_catalyst_text": r.get("industry_catalyst_text"), + "company_review": r.get("company_review"), "company_review_text": r.get("company_review_text"), + "company_gate": r.get("company_gate")} for r in passed[:n]] return {"date": plan.get("date"), "considered": len(pool), "eligible": len(passed), "items": items, "dropped": dropped, "st_unknown": st_unknown, @@ -710,6 +735,9 @@ def parse_logic_states(payload) -> dict: "as_of": _text_or_none(v.get("as_of")), "na": _text_or_none(v.get("na"))} if v else None) st["valuation_text"] = _text_or_none(it.get("valuation_text")) + # 公司深度两键随状态回 (2026-09-09 接入方案): 持仓页"证据还在不在"旁显示质地与失效条件。 + st["company_review"] = _company_review_or_none(it.get("company_review")) + st["company_review_text"] = _text_or_none(it.get("company_review_text")) out[code] = st return out diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py index 033612d..f0283fe 100644 --- a/app/services/proposal_service.py +++ b/app/services/proposal_service.py @@ -743,6 +743,16 @@ def _make_proposal(c, price, verdict) -> str: "judge_verdict": verdict.get("verdict"), "judge_conf": verdict.get("confidence"), # 两个期限的头 (2026-09-08 量价研判链): 决策系统并列给的 5 日与 20 日判断, 只显示不触发。 "judge_pv_heads": verdict.get("pv_heads")} + # 建议方案补研判一档 (2026-09-09 接入方案): 把握度低或不可用 → 试探仓, 只在人工确认后下 (数量交人, 不自动改)。 + try: + from app.services import advice_service as _adv + adv2 = _adv.apply_judge(hn.get("advice"), verdict, param_store.get_int("PMS_ADVICE_TRIAL_CONF_MIN", 60)) + if isinstance(adv2, dict): + hn["advice"], hn["advice_text"] = adv2, adv2.get("text") + if adv2.get("needs_user_confirm"): + hn["needs_user_confirm"] = True + except Exception: # noqa: BLE001 —— 建议方案出错不拦提议 + pass try: pms_repo.insert_proposal( proposal_id=pid, ts_code=c["ts_code"], action=c["action"], qty=c["qty"], @@ -782,6 +792,8 @@ def _scan_params(view: dict) -> dict: # 减持提议开关 (默认关)、减持比例 (默认与保垫减仓相同的三分之一)。 "logic_state_route": param_store.get_bool("PMS_LOGIC_STATE_ROUTE", True), "open_route_by_logic": param_store.get_bool("PMS_LOGIC_STATE_ROUTE", True), + # 建议方案直接作为新建仓的仓位档与分批 (2026-09-09 接入方案第六之二节; 模拟侧默认开) + "plan_by_advice": param_store.get_bool("PMS_PLAN_BY_ADVICE", True), "logic_doubt_trim": param_store.get_bool("PMS_LOGIC_DOUBT_TRIM", False), "logic_doubt_trim_ratio": param_store.get_float("PMS_LOGIC_DOUBT_TRIM_RATIO", 1.0 / 3), }) diff --git a/app/web/static/index.html b/app/web/static/index.html index eb0890d..8799c3c 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -1036,6 +1036,10 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
{{ propText(p, 'pricing_text') }}
{{ propText(p, 'news_text') }}
{{ propText(p, 'industry_catalyst_text') }}
+ +
公司深度:{{ propText(p, 'company_review_text') }}
+
判决收敛:{{ propText(p, 'company_gate') }}
+
{{ propText(p, 'advice_text') }}
择时层两个期限:{{ propHeads(p) }}
{{ propWhy(p) }}
约 {{ p.qty }} 股 · 参考价 {{ (p.hard_numbers||{}).price==null ? '—' : (p.hard_numbers||{}).price }} @@ -1602,6 +1606,9 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
证据截止 {{ s.row.logic_now.as_of || '—' }}
+ +
+ {{ s.row.company.line }}
{{ (s.row.logic_now && s.row.logic_now.text) || '今天没有读数' }} diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 532d949..2071dec 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -56,6 +56,8 @@ 强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱 来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流/ 到价提议挂着时止损照落并作废该提议 (17 例) + test_batch23_units.py 公司质地与建议方案接入 (2026-09-09 接入方案): 解析层收公司深度两键/整句送研判可关/建议 + 方案矩阵与研判补档/持仓页公司块。 test_batch22_units.py 逻辑状态四态接入 (2026-09-07 第三件): 解析层收逻辑状态/硬数字带它而研判 白名单不收/判决候选而逻辑存疑强制交人/持仓存疑停增持侧/研究走弱 减持默认关且开了必交人/同轮减持优先级/早上取回与映射新鲜度/策略 @@ -110,7 +112,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py", "test_batch13_units.py", "test_batch14_units.py", "test_batch15_units.py", "test_batch16_units.py", "test_batch17_units.py", "test_batch18_units.py", "test_batch19_units.py", "test_batch20_units.py", - "test_batch21_units.py", "test_batch22_units.py", + "test_batch21_units.py", "test_batch22_units.py", "test_batch23_units.py", "test_page_enum_guard.py", "test_wiring.py"] diff --git a/scripts/test_batch23_units.py b/scripts/test_batch23_units.py new file mode 100644 index 0000000..2bac6e3 --- /dev/null +++ b/scripts/test_batch23_units.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +"""公司质地与建议方案接入 (2026-09-09 接入方案, 台账 053)。全部离线, 不连库不调模型。 + + 1. 解析层收公司深度两键 (固定键、缺质地归 None), 候选选择透传, 硬数字带它。 + 2. 整句进送研判白名单; 开关关掉就不送; 原值字典不送。 + 3. 逐票逻辑状态解析收公司深度两键; 持仓页 company_view 一行。 + 4. 建议方案矩阵: 六种情形各一例; 研判补档; 开关开着时评估参数按建议。 +""" +import os +import sys +import traceback + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.core import action_engine as ae # noqa: E402 +from app.services import advice_service as adv # noqa: E402 +from app.services import judge as jd # noqa: E402 +from app.services import logic_state_service as lss # noqa: E402 +from app.services import param_store # noqa: E402 +from app.services import plan_feed as pf # noqa: E402 + +RESULTS = [] + + +def case(name): + def deco(fn): + RESULTS.append((name, fn)) + return fn + return deco + + +class _Patch: + def __init__(self): self._saved = [] + def __enter__(self): return self + def __call__(self, obj, name, value): + self._saved.append((obj, name, getattr(obj, name))); setattr(obj, name, value) + def __exit__(self, *a): + for obj, name, v in reversed(self._saved): + setattr(obj, name, v) + + +def _cr(overall="好", valuation="贵", doubt=False): + return {"period": "2026Q1", "overall": overall, "valuation": valuation, "doubt_hard": doubt, + "groups": {"回报与护城河": "好", "盈余质量与财务安全": "好", "成长与含金量": "好"}, + "grade_counts": {"好": 5, "中": 3, "差": 1, "证据不足": 1}, "thesis": "质地好但估值贵", "confidence": "中", + "invalidation": "下一期看现金流", "antithesis": "覆盖少", "catalyst": "无法判断", "brain_status": "ok", + "ran_at": "2026-09-08", "age_days": 1, "report_url": "http://192.168.16.178:8000/company/601126.SH/review", "extra": 1} + + +@case("解析层收公司深度两键: 固定键、缺质地归 None、候选透传、硬数字带它") +def test_passthrough(): + row = pf._rows([{"code": "601126.SH", "rank": 1, "score": 210, "company_review": _cr(), "company_review_text": "公司深度:质地好…", + "company_gate": ""}], "main")[0] + assert row["company_review"]["overall"] == "好" and "extra" not in row["company_review"], row["company_review"] + assert row["company_review"]["grade_counts"]["好"] == 5 and row["company_review"]["groups"]["回报与护城河"] == "好" + assert row["company_review_text"].startswith("公司深度") + empty = pf._rows([{"code": "601126.SH", "rank": 1, "score": 210, "company_review": {"overall": None}}], "main")[0] + assert empty["company_review"] is None and empty["company_review_text"] is None + plan = {"date": "2026-09-09", "main": [row], "observe": []} + item = pf.select_candidates(plan, top_n=10)["items"][0] + assert item["company_review"]["valuation"] == "贵" + c = {**item, "price": 10.0} + params = {"scale": 1_000_000, "stock_target_default": 0.06, "batch_split": (0.5, 0.25, 0.25)} + with _Patch() as p: + p(ae, "check_all_caps", lambda **kw: []) + p(ae, "_new_name_ctx", lambda caps, c: caps) + cand, why = ae.eval_open(c, params, {"names_count": 0, "max_names": 10}, 500_000) + hn = cand["hard_numbers"] + assert hn["company_review"]["overall"] == "好" and hn["company_review_text"] == row["company_review_text"], why + + +@case("整句送研判且开关可关; 原值字典不送") +def test_judge_whitelist(): + hn = {"price": 10.0, "company_review": _cr(), "company_review_text": "公司深度:质地好…", "advice": {"tier": "减半仓"}} + with _Patch() as p: + p(param_store, "get_bool", lambda k, d=None: True) + sent = jd._judge_hard_numbers("OPEN", hn) + assert sent["company_review_text"] == hn["company_review_text"] and "company_review" not in sent and "advice" not in sent, sent + with _Patch() as p: + p(param_store, "get_bool", lambda k, d=None: False if k == "PMS_JUDGE_COMPANY_TEXT" else True) + sent2 = jd._judge_hard_numbers("OPEN", hn) + assert "company_review_text" not in sent2, sent2 + assert "company_review_text" in jd.OPEN_JUDGE_KEYS + + +@case("逐票逻辑状态解析收公司深度; 持仓页 company_view 一行") +def test_positions_view(): + payload = {"date": "2026-09-09", "states": [{"code": "SH601126", "state": "逻辑成立", "why": None, "as_of": "2026-09-08", + "reasons": ["a"], "company_review": _cr(), "company_review_text": "公司深度:质地好…"}]} + st = pf.parse_logic_states(payload)["SH601126"] + assert st["company_review"]["overall"] == "好" and st["company_review_text"].startswith("公司深度") + v = lss.company_view(st) + assert v["overall"] == "好" and "质地好" in v["line"] and v["invalidation"] == "下一期看现金流" and v["report_url"] + assert lss.company_view({"state": "逻辑成立"}) is None + rows = [{"ts_code": "SH601126", "total_qty": 0}] + with _Patch() as p: + p(lss, "state_map", lambda: {"SH601126": st}) + lss.decorate_positions(rows) + assert rows[0]["company"]["overall"] == "好" + + +@case("建议方案矩阵六情形与研判补档") +def test_advice_matrix(): + params = {"stock_target_default": 0.06, "batch_split": (0.5, 0.25, 0.25)} + val_good = {"neut": 12.0, "pess": 9.5, "price": 10.0} # 上行 20%、下行 5% → 赔率 4 + val_bad = {"neut": 10.5, "pess": 8.0, "price": 10.0} # 上行 5%、下行 20% → 赔率 0.25 + a = adv.advise({"company_review": _cr("好", "中"), "logic_state": {"state": "逻辑成立"}, "valuation": val_good}, params) + assert a["tier"] == "标准仓" and a["splits"] == (0.5, 0.25, 0.25) and abs(a["target_pct"] - 0.06) < 1e-9, a + b = adv.advise({"company_review": _cr("好", "贵"), "logic_state": {"state": "逻辑成立"}, "valuation": val_good}, params) + assert b["tier"] == "减半仓" and b["splits"] == (0.5, 0.5) and abs(b["target_pct"] - 0.03) < 1e-9, b + c = adv.advise({"company_review": _cr("中", "中"), "logic_state": {"state": "逻辑成立"}, "valuation": val_good}, params) + assert c["tier"] == "标准仓" and c["splits"] == (0.3, 0.35, 0.35) and "二十日头看多" in "".join(c["conditions"]), c + d = adv.advise({"company_review": _cr("好", "中"), "logic_state": {"state": "逻辑成立"}, "valuation": val_bad}, params) + assert d["tier"] == "试探仓" and abs(d["target_pct"] - 0.01) < 1e-9 and "人工确认" in "".join(d["conditions"]), d + e = adv.advise({"company_review": _cr("好", "中"), "logic_state": {"state": "逻辑成立"}, "valuation": val_good, + "pricing_state": {"state": "高位兑现"}}, params) + assert e["delay_first"] and e["tier"] == "标准仓", e + f = adv.advise({"company_review": _cr("差", "中", True), "logic_state": {"state": "逻辑存疑"}}, params) + assert f["tier"] == "不建" and f["target_pct"] == 0.0 + g = adv.advise({"company_review": None, "valuation": val_good}, params) + assert g["tier"] == "标准仓" and "沿用机械方案" in "".join(g["why"]) and g["splits"] == (0.5, 0.25, 0.25) + # 研判补档 + a2 = adv.apply_judge(a, {"verdict": "APPROVE", "confidence": 45}, 60) + assert a2["tier"] == "试探仓" and a2["needs_user_confirm"] and "研判" in "".join(a2["why"]), a2 + a3 = adv.apply_judge(a, {"verdict": "APPROVE", "confidence": 80}, 60) + assert a3["tier"] == "标准仓" + a4 = adv.apply_judge(a, {"verdict": "UNAVAILABLE", "confidence": None}, 60) + assert a4["tier"] == "试探仓" + # 评估参数 + p2 = adv.params_for(b, params) + assert abs(p2["stock_target_default"] - 0.03) < 1e-9 and p2["batch_split"] == (0.5, 0.5) + assert adv.params_for(f, params) is params + assert "建议方案:减半仓" in adv.text(b) and "机械方案 6.0%" in adv.text(b) + + +@case("扫描时建议方案进硬数字, 开关开着时按建议仓位评估") +def test_scan_open_uses_advice(): + item = {"ts_code": "601126.SH", "code": "601126.SH", "rank": 1, "score": 210, "price": 10.0, "verdict": "候选", + "company_review": _cr("好", "贵"), "company_review_text": "x", "logic_state": {"state": "逻辑成立"}, + "valuation": {"neut": 12.0, "pess": 9.5, "price": 10.0}} + params = {"scale": 1_000_000, "stock_target_default": 0.06, "batch_split": (0.5, 0.25, 0.25), "plan_by_advice": True, + "open_route_by_verdict": False, "open_route_by_logic": False} + seen = {} + def fake_eval(c, p, ctx, left): + seen["target"] = p.get("stock_target_default"); seen["split"] = p.get("batch_split") + return {"ts_code": c["ts_code"], "action": ae.A_OPEN, "qty": 100, "hard_numbers": {}}, None + with _Patch() as p: + p(ae, "eval_open", fake_eval) + out = ae.scan_open(candidates=[item], params=params, caps={"names_count": 0, "max_names": 10}, room_amt=500_000, slots=5) + assert abs(seen["target"] - 0.03) < 1e-9 and seen["split"] == (0.5, 0.5), (seen, out.get("skipped")) + cands = out["candidates"] + hn = cands[0]["hard_numbers"] + assert hn["advice"]["tier"] == "减半仓" and hn["advice_text"].startswith("建议方案") and hn["plan_by_advice"] is True + + +def main(): + ok = 0 + for name, fn in RESULTS: + try: + fn() + ok += 1 + print(f" ok {name}") + except Exception: + print(f" FAIL {name}") + traceback.print_exc() + print("-" * 60) + if ok == len(RESULTS): + print(f"ALL PASS ({ok} cases)") + return 0 + print(f"FAILED {len(RESULTS) - ok}/{len(RESULTS)}") + return 1 + + +if __name__ == "__main__": + sys.exit(main())