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())