公司质地与建议方案接入:公司深度两键透传、整句送研判可关、建议方案矩阵与研判补档、持仓页一行、三个运行参数、单测(台账 053)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-09 12:15:03 +08:00
parent 499b1342e4
commit 0724509624
11 changed files with 421 additions and 5 deletions

View File

@ -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 侧不需要改:硬数字逐键渲染进提示词。应答形状不变。

View File

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

View File

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

View File

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

View File

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

View File

@ -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: 时刻/日期/逐票状态), 扫描层与页面只读, 勿手改"),
}

View File

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

View File

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

View File

@ -1036,6 +1036,10 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<div class="st muted" v-if="propText(p, 'pricing_text')">{{ propText(p, 'pricing_text') }}</div>
<div class="st muted" v-if="propText(p, 'news_text')">{{ propText(p, 'news_text') }}</div>
<div class="st muted" v-if="propText(p, 'industry_catalyst_text')">{{ propText(p, 'industry_catalyst_text') }}</div>
<!-- 公司深度与建议方案 (2026-09-09 接入方案): 质地档、评议摘要、报告链接; 建议方案与机械方案并排。 -->
<div class="st" v-if="propText(p, 'company_review_text')"><span class="muted">公司深度:</span>{{ propText(p, 'company_review_text') }}</div>
<div class="st" v-if="propText(p, 'company_gate')"><span class="muted">判决收敛:</span>{{ propText(p, 'company_gate') }}</div>
<div class="st" v-if="propText(p, 'advice_text')">{{ propText(p, 'advice_text') }}</div>
<div class="st" v-if="propHeads(p)"><span class="muted">择时层两个期限:</span>{{ propHeads(p) }}</div>
<div class="st muted">{{ propWhy(p) }}</div>
<div class="st">约 {{ 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;}
<div class="muted mono" style="font-size:11px">证据截止 {{ s.row.logic_now.as_of || '—' }}<template
v-if="s.row.logic_now.settle_note && s.row.logic_now.settle_note!=='维持'">{{ s.row.logic_now.settle_note }}</template></div>
</template>
<!-- 公司质地 · 失效条件 (2026-09-09 接入方案): 数据基座代码合成的质地档与评议给的失效条件, 只显示。 -->
<div class="muted" v-if="s.row.company && s.row.company.line" :title="s.row.company.text || ''" style="font-size:11px">
{{ s.row.company.line }}<template v-if="s.row.company.invalidation">;失效条件:{{ s.row.company.invalidation }}</template></div>
<span v-else class="muted">{{ (s.row.logic_now && s.row.logic_now.text) || '今天没有读数' }}</span>
</template>
</el-table-column>

View File

@ -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"]

View File

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