tradingSystem/scripts/test_batch36_units.py

341 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""任务命令结算按方案额 (2026-10-08)。后端离线单测, 不连库。
由来: 建仓 / 升仓 / 个股减仓这类任务命令, 方案全部成交也到不了 DONE。命令目标
(progress.target_amount) 是「规模 × 比例 − 现有市值」的理论金额, 没取整到手; 方案是整手的,
方案额几乎总比目标少一截零头。结算拿理论目标当完成线, 一股不差地成交完也够不着 ——
命令一直挂在执行中、窗口末转部分完成, 同股重下被判重复、暂停买入与一键清仓报冲突。
开发机实算: 现价 10.37 建仓 6% × 100 万, 目标 60,000、方案 59,109, 全部成交仍差 891 元。
修法: 结算线 = min(目标, 方案额) − 未解锁 GATED 批金额 (command_spec.effective_target)。
这里测三块:
A effective_target 纯逻辑: 取整零头、方案额高于目标、GATED 扣减与边界。
B 方案生成接结算: plan_open_target / plan_reduce_stock 产出的方案全部成交即判 DONE。
C 服务层 refresh_progress (内存桩): 建仓端到端与同股重下、HALT 行不进方案额、
方案没凑够目标的口径、没成交完的照旧不放过。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core import command_spec as cs # noqa: E402
from app.core import planner as pl # noqa: E402
RESULTS = []
PX = 10.37 # 开发机实算用的现价
SCALE = 1_000_000.0
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
def _ctx(**kw):
"""plan_open_target 的组合上下文: 空仓、上限宽松, 只让取整这一件事起作用。"""
c = {"scale": SCALE, "portfolio_cap": 0.60, "stock_cap": 0.08, "max_names": 15,
"portfolio_mv": 0.0, "names_count": 0, "stock_mv": 0.0, "is_new_name": True,
"sector": None}
c.update(kw)
return c
def _settle(target, items, filled_ratio=1.0, window_over=False):
"""把方案条目当作非 GATED 批按比例成交, 走一遍与 refresh_progress 相同的结算口径。"""
planned = sum(i["amount"] for i in items if i["action"] != pl.A_HALT)
gated = sum(i["amount"] for i in items if i.get("gated"))
done = sum(i["amount"] * filled_ratio for i in items
if not i.get("gated") and i["action"] != pl.A_HALT)
return cs.settle_task_status(cs.effective_target(target, planned, gated), done, window_over)
# ================================================================ A 纯逻辑
@case("A1 一手取整零头: 目标 60,000、方案 59,109 全部成交 → 判 DONE (旧口径永远够不着)")
def _():
eff = cs.effective_target(60_000.0, 59_109.0, 0.0)
assert abs(eff - 59_109.0) < 1e-9, eff
assert cs.settle_task_status(eff, 59_109.0, False) == cs.ST_DONE
# 钉住由来: 旧口径拿理论目标当线, 全部成交仍差 891 元, 窗口内执行中、窗口后部分完成
assert cs.settle_task_status(60_000.0, 59_109.0, False) == cs.ST_EXECUTING
assert cs.settle_task_status(60_000.0, 59_109.0, True) == cs.ST_PARTIAL
@case("A2 方案额高于目标 (清弱票整票卖、升仓取整微超): 结算线仍是目标, 没够目标不判 DONE")
def _():
eff = cs.effective_target(200_000.0, 201_500.0, 0.0)
assert eff == 200_000.0, eff
assert cs.settle_task_status(eff, 150_000.0, False) == cs.ST_EXECUTING
assert cs.settle_task_status(eff, 200_000.0, False) == cs.ST_DONE
@case("A3 GATED 扣减口径不变; 结算线不为负; 空值按 0")
def _():
# 三批建仓: 方案 58,072 里 FILL/ADD 两批 29,036 是 GATED → 结算线 = 首批 29,036
assert abs(cs.effective_target(60_000.0, 58_072.0, 29_036.0) - 29_036.0) < 1e-9
assert cs.effective_target(10_000.0, 5_000.0, 8_000.0) == 0.0
assert cs.effective_target(None, None) == 0.0
assert cs.effective_target(50_000.0, None) == 50_000.0
# ================================================================ B 方案生成接结算
@case("B1 建仓 10.37 元 × 6%: 单批与 50/25/25 两种拆法, 首批全部成交都判 DONE; 没成交完不放过")
def _():
one = pl.plan_open_target(ts_code="600000.SH", target_pct=0.06, price=PX, ctx=_ctx(),
params={"batch_split": (1.0,)})
assert one["target_amount"] == 60_000.0 and one["planned_amount"] == 59_109.0, one
assert len(one["items"]) == 1 and one["items"][0]["qty"] == 5_700, one["items"]
three = pl.plan_open_target(ts_code="600000.SH", target_pct=0.06, price=PX, ctx=_ctx(),
params={"batch_split": (0.5, 0.25, 0.25)})
assert [i["qty"] for i in three["items"]] == [2_800, 1_400, 1_400], three["items"]
assert [bool(i.get("gated")) for i in three["items"]] == [False, True, True]
for r in (one, three):
assert _settle(r["target_amount"], r["items"]) == cs.ST_DONE, r
assert _settle(r["target_amount"], r["items"], filled_ratio=0.9) == cs.ST_EXECUTING
assert _settle(r["target_amount"], r["items"], filled_ratio=0.9,
window_over=True) == cs.ST_PARTIAL
@case("B2 个股减至 5%: 持 10,000 股 @10.37, 超出 53,700 → 方案 5,100 股 52,887, 全部成交判 DONE")
def _():
r = pl.plan_reduce_stock(ts_code="600000.SH", target_pct=0.05,
position={"total_qty": 10_000, "price": PX}, scale=SCALE)
assert abs(r["target_amount"] - 53_700.0) < 1e-6, r
assert r["items"][0]["qty"] == 5_100 and abs(r["planned_amount"] - 52_887.0) < 1e-6, r
assert _settle(r["target_amount"], r["items"]) == cs.ST_DONE
assert _settle(r["target_amount"], r["items"], filled_ratio=0.98) == cs.ST_EXECUTING
# ================================================================ C 服务层
def _fill(fake, plans):
for p in plans:
fake.update_plan(p["plan_id"], status="DONE", filled_qty=p["qty"])
@case("C1 建仓端到端 (50/25/25): 首批成交完即 DONE、GATED 批照记; 结算前同股重下判重复, 结算后放行")
def _():
from test_wiring import install_fakes
from app.services import command_service as csvc
fake = install_fakes(prices={"600000.SH": PX}, params={"PMS_TOTAL_SCALE": str(int(SCALE))})
r = csvc.issue("OPEN_TARGET", {"ts_code": "600000.SH", "target_pct": "6%"})
assert r["ok"] and r["status"] == "EXECUTING", r
cid = r["command_id"]
plans = fake.list_plans(command_id=cid)
base = [p for p in plans if p["status"] == "PENDING"]
assert len(base) == 1 and base[0]["qty"] == 2_800, plans
csvc.refresh_progress(cid)
assert fake.commands[cid]["status"] == "EXECUTING", fake.commands[cid]
dup = csvc.issue("OPEN_TARGET", {"ts_code": "600000.SH", "target_pct": "6%"})
assert not dup["ok"] and any("重复下达" in c["reason"] for c in dup["conflicts"]), dup
_fill(fake, base)
csvc.refresh_progress(cid)
c = fake.commands[cid]
assert c["status"] == "DONE", c
# 页面照实显示: 目标仍是 60,000, 已成交 29,036, GATED 两批 29,036 单列
assert c["progress"]["target_amount"] == 60_000.0, c["progress"]
assert abs(c["progress"]["done_amount"] - 29_036.0) < 1e-6, c["progress"]
assert abs(c["progress"]["gated_amount"] - 29_036.0) < 1e-6, c["progress"]
assert [p["status"] for p in fake.list_plans(command_id=cid)] == ["DONE", "CANCELLED", "CANCELLED"]
again = csvc.issue("OPEN_TARGET", {"ts_code": "600000.SH", "target_pct": "6%"})
assert again["ok"] and not again.get("conflicts"), again
@case("C2 建仓单批 (开发机实算口径): 方案 59,109 全部成交 → DONE, 进度照记目标 60,000")
def _():
from test_wiring import install_fakes
from app.services import command_service as csvc
fake = install_fakes(prices={"600000.SH": PX},
params={"PMS_TOTAL_SCALE": str(int(SCALE)), "PMS_BATCH_SPLIT": "1.0"})
r = csvc.issue("OPEN_TARGET", {"ts_code": "600000.SH", "target_pct": "6%"})
assert r["ok"], r
cid = r["command_id"]
plans = fake.list_plans(command_id=cid)
assert len(plans) == 1 and plans[0]["qty"] == 5_700, plans
_fill(fake, plans)
out = csvc.refresh_progress(cid)
assert out["commands"][0]["status"] == "DONE", out
prog = fake.commands[cid]["progress"]
assert prog["target_amount"] == 60_000.0 and abs(prog["done_amount"] - 59_109.0) < 1e-6, prog
@case("C3 撤单 HALT 行不进方案额: 降仓顺手撤的在途买单金额再大, 卖出方案成交完即 DONE")
def _():
from test_wiring import install_fakes
from app.services import command_service as csvc
fake = install_fakes(params={"PMS_TOTAL_SCALE": str(int(SCALE))})
fake.insert_command(command_id="CMD_H1", cmd_class=cs.CLS_TASK, cmd_type="REDUCE_EXPOSURE",
ts_code=None, params={}, status="EXECUTING",
progress={"target_amount": 50_000.0, "planned_amount": 49_800.0,
"deadline": "2099-01-01"})
fake.insert_plans([
{"plan_id": "PL_H1", "command_id": "CMD_H1", "ts_code": "000001.SZ", "action": "HALT",
"qty": 8_000, "amount": 80_000.0, "status": "DONE", "filled_qty": 0,
"priority": 10, "deadline": "2099-01-01"},
{"plan_id": "PL_H2", "command_id": "CMD_H1", "ts_code": "600000.SH", "action": "TRIM",
"qty": 4_800, "amount": 49_800.0, "status": "DONE", "filled_qty": 4_800,
"priority": 30, "deadline": "2099-01-01"},
])
csvc.refresh_progress("CMD_H1")
# HALT 行若算进方案额: min(50,000, 129,800) = 50,000 > 49,800, 又卡住
assert fake.commands["CMD_H1"]["status"] == "DONE", fake.commands["CMD_H1"]
@case("C4 方案本身没凑够目标 (升仓候选不够): 方案全部成交判 DONE, 缺口原样留在进度里")
def _():
from test_wiring import install_fakes
from app.services import command_service as csvc
fake = install_fakes(params={"PMS_TOTAL_SCALE": str(int(SCALE))})
fake.insert_command(command_id="CMD_S1", cmd_class=cs.CLS_TASK,
cmd_type="INCREASE_EXPOSURE", ts_code=None, params={},
status="EXECUTING",
progress={"target_amount": 200_000.0, "planned_amount": 120_000.0,
"gap": 80_000.0, "deadline": "2099-01-01"})
fake.insert_plans([
{"plan_id": "PL_S1", "command_id": "CMD_S1", "ts_code": "600000.SH", "action": "OPEN",
"qty": 12_000, "amount": 120_000.0, "status": "DONE", "filled_qty": 12_000,
"priority": 10, "deadline": "2099-01-01"},
])
csvc.refresh_progress("CMD_S1")
c = fake.commands["CMD_S1"]
assert c["status"] == "DONE", c
assert c["progress"]["gap"] == 80_000.0 and c["progress"]["target_amount"] == 200_000.0, c
# 降仓可卖不够那一侧的方案注记同步改口: 不再承诺「置部分完成」
r = pl.plan_reduce_exposure(release_amount=10_000_000, positions=[
{"ts_code": "600000.SH", "price": 10.0, "total_qty": 1_000, "base_qty": 1_000,
"cushion_pct": 0.05, "neg_cushion_days": 0}])
gap_note = [n for n in r["notes"] if "可减持仓不足" in n]
assert gap_note and "部分完成" not in gap_note[0] and "缺口" in gap_note[0], r["notes"]
@case("C5 没成交完的照旧不放过: 成交一半窗口内 EXECUTING、窗口过后 PARTIAL; 有未出清方案不判 DONE")
def _():
from test_wiring import install_fakes
from app.services import command_service as csvc
fake = install_fakes(params={"PMS_TOTAL_SCALE": str(int(SCALE))})
for cid, dl in (("CMD_P1", "2099-01-01"), ("CMD_P2", "2000-01-01")):
fake.insert_command(command_id=cid, cmd_class=cs.CLS_TASK, cmd_type="OPEN_TARGET",
ts_code="600000.SH", params={}, status="EXECUTING",
progress={"target_amount": 60_000.0, "deadline": dl})
fake.insert_plans([
{"plan_id": f"PL_{cid}", "command_id": cid, "ts_code": "600000.SH",
"action": "OPEN", "qty": 5_700, "amount": 59_109.0, "status": "EXECUTING",
"filled_qty": 2_800, "priority": 10, "deadline": dl}])
csvc.refresh_progress("CMD_P1")
csvc.refresh_progress("CMD_P2")
assert fake.commands["CMD_P1"]["status"] == "EXECUTING", fake.commands["CMD_P1"]
assert fake.commands["CMD_P2"]["status"] == "PARTIAL", fake.commands["CMD_P2"]
# 金额都到了但还有一条 0 金额方案没出清 (取不到现价的整票卖单) → 不判 DONE
fake.insert_command(command_id="CMD_P3", cmd_class=cs.CLS_TASK, cmd_type="EXIT_STOCK",
ts_code="600000.SH", params={}, status="EXECUTING",
progress={"target_amount": 30_000.0, "deadline": "2099-01-01"})
fake.insert_plans([
{"plan_id": "PL_P3a", "command_id": "CMD_P3", "ts_code": "600000.SH", "action": "EXIT",
"qty": 3_000, "amount": 30_000.0, "status": "DONE", "filled_qty": 3_000,
"priority": 20, "deadline": "2099-01-01"},
{"plan_id": "PL_P3b", "command_id": "CMD_P3", "ts_code": "000001.SZ", "action": "EXIT",
"qty": 1_000, "amount": 0.0, "status": "PENDING", "filled_qty": 0,
"priority": 20, "deadline": "2099-01-01"}])
csvc.refresh_progress("CMD_P3")
assert fake.commands["CMD_P3"]["status"] == "EXECUTING", fake.commands["CMD_P3"]
@case("C6 非零目标但方案为空不判完成")
def _():
from test_wiring import install_fakes
from app.services import command_service as svc
fake = install_fakes()
fake.insert_command(command_id="EMPTY", cmd_class=cs.CLS_TASK, cmd_type="OPEN_TARGET",
ts_code="600000.SH", params={}, status="EXECUTING",
progress={"target_amount": 60_000, "deadline": "2099-01-01"})
svc.refresh_progress("EMPTY")
assert fake.commands["EMPTY"]["status"] == "EXECUTING"
@case("C7 完成清理失败不置 DONE,取消成功后进程失败仍可重试")
def _():
from test_wiring import install_fakes
from app.repo import pms_repo
from app.services import command_service as svc
for stage in ("save", "cancel", "final"):
fake = install_fakes(prices={"600000.SH": PX}, params={"PMS_TOTAL_SCALE": str(int(SCALE))})
cmd = svc.issue("OPEN_TARGET", {"ts_code": "600000.SH", "target_pct": "6%"})
cid = cmd["command_id"]
_fill(fake, fake.list_plans(command_id=cid, statuses=["PENDING"]))
update, cancel = pms_repo.update_command, pms_repo.cancel_gated_plans_of_command
def broken_update(cid, **kw):
if (stage == "save" and kw.get("status") is None) or (stage == "final" and kw.get("status") == "DONE"):
return 0
return update(cid, **kw)
try:
pms_repo.update_command = broken_update
if stage == "cancel":
pms_repo.cancel_gated_plans_of_command = lambda cid: 0
try:
svc.refresh_progress(cid)
raise AssertionError("结算写入失败必须报错")
except RuntimeError:
pass
assert fake.commands[cid]["status"] == "EXECUTING"
if stage in ("save", "cancel"):
assert len(fake.list_plans(command_id=cid, statuses=["GATED"])) == 2
finally:
pms_repo.update_command, pms_repo.cancel_gated_plans_of_command = update, cancel
svc.refresh_progress(cid)
assert fake.commands[cid]["status"] == "DONE"
assert not fake.list_plans(command_id=cid, statuses=["GATED"])
assert fake.commands[cid]["progress"]["settlement_gated_amount"] == 29_036.0
@case("C8 只取消本命令 GATED,不改在途与其他命令;真实 SQL 限定同一范围")
def _():
import importlib.util
from pathlib import Path
from test_wiring import install_fakes
fake = install_fakes()
for i, (cid, status) in enumerate([("A", "GATED"), ("A", "EXECUTING"), ("A", "PENDING"), ("B", "GATED")]):
fake.insert_plans([{"plan_id": str(i), "command_id": cid, "status": status}])
assert fake.cancel_gated_plans_of_command("A") == 1
assert [p["status"] for p in fake.plans] == ["CANCELLED", "EXECUTING", "PENDING", "GATED"]
path = Path(__file__).resolve().parents[1] / "app/repo/pms_repo.py"
spec = importlib.util.spec_from_file_location("repo_sql_check", path)
repo = importlib.util.module_from_spec(spec)
spec.loader.exec_module(repo)
calls = []
repo.execute = lambda sql, params: calls.append((sql, params)) or 1
assert repo.cancel_gated_plans_of_command("A") == 1
sql, params = calls[0]
from app.db.session import assert_single_table
assert_single_table(sql)
assert "WHERE command_id = :cid AND status = 'GATED'" in sql and params["cid"] == "A"
def main():
ok = 0
for name, fn in RESULTS:
try:
fn()
ok += 1
print(" ok " + name)
except Exception:
import traceback
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())