diff --git a/app/services/advice_service.py b/app/services/advice_service.py
index 544871c..958e93c 100644
--- a/app/services/advice_service.py
+++ b/app/services/advice_service.py
@@ -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):
diff --git a/app/services/ledger_service.py b/app/services/ledger_service.py
index c147a8f..33f5fc0 100644
--- a/app/services/ledger_service.py
+++ b/app/services/ledger_service.py
@@ -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"),
diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py
index a43b4cb..18d95d4 100644
--- a/app/services/proposal_service.py
+++ b/app/services/proposal_service.py
@@ -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:
diff --git a/app/web/static/index.html b/app/web/static/index.html
index dced3d6..da5002f 100644
--- a/app/web/static/index.html
+++ b/app/web/static/index.html
@@ -1089,6 +1089,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
建议档位:{{ propAdviceTier(p) }}
{{ propSizeMismatch(p) }}
+ {{ propResized(p) }}
选股系统:{{ propVerdictLine(p) }}
决策系统:{{ propJudgeShort(p) }}(把握度 {{ (p.hard_numbers||{}).judge_conf }})
@@ -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,
diff --git a/scripts/run_tests.py b/scripts/run_tests.py
index 4712871..df81d17 100644
--- a/scripts/run_tests.py
+++ b/scripts/run_tests.py
@@ -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"]
diff --git a/scripts/test_batch26_units.py b/scripts/test_batch26_units.py
new file mode 100644
index 0000000..992b4bd
--- /dev/null
+++ b/scripts/test_batch26_units.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())