数量按建议档位重算:卡上写试探仓 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:
parent
d30440edb2
commit
c9b468e7a2
|
|
@ -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]:
|
||||
"""提议落账前补研判一档: 把握度低于 conf_min 或结论不可用 → 试探仓, 只在人工确认后下。不改数量, 数量交人。"""
|
||||
"""提议落账前补研判一档: 把握度低于 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()
|
||||
|
|
@ -109,6 +114,71 @@ def apply_judge(adv: Optional[dict], judge: Optional[dict], conf_min: int = 60)
|
|||
return adv
|
||||
|
||||
|
||||
def resize_to_advice(adv: Optional[dict], hn: Optional[dict], ts_code: str) -> tuple:
|
||||
"""按建议档位把这一批的股数重算一遍。返回 (改动字典, 说明);不该改或算不出时返回 (None, 原因)。
|
||||
|
||||
## 为什么需要它
|
||||
|
||||
候选产出时 advise() 的档位是**进过**数量的(params_for 把 target_pct 塞进
|
||||
stock_target_default,action_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):
|
||||
|
|
|
|||
|
|
@ -464,12 +464,60 @@ def _open_instructions() -> list:
|
|||
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):
|
||||
code, qty, px = act["ts_code"], int(act["qty"]), float(act["price"] or 0)
|
||||
if qty <= 0:
|
||||
return
|
||||
pms_repo.ensure_position(code)
|
||||
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,
|
||||
open_price=px, open_date=datetime.now().date(),
|
||||
instruction_id=act.get("instruction_id"),
|
||||
|
|
|
|||
|
|
@ -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 "")
|
||||
+ "。能不能买到由择时区间决定,可能当日买不上。") 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)
|
||||
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)
|
||||
# 自动执行开关 (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:
|
||||
pid = _make_proposal(c, price, verdict)
|
||||
# 交人的原因按从具体到笼统取: 候选自带的 (关注判决等, 见 action_engine.verdict_confirm_why)
|
||||
# → 来源强制的 (研究证据走弱那类减持) → 深档补仓那条老规矩 → 研判不可用 → 档位。
|
||||
# 重问放行排在最前: 它是这张提议最需要人知道的那件事 (曾被驳回、什么变了、可能买不上)
|
||||
why = (reask_why or c.get("confirm_why") or src_why
|
||||
# 交人的原因按从具体到笼统取: 上游标的硬风险 → 重问放行 → 候选自带的 (关注判决等,
|
||||
# 见 action_engine.verdict_confirm_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 verdict.get("degraded") else None)
|
||||
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"),
|
||||
# 两个期限的头 (2026-09-08 量价研判链): 决策系统并列给的 5 日与 20 日判断, 只显示不触发。
|
||||
"judge_pv_heads": verdict.get("pv_heads")}
|
||||
# 建议方案补研判一档 (2026-09-09 接入方案): 把握度低或不可用 → 试探仓, 只在人工确认后下 (数量交人, 不自动改)。
|
||||
# 建议方案补研判一档 (2026-09-09 接入方案): 把握度低或不可用 → 试探仓, 只在人工确认后下。
|
||||
#
|
||||
# 2026-09-10 补上数量重算。原先这里只改档位文字、不改数量 —— 候选产出时 advise()
|
||||
# 的档位是进过数量的, 但研判回来之后这一降档没跟上, 于是卡上写「试探仓, 目标 1.0%」
|
||||
# 而采纳后真下的是标准仓 6% 的第一批。实测 8 张待确认提议里 7 张是这个样子, 差三倍。
|
||||
# 采纳按钮那边直接抄提议里的股数原样落单 (只有卖出侧会按可卖量重算), 所以不在这里
|
||||
# 改, 后面就没有第二次机会了。
|
||||
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))
|
||||
|
|
@ -826,6 +849,23 @@ def _make_proposal(c, price, verdict) -> str:
|
|||
hn["advice"], hn["advice_text"] = adv2, adv2.get("text")
|
||||
if adv2.get("needs_user_confirm"):
|
||||
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 —— 建议方案出错不拦提议
|
||||
pass
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -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="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.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) {
|
||||
const h = p.hard_numbers || {};
|
||||
// 后端算不出新数量时会留一句原因(例如「一手就超过机械档,这只票对当前规模太贵」)。
|
||||
// 有这句就直接说它 —— 比让人自己去对两个百分比清楚。
|
||||
if (h.advice_resize_note) {
|
||||
return '注意:数量没能按建议档位重算(' + h.advice_resize_note + ')。采纳后下的就是上面那个数。';
|
||||
}
|
||||
const a = h.advice;
|
||||
if (!a || typeof a !== 'object') return '';
|
||||
const advPct = Number(a.target_pct), usedPct = Number(h.target_pct);
|
||||
|
|
@ -2813,6 +2819,16 @@ createApp({
|
|||
return '注意:上面那个股数是按 ' + (usedPct * 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 字, 而它常常是一句技术报错 (例如研判超时那句)。
|
||||
// 卡面只留前 60 字, 原话收进展开。
|
||||
function propJudgeShort(p) {
|
||||
|
|
@ -3998,7 +4014,7 @@ createApp({
|
|||
propVerdictLine, propLogic, propLogicMore, propText, propHeads,
|
||||
hardCn, opCn, paramsCn, dropCn, plainCheck,
|
||||
// 2026-09-10 提议卡重排新增
|
||||
propScale, propSizePct, propAdviceTier, propSizeMismatch, propJudgeShort,
|
||||
propScale, propSizePct, propAdviceTier, propSizeMismatch, propResized, propJudgeShort,
|
||||
propMoreOpen, togglePropMore,
|
||||
pHaltBuy, pResumeBuy, pHaltAll, pResumeAll, pReduce, pIncrease, pLiquidate, pSectorExit,
|
||||
pctOf, tgtPos, posMoveValid, posMovePreview, doPosMove, heldSectors, secSel, doSectorExit,
|
||||
|
|
|
|||
|
|
@ -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_batch21_units.py", "test_batch22_units.py", "test_batch23_units.py",
|
||||
"test_batch24_units.py", "test_batch25_units.py",
|
||||
"test_batch26_units.py",
|
||||
"test_page_enum_guard.py",
|
||||
"test_page_wiring_guard.py",
|
||||
"test_wiring.py"]
|
||||
|
|
|
|||
|
|
@ -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 元,两批各 30000,30000/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())
|
||||
Loading…
Reference in New Issue