341 lines
17 KiB
Python
341 lines
17 KiB
Python
# -*- 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())
|