tradingSystem/scripts/test_batch26_units.py

293 lines
13 KiB
Python
Raw Normal View History

数量按建议档位重算:卡上写试探仓 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>
2026-09-10 13:42:39 +08:00
# -*- 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」。
# 次序上重问放行仍排在最前2026-09-09 拍板),硬风险紧随其后 —— 两者实际上
# 几乎不会同时出现:上游标了硬风险的票判决多半不是「候选」,走不到重问那一步。
assert "risk_why or c.get(" in src or "or risk_why or" in src, \
"交人原因里没有硬风险这一档"
数量按建议档位重算:卡上写试探仓 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>
2026-09-10 13:42:39 +08:00
@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())