tradingSystem/app/services/advice_service.py

208 lines
12 KiB
Python
Raw Normal View History

# -*- 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]:
数量按建议档位重算:卡上写试探仓 1%,采纳后就真的只买 1% 一、问题。155 实测 8 张待确认提议,7 张卡上写「建议方案:试探仓(目标 1.0%, 分批 100%)」,而每一张采纳后实际会买 3.00%(标准仓 6% 的第一批),正好三倍。 只有 300118.SZ 那张标准仓的是自洽的。 根因是次序。候选产出时 advise() 的档位是进过数量的(params_for 把 target_pct 塞进 stock_target_default,action_engine 据此拆批)。但研判回来之后 apply_judge 还会再降一档 —— 把握度低或结论不可用就降成试探仓 —— 那一步只改档位文字,数量 已经算完了。apply_judge 的说明原文写着「不改数量, 数量交人」,而页面上采纳按钮 根本没有填数量的地方,这个「交人」无处兑现;采纳时后端直接抄提议里的股数原样 落单(web/main.py 只有卖出侧会按可卖量重算)。 二、改法。新增 advice_service.resize_to_advice,在 apply_judge 降档之后重算这一批 的股数,并把 target_pct / target_amount / base_amount / batch_scheme 一起改掉 —— 否则卡上那三个数字仍然是旧口径。总规模不另外读参数表,直接从硬数字里的 target_amount ÷ target_pct 反推,保证与当初算数量用的是同一个规模。 买不足一手时抬到一手:300604.SZ 现价 264.81,1% 只有 75 股,抬到 100 股实际 占 1.32%,仍远小于机械档 6%。但一手金额不许超过机械档 —— 超过说明这只票对当前 规模本来就太贵,那就不改数量、把原因留在卡上,不默默按旧数量下单。 三、一并补上持仓的目标仓位。pms_position.target_pct 这一列全库从来没有任何一处 写过,而动作引擎算补仓与加仓金额时先读它、读不到退全局 6%。不补的话会得到一个 更难看的组合:第一笔只买 1%,随后系统自己按 6% 一路补,试探仓的意思当场作废。 现在在建仓成交入账时记一次,且只记第一次(目标仓位是建这只仓时定的意思, 后续加仓不该改)。记不上绝不拦成交入账。 四、补一个洞:上游标了硬风险的候选一律交人,哪怕档位是 full。 这一条原先只写在 _verdict_auto_exec_why 那条更窄的旁路里(第四条「上游风险列表 为空」),而一票否决那条链完全不看 risk。后果是反的:档位是 propose_only 时旁路 会拦住带风险的票,一旦档位改成 full,那条旁路根本不走,带硬风险的候选反而畅通 无阻直接落指令 —— 越放开越不设防。交人原因也把硬风险排到最前,不再笼统说 「档位 propose_only」。 硬风险是选股系统在候选卡上标的三类:昨夜给出卖出/回避/已剔除信号、用的传导快照日 与计划日不符、吸筹为高位派发。 五、方向。这一批改动只会让发出去的钱变少或不变,不会变多:同一张卡从 1200 股 58,260 元降到 400 股 19,420 元;硬风险那条是把自动执行改成必须人点头。 新增 test_batch26_units.py 十七例,D 组专门钉「重算之后的金额一定不超过原来那一笔」。 前端跟上两种结果:重算成功了说一声「数量已按试探仓重算:1200 股 → 400 股」, 重算不成的把原因摆在卡上。两道页面守卫通过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:42:39 +08:00
"""提议落账前补研判一档: 把握度低于 conf_min 或结论不可用 → 试探仓, 只在人工确认后下。
这个函数本身只改档位不改数量 数量由调用方在拿到新档位之后调 resize_to_advice
重算2026-09-10 之前这里的说明写的是不改数量, 数量交人, 而页面上采纳按钮
根本没有填数量的地方, 那个交人无处兑现, 结果是卡上写试探仓 1%实下 3%
"""
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
数量按建议档位重算:卡上写试探仓 1%,采纳后就真的只买 1% 一、问题。155 实测 8 张待确认提议,7 张卡上写「建议方案:试探仓(目标 1.0%, 分批 100%)」,而每一张采纳后实际会买 3.00%(标准仓 6% 的第一批),正好三倍。 只有 300118.SZ 那张标准仓的是自洽的。 根因是次序。候选产出时 advise() 的档位是进过数量的(params_for 把 target_pct 塞进 stock_target_default,action_engine 据此拆批)。但研判回来之后 apply_judge 还会再降一档 —— 把握度低或结论不可用就降成试探仓 —— 那一步只改档位文字,数量 已经算完了。apply_judge 的说明原文写着「不改数量, 数量交人」,而页面上采纳按钮 根本没有填数量的地方,这个「交人」无处兑现;采纳时后端直接抄提议里的股数原样 落单(web/main.py 只有卖出侧会按可卖量重算)。 二、改法。新增 advice_service.resize_to_advice,在 apply_judge 降档之后重算这一批 的股数,并把 target_pct / target_amount / base_amount / batch_scheme 一起改掉 —— 否则卡上那三个数字仍然是旧口径。总规模不另外读参数表,直接从硬数字里的 target_amount ÷ target_pct 反推,保证与当初算数量用的是同一个规模。 买不足一手时抬到一手:300604.SZ 现价 264.81,1% 只有 75 股,抬到 100 股实际 占 1.32%,仍远小于机械档 6%。但一手金额不许超过机械档 —— 超过说明这只票对当前 规模本来就太贵,那就不改数量、把原因留在卡上,不默默按旧数量下单。 三、一并补上持仓的目标仓位。pms_position.target_pct 这一列全库从来没有任何一处 写过,而动作引擎算补仓与加仓金额时先读它、读不到退全局 6%。不补的话会得到一个 更难看的组合:第一笔只买 1%,随后系统自己按 6% 一路补,试探仓的意思当场作废。 现在在建仓成交入账时记一次,且只记第一次(目标仓位是建这只仓时定的意思, 后续加仓不该改)。记不上绝不拦成交入账。 四、补一个洞:上游标了硬风险的候选一律交人,哪怕档位是 full。 这一条原先只写在 _verdict_auto_exec_why 那条更窄的旁路里(第四条「上游风险列表 为空」),而一票否决那条链完全不看 risk。后果是反的:档位是 propose_only 时旁路 会拦住带风险的票,一旦档位改成 full,那条旁路根本不走,带硬风险的候选反而畅通 无阻直接落指令 —— 越放开越不设防。交人原因也把硬风险排到最前,不再笼统说 「档位 propose_only」。 硬风险是选股系统在候选卡上标的三类:昨夜给出卖出/回避/已剔除信号、用的传导快照日 与计划日不符、吸筹为高位派发。 五、方向。这一批改动只会让发出去的钱变少或不变,不会变多:同一张卡从 1200 股 58,260 元降到 400 股 19,420 元;硬风险那条是把自动执行改成必须人点头。 新增 test_batch26_units.py 十七例,D 组专门钉「重算之后的金额一定不超过原来那一笔」。 前端跟上两种结果:重算成功了说一声「数量已按试探仓重算:1200 股 → 400 股」, 重算不成的把原因摆在卡上。两道页面守卫通过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:42:39 +08:00
def resize_to_advice(adv: Optional[dict], hn: Optional[dict], ts_code: str) -> tuple:
"""按建议档位把这一批的股数重算一遍。返回 (改动字典, 说明);不该改或算不出时返回 (None, 原因)。
## 为什么需要它
候选产出时 advise() 的档位是**进过**数量的params_for target_pct 塞进
stock_target_defaultaction_engine 据此拆批但研判回来之后 apply_judge 还会
再降一档 把握度低或结论不可用就降成试探仓 而那一步只改档位文字数量已经
算完了于是卡上写的和真下的对不上
2026-09-10 实测155 8 张待确认提议7 张写着试探仓目标 1.0%
而每一张实际都会买 3.00%标准仓 6% 的第一批正好三倍
apply_judge 的函数说明原文是不改数量, 数量交人但页面上采纳按钮根本没有
填数量的地方这个交人无处兑现采纳时后端直接抄提议里的股数原样落单
## 总规模从哪来
不另外去读参数表直接从硬数字里已有的两个数反推target_amount 是整只目标金额
target_pct 是它占的比例相除就是总规模这样不会出现重算用的规模
当初算数量用的规模不是同一个数的情况
## 买不足一手怎么办
抬到一手但一手的金额不许超过机械档的目标金额试探仓的本意是小仓位试水
100 264 元的票是 1.32%仍远小于 6%抬上去是合理的而如果一手就已经
超过机械档说明这只票对当前规模本来就太贵那就不改数量交回调用方处置
"""
from app.core.sizer import lot_of, split_batches
if not isinstance(adv, dict) or not isinstance(hn, dict):
return None, "没有建议方案或硬数字"
new_pct = _f(adv.get("target_pct"))
old_pct = _f(hn.get("target_pct"))
price = _f(hn.get("price"))
old_amt = _f(hn.get("target_amount"))
if new_pct <= 0 or old_pct <= 0 or price <= 0 or old_amt <= 0:
return None, "缺少重算所需的读数(现价、目标金额或目标仓位)"
if abs(new_pct - old_pct) < 1e-9:
return None, "档位没变,不用重算"
scale = old_amt / old_pct
want = scale * new_pct
lot = lot_of(ts_code)
splits = tuple(adv.get("splits") or (1.0,))
# merge=False: 试探仓本来就是一次买满 (splits=(1.0,)), 不要让降档梯子把它改成别的分批。
sp = split_batches(want, price, splits=splits, merge=False, min_lot=lot)
if sp.get("ok"):
qty = int(sp["batches"][0]["qty"])
full = sum(b["qty"] * price for b in sp["batches"])
return {"qty": qty, "target_pct": round(new_pct, 4), "target_amount": round(full, 2),
"base_amount": round(qty * price, 2),
"batch_scheme": ",".join(str(x) for x in splits), "note": ""}, ""
mech_pct = _f((adv.get("mechanical") or {}).get("target_pct"), old_pct)
mech_amt = scale * mech_pct
one_lot_amt = lot * price
if one_lot_amt > mech_amt:
return None, (f"一手 {lot} 股就要 {one_lot_amt:,.0f} 元,"
f"超过机械档的 {mech_amt:,.0f} 元({mech_pct:.0%}),这只票对当前规模太贵")
return {"qty": lot, "target_pct": round(one_lot_amt / scale, 4),
"target_amount": round(one_lot_amt, 2), "base_amount": round(one_lot_amt, 2),
"batch_scheme": "1.0",
"note": f"目标 {want:,.0f} 元买不足一手,抬到一手 {lot} 股({one_lot_amt / scale:.2%}"}, ""
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