数量按建议档位重算:卡上写试探仓 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>
This commit is contained in:
zlt 2026-09-10 13:42:39 +08:00
parent d30440edb2
commit c9b468e7a2
6 changed files with 472 additions and 8 deletions

View File

@ -92,7 +92,12 @@ def advise(c: dict, params: dict) -> dict[str, Any]:
def apply_judge(adv: Optional[dict], judge: Optional[dict], conf_min: int = 60) -> Optional[dict]: def apply_judge(adv: Optional[dict], judge: Optional[dict], conf_min: int = 60) -> Optional[dict]:
"""提议落账前补研判一档: 把握度低于 conf_min 或结论不可用 → 试探仓, 只在人工确认后下。不改数量, 数量交人。""" """提议落账前补研判一档: 把握度低于 conf_min 或结论不可用 → 试探仓, 只在人工确认后下。
这个函数本身只改档位不改数量 数量由调用方在拿到新档位之后调 resize_to_advice
重算2026-09-10 之前这里的说明写的是不改数量, 数量交人, 而页面上采纳按钮
根本没有填数量的地方, 那个交人无处兑现, 结果是卡上写试探仓 1%实下 3%
"""
if not isinstance(adv, dict) or not isinstance(judge, dict): if not isinstance(adv, dict) or not isinstance(judge, dict):
return adv return adv
verdict = str(judge.get("verdict") or "").upper() verdict = str(judge.get("verdict") or "").upper()
@ -109,6 +114,71 @@ def apply_judge(adv: Optional[dict], judge: Optional[dict], conf_min: int = 60)
return adv return adv
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: def params_for(adv: dict, params: dict) -> dict:
"""把建议方案变成评估参数 (开关开着时用): 目标仓位乘档位系数, 分批按建议。不建的返回原参数 (判决已拦)。""" """把建议方案变成评估参数 (开关开着时用): 目标仓位乘档位系数, 分批按建议。不建的返回原参数 (判决已拦)。"""
if not isinstance(adv, dict) or adv.get("tier") in (None, TIER_NONE): if not isinstance(adv, dict) or adv.get("tier") in (None, TIER_NONE):

View File

