293 lines
13 KiB
Python
293 lines
13 KiB
Python
# -*- 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」。
|
||
# 次序上重问放行仍排在最前(2026-09-09 拍板),硬风险紧随其后 —— 两者实际上
|
||
# 几乎不会同时出现:上游标了硬风险的票判决多半不是「候选」,走不到重问那一步。
|
||
assert "risk_why or c.get(" in src or "or 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())
|