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