@ -464,12 +464,60 @@ def _open_instructions() -> list:
return out return out
def _remember_target_pct(code: str, instruction_id) -> None:
"""建仓成交入账时,把这只票的目标仓位记到持仓行上。只在第一次记,之后不覆盖。
## 不记会怎样
动作引擎算补仓与加仓金额时先读持仓行上的 target_pct读不到就退全局默认
app/core/action_engine.py batch_amount room_to_target默认 6%
而这一列从来没有任何地方写过 全库 grep 只有两处传参给 planner没有一处写库
后果是一只按试探仓 1% 建起来的仓下一跳 eval_fill 会按 6% 25% 再买一笔
eval_add 再买一笔一路补到 6%试探仓的意思当场作废而且人看不见这个过程
2026-09-10 提议数量按建议档位重算一起补上只改第一笔不改这里会得到一个
更难看的组合 第一笔只买 1%随后系统自己按 6% 一路补两条必须一起改
## 只在第一次记
目标仓位是建这只仓时定的意思后续加仓不该改它已经有值就原样不动
"""
if not instruction_id:
return
try:
cur = pms_repo.get_position(code) or {}
if cur.get("target_pct") is not None:
return
ins = pms_repo.get_instruction(str(instruction_id)) or {}
if str(ins.get("origin_type") or "") != "proposal" or not ins.get("origin_id"):
return
prop = pms_repo.get_proposal(str(ins["origin_id"])) or {}
# pms_repo.get_proposal 已经把 hard_numbers 解析成字典了, 这里不必再解一遍。
hn = prop.get("hard_numbers")
if not isinstance(hn, dict):
return
adv = hn.get("advice") if isinstance(hn.get("advice"), dict) else {}
pct = adv.get("target_pct")
if pct is None:
pct = hn.get("target_pct")
pct = float(pct) if pct is not None else None
if pct is None or pct <= 0:
return
pms_repo.update_position(code, target_pct=round(pct, 4))
logger.info("持仓 %s 目标仓位记为 %.2f%%(来自提议 %s 的建议档位 %s",
code, pct * 100, ins["origin_id"], adv.get("tier") or "")
except Exception as e: # noqa: BLE001 —— 记不上目标仓位绝不能拦住成交入账
logger.warning("持仓 %s 目标仓位没记上: %r", code, e)
def _apply_action(act: dict): def _apply_action(act: dict):
code, qty, px = act["ts_code"], int(act["qty"]), float(act["price"] or 0) code, qty, px = act["ts_code"], int(act["qty"]), float(act["price"] or 0)
if qty <= 0: if qty <= 0:
return return
pms_repo.ensure_position(code) pms_repo.ensure_position(code)
if act["kind"] == "BUY": if act["kind"] == "BUY":
_remember_target_pct(code, act.get("instruction_id"))
pms_repo.insert_lot(ts_code=code, lot_type=act.get("lot_type") or "BASE", qty=qty, pms_repo.insert_lot(ts_code=code, lot_type=act.get("lot_type") or "BASE", qty=qty,
open_price=px, open_date=datetime.now().date(), open_price=px, open_date=datetime.now().date(),
instruction_id=act.get("instruction_id"), instruction_id=act.get("instruction_id"),

View File

@ -636,8 +636,23 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
# 拍了板买不上是择时区间和追高原则在起作用, 那两条一个字没改。 # 拍了板买不上是择时区间和追高原则在起作用, 那两条一个字没改。
reask_why = ("盘中重问放行,交你拍板:" + str((c.get("reask") or {}).get("why") or "") reask_why = ("盘中重问放行,交你拍板:" + str((c.get("reask") or {}).get("why") or "")
+ "。能不能买到由择时区间决定,可能当日买不上。") if c.get("reask") else None + "。能不能买到由择时区间决定,可能当日买不上。") if c.get("reask") else None
# 上游标了硬风险的票必须交人 (2026-09-10 补的洞)。
#
# 这一条原先只写在下面那条更窄的旁路里 (_verdict_auto_exec_why 的第四条「上游风险
# 列表为空」), 而一票否决这条链完全不看 risk。后果是反的: 档位是 propose_only 时
# 旁路会拦住带风险的票, 一旦档位改成 full, 那条旁路根本不走, 带硬风险的候选反而
# 畅通无阻直接落指令 —— 越放开越不设防。
#
# 硬风险是选股系统在候选卡上标的三类 (akg-factor-bridge/card.py): 昨夜给出
# 卖出/回避/已剔除信号、用的传导快照日与计划日不符、吸筹为高位派发。这三类都是
# 「有人看见了不对劲」, 不是评分低, 所以归人裁决而不是归档位。
_hn_risk = (c.get("hard_numbers") or {}).get("risk")
risk_why = None
if _hn_risk:
_rl = _hn_risk if isinstance(_hn_risk, (list, tuple)) else [_hn_risk]
risk_why = "选股系统标了风险,交你拍板:" + "".join(str(x) for x in _rl if x)
force_queue = (bool(c.get("needs_user_confirm")) or bool(src_why) force_queue = (bool(c.get("needs_user_confirm")) or bool(src_why)
or bool(verdict.get("degraded")) or bool(reask_why)) or bool(verdict.get("degraded")) or bool(reask_why) or bool(risk_why))
auto_exec = (not force_queue) and (side == "sell" or autonomy == AUTONOMY_FULL) auto_exec = (not force_queue) and (side == "sell" or autonomy == AUTONOMY_FULL)
# 自动执行开关 (2026-09-03, PMS_OPEN_AUTO_EXEC_ON_VERDICT): 新建仓档位是 propose_only 时, # 自动执行开关 (2026-09-03, PMS_OPEN_AUTO_EXEC_ON_VERDICT): 新建仓档位是 propose_only 时,
# 「判决候选 + 决策系统研判真回了通过 + 规则闸通过 (走到这里就是通过了) + 上游风险列表 # 「判决候选 + 决策系统研判真回了通过 + 规则闸通过 (走到这里就是通过了) + 上游风险列表
@ -671,10 +686,12 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
else "档位 full")))}) else "档位 full")))})
else: else:
pid = _make_proposal(c, price, verdict) pid = _make_proposal(c, price, verdict)
# 交人的原因按从具体到笼统取: 候选自带的 (关注判决等, 见 action_engine.verdict_confirm_why) # 交人的原因按从具体到笼统取: 上游标的硬风险 → 重问放行 → 候选自带的 (关注判决等,
# → 来源强制的 (研究证据走弱那类减持) → 深档补仓那条老规矩 → 研判不可用 → 档位。 # 见 action_engine.verdict_confirm_why) → 来源强制的 (研究证据走弱那类减持) →
# 重问放行排在最前: 它是这张提议最需要人知道的那件事 (曾被驳回、什么变了、可能买不上) # 深档补仓那条老规矩 → 研判不可用 → 档位。
why = (reask_why or c.get("confirm_why") or src_why # 硬风险排在最前 (2026-09-10): 它是「有人看见了不对劲」, 比「曾被驳回」更该先说;
# 重问放行紧随其后, 它是这张提议第二需要人知道的事 (曾被驳回、什么变了、可能买不上)。
why = (risk_why or reask_why or c.get("confirm_why") or src_why
or ("深档补仓强制确认" if c.get("needs_user_confirm") else None) or ("深档补仓强制确认" if c.get("needs_user_confirm") else None)
or ("研判不可用, 降级人工确认" if verdict.get("degraded") else None) or ("研判不可用, 降级人工确认" if verdict.get("degraded") else None)
or ("新建仓档位 propose_only" if is_open else "档位 propose_only")) or ("新建仓档位 propose_only" if is_open else "档位 propose_only"))
@ -818,7 +835,13 @@ def _make_proposal(c, price, verdict) -> str:
"judge_verdict": verdict.get("verdict"), "judge_conf": verdict.get("confidence"), "judge_verdict": verdict.get("verdict"), "judge_conf": verdict.get("confidence"),
# 两个期限的头 (2026-09-08 量价研判链): 决策系统并列给的 5 日与 20 日判断, 只显示不触发。 # 两个期限的头 (2026-09-08 量价研判链): 决策系统并列给的 5 日与 20 日判断, 只显示不触发。
"judge_pv_heads": verdict.get("pv_heads")} "judge_pv_heads": verdict.get("pv_heads")}
# 建议方案补研判一档 (2026-09-09 接入方案): 把握度低或不可用 → 试探仓, 只在人工确认后下 (数量交人, 不自动改)。 # 建议方案补研判一档 (2026-09-09 接入方案): 把握度低或不可用 → 试探仓, 只在人工确认后下。
#
# 2026-09-10 补上数量重算。原先这里只改档位文字、不改数量 —— 候选产出时 advise()
# 的档位是进过数量的, 但研判回来之后这一降档没跟上, 于是卡上写「试探仓, 目标 1.0%」
# 而采纳后真下的是标准仓 6% 的第一批。实测 8 张待确认提议里 7 张是这个样子, 差三倍。
# 采纳按钮那边直接抄提议里的股数原样落单 (只有卖出侧会按可卖量重算), 所以不在这里
# 改, 后面就没有第二次机会了。
try: try:
from app.services import advice_service as _adv 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)) adv2 = _adv.apply_judge(hn.get("advice"), verdict, param_store.get_int("PMS_ADVICE_TRIAL_CONF_MIN", 60))
@ -826,6 +849,23 @@ def _make_proposal(c, price, verdict) -> str:
hn["advice"], hn["advice_text"] = adv2, adv2.get("text") hn["advice"], hn["advice_text"] = adv2, adv2.get("text")
if adv2.get("needs_user_confirm"): if adv2.get("needs_user_confirm"):
hn["needs_user_confirm"] = True hn["needs_user_confirm"] = True
fix, resize_why = _adv.resize_to_advice(adv2, hn, c["ts_code"])
if fix:
old_qty = c.get("qty")
c["qty"] = fix["qty"]
hn["target_pct"] = fix["target_pct"]
hn["target_amount"] = fix["target_amount"]
hn["base_amount"] = fix["base_amount"]
hn["batch_scheme"] = fix["batch_scheme"]
hn["advice_resized"] = {"from_qty": old_qty, "to_qty": fix["qty"],
"tier": adv2.get("tier"), "note": fix.get("note") or ""}
logger.info("提议按建议档位重算数量 %s: %s%s 股 (%s)%s",
c["ts_code"], old_qty, fix["qty"], adv2.get("tier"),
"; " + fix["note"] if fix.get("note") else "")
elif resize_why and "档位没变" not in resize_why:
# 算不出来要留痕, 让人在卡上看得见「为什么数量还是原来那个」,
# 而不是默默按旧数量下单。
hn["advice_resize_note"] = resize_why
except Exception: # noqa: BLE001 —— 建议方案出错不拦提议 except Exception: # noqa: BLE001 —— 建议方案出错不拦提议
pass pass
try: try:

View File

@ -1089,6 +1089,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<!-- 建议档位与实际下单量对不对得上。对不上就直接说, 别让人自己去算。 --> <!-- 建议档位与实际下单量对不对得上。对不上就直接说, 别让人自己去算。 -->
<div class="st" v-if="propAdviceTier(p)"><span class="muted">建议档位:</span>{{ propAdviceTier(p) }}</div> <div class="st" v-if="propAdviceTier(p)"><span class="muted">建议档位:</span>{{ propAdviceTier(p) }}</div>
<div class="st" v-if="propSizeMismatch(p)" style="color:#d8574a">{{ propSizeMismatch(p) }}</div> <div class="st" v-if="propSizeMismatch(p)" style="color:#d8574a">{{ propSizeMismatch(p) }}</div>
<div class="st muted" v-if="propResized(p)">{{ propResized(p) }}</div>
<div class="st" v-if="(p.hard_numbers||{}).verdict"><span class="muted">选股系统:</span>{{ propVerdictLine(p) }}</div> <div class="st" v-if="(p.hard_numbers||{}).verdict"><span class="muted">选股系统:</span>{{ propVerdictLine(p) }}</div>
<div class="st" v-if="p.judge_reason"><span class="muted">决策系统:</span>{{ propJudgeShort(p) }}<span class="muted" v-if="(p.hard_numbers||{}).judge_conf!=null">(把握度 {{ (p.hard_numbers||{}).judge_conf }}</span></div> <div class="st" v-if="p.judge_reason"><span class="muted">决策系统:</span>{{ propJudgeShort(p) }}<span class="muted" v-if="(p.hard_numbers||{}).judge_conf!=null">(把握度 {{ (p.hard_numbers||{}).judge_conf }}</span></div>
@ -2805,6 +2806,11 @@ createApp({
// 在那之前, 这一行先把矛盾摆到人眼前 —— 让人看错数字下单, 比让页面难看严重得多。 // 在那之前, 这一行先把矛盾摆到人眼前 —— 让人看错数字下单, 比让页面难看严重得多。
function propSizeMismatch(p) { function propSizeMismatch(p) {
const h = p.hard_numbers || {}; const h = p.hard_numbers || {};
// 后端算不出新数量时会留一句原因(例如「一手就超过机械档,这只票对当前规模太贵」)。
// 有这句就直接说它 —— 比让人自己去对两个百分比清楚。
if (h.advice_resize_note) {
return '注意:数量没能按建议档位重算(' + h.advice_resize_note + ')。采纳后下的就是上面那个数。';
}
const a = h.advice; const a = h.advice;
if (!a || typeof a !== 'object') return ''; if (!a || typeof a !== 'object') return '';
const advPct = Number(a.target_pct), usedPct = Number(h.target_pct); const advPct = Number(a.target_pct), usedPct = Number(h.target_pct);
@ -2813,6 +2819,16 @@ createApp({
return '注意:上面那个股数是按 ' + (usedPct * 100).toFixed(1) + '% 那一档算的,' return '注意:上面那个股数是按 ' + (usedPct * 100).toFixed(1) + '% 那一档算的,'
+ '不是按建议档位的 ' + (advPct * 100).toFixed(1) + '%。采纳后下的就是上面那个数。'; + '不是按建议档位的 ' + (advPct * 100).toFixed(1) + '%。采纳后下的就是上面那个数。';
} }
// 数量按建议档位重算过就说一声。这是正面反馈:让人知道卡上写的档位与真下的股数
// 是对上的,不用自己再算一遍。抬到一手那种情形另有一句 note一并带出来。
function propResized(p) {
const r = (p.hard_numbers || {}).advice_resized;
if (!r || typeof r !== 'object') return '';
const bits = ['数量已按「' + (r.tier || '建议档位') + '」重算:'
+ r.from_qty + ' 股 → ' + r.to_qty + ' 股'];
if (r.note) bits.push(r.note);
return bits.join('');
}
// 决策系统那句话最长到 500 字, 而它常常是一句技术报错 (例如研判超时那句)。 // 决策系统那句话最长到 500 字, 而它常常是一句技术报错 (例如研判超时那句)。
// 卡面只留前 60 字, 原话收进展开。 // 卡面只留前 60 字, 原话收进展开。
function propJudgeShort(p) { function propJudgeShort(p) {
@ -3998,7 +4014,7 @@ createApp({
propVerdictLine, propLogic, propLogicMore, propText, propHeads, propVerdictLine, propLogic, propLogicMore, propText, propHeads,
hardCn, opCn, paramsCn, dropCn, plainCheck, hardCn, opCn, paramsCn, dropCn, plainCheck,
// 2026-09-10 提议卡重排新增 // 2026-09-10 提议卡重排新增
propScale, propSizePct, propAdviceTier, propSizeMismatch, propJudgeShort, propScale, propSizePct, propAdviceTier, propSizeMismatch, propResized, propJudgeShort,
propMoreOpen, togglePropMore, propMoreOpen, togglePropMore,
pHaltBuy, pResumeBuy, pHaltAll, pResumeAll, pReduce, pIncrease, pLiquidate, pSectorExit, pHaltBuy, pResumeBuy, pHaltAll, pResumeAll, pReduce, pIncrease, pLiquidate, pSectorExit,
pctOf, tgtPos, posMoveValid, posMovePreview, doPosMove, heldSectors, secSel, doSectorExit, pctOf, tgtPos, posMoveValid, posMovePreview, doPosMove, heldSectors, secSel, doSectorExit,

View File

@ -124,6 +124,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
"test_batch18_units.py", "test_batch19_units.py", "test_batch20_units.py", "test_batch18_units.py", "test_batch19_units.py", "test_batch20_units.py",
"test_batch21_units.py", "test_batch22_units.py", "test_batch23_units.py", "test_batch21_units.py", "test_batch22_units.py", "test_batch23_units.py",
"test_batch24_units.py", "test_batch25_units.py", "test_batch24_units.py", "test_batch25_units.py",
"test_batch26_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,289 @@
# -*- coding: utf-8 -*-
"""建议档位与真下股数对齐2026-09-10。全部离线不连库不连 Redis 不调模型。
## 这一批在钉什么
2026-09-10 实测155 8 张待确认提议7 张卡上写着建议方案试探仓目标 1.0%
分批 100%而每一张采纳后实际会买 3.00%标准仓 6% 的第一批正好三倍
002812.SZ 1200×48.55 实占 3.00% 卡上写 1%
300604.SZ 200×264.81 实占 3.00% 卡上写 1%
300638.SZ 3300×18.11 实占 3.05% 卡上写 1%
只有 300118.SZ标准仓是自洽的
根因是次序候选产出时 advise() 的档位是**进过**数量的params_for target_pct 塞进
stock_target_default但研判回来之后 apply_judge 还会再降一档那一步只改档位文字
数量已经算完了apply_judge 的说明原文写着不改数量, 数量交人而页面上采纳按钮
根本没有填数量的地方这个交人无处兑现 采纳时后端直接抄提议里的股数原样落单
A 按建议档位重算正常降档总规模从硬数字反推批次比例跟着换
B 买不足一手抬到一手一手就超过机械档的不改交回调用方并说清原因
C 不该改的不改档位没变缺读数没有建议方案
D 方向只能是变小重算之后的金额不许超过原来那一笔
E 上游标了硬风险的候选一律交人 哪怕档位是 full
F 持仓的目标仓位在建仓成交入账时记一次且只记第一次
跑法随全套一起跑scripts/run_tests.py单跑是
python3 scripts/test_batch26_units.py
A E 组在开发机上就能跑纯逻辑与静态扫描F 组要 import 仓储层开发机没装
sqlalchemy得在容器里跑
docker compose run --rm --no-deps -v $PWD:/app pms-web python scripts/test_batch26_units.py
"""
import os
import sys
import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services import advice_service as adv # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
SCALE = 2_000_000.0 # 155 上 PMS_TOTAL_SCALE 的实际值
def _hn(price, target_pct=0.06):
"""造一份候选产出时的硬数字。target_amount 是整只目标金额,与 target_pct 配套 ——
重算时的总规模就是从这两个数反推的不另外去读参数表"""
return {"price": price, "target_pct": target_pct,
"target_amount": round(SCALE * target_pct, 2)}
def _adv(tier, target_pct, splits, mech_pct=0.06):
return {"tier": tier, "target_pct": target_pct, "splits": tuple(splits),
"mechanical": {"target_pct": mech_pct, "splits": (0.5, 0.25, 0.25)}}
# ============================================================ A 按建议档位重算
@case("A1 试探仓降档6% 的第一批 → 1% 一次买满(恩捷股份那张卡的真实读数)")
def _():
hn = _hn(48.55)
fix, why = adv.resize_to_advice(_adv("试探仓", 0.01, (1.0,)), hn, "002812.SZ")
assert fix, why
# 1% = 20000 元48.55 一股,整百取整 → 400 股
assert fix["qty"] == 400, fix
assert abs(fix["target_pct"] - 0.01) < 1e-9, fix
assert abs(fix["base_amount"] - 400 * 48.55) < 0.01, fix
# 重算前是 1200 股58,260 元),重算后 400 股19,420 元),正好是原来的三分之一
assert fix["qty"] * 3 == 1200, fix
@case("A2 总规模从硬数字反推,不去读参数表(换一个规模照样对)")
def _():
hn = {"price": 10.0, "target_pct": 0.06, "target_amount": 60000.0} # 规模 100 万
fix, why = adv.resize_to_advice(_adv("试探仓", 0.01, (1.0,)), hn, "600000.SH")
assert fix, why
assert fix["qty"] == 1000, fix # 100 万 × 1% = 1 万元 ÷ 10 元 = 1000 股
@case("A3 批次比例跟着档位换(试探仓是一次买满,不许被降档梯子改成分批)")
def _():
fix, why = adv.resize_to_advice(_adv("试探仓", 0.01, (1.0,)), _hn(20.0), "600000.SH")
assert fix, why
assert fix["batch_scheme"] == "1.0", fix
# 一次买满:这一批的金额就等于整只目标金额
assert abs(fix["base_amount"] - fix["target_amount"]) < 0.01, fix
@case("A4 减半仓走两批,第一批只拿一半")
def _():
fix, why = adv.resize_to_advice(_adv("减半仓", 0.03, (0.5, 0.5)), _hn(20.0), "600000.SH")
assert fix, why
# 3% = 60000 元,两批各 3000030000/20 = 1500 股
assert fix["qty"] == 1500, fix
assert fix["batch_scheme"] == "0.5,0.5", fix
# ============================================================ B 买不足一手
@case("B1 高价票买不足一手 → 抬到一手300604.SZ 现价 264.81 的真实情形)")
def _():
fix, why = adv.resize_to_advice(_adv("试探仓", 0.01, (1.0,)), _hn(264.81), "300604.SZ")
assert fix, why
# 1% = 20000 元 ÷ 264.81 = 75 股,不足一手 → 抬到 100 股
assert fix["qty"] == 100, fix
assert "买不足一手" in (fix.get("note") or ""), fix
# 抬上去之后实际占比 1.32%,仍远小于机械档 6%
assert 0.013 < fix["target_pct"] < 0.014, fix
@case("B2 科创板一手是 200 股,不是 100 股")
def _():
fix, why = adv.resize_to_advice(_adv("试探仓", 0.01, (1.0,)), _hn(150.0), "688001.SH")
assert fix, why
# 1% = 20000 元 ÷ 150 = 133 股 < 200 股 → 抬到 200 股
assert fix["qty"] == 200, fix
@case("B3 一手就超过机械档 → 不改数量,交回调用方并说清原因")
def _():
# 一股 1500 元,一手 15 万;机械档 6% 是 12 万 —— 这只票对当前规模本来就太贵
fix, why = adv.resize_to_advice(_adv("试探仓", 0.01, (1.0,)), _hn(1500.0), "600000.SH")
assert fix is None, fix
assert "太贵" in why and "一手" in why, why
# ============================================================ C 不该改的不改
@case("C1 档位没变就不重算(标准仓那张卡本来就是自洽的)")
def _():
fix, why = adv.resize_to_advice(_adv("标准仓", 0.06, (0.5, 0.25, 0.25)), _hn(9.27), "300118.SZ")
assert fix is None, fix
assert "档位没变" in why, why
@case("C2 缺现价、缺目标金额、缺目标仓位,一律不改")
def _():
a = _adv("试探仓", 0.01, (1.0,))
for bad in ({"price": 0, "target_pct": 0.06, "target_amount": 120000.0},
{"price": 48.55, "target_pct": 0, "target_amount": 120000.0},
{"price": 48.55, "target_pct": 0.06, "target_amount": 0}):
fix, why = adv.resize_to_advice(a, bad, "002812.SZ")
assert fix is None, (bad, fix)
assert "缺少重算所需的读数" in why, why
@case("C3 没有建议方案或没有硬数字,不改也不抛")
def _():
assert adv.resize_to_advice(None, _hn(48.55), "002812.SZ")[0] is None
assert adv.resize_to_advice(_adv("试探仓", 0.01, (1.0,)), None, "002812.SZ")[0] is None
# ============================================================ D 方向只能是变小
@case("D1 重算之后这一笔的金额一定不超过原来那一笔(这条改动只许让钱变少)")
def _():
for price in (5.0, 9.27, 18.11, 25.24, 37.37, 48.55, 100.0, 264.81):
hn = _hn(price)
fix, _why = adv.resize_to_advice(_adv("试探仓", 0.01, (1.0,)), hn, "600000.SH")
if not fix:
continue
# 原来那一笔6% 三批的第一批 50%
old_base = SCALE * 0.06 * 0.5
assert fix["base_amount"] <= old_base + 0.01, (price, fix["base_amount"], old_base)
@case("D2 抬到一手也不许超过机械档的整只目标金额")
def _():
for price in (200.0, 264.81, 500.0, 1000.0, 1199.0):
fix, _why = adv.resize_to_advice(_adv("试探仓", 0.01, (1.0,)), _hn(price), "600000.SH")
if fix:
assert fix["base_amount"] <= SCALE * 0.06 + 0.01, (price, fix)
# ============================================================ E 硬风险一律交人
@case("E1 上游标了硬风险的候选强制入人工队列,哪怕档位是 full")
def _():
src = open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"app", "services", "proposal_service.py"), encoding="utf-8").read()
# 一票否决那一行必须把 risk_why 算进去。它排在档位判断之前,
# 所以 full 档也拦得住 —— 这正是 2026-09-10 补的那个洞。
line = [l for l in src.split("\n") if "force_queue = (" in l]
assert line, "找不到 force_queue 那一行,这道检查的前提没了"
seg = src[src.index("force_queue = ("):src.index("auto_exec = (not force_queue)")]
assert "risk_why" in seg, "硬风险没有算进一票否决full 档下带风险的票会被自动买入"
# 而且交人的原因要能说出是风险,不能笼统说「档位 propose_only」
assert "why = (risk_why or" in src, "交人原因没有把硬风险排在最前"
@case("E2 硬风险的判据取的是候选硬数字里的 risk与那条更窄的旁路同一个键")
def _():
src = open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"app", "services", "proposal_service.py"), encoding="utf-8").read()
assert '_hn_risk = (c.get("hard_numbers") or {}).get("risk")' in src, \
"判据的键名与 _verdict_auto_exec_why 里那处不一致,两边会判出不同结果"
# ============================================================ F 持仓目标仓位
@case("F1 建仓成交入账时记一次目标仓位;已经有值就不覆盖")
def _():
from app.services import ledger_service as ls
from app.repo import pms_repo
calls = []
saved = (pms_repo.get_position, pms_repo.get_instruction,
pms_repo.get_proposal, pms_repo.update_position)
try:
pms_repo.get_instruction = lambda i: {"origin_type": "proposal", "origin_id": "PRP_X"}
pms_repo.get_proposal = lambda p: {"hard_numbers": {"advice": {"target_pct": 0.01, "tier": "试探仓"}}}
pms_repo.update_position = lambda code, **kw: calls.append((code, kw))
# 第一次:持仓行上还没有目标仓位 → 记
pms_repo.get_position = lambda c: {"target_pct": None}
ls._remember_target_pct("002812.SZ", "INS_1")
assert calls == [("002812.SZ", {"target_pct": 0.01})], calls
# 第二次:已经有值 → 不覆盖(目标仓位是建这只仓时定的意思,后续加仓不该改)
calls.clear()
pms_repo.get_position = lambda c: {"target_pct": 0.01}
ls._remember_target_pct("002812.SZ", "INS_2")
assert calls == [], calls
finally:
(pms_repo.get_position, pms_repo.get_instruction,
pms_repo.get_proposal, pms_repo.update_position) = saved
@case("F2 不是提议来源的指令不记;记不上也绝不能拦住成交入账")
def _():
from app.services import ledger_service as ls
from app.repo import pms_repo
calls = []
saved = (pms_repo.get_position, pms_repo.get_instruction,
pms_repo.get_proposal, pms_repo.update_position)
try:
pms_repo.get_position = lambda c: {"target_pct": None}
pms_repo.update_position = lambda code, **kw: calls.append((code, kw))
# 命令来源的指令:不记
pms_repo.get_instruction = lambda i: {"origin_type": "command", "origin_id": "CMD_1"}
pms_repo.get_proposal = lambda p: {}
ls._remember_target_pct("002812.SZ", "INS_1")
assert calls == [], calls
# 取数抛异常:吞掉,不往上冒(成交入账不能被这件事拦住)
def _boom(*a, **k):
raise RuntimeError("库挂了")
pms_repo.get_instruction = _boom
ls._remember_target_pct("002812.SZ", "INS_2") # 不抛就算过
assert calls == [], calls
# 没有指令号(外部成交并入 BASE 那种):直接返回
ls._remember_target_pct("002812.SZ", None)
assert calls == [], calls
finally:
(pms_repo.get_position, pms_repo.get_instruction,
pms_repo.get_proposal, pms_repo.update_position) = saved
@case("F3 目标仓位这一列在仓储层的可写白名单里(不在的话上面那次写会被静默拒掉)")
def _():
from app.repo import pms_repo
assert "target_pct" in pms_repo.POSITION_COLS
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())