tradingSystem/app/services/advice_service.py

138 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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