1333 lines
63 KiB
Python
1333 lines
63 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
装配自检 (零外部依赖, 不连库)
|
|
==============================
|
|
运行: python scripts/test_wiring.py
|
|
|
|
用内存桩替换 repo/行情, 验证「服务层 → 核心逻辑 → 落表」整条链路的接线是否正确:
|
|
导入链、API 路由、调度表与守卫、单表访问守卫、交易日历、参数中心回退,
|
|
以及命令下达→方案落表→撤销、成交回放→批次入账→成本重算 两条主干流程。
|
|
"""
|
|
import os
|
|
import sys
|
|
import traceback
|
|
from datetime import date, datetime
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
RESULTS = []
|
|
|
|
|
|
def case(name):
|
|
def deco(fn):
|
|
RESULTS.append((name, fn))
|
|
return fn
|
|
return deco
|
|
|
|
|
|
# ================================================================ 内存桩
|
|
class FakeRepo:
|
|
"""pms_repo 的内存替身 (只实现服务层用到的函数, 语义与 SQL 版一致)。"""
|
|
|
|
def __init__(self):
|
|
self.params, self.commands, self.plans = {}, {}, []
|
|
self.positions, self.lots, self.instructions = {}, [], {}
|
|
self.proposals, self.ledger, self.reports, self.industry = {}, [], {}, {}
|
|
self._lot_id = 0
|
|
|
|
# --- runtime param ---
|
|
def all_params(self):
|
|
return {k: {"param_value": v, "updated_by": "test", "updated_at": ""}
|
|
for k, v in self.params.items()}
|
|
|
|
def set_param(self, key, value, updated_by="user"):
|
|
self.params[key] = str(value)
|
|
return 1
|
|
|
|
def get_param(self, key):
|
|
return self.params.get(key)
|
|
|
|
# --- command ---
|
|
def next_command_seq(self, ymd):
|
|
return len(self.commands) + 1
|
|
|
|
def insert_command(self, **kw):
|
|
self.commands[kw["command_id"]] = {
|
|
"id": len(self.commands) + 1, "command_id": kw["command_id"],
|
|
"cmd_class": kw["cmd_class"], "cmd_type": kw["cmd_type"], "ts_code": kw["ts_code"],
|
|
"params": kw["params"], "status": kw["status"], "progress": kw.get("progress") or {},
|
|
"issued_by": kw.get("issued_by"), "issued_at": datetime.now(), "done_at": None,
|
|
"note": kw.get("note")}
|
|
return 1
|
|
|
|
def get_command(self, cid):
|
|
return self.commands.get(cid)
|
|
|
|
def list_commands(self, *, statuses=None, cmd_class=None, limit=200):
|
|
out = [c for c in self.commands.values()
|
|
if (not statuses or c["status"] in statuses)
|
|
and (not cmd_class or c["cmd_class"] == cmd_class)]
|
|
return sorted(out, key=lambda c: -c["id"])[:limit]
|
|
|
|
def update_command(self, cid, *, status=None, progress=None, done_at=None, note=None):
|
|
c = self.commands.get(cid)
|
|
if not c:
|
|
return 0
|
|
if status is not None:
|
|
c["status"] = status
|
|
if progress is not None:
|
|
c["progress"] = progress
|
|
if done_at is not None:
|
|
c["done_at"] = done_at
|
|
if note is not None:
|
|
c["note"] = note
|
|
return 1
|
|
|
|
def supersede_param_commands(self, cmd_type, ts_code=None, keep_command_id=None):
|
|
n = 0
|
|
for c in self.commands.values():
|
|
if (c["cmd_class"] == "param" and c["status"] == "EFFECTIVE"
|
|
and c["cmd_type"] == cmd_type and c["ts_code"] == ts_code
|
|
and c["command_id"] != keep_command_id):
|
|
c["status"] = "SUPERSEDED"
|
|
n += 1
|
|
return n
|
|
|
|
def latest_effective(self, cmd_type, ts_code=None):
|
|
hits = [c for c in self.commands.values()
|
|
if c["cmd_class"] == "param" and c["status"] == "EFFECTIVE"
|
|
and c["cmd_type"] == cmd_type and (not ts_code or c["ts_code"] == ts_code)]
|
|
return sorted(hits, key=lambda c: c["id"])[-1] if hits else None
|
|
|
|
def list_effective_stock_params(self, cmd_types=None):
|
|
return [c for c in self.commands.values()
|
|
if c["cmd_class"] == "param" and c["status"] == "EFFECTIVE" and c["ts_code"]
|
|
and (not cmd_types or c["cmd_type"] in cmd_types)]
|
|
|
|
# --- plan ---
|
|
def insert_plans(self, rows):
|
|
for r in rows:
|
|
d = dict(r)
|
|
d.setdefault("filled_qty", 0)
|
|
self.plans.append(d)
|
|
return len(rows)
|
|
|
|
def list_plans(self, *, command_id=None, statuses=None, ts_code=None, limit=500):
|
|
out = [p for p in self.plans
|
|
if (not command_id or p["command_id"] == command_id)
|
|
and (not statuses or p["status"] in statuses)
|
|
and (not ts_code or p["ts_code"] == ts_code)]
|
|
return sorted(out, key=lambda p: p.get("priority", 100))[:limit]
|
|
|
|
def update_plan(self, plan_id, *, status=None, filled_qty=None):
|
|
for p in self.plans:
|
|
if p["plan_id"] == plan_id:
|
|
if status:
|
|
p["status"] = status
|
|
if filled_qty is not None:
|
|
p["filled_qty"] = filled_qty
|
|
return 1
|
|
return 0
|
|
|
|
def cancel_plans_of_command(self, cid):
|
|
n = 0
|
|
for p in self.plans:
|
|
if p["command_id"] == cid and p["status"] in ("PENDING", "EXECUTING"):
|
|
p["status"] = "CANCELLED"
|
|
n += 1
|
|
return n
|
|
|
|
def set_plans_deadline(self, cid, dl):
|
|
for p in self.plans:
|
|
if p["command_id"] == cid:
|
|
p["deadline"] = dl
|
|
return 1
|
|
|
|
# --- position / lot ---
|
|
def list_positions(self, *, only_open=False):
|
|
out = list(self.positions.values())
|
|
return [p for p in out if int(p.get("total_qty") or 0) > 0] if only_open else out
|
|
|
|
def get_position(self, code):
|
|
return self.positions.get(code)
|
|
|
|
def ensure_position(self, code):
|
|
self.positions.setdefault(code, {"ts_code": code, "status": "PLANNED", "total_qty": 0,
|
|
"avail_qty": 0, "frozen_reason": "NONE"})
|
|
return 1
|
|
|
|
def update_position(self, code, **fields):
|
|
self.ensure_position(code)
|
|
self.positions[code].update(fields)
|
|
return 1
|
|
|
|
def bump_position_qty(self, code, *, total_delta=0, avail_delta=0):
|
|
self.ensure_position(code)
|
|
p = self.positions[code]
|
|
p["total_qty"] = max(0, int(p.get("total_qty") or 0) + total_delta)
|
|
p["avail_qty"] = max(0, int(p.get("avail_qty") or 0) + avail_delta)
|
|
return 1
|
|
|
|
def reset_avail_all(self):
|
|
for p in self.positions.values():
|
|
p["avail_qty"] = p.get("total_qty", 0)
|
|
p["t0_count_today"] = 0
|
|
return len(self.positions)
|
|
|
|
def list_lots(self, ts_code=None, *, status="OPEN", limit=1000):
|
|
out = [l for l in self.lots
|
|
if (not ts_code or l["ts_code"] == ts_code)
|
|
and (not status or l["status"] == status)]
|
|
return sorted(out, key=lambda l: (str(l["open_date"]), l["id"]))[:limit]
|
|
|
|
def insert_lot(self, *, ts_code, lot_type, qty, open_price, open_date, instruction_id=None,
|
|
note=None):
|
|
self._lot_id += 1
|
|
self.lots.append({"id": self._lot_id, "ts_code": ts_code, "lot_type": lot_type,
|
|
"qty": int(qty), "open_price": float(open_price),
|
|
"open_date": open_date, "closed_qty": 0, "close_avg_price": None,
|
|
"realized_pnl": 0.0, "status": "OPEN",
|
|
"instruction_id": instruction_id, "note": note})
|
|
return 1
|
|
|
|
def close_lot_qty(self, lot_id, *, qty, close_price, realized_pnl):
|
|
for l in self.lots:
|
|
if l["id"] == lot_id:
|
|
old_closed = int(l["closed_qty"])
|
|
l["close_avg_price"] = ((float(l["close_avg_price"] or 0) * old_closed
|
|
+ close_price * qty) / (old_closed + qty))
|
|
l["qty"] = max(0, int(l["qty"]) - qty)
|
|
l["closed_qty"] = old_closed + qty
|
|
l["realized_pnl"] += realized_pnl
|
|
l["status"] = "CLOSED" if l["qty"] <= 0 else "OPEN"
|
|
return 1
|
|
return 0
|
|
|
|
def update_lot(self, lot_id, **fields):
|
|
for l in self.lots:
|
|
if l["id"] == lot_id:
|
|
l.update(fields)
|
|
return 1
|
|
return 0
|
|
|
|
# --- instruction / proposal / ledger / report / industry ---
|
|
def insert_instruction(self, **kw):
|
|
kw.setdefault("exec_qty", 0)
|
|
kw["created_at"] = kw["updated_at"] = datetime.now()
|
|
self.instructions[kw["instruction_id"]] = kw
|
|
return 1
|
|
|
|
def list_instructions(self, *, statuses=None, side=None, ts_code=None, limit=300):
|
|
out = [i for i in self.instructions.values()
|
|
if (not statuses or i["status"] in statuses)
|
|
and (not side or i.get("side") == side)
|
|
and (not ts_code or i["ts_code"] == ts_code)]
|
|
return out[:limit]
|
|
|
|
def get_instruction(self, iid):
|
|
return self.instructions.get(iid)
|
|
|
|
def update_instruction(self, iid, **kw):
|
|
i = self.instructions.get(iid)
|
|
if not i:
|
|
return 0
|
|
for k, v in kw.items():
|
|
if v is not None:
|
|
i[k] = v
|
|
i["updated_at"] = datetime.now()
|
|
return 1
|
|
|
|
def add_instruction_exec(self, iid, qty):
|
|
i = self.instructions.get(iid)
|
|
if i:
|
|
i["exec_qty"] = int(i.get("exec_qty") or 0) + int(qty)
|
|
return 1
|
|
|
|
def insert_proposal(self, **kw):
|
|
kw["status"] = kw.get("status", "WAIT_USER")
|
|
kw["hard_numbers"] = kw.get("hard_numbers") or {}
|
|
self.proposals[kw["proposal_id"]] = kw
|
|
return 1
|
|
|
|
def list_proposals(self, *, statuses=("WAIT_USER",), limit=200):
|
|
return [p for p in self.proposals.values() if p["status"] in statuses][:limit]
|
|
|
|
def get_proposal(self, pid):
|
|
return self.proposals.get(pid)
|
|
|
|
def decide_proposal(self, pid, status):
|
|
p = self.proposals.get(pid)
|
|
if p and p["status"] == "WAIT_USER":
|
|
p["status"] = status
|
|
return 1
|
|
return 0
|
|
|
|
def expire_proposals(self, now=None):
|
|
return 0
|
|
|
|
def insert_ledger(self, **kw):
|
|
self.ledger.append(kw)
|
|
return 1
|
|
|
|
def list_ledger(self, *, ts_code=None, limit=200):
|
|
return self.ledger[-limit:]
|
|
|
|
def upsert_report(self, ymd, report):
|
|
self.reports[int(ymd)] = report
|
|
return 1
|
|
|
|
def get_report(self, ymd):
|
|
r = self.reports.get(int(ymd))
|
|
return {"ymd": ymd, "report": r} if r else None
|
|
|
|
def latest_report(self):
|
|
if not self.reports:
|
|
return None
|
|
y = max(self.reports)
|
|
return {"ymd": y, "report": self.reports[y]}
|
|
|
|
def get_industry(self, code):
|
|
return self.industry.get(code)
|
|
|
|
def list_industry(self, limit=5000):
|
|
return [{"ts_code": k, "industry": v} for k, v in self.industry.items()]
|
|
|
|
def upsert_industry(self, rows):
|
|
for r in rows:
|
|
self.industry[r["ts_code"]] = r["industry"]
|
|
return len(rows)
|
|
|
|
|
|
class FakeQmtRepo:
|
|
"""ws 通道三表的内存替身 (pms_qmt_order / pms_qmt_inbox / pms_ws_state)。
|
|
|
|
只实现 dispatcher 用得到的那几个 —— 出栈、落库、seq 水位是常驻进程 (app/ws/runner.py)
|
|
的事, 由 test_batch6_units.py 的纯逻辑用例覆盖; 这里只管「服务层接线对不对」。
|
|
"""
|
|
LIVE = ("QUEUED", "SENDING", "SENT", "ACCEPTED", "SUBMITTED", "PARTIAL")
|
|
|
|
def __init__(self):
|
|
self.state = {"id": 1, "last_seq": 0, "acked_seq": 0, "server_seq": 0,
|
|
"conn_state": "INIT", "heartbeat_at": None, "connected_at": None,
|
|
"resync_flag": 0, "last_error": None, "stat": {}}
|
|
self.orders = {}
|
|
|
|
def set_channel(self, conn_state="ONLINE", alive=True):
|
|
"""摆一个通道状态出来 —— 「进程活没活」和「连接通没通」是两件事。"""
|
|
self.state["conn_state"] = conn_state
|
|
self.state["heartbeat_at"] = datetime.now() if alive else None
|
|
|
|
# ---- 以下与真 repo 同名同义 ----
|
|
def get_state(self):
|
|
return dict(self.state)
|
|
|
|
def inbox_pending_count(self):
|
|
return 0
|
|
|
|
def queue_depth(self):
|
|
d = {}
|
|
for o in self.orders.values():
|
|
d[o["status"]] = d.get(o["status"], 0) + 1
|
|
return d
|
|
|
|
def enqueue_order(self, *, instruction_id, parent_id, ts_code, side, qty, limit_price,
|
|
valid_until, intent="OPEN", note=None):
|
|
self.orders[instruction_id] = {
|
|
"instruction_id": instruction_id, "parent_id": parent_id, "ts_code": ts_code,
|
|
"side": side, "qty": int(qty), "limit_price": float(limit_price),
|
|
"valid_until": int(valid_until), "intent": intent, "note": note,
|
|
"status": "QUEUED", "cancel_state": "NONE", "cancel_id": None}
|
|
return 1
|
|
|
|
def request_cancel(self, *, parent_id, cancel_id):
|
|
n = 0
|
|
for o in self.orders.values():
|
|
if (o["parent_id"] == parent_id and o["cancel_state"] == "NONE"
|
|
and o["status"] in self.LIVE):
|
|
o.update({"cancel_state": "REQUESTED", "cancel_id": cancel_id})
|
|
n += 1
|
|
return n
|
|
|
|
|
|
def install_fakes(prices=None, positions=None, params=None, high5=None):
|
|
"""把内存桩装到各模块上, 返回 FakeRepo 实例 (ws 通道桩挂在 .qmt 上)。"""
|
|
from app.repo import downstream_repo, pms_repo, qmt_repo
|
|
from app.services import industry, market, param_store, portfolio
|
|
|
|
fake = FakeRepo()
|
|
fake.qmt = FakeQmtRepo()
|
|
for _n in ("get_state", "inbox_pending_count", "queue_depth", "enqueue_order",
|
|
"request_cancel"):
|
|
setattr(qmt_repo, _n, getattr(fake.qmt, _n))
|
|
fake.params.update(params or {})
|
|
for p in (positions or []):
|
|
base = {"ts_code": p["ts_code"], "status": "HOLDING", "frozen_reason": "NONE",
|
|
"total_qty": 0, "avail_qty": 0, "base_qty": 0, "fill_qty": 0, "add_qty": 0,
|
|
"dca_qty": 0, "t0_qty": 0, "avg_cost": None, "cushion_peak": 0,
|
|
"realized_t_profit": 0, "t0_enabled": 0}
|
|
base.update(p)
|
|
fake.positions[p["ts_code"]] = base
|
|
|
|
for mod in (pms_repo,):
|
|
for name in dir(FakeRepo):
|
|
if name.startswith("_"):
|
|
continue
|
|
setattr(mod, name, getattr(fake, name))
|
|
market.get_price = lambda c: (prices or {}).get(c)
|
|
market.get_prices = lambda cs_: {c: (prices or {}).get(c) for c in (cs_ or [])}
|
|
market.get_refs = lambda c, **kw: {"support": None, "pressure": None, "stop": None,
|
|
"source": "none"}
|
|
market.get_ma5 = lambda c: (prices or {}).get(c)
|
|
market.get_high5 = lambda c: (high5 or prices or {}).get(c)
|
|
market.day_snapshot = lambda c: ({} if not (prices or {}).get(c) else {
|
|
"price": prices[c], "vwap": prices[c], "open": prices[c], "high": prices[c] * 1.02,
|
|
"low": prices[c] * 0.98, "day_chg_from_open": 0.0, "bars": 60})
|
|
industry.get_many = lambda cs_: {c: None for c in (cs_ or [])}
|
|
industry.get = lambda c: None
|
|
industry.ready = lambda: False
|
|
industry.status = lambda: {"source": "", "ready": False, "count": 0, "hint": "test"}
|
|
# 下游只读表默认桩 (单测不触网; 个别用例内再按需覆盖)
|
|
downstream_repo.fetch_buy_plans = lambda **kw: []
|
|
downstream_repo.fetch_filled_orders = lambda **kw: []
|
|
downstream_repo.fetch_positions = lambda: {"rows": [], "columns": {"qty": None},
|
|
"raw_count": 0}
|
|
downstream_repo.fetch_refs = lambda c: None
|
|
downstream_repo.latest_filled_order_id = lambda: "ANCHOR_0"
|
|
# 回放游标预置成非空 —— 否则 replay_fills 会走「冷启动只对齐不追认」那条路 (见
|
|
# ledger_service._seed_cursor), 下面那几个回放用例就测不到入账。冷启动本身另有专门用例。
|
|
# 用 "0" 而不是随便一个字符串: next_cursor 只进不退, 且非数字 id 会退化成字典序比较,
|
|
# 占位值若比真实 order_id 大 (比如 "SEED"), 游标就永远推不动了。
|
|
fake.params.setdefault("PMS_REPLAY_CURSOR", "0")
|
|
param_store._cache.update({"at": 0.0, "data": {}, "error": None, "loaded": False})
|
|
portfolio.save_neg_streak({})
|
|
return fake
|
|
|
|
|
|
# ================================================================ 用例
|
|
@case("装配·全部模块可导入 (无循环依赖)")
|
|
def _():
|
|
import importlib
|
|
for m in ["app.core.sizer", "app.core.cushion", "app.core.command_spec", "app.core.planner",
|
|
"app.core.recon", "app.core.tradedays", "app.db.session", "app.repo.pms_repo",
|
|
"app.repo.downstream_repo", "app.services.param_store", "app.services.industry",
|
|
"app.services.market", "app.services.portfolio", "app.services.command_service",
|
|
"app.services.ledger_service", "app.web.main", "app.scheduler"]:
|
|
importlib.import_module(m)
|
|
|
|
|
|
@case("装配·API 路由齐全 (四块页面 + 运维)")
|
|
def _():
|
|
from app.web.main import app
|
|
paths = {r.path for r in app.routes}
|
|
need = ["/", "/health", "/api/overview", "/api/params", "/api/commands",
|
|
"/api/commands/catalog", "/api/commands/active", "/api/commands/{command_id}",
|
|
"/api/commands/{command_id}/cancel", "/api/plans", "/api/positions",
|
|
"/api/positions/{ts_code}/lots", "/api/instructions", "/api/ledger", "/api/report",
|
|
"/api/proposals", "/api/proposals/{proposal_id}/decide", "/api/ops/replay",
|
|
"/api/ops/reconcile", "/api/ops/daily-settle", "/api/ops/report",
|
|
"/api/ops/downstream-schema", "/api/industry"]
|
|
missing = [p for p in need if p not in paths]
|
|
assert not missing, missing
|
|
import os as _os
|
|
from app.web import main as web
|
|
assert _os.path.exists(_os.path.join(web.STATIC_DIR, "index.html")), "单页文件缺失"
|
|
|
|
|
|
@case("装配·调度表覆盖设计 §10 全部调度位")
|
|
def _():
|
|
from app import scheduler as sch
|
|
names = set(sch.celery_app.conf.beat_schedule)
|
|
assert names == {"premarket", "command_poll", "replay_fills", "intraday_exec",
|
|
"signal_digest", "t0_close", "daily_settle", "daily_report"}, names
|
|
tasks = set(sch.celery_app.tasks)
|
|
for t in ("pms.premarket", "pms.command_poll", "pms.replay_fills", "pms.daily_settle",
|
|
"pms.daily_report", "pms.t0_close", "pms.intraday_exec", "pms.signal_digest"):
|
|
assert t in tasks, t
|
|
assert sch.in_session(datetime(2026, 7, 27, 10, 0)) is True
|
|
assert sch.in_session(datetime(2026, 7, 27, 12, 0)) is False
|
|
assert sch.in_session(datetime(2026, 7, 27, 14, 59)) is True
|
|
|
|
|
|
@case("装配·调度守卫: 非交易日跳过 / 休假模式跳过 / 异常不外抛")
|
|
def _():
|
|
from app import scheduler as sch
|
|
from app.core import tradedays as tdm
|
|
orig = tdm.is_trade_day
|
|
try:
|
|
tdm.is_trade_day = lambda d=None: False
|
|
|
|
@sch.guard(trade_day=True)
|
|
def t1():
|
|
raise AssertionError("非交易日不应执行")
|
|
assert t1() == {"skipped": "not_trade_day"}
|
|
|
|
tdm.is_trade_day = lambda d=None: True
|
|
install_fakes(params={"PMS_GLOBAL_EXEC_HALT": "true"})
|
|
|
|
@sch.guard(trade_day=True)
|
|
def t2():
|
|
raise AssertionError("休假模式不应执行")
|
|
assert t2() == {"skipped": "exec_halt"}
|
|
|
|
install_fakes(params={})
|
|
|
|
@sch.guard(trade_day=True)
|
|
def t3():
|
|
raise ValueError("boom")
|
|
r = t3()
|
|
assert "error" in r and "boom" in r["error"], r
|
|
finally:
|
|
tdm.is_trade_day = orig
|
|
|
|
|
|
@case("装配·严格单表访问守卫 (JOIN 与多表一律拒绝)")
|
|
def _():
|
|
from app.db.session import MultiTableSQL, assert_single_table
|
|
assert_single_table("SELECT * FROM pms_position WHERE ts_code = :c")
|
|
assert_single_table("UPDATE pms_lot SET qty = 0 WHERE id = :id")
|
|
for bad in ["SELECT a.* FROM pms_position a JOIN pms_lot b ON a.ts_code = b.ts_code",
|
|
"SELECT * FROM pms_position, pms_lot",
|
|
"SELECT * FROM pms_plan WHERE ts_code IN (SELECT ts_code FROM pms_position)"]:
|
|
try:
|
|
assert_single_table(bad)
|
|
assert False, f"未拦截: {bad}"
|
|
except MultiTableSQL:
|
|
pass
|
|
|
|
|
|
@case("交易日历·执行窗口与剩余交易日 (跨周末)")
|
|
def _():
|
|
from app.core import tradedays as tdm
|
|
fri, mon = date(2026, 7, 24), date(2026, 7, 27) # 2026-07-24 周五, 07-27 周一
|
|
assert tdm.is_trade_day(fri) and tdm.is_trade_day(mon)
|
|
assert tdm.is_trade_day(date(2026, 7, 25)) is False # 周六
|
|
assert tdm.next_trade_day(fri, 1) == mon
|
|
assert tdm.window_deadline(mon, 1) == mon
|
|
assert tdm.window_deadline(mon, 3) == date(2026, 7, 29)
|
|
assert tdm.window_deadline(fri, 2) == mon # 周五起 2 个交易日 = 周一
|
|
assert tdm.trade_days_left(date(2026, 7, 29), mon) == 3
|
|
assert tdm.trade_days_left(date(2026, 7, 20), mon) == 0
|
|
assert tdm.ymd(mon) == 20260727
|
|
|
|
|
|
@case("参数中心·表值优先/文件回退/非法值拒绝")
|
|
def _():
|
|
from app.services import param_store
|
|
from config.settings import settings
|
|
fake = install_fakes(params={"PMS_TOTAL_SCALE": "3000000", "PMS_AUTONOMY": "full"})
|
|
assert param_store.get("PMS_TOTAL_SCALE") == 3_000_000.0
|
|
assert param_store.get("PMS_AUTONOMY") == "full"
|
|
assert param_store.get("PMS_STOCK_CAP") == settings.PMS_STOCK_CAP # 未改写 → 文件初值
|
|
assert param_store.get_bool("PMS_GLOBAL_BUY_HALT") is False # 运行态默认
|
|
assert param_store.set_param("PROXY_DB_URL", "x")["ok"] is False # 基础设施不可改
|
|
assert param_store.set_param("PMS_PORTFOLIO_CAP", 1.5)["ok"] is False # 越界
|
|
assert param_store.set_param("PMS_AUTONOMY", "auto")["ok"] is False # 枚举非法
|
|
assert param_store.set_param("PMS_PORTFOLIO_CAP", 0.5)["ok"] is True
|
|
assert fake.params["PMS_PORTFOLIO_CAP"] == "0.5"
|
|
assert param_store.get("PMS_PORTFOLIO_CAP") == 0.5
|
|
sp = param_store.sizing_params()
|
|
assert sp["scale"] == 3_000_000.0 and sp["portfolio_cap"] == 0.5
|
|
|
|
|
|
@case("命令服务·参数命令立即生效并覆盖旧记录")
|
|
def _():
|
|
from app.services import command_service as csvc, param_store
|
|
fake = install_fakes()
|
|
r1 = csvc.issue("SET_SCALE", {"scale": 2_000_000})
|
|
assert r1["ok"] and r1["status"] == "EFFECTIVE", r1
|
|
assert param_store.get("PMS_TOTAL_SCALE") == 2_000_000.0
|
|
r2 = csvc.issue("SET_SCALE", {"scale": 2_500_000})
|
|
assert r2["ok"] and param_store.get("PMS_TOTAL_SCALE") == 2_500_000.0
|
|
assert fake.commands[r1["command_id"]]["status"] == "SUPERSEDED"
|
|
assert fake.commands[r2["command_id"]]["status"] == "EFFECTIVE"
|
|
bad = csvc.issue("SET_SCALE", {"scale": -1})
|
|
assert not bad["ok"] and any("OUT_OF_RANGE" in e for e in bad["errors"]), bad
|
|
|
|
|
|
@case("命令服务·个股参数命令投影到账本 (冻结/做T/止损价)")
|
|
def _():
|
|
from app.services import command_service as csvc
|
|
fake = install_fakes(positions=[{"ts_code": "600000.SH", "total_qty": 1000,
|
|
"avg_cost": 10.0}])
|
|
assert csvc.issue("FREEZE_STOCK", {"ts_code": "600000.SH"})["ok"]
|
|
assert fake.positions["600000.SH"]["frozen_reason"] == "COMMAND_HALT"
|
|
assert csvc.issue("T0_ENABLE", {"ts_code": "600000.SH", "t_ratio": "30%"})["ok"]
|
|
assert fake.positions["600000.SH"]["t0_enabled"] == 1
|
|
assert abs(float(fake.positions["600000.SH"]["t0_ratio"]) - 0.30) < 1e-9
|
|
assert csvc.issue("SET_STOP_PRICE", {"ts_code": "600000.SH", "price": 9.2})["ok"]
|
|
assert fake.positions["600000.SH"]["stop_ref"] == 9.2
|
|
assert fake.positions["600000.SH"]["ref_source"] == "user"
|
|
assert csvc.issue("UNFREEZE_STOCK", {"ts_code": "600000.SH"})["ok"]
|
|
assert fake.positions["600000.SH"]["frozen_reason"] == "NONE"
|
|
sp = csvc.effective_stock_params()["600000.SH"]
|
|
assert sp["frozen"] is False and sp["t0"] is True and sp["stop_price"] == 9.2, sp
|
|
# 做T比例超硬上限 1/3 被拒
|
|
assert not csvc.issue("T0_ENABLE", {"ts_code": "600000.SH", "t_ratio": "40%"})["ok"]
|
|
|
|
|
|
@case("命令服务·降仓命令端到端 (方案落表 + 进度 + 撤销)")
|
|
def _():
|
|
from app.services import command_service as csvc
|
|
fake = install_fakes(
|
|
prices={"600000.SH": 10.0, "000001.SZ": 8.0},
|
|
params={"PMS_TOTAL_SCALE": "2000000", "PMS_WEAK_NEG_DAYS": "5"},
|
|
positions=[{"ts_code": "600000.SH", "total_qty": 14000, "base_qty": 7000,
|
|
"fill_qty": 3500, "add_qty": 3500, "avg_cost": 8.93},
|
|
{"ts_code": "000001.SZ", "total_qty": 10000, "base_qty": 10000,
|
|
"avg_cost": 8.8}])
|
|
from app.services import portfolio
|
|
portfolio.save_neg_streak({"000001.SZ": 6})
|
|
|
|
r = csvc.issue("REDUCE_EXPOSURE", {"pct": "5%", "window_tdays": 3})
|
|
assert r["ok"] and r["status"] == "EXECUTING", r
|
|
cid = r["command_id"]
|
|
plans = fake.list_plans(command_id=cid)
|
|
assert plans, "方案未落表"
|
|
assert {p["action"] for p in plans} <= {"EXIT", "TRIM", "HALT"}, plans
|
|
exit_p = [p for p in plans if p["action"] == "EXIT"]
|
|
assert exit_p and exit_p[0]["ts_code"] == "000001.SZ", plans # 弱票优先清
|
|
assert all(p["deadline"] for p in plans)
|
|
prog = fake.commands[cid]["progress"]
|
|
assert prog["target_amount"] == 100_000.0 and prog["plan_count"] == len(plans), prog
|
|
|
|
# 进度结算: 方案未成交 → 保持 EXECUTING; 全部成交 → DONE
|
|
r2 = csvc.refresh_progress(cid)
|
|
assert r2["commands"][0]["status"] in ("EXECUTING", "PARTIAL"), r2
|
|
for p in plans:
|
|
fake.update_plan(p["plan_id"], status="DONE", filled_qty=p["qty"])
|
|
csvc.refresh_progress(cid)
|
|
assert fake.commands[cid]["status"] == "DONE", fake.commands[cid]
|
|
|
|
# 撤销: 已完成命令不可撤
|
|
assert csvc.cancel(cid)["ok"] is False
|
|
r3 = csvc.issue("REDUCE_EXPOSURE", {"pct": "3%"}, force_conflict=True)
|
|
assert csvc.cancel(r3["command_id"])["ok"] is True
|
|
assert fake.commands[r3["command_id"]]["status"] == "CANCELLED"
|
|
assert all(p["status"] == "CANCELLED"
|
|
for p in fake.list_plans(command_id=r3["command_id"]))
|
|
|
|
|
|
@case("命令服务·冲突拦截与强制下达")
|
|
def _():
|
|
from app.services import command_service as csvc
|
|
install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
|
|
positions=[{"ts_code": "600000.SH", "total_qty": 14000, "base_qty": 7000,
|
|
"avg_cost": 9.0}])
|
|
a = csvc.issue("REDUCE_EXPOSURE", {"pct": "5%"})
|
|
assert a["ok"], a
|
|
b = csvc.issue("INCREASE_EXPOSURE", {"pct": "5%"})
|
|
assert not b["ok"] and b["conflicts"], b
|
|
assert any("方向相反" in c["reason"] for c in b["conflicts"])
|
|
c = csvc.issue("INCREASE_EXPOSURE", {"pct": "5%"}, force_conflict=True)
|
|
assert c["ok"] and c["conflicts"], c # 强制下达但冲突仍留痕
|
|
|
|
|
|
@case("命令服务·全局暂停买入撤在途买入指令并置开关")
|
|
def _():
|
|
from app.services import command_service as csvc, param_store
|
|
fake = install_fakes(prices={"600000.SH": 10.0})
|
|
fake.insert_instruction(instruction_id="INS_1", origin_type="plan", origin_id="P1",
|
|
ts_code="600000.SH", action="OPEN", side="buy", qty=1000,
|
|
limit_price=10.0, status="DISPATCHED")
|
|
fake.insert_instruction(instruction_id="INS_2", origin_type="plan", origin_id="P2",
|
|
ts_code="600000.SH", action="EXIT", side="sell", qty=500,
|
|
limit_price=10.0, status="DISPATCHED")
|
|
r = csvc.issue("HALT_BUY", {})
|
|
assert r["ok"] and r["status"] == "DONE", r
|
|
assert fake.instructions["INS_1"]["status"] == "CANCELLED"
|
|
assert fake.instructions["INS_2"]["status"] == "DISPATCHED" # 卖出不受影响
|
|
assert param_store.get_bool("PMS_GLOBAL_BUY_HALT") is True
|
|
assert csvc.issue("RESUME_BUY", {})["ok"]
|
|
assert param_store.get_bool("PMS_GLOBAL_BUY_HALT") is False
|
|
|
|
|
|
@case("账本服务·成交回放入账 → 批次/持仓/成本重算")
|
|
def _():
|
|
from app.services import ledger_service as ls
|
|
from app.repo import downstream_repo
|
|
fake = install_fakes(prices={"600000.SH": 11.0})
|
|
fake.insert_instruction(instruction_id="INS_A", origin_type="plan", origin_id="P1",
|
|
ts_code="600000.SH", action="OPEN", side="buy", qty=6000,
|
|
limit_price=10.0, status="DISPATCHED")
|
|
# 建单时点必须显式钉死: 用 datetime.now() 会让本用例的通过与否取决于跑测试的钟点
|
|
# (时间守卫要求 下发 ≤ 成交), 17:12 跑就会误判成外部成交 —— 实机上已踩到。
|
|
fake.instructions["INS_A"]["created_at"] = "2026-07-27 09:30:00"
|
|
# updated_at 故意置在成交之后: 认领必须看建单时点, 不能看 updated_at
|
|
# (否则部分成交回写一次 updated_at, 后续成交就会被自己挡住)
|
|
fake.instructions["INS_A"]["updated_at"] = "2026-07-27 23:59:59"
|
|
fills = [{"order_id": 101, "ts_code": "600000.SH", "side": "buy", "qty": 6000,
|
|
"price": 10.0, "done_time": "2026-07-27 09:40:00"}]
|
|
orig = downstream_repo.fetch_filled_orders
|
|
try:
|
|
downstream_repo.fetch_filled_orders = lambda **kw: fills
|
|
r = ls.replay_fills()
|
|
assert r["ok"] and r["actions"] == 1, r
|
|
assert fake.positions["600000.SH"]["total_qty"] == 6000
|
|
assert fake.positions["600000.SH"]["avail_qty"] == 0 # T+1: 当日买入不可卖
|
|
assert abs(float(fake.positions["600000.SH"]["avg_cost"]) - 10.0) < 1e-6
|
|
assert fake.positions["600000.SH"]["base_qty"] == 6000
|
|
assert abs(float(fake.positions["600000.SH"]["cushion_pct"]) - 0.10) < 1e-4
|
|
assert fake.instructions["INS_A"]["status"] == "CONFIRMED"
|
|
assert fake.params["PMS_REPLAY_CURSOR"] == "101"
|
|
# 幂等: 游标已推进, 同一批不再重复入账
|
|
downstream_repo.fetch_filled_orders = lambda **kw: []
|
|
r2 = ls.replay_fills()
|
|
assert r2["fills"] == 0 and fake.positions["600000.SH"]["total_qty"] == 6000
|
|
finally:
|
|
downstream_repo.fetch_filled_orders = orig
|
|
|
|
|
|
@case("账本服务·卖出回放按核销次序 + 摊薄成本下降")
|
|
def _():
|
|
from app.services import ledger_service as ls
|
|
from app.repo import downstream_repo
|
|
fake = install_fakes(prices={"600000.SH": 12.0})
|
|
fake.insert_lot(ts_code="600000.SH", lot_type="BASE", qty=6000, open_price=10.0,
|
|
open_date="2026-07-01")
|
|
fake.insert_lot(ts_code="600000.SH", lot_type="ADD", qty=3000, open_price=11.0,
|
|
open_date="2026-07-20")
|
|
fake.update_position("600000.SH", total_qty=9000, avail_qty=9000)
|
|
fills = [{"order_id": 201, "ts_code": "600000.SH", "side": "sell", "qty": 3000,
|
|
"price": 12.0, "done_time": "2026-07-27 10:00:00"}]
|
|
orig = downstream_repo.fetch_filled_orders
|
|
try:
|
|
downstream_repo.fetch_filled_orders = lambda **kw: fills
|
|
r = ls.replay_fills()
|
|
assert r["ok"], r
|
|
add_lot = [l for l in fake.lots if l["lot_type"] == "ADD"][0]
|
|
assert add_lot["qty"] == 0 and add_lot["status"] == "CLOSED" # ADD 先核销
|
|
base_lot = [l for l in fake.lots if l["lot_type"] == "BASE"][0]
|
|
assert base_lot["qty"] == 6000 # 底仓保留
|
|
p = fake.positions["600000.SH"]
|
|
assert p["total_qty"] == 6000 and p["avail_qty"] == 6000
|
|
# 摊薄成本 = (60000+33000-36000)/6000 = 9.5
|
|
assert abs(float(p["avg_cost"]) - 9.5) < 1e-6, p["avg_cost"]
|
|
finally:
|
|
downstream_repo.fetch_filled_orders = orig
|
|
|
|
|
|
@case("账本服务·首次回放只对齐游标, 不把下游历史当外部成交追认")
|
|
def _():
|
|
from app.repo import downstream_repo
|
|
from app.services import ledger_service as ls
|
|
fake = install_fakes()
|
|
fake.params.pop("PMS_REPLAY_CURSOR", None) # 冷启动: 从没设过游标
|
|
hist = [{"order_id": f"BUY_X_{i}", "ts_code": "600000.SH", "side": "buy", "qty": 100,
|
|
"price": 10.0, "done_time": "2020-01-01 09:40:00"} for i in range(500)]
|
|
orig = downstream_repo.fetch_filled_orders
|
|
try:
|
|
downstream_repo.fetch_filled_orders = lambda **kw: hist
|
|
downstream_repo.latest_filled_order_id = lambda: "SELL_ZZZ_999"
|
|
r = ls.replay_fills()
|
|
# 关键: 一条都不能入账。旧系统多年的历史成交若被并入 BASE, 摊薄成本与安全垫全错,
|
|
# 而补仓/加仓/保垫减仓都挂在安全垫上 —— 一错就是整条纪律链。
|
|
assert r.get("seeded") and r["fills"] == 0 and r["actions"] == 0, r
|
|
assert r["cursor"] == "SELL_ZZZ_999", r
|
|
assert not fake.lots and not fake.positions, "冷启动不该产生任何批次或持仓"
|
|
assert fake.params["PMS_REPLAY_CURSOR"] == "SELL_ZZZ_999"
|
|
|
|
# 游标就位后, 新成交照常入账
|
|
new = [{"order_id": "ZZZ_NEW", "ts_code": "600000.SH", "side": "buy", "qty": 100,
|
|
"price": 10.0, "done_time": "2026-07-29 09:40:00"}]
|
|
downstream_repo.fetch_filled_orders = lambda **kw: new
|
|
r2 = ls.replay_fills()
|
|
assert r2["ok"] and r2["actions"] == 1, r2
|
|
|
|
# 显式全量: 游标设 ALL 才追认历史
|
|
fake.params["PMS_REPLAY_CURSOR"] = ls.CURSOR_ALL
|
|
downstream_repo.fetch_filled_orders = lambda **kw: hist[:3]
|
|
r3 = ls.replay_fills()
|
|
assert r3["fills"] == 3, r3
|
|
finally:
|
|
downstream_repo.fetch_filled_orders = orig
|
|
|
|
|
|
@case("账本服务·对账补仓位取下游成本价, 不拿现价充数")
|
|
def _():
|
|
from app.core import recon as rc
|
|
from app.repo import downstream_repo
|
|
from app.services import ledger_service as ls, market
|
|
fake = install_fakes(prices={"600000.SH": 10.0, "000001.SZ": 30.0})
|
|
# 下游有两只票: A 真实成本 20 (现价 10, 实亏 50%), B 下游没给成本价
|
|
downstream_repo.fetch_positions = lambda: {
|
|
"columns": {"qty": "total_quantity", "cost": "cost_price"}, "raw_count": 2,
|
|
"rows": [{"ts_code": "600000.SH", "qty": 1000, "avail_qty": 1000, "cost": 20.0,
|
|
"price": 10.0},
|
|
{"ts_code": "000001.SZ", "qty": 500, "avail_qty": 500, "cost": None,
|
|
"price": 30.0}]}
|
|
r = ls.reconcile(apply_fix=True)
|
|
assert r["ok"], r
|
|
by = {f["ts_code"]: f for f in r["fixes"]}
|
|
# A: 必须记 20 而不是 10 —— 记成 10 的话安全垫是 0, 这只实亏 50% 的票就永远不进补仓评估
|
|
assert by["600000.SH"]["price"] == 20.0, by["600000.SH"]
|
|
assert by["600000.SH"]["price_source"] == "下游成本价"
|
|
# B: 下游没成本价才退到现价, 且留痕注明是估的
|
|
assert by["000001.SZ"]["price"] == 30.0 and "兜底" in by["000001.SZ"]["price_source"]
|
|
# 落到账本上: 摊薄成本 = 真实成本, 安全垫 = (10-20)/20 = -50%
|
|
pos = fake.positions["600000.SH"]
|
|
assert abs(float(pos["avg_cost"]) - 20.0) < 1e-6, pos
|
|
assert abs(float(pos["cushion_pct"]) - (-0.5)) < 1e-4, pos
|
|
|
|
# 纯逻辑侧: cost_map 缺项时逐只独立回退, 不会一只没成本就全退现价
|
|
fixes = rc.build_recon_fixes(
|
|
[{"ts_code": "A", "delta": 100}, {"ts_code": "B", "delta": 200}],
|
|
price_map={"A": 1.0, "B": 2.0}, cost_map={"A": 9.0})
|
|
assert [f["price"] for f in fixes] == [9.0, 2.0], fixes
|
|
|
|
|
|
@case("账本服务·对账以下游为准 + 连续不一致升级")
|
|
def _():
|
|
from app.services import ledger_service as ls
|
|
from app.repo import downstream_repo
|
|
fake = install_fakes(prices={"600000.SH": 10.0})
|
|
fake.insert_lot(ts_code="600000.SH", lot_type="BASE", qty=1000, open_price=10.0,
|
|
open_date="2026-07-01")
|
|
fake.update_position("600000.SH", total_qty=1000, avail_qty=1000)
|
|
orig = downstream_repo.fetch_positions
|
|
try:
|
|
downstream_repo.fetch_positions = lambda: {
|
|
"rows": [{"ts_code": "600000.SH", "qty": 1500, "avail_qty": 1500}],
|
|
"columns": {"code": "stock_code", "qty": "current_qty"}, "raw_count": 1}
|
|
r = ls.reconcile()
|
|
assert r["ok"] and len(r["diffs"]) == 1 and r["diffs"][0]["delta"] == 500, r
|
|
assert fake.positions["600000.SH"]["total_qty"] == 1500 # 以下游为准
|
|
assert any(l["lot_type"] == "RECON" for l in fake.lots) # 修正留痕
|
|
assert any(x.get("action") == "RECON" for x in fake.ledger)
|
|
assert r["severity"] == "WARN"
|
|
for _i in range(2): # 连续第 3 日 → ERROR
|
|
downstream_repo.fetch_positions = lambda: {
|
|
"rows": [{"ts_code": "600000.SH", "qty": 1500 + 100 * (_i + 1)}],
|
|
"columns": {"qty": "current_qty"}, "raw_count": 1}
|
|
r = ls.reconcile()
|
|
assert r["severity"] == "ERROR", r
|
|
finally:
|
|
downstream_repo.fetch_positions = orig
|
|
|
|
|
|
@case("账本服务·日报生成 (关注区 + 次日除权检测快照)")
|
|
def _():
|
|
from app.services import ledger_service as ls
|
|
fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
|
|
positions=[{"ts_code": "600000.SH", "total_qty": 6000,
|
|
"avg_cost": 9.0}])
|
|
rep = ls.build_daily_report()
|
|
assert rep["totals"]["portfolio_mv"] == 60_000.0
|
|
assert rep["snapshot"]["600000.SH"] == {"qty": 6000, "price": 10.0}
|
|
assert any(a["type"] == "行业约束停用" for a in rep["attention"]), rep["attention"]
|
|
assert fake.reports[rep["ymd"]]["ymd"] == rep["ymd"]
|
|
|
|
|
|
@case("账本服务·除权检测走通 (10送10 → 批次按比例调整)")
|
|
def _():
|
|
from app.services import ledger_service as ls
|
|
fake = install_fakes(prices={"600000.SH": 5.0}, params={"PMS_TOTAL_SCALE": "2000000"},
|
|
positions=[{"ts_code": "600000.SH", "total_qty": 2000,
|
|
"avg_cost": 10.0}])
|
|
fake.insert_lot(ts_code="600000.SH", lot_type="BASE", qty=2000, open_price=10.0,
|
|
open_date="2026-07-01")
|
|
fake.upsert_report(20260726, {"snapshot": {"600000.SH": {"qty": 1000, "price": 10.0}}})
|
|
r = ls.detect_and_apply_ex_right()
|
|
assert r["ex_rights"] and abs(r["ex_rights"][0]["ratio"] - 2.0) < 1e-6, r
|
|
lot = fake.lots[0]
|
|
assert lot["qty"] == 4000 and abs(lot["open_price"] - 5.0) < 1e-6, lot
|
|
|
|
|
|
@case("盘前准备·T+1 可卖重置")
|
|
def _():
|
|
from app.services import ledger_service as ls
|
|
fake = install_fakes(prices={"600000.SH": 10.0},
|
|
positions=[{"ts_code": "600000.SH", "total_qty": 6000, "avail_qty": 0,
|
|
"avg_cost": 9.0}])
|
|
r = ls.premarket()
|
|
assert r["avail_reset"] >= 1, r
|
|
assert fake.positions["600000.SH"]["avail_qty"] == 6000
|
|
|
|
|
|
@case("执行器·方案转指令 (GATED 批不转, HALT 直接完结)")
|
|
def _():
|
|
from app.services import command_service as csvc, executor
|
|
fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
|
|
positions=[{"ts_code": "600000.SH", "total_qty": 14000,
|
|
"base_qty": 7000, "fill_qty": 3500, "add_qty": 3500,
|
|
"avail_qty": 14000, "avg_cost": 9.0}])
|
|
r = csvc.issue("REDUCE_EXPOSURE", {"pct": "3%"})
|
|
assert r["ok"], r
|
|
# 掺一条 GATED 方案 (建仓补足批) 与一条 HALT 方案
|
|
fake.insert_plans([
|
|
{"plan_id": "P_G", "command_id": r["command_id"], "ts_code": "600000.SH",
|
|
"action": "FILL", "qty": 3000, "amount": 30000, "priority": 20,
|
|
"deadline": "2026-07-30", "status": "GATED", "reason": "回踩补足批"},
|
|
{"plan_id": "P_H", "command_id": r["command_id"], "ts_code": "600000.SH",
|
|
"action": "HALT", "qty": 0, "amount": 0, "priority": 10,
|
|
"deadline": "2026-07-30", "status": "PENDING", "reason": "撤在途买入"}])
|
|
m = executor.materialize_plans()
|
|
assert m["created"], m
|
|
codes = {fake.instructions[i]["ts_code"] for i in m["created"]}
|
|
assert codes == {"600000.SH"}, codes
|
|
assert all(fake.instructions[i]["status"] == "PROPOSED" for i in m["created"])
|
|
gated = [p for p in fake.plans if p["plan_id"] == "P_G"][0]
|
|
assert gated["status"] == "GATED", gated # 未解锁的批次不转指令
|
|
halt = [p for p in fake.plans if p["plan_id"] == "P_H"][0]
|
|
assert halt["status"] == "DONE", halt
|
|
assert any("HALT" in s["why"] for s in m["skipped"]), m["skipped"]
|
|
# 幂等: 再跑一次不重复建指令
|
|
n1 = len(fake.instructions)
|
|
executor.materialize_plans()
|
|
assert len(fake.instructions) == n1
|
|
|
|
|
|
@case("执行器·出手一跳 (影子模式): 择时→规则闸→下发→子单→评审留痕")
|
|
def _():
|
|
from datetime import datetime as _dt
|
|
from app.services import executor
|
|
fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
|
|
positions=[{"ts_code": "600000.SH", "total_qty": 6000,
|
|
"avail_qty": 6000, "avg_cost": 9.0}])
|
|
fake.insert_instruction(instruction_id="INS_S", origin_type="plan", origin_id="P1",
|
|
ts_code="600000.SH", action="TRIM", side="sell", qty=3000,
|
|
window_tdays=3, status="PROPOSED",
|
|
progress={"deadline": "2026-07-29", "is_command": True,
|
|
"children": []})
|
|
# 周一 10:05, 距截止 3 个交易日 → 当日配额 1000
|
|
r = executor.run_tick(now=_dt(2026, 7, 27, 10, 5))
|
|
assert r["ok"] and len(r["fired"]) == 1, r
|
|
fired = r["fired"][0]
|
|
assert fired["qty"] == 1000 and fired["mode"] == "shadow", fired
|
|
ins = fake.instructions["INS_S"]
|
|
assert ins["status"] == "DISPATCHED"
|
|
assert len(ins["progress"]["children"]) == 1
|
|
assert ins["progress"]["children"][0]["qty"] == 1000
|
|
assert ins["progress"]["dispatched_at"]
|
|
assert any(x["verdict"] == "PASS" and x["arbiter"] == "rule" for x in fake.ledger)
|
|
# 同一天再跳一次: 配额已出完, 不重复下发
|
|
r2 = executor.run_tick(now=_dt(2026, 7, 27, 10, 6))
|
|
assert not r2["fired"] and r2["waited"], r2
|
|
assert len(fake.instructions["INS_S"]["progress"]["children"]) == 1
|
|
|
|
|
|
@case("执行器·规则闸拦截时不下发且落拒绝留痕")
|
|
def _():
|
|
from datetime import datetime as _dt
|
|
from app.services import executor
|
|
fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
|
|
positions=[{"ts_code": "600000.SH", "total_qty": 6000,
|
|
"avail_qty": 6000, "avg_cost": 9.0}])
|
|
fake.insert_instruction(instruction_id="INS_B", origin_type="plan", origin_id="P2",
|
|
ts_code="600000.SH", action="OPEN", side="buy", qty=1000,
|
|
window_tdays=1, status="PROPOSED",
|
|
progress={"deadline": "2026-07-27", "is_command": True,
|
|
"children": []})
|
|
from app.services import param_store
|
|
param_store.set_param("PMS_GLOBAL_BUY_HALT", True, "test")
|
|
r = executor.run_tick(now=_dt(2026, 7, 27, 10, 5))
|
|
assert not r["fired"] and r["rejected"], r
|
|
assert any("BUY_HALT" in f for f in r["rejected"][0]["failed"]), r["rejected"]
|
|
assert fake.instructions["INS_B"]["status"] == "PROPOSED" # 未下发
|
|
assert any(x["verdict"] == "REJECT" for x in fake.ledger)
|
|
|
|
|
|
@case("执行器·试算模式只算不发不落库")
|
|
def _():
|
|
from datetime import datetime as _dt
|
|
from app.services import executor
|
|
fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
|
|
positions=[{"ts_code": "600000.SH", "total_qty": 6000,
|
|
"avail_qty": 6000, "avg_cost": 9.0}])
|
|
fake.insert_instruction(instruction_id="INS_D", origin_type="plan", origin_id="P3",
|
|
ts_code="600000.SH", action="EXIT", side="sell", qty=2000,
|
|
window_tdays=1, status="PROPOSED",
|
|
progress={"deadline": "2026-07-27", "children": []})
|
|
r = executor.run_tick(now=_dt(2026, 7, 27, 10, 5), dry_run=True)
|
|
assert r["fired"] and r["fired"][0]["dry_run"] is True, r
|
|
assert r["fired"][0]["qty"] == 2000 # 末日全出
|
|
assert fake.instructions["INS_D"]["status"] == "PROPOSED"
|
|
assert not fake.instructions["INS_D"]["progress"]["children"]
|
|
assert not fake.ledger
|
|
|
|
|
|
@case("执行器·窗口收口: 成交回写方案 + 足额置确认 + 命令类部分完成")
|
|
def _():
|
|
from datetime import datetime as _dt
|
|
from app.services import executor
|
|
fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
|
|
positions=[{"ts_code": "600000.SH", "total_qty": 6000,
|
|
"avail_qty": 6000, "avg_cost": 9.0}])
|
|
fake.insert_plans([{"plan_id": "P_F", "command_id": "CMD_X", "ts_code": "600000.SH",
|
|
"action": "TRIM", "qty": 2000, "amount": 20000, "priority": 30,
|
|
"deadline": "2026-07-27", "status": "EXECUTING", "reason": "收利润"}])
|
|
fake.insert_instruction(instruction_id="INS_F", origin_type="plan", origin_id="P_F",
|
|
ts_code="600000.SH", action="TRIM", side="sell", qty=2000,
|
|
status="DISPATCHED", exec_qty=2000,
|
|
progress={"deadline": "2026-07-27", "is_command": True,
|
|
"children": []})
|
|
fake.insert_instruction(instruction_id="INS_P", origin_type="plan", origin_id="P_F",
|
|
ts_code="600000.SH", action="TRIM", side="sell", qty=2000,
|
|
status="DISPATCHED", exec_qty=500,
|
|
progress={"deadline": "2026-07-20", "is_command": True,
|
|
"children": []})
|
|
fake.insert_instruction(instruction_id="INS_A2", origin_type="proposal", origin_id="PR1",
|
|
ts_code="600000.SH", action="ADD", side="buy", qty=1000,
|
|
status="DISPATCHED", exec_qty=0,
|
|
progress={"deadline": "2026-07-20", "is_command": False,
|
|
"children": []})
|
|
r = executor.sweep_windows(now=_dt(2026, 7, 27, 15, 10))
|
|
assert fake.instructions["INS_F"]["status"] == "CONFIRMED", fake.instructions["INS_F"]
|
|
assert [p for p in fake.plans if p["plan_id"] == "P_F"][0]["filled_qty"] == 500
|
|
assert any(x["instruction_id"] == "INS_P" for x in r["partial"]), r
|
|
assert fake.instructions["INS_P"]["progress"]["window_verdict"]["verdict"] == "PARTIAL"
|
|
assert "INS_A2" in r["expired"], r # 自主类窗口耗尽即作废
|
|
assert fake.instructions["INS_A2"]["status"] == "EXPIRED"
|
|
|
|
|
|
@case("下发通道·影子回执 / ws 出口队列 / 进程与连接两级降级 / 撤销走本地置状态")
|
|
def _():
|
|
from datetime import datetime as _dtm
|
|
from app.services import dispatcher, executor, param_store
|
|
fake = install_fakes()
|
|
assert dispatcher.mode() == "shadow"
|
|
assert set(dispatcher.describe()["modes"]) == {"shadow", "ws"}, dispatcher.describe()
|
|
d = dispatcher.dispatch(instruction_id="INS_1", ts_code="600000.SH", side="sell",
|
|
qty=1000, limit_price=9.98)
|
|
assert d["ok"] and d["ref"] == "manual:INS_1" and "人工" in d["note"], d
|
|
# 已作废的通道名不能再被设进来
|
|
for dead in ("bad_mode", "plan_x", "channel_y"):
|
|
assert param_store.set_param("PMS_DISPATCH_MODE", dead)["ok"] is False, dead
|
|
assert param_store.set_param("PMS_DISPATCH_MODE", "ws")["ok"] is True
|
|
vu = _dtm.now().replace(hour=14, minute=45, second=0, microsecond=0)
|
|
|
|
def _send(iid, side="sell", qty=1000, px=9.98):
|
|
return dispatcher.dispatch(instruction_id=iid, parent_id=iid.rsplit("_D", 1)[0],
|
|
ts_code="600000.SH", side=side, qty=qty,
|
|
limit_price=px, valid_until=vu, intent="TRIM")
|
|
|
|
# ① ws 进程没在跑 (心跳陈旧): 买卖一律拒发 —— 排进队列也没人发, 装作成功更危险
|
|
fake.qmt.set_channel("OFFLINE", alive=False)
|
|
for side in ("sell", "buy"):
|
|
r = _send(f"INS_2{side}_D01", side=side, qty=1000)
|
|
assert r["ok"] is False and r["mode"] == "ws" and "未在线" in r["error"], r
|
|
assert not fake.qmt.orders, "进程不在时不该往出口表里塞东西"
|
|
|
|
# ② 进程在、连接断: 协议 §6.3 —— 停发增持, 减持照常入队等重连
|
|
fake.qmt.set_channel("OFFLINE", alive=True)
|
|
rb = _send("INS_3buy_D01", side="buy", qty=1000)
|
|
assert rb["ok"] is False and "§6.3" in rb["error"], rb
|
|
rs = _send("INS_3sell_D01", side="sell", qty=1000)
|
|
assert rs["ok"] and "重连后" in rs["note"], rs
|
|
assert fake.qmt.orders["INS_3sell_D01"]["status"] == "QUEUED"
|
|
assert fake.qmt.orders["INS_3sell_D01"]["parent_id"] == "INS_3sell"
|
|
|
|
# ③ 连接正常: 买入也放行, 落出口表即返回 (不等 QMT 的 ack)
|
|
fake.qmt.set_channel("ONLINE", alive=True)
|
|
rb2 = _send("INS_4buy_D01", side="buy", qty=1000, px=9.876)
|
|
assert rb2["ok"] and rb2["ref"] == "INS_4buy_D01", rb2
|
|
o = fake.qmt.orders["INS_4buy_D01"]
|
|
assert o["limit_price"] == 9.88 and o["valid_until"] > 10 ** 12, o # 2位小数 + 毫秒
|
|
|
|
# ④ 不合协议的参数在本地就拦下, 不换对端一个 BAD_PARAM
|
|
bad = _send("INS_5buy_D01", side="buy", qty=150) # 买入非整百
|
|
assert bad["ok"] is False and "不合协议" in bad["error"], bad
|
|
assert "INS_5buy_D01" not in fake.qmt.orders
|
|
|
|
# ⑤ 撤单: 标记父指令名下所有在途子单, 由 ws 进程逐张发 cancel_order
|
|
c = dispatcher.cancel(instruction_id="INS_3sell")
|
|
assert c["ok"] and c["cancelled"] == 1 and c["cancel_id"].startswith("CXL-"), c
|
|
assert fake.qmt.orders["INS_3sell_D01"]["cancel_state"] == "REQUESTED"
|
|
assert dispatcher.cancel(instruction_id="INS_NOBODY")["cancelled"] == 0
|
|
param_store.set_param("PMS_DISPATCH_MODE", "shadow")
|
|
|
|
fake.insert_instruction(instruction_id="INS_C", origin_type="plan", origin_id="P1",
|
|
ts_code="600000.SH", action="TRIM", side="sell", qty=1000,
|
|
status="DISPATCHED", progress={"children": []})
|
|
r = executor.cancel_instruction("INS_C")
|
|
assert r["ok"] and fake.instructions["INS_C"]["status"] == "CANCELLED", r
|
|
assert executor.cancel_instruction("INS_C")["ok"] is False
|
|
|
|
|
|
def _prop_fakes(**kw):
|
|
"""自主提议用的组合: A 票厚垫创新高(可加仓), B 票垫子回吐过半(可保垫减仓)。"""
|
|
return install_fakes(
|
|
prices={"600000.SH": 11.0, "000001.SZ": 10.4},
|
|
high5={"600000.SH": 11.0, "000001.SZ": 12.0}, # B 没创新高, 只该出 TRIM
|
|
params={"PMS_TOTAL_SCALE": "2000000", **(kw.get("params") or {})},
|
|
positions=[{"ts_code": "600000.SH", "total_qty": 6000, "avail_qty": 6000,
|
|
"base_qty": 6000, "avg_cost": 10.0, "cushion_peak": 0.0,
|
|
"target_pct": 0.06},
|
|
{"ts_code": "000001.SZ", "total_qty": 6000, "avail_qty": 6000,
|
|
"base_qty": 6000, "avg_cost": 10.0, "cushion_peak": 0.08,
|
|
"target_pct": 0.06}])
|
|
|
|
|
|
@case("自主提议·propose_only: 减持自动执行 / 增持入队 / 研判未接通即降级留痕")
|
|
def _():
|
|
from app.services import proposal_service as ps
|
|
fake = _prop_fakes(params={"PMS_AUTONOMY": "propose_only"})
|
|
r = ps.scan_and_route()
|
|
assert r["ok"], r
|
|
ex = {(x["ts_code"], x["action"]) for x in r["executed"]}
|
|
qd = {(x["ts_code"], x["action"]) for x in r["queued"]}
|
|
assert ("000001.SZ", "TRIM") in ex, r # 减持方向不设确认门槛
|
|
assert ("600000.SH", "ADD") in qd, r # 增持入队
|
|
assert r["degraded"] is True, r # 研判未接通 → 降级
|
|
assert any("研判不可用" in x["why"] for x in r["queued"]), r["queued"]
|
|
# 减持落了指令, 增持落了提议
|
|
trim_ins = [i for i in fake.instructions.values() if i["action"] == "TRIM"]
|
|
assert trim_ins and trim_ins[0]["side"] == "sell" and trim_ins[0]["qty"] == 2000, trim_ins
|
|
add_prop = [p for p in fake.proposals.values() if p["action"] == "ADD"]
|
|
assert add_prop and add_prop[0]["qty"] == 2700, add_prop
|
|
assert add_prop[0]["hard_numbers"]["price"] == 11.0
|
|
# 再扫一轮不重复提 (在途去重)
|
|
r2 = ps.scan_and_route()
|
|
assert not r2["executed"] and not r2["queued"], r2
|
|
assert any(s.get("why") == "已有在途提议/指令" for s in r2["skipped"]), r2["skipped"]
|
|
|
|
|
|
@case("自主提议·full + 研判通过: 增持直接落指令; 深档补仓仍强制确认")
|
|
def _():
|
|
from app.services import judge, proposal_service as ps
|
|
orig = judge.request
|
|
try:
|
|
judge.request = lambda c, context=None, **kw: {"verdict": judge.PASS,
|
|
"reason": "研判通过(桩)",
|
|
"degraded": False, "raw": None}
|
|
fake = _prop_fakes(params={"PMS_AUTONOMY": "full"})
|
|
r = ps.scan_and_route()
|
|
assert r["degraded"] is False, r
|
|
ex = {(x["ts_code"], x["action"]) for x in r["executed"]}
|
|
assert ("600000.SH", "ADD") in ex and ("000001.SZ", "TRIM") in ex, r
|
|
assert not r["queued"], r
|
|
add_ins = [i for i in fake.instructions.values() if i["action"] == "ADD"]
|
|
assert add_ins and add_ins[0]["side"] == "buy" and add_ins[0]["qty"] == 2700, add_ins
|
|
assert add_ins[0]["progress"]["is_command"] is False
|
|
|
|
# 深档补仓: 即使档位 full、研判通过, 也必须入队等用户点头
|
|
fake2 = install_fakes(prices={"600519.SH": 8.4}, high5={"600519.SH": 9.9},
|
|
params={"PMS_TOTAL_SCALE": "2000000", "PMS_AUTONOMY": "full"},
|
|
positions=[{"ts_code": "600519.SH", "total_qty": 6000,
|
|
"avail_qty": 6000, "base_qty": 6000,
|
|
"avg_cost": 10.0, "cushion_peak": 0.0,
|
|
"target_pct": 0.06}])
|
|
r2 = ps.scan_and_route()
|
|
qd = {(x["ts_code"], x["action"]) for x in r2["queued"]}
|
|
assert ("600519.SH", "DCA") in qd, r2
|
|
assert any("深档" in x["why"] for x in r2["queued"]), r2["queued"]
|
|
prop = [p for p in fake2.proposals.values() if p["action"] == "DCA"][0]
|
|
assert prop["qty"] == 3000 and prop["hard_numbers"]["stage"] == 2, prop
|
|
finally:
|
|
judge.request = orig
|
|
|
|
|
|
@case("自主提议·研判驳回与规则闸拦截各自留痕")
|
|
def _():
|
|
from app.services import judge, proposal_service as ps
|
|
orig = judge.request
|
|
try:
|
|
judge.request = lambda c, context=None, **kw: {"verdict": judge.REJECT,
|
|
"reason": "形态走坏, 不宜加仓",
|
|
"degraded": False, "raw": None}
|
|
fake = _prop_fakes(params={"PMS_AUTONOMY": "full"})
|
|
r = ps.scan_and_route()
|
|
rej = {(x["ts_code"], x["action"], x["by"]) for x in r["rejected"]}
|
|
assert ("600000.SH", "ADD", "judge") in rej, r
|
|
assert any(x["arbiter"] == "judge" and x["verdict"] == "REJECT" for x in fake.ledger)
|
|
assert not any(i["action"] == "ADD" for i in fake.instructions.values())
|
|
finally:
|
|
judge.request = orig
|
|
|
|
# 规则闸拦截: 全局暂停买入
|
|
fake2 = _prop_fakes(params={"PMS_AUTONOMY": "full", "PMS_GLOBAL_BUY_HALT": "true"})
|
|
r2 = ps.scan_and_route()
|
|
rej2 = {(x["ts_code"], x["action"], x["by"]) for x in r2["rejected"]}
|
|
assert ("600000.SH", "ADD", "rule") in rej2, r2
|
|
assert any("BUY_HALT" in f for x in r2["rejected"] for f in x["failed"]), r2
|
|
assert any(x["arbiter"] == "rule" and x["verdict"] == "REJECT" for x in fake2.ledger)
|
|
# 减持不受暂停买入影响, 照样执行
|
|
assert ("000001.SZ", "TRIM") in {(x["ts_code"], x["action"]) for x in r2["executed"]}, r2
|
|
|
|
|
|
@case("自主提议·档位 off 与休假模式不扫描; 试算不落表")
|
|
def _():
|
|
from app.services import proposal_service as ps
|
|
_prop_fakes(params={"PMS_AUTONOMY": "off"})
|
|
r = ps.scan_and_route()
|
|
assert r["candidates"] == 0 and any("off" in s["why"] for s in r["skipped"]), r
|
|
|
|
_prop_fakes(params={"PMS_AUTONOMY": "full", "PMS_GLOBAL_EXEC_HALT": "true"})
|
|
r2 = ps.scan_and_route()
|
|
assert any("休假" in s["why"] for s in r2["skipped"]), r2
|
|
|
|
fake = _prop_fakes(params={"PMS_AUTONOMY": "propose_only"})
|
|
r3 = ps.scan_and_route(dry_run=True)
|
|
assert (r3["executed"] or r3["queued"]) and all(
|
|
x.get("dry_run") for x in r3["executed"] + r3["queued"]), r3
|
|
assert not fake.instructions and not fake.proposals and not fake.ledger
|
|
|
|
|
|
@case("研判闸·未配置即不可用, 动作不在研判范围则直接放行")
|
|
def _():
|
|
from app.services import judge
|
|
install_fakes()
|
|
assert judge.available() is False
|
|
st = judge.status()
|
|
assert st["available"] is False and "未配置" in st["reason"], st
|
|
r = judge.request({"action": "ADD", "ts_code": "600000.SH", "qty": 100})
|
|
assert r["verdict"] == judge.UNAVAILABLE and r["degraded"] is True, r
|
|
r2 = judge.request({"action": "TRIM", "ts_code": "600000.SH", "qty": 100})
|
|
assert r2["verdict"] == judge.PASS and r2["degraded"] is False, r2 # TRIM 不在研判范围
|
|
|
|
|
|
class FakeRedis:
|
|
"""Redis Stream 的最小替身 (消费组 + xreadgroup + ack)。"""
|
|
|
|
def __init__(self, msgs=None):
|
|
self.msgs = dict(msgs or {})
|
|
self.acked, self.groups = [], []
|
|
|
|
def xgroup_create(self, key, group, id="$", mkstream=False):
|
|
self.groups.append((key, group))
|
|
|
|
def xreadgroup(self, group, consumer, streams, count=10, block=0):
|
|
key = list(streams)[0]
|
|
m = self.msgs.pop(key, [])
|
|
return [(key, m)] if m else []
|
|
|
|
def xack(self, key, group, msg_id):
|
|
self.acked.append(msg_id)
|
|
|
|
def xlen(self, key):
|
|
return len(self.msgs.get(key, []))
|
|
|
|
def xinfo_groups(self, key):
|
|
return [{"name": g, "pending": 0} for k, g in self.groups if k == key]
|
|
|
|
|
|
def _install_signal_fakes(fake, sell_msgs=None, intraday_msgs=None):
|
|
"""把两条流的假客户端装上, 返回 {db: FakeRedis}。"""
|
|
import json as _json
|
|
from datetime import datetime as _dt
|
|
from app.services import signal_service as ss
|
|
from config.settings import settings as _st
|
|
ymd = _dt.now().strftime("%Y-%m-%d")
|
|
r2 = FakeRedis({f"intraday_signals:{ymd}": list(intraday_msgs or [])})
|
|
r3 = FakeRedis({"bionic:signals:llm_sell_actions": list(sell_msgs or [])})
|
|
by_db = {_st.SIGNAL_REDIS_DB_INTRADAY: r2, _st.SIGNAL_REDIS_DB_ACTIONS: r3}
|
|
ss._client = lambda db: by_db[db]
|
|
return by_db
|
|
|
|
|
|
def _sell_msg(mid, code, conf, reason="逻辑走坏"):
|
|
import json as _json
|
|
return (mid, {"data": _json.dumps({"ts_code": code, "action": "SELL",
|
|
"confidence": conf, "llm_reason": reason},
|
|
ensure_ascii=False)})
|
|
|
|
|
|
@case("信号消化·高置信风控卖出转清仓指令; 中等置信落提议; 当日去重")
|
|
def _():
|
|
from app.services import signal_service as ss
|
|
fake = install_fakes(prices={"600000.SH": 10.0, "000001.SZ": 8.0},
|
|
params={"PMS_TOTAL_SCALE": "2000000"},
|
|
positions=[{"ts_code": "600000.SH", "total_qty": 6000,
|
|
"avail_qty": 6000, "avg_cost": 10.0},
|
|
{"ts_code": "000001.SZ", "total_qty": 3000,
|
|
"avail_qty": 3000, "avg_cost": 8.0}])
|
|
by_db = _install_signal_fakes(fake, sell_msgs=[
|
|
_sell_msg("3-1", "600000.SH", 92), # 高置信 → 清仓
|
|
_sell_msg("3-2", "000001.SZ", 80), # 中置信 → 提议
|
|
_sell_msg("3-3", "600519.SH", 95), # 没持仓 → 忽略
|
|
])
|
|
r = ss.consume()
|
|
assert r["ok"], r
|
|
assert [x["ts_code"] for x in r["exits"]] == ["600000.SH"], r
|
|
assert r["exits"][0]["qty"] == 6000
|
|
assert [x["ts_code"] for x in r["proposals"]] == ["000001.SZ"], r
|
|
assert r["proposals"][0]["qty"] == 1000 # 3000 的 1/3
|
|
assert r["ignored"] >= 1, r
|
|
assert len(by_db[3].acked) == 3, by_db[3].acked # 三条都 ACK
|
|
|
|
ins = [i for i in fake.instructions.values() if i["action"] == "EXIT"]
|
|
assert ins and ins[0]["side"] == "sell" and ins[0]["progress"]["from_signal"] is True
|
|
assert any(x["arbiter"] == "rule" and x["action"] == "EXIT" for x in fake.ledger)
|
|
prop = [p for p in fake.proposals.values() if p["action"] == "TRIM"]
|
|
assert prop and prop[0]["hard_numbers"]["signal_source"] == "risk_sell", prop
|
|
|
|
# 同一条信号再来一次: 当日去重 + 在途检查, 不重复下指令
|
|
n_ins = len(fake.instructions)
|
|
_install_signal_fakes(fake, sell_msgs=[_sell_msg("3-4", "600000.SH", 92)])
|
|
r2 = ss.consume()
|
|
assert not r2["exits"] and len(fake.instructions) == n_ins, r2
|
|
|
|
|
|
@case("信号消化·BUY 只留痕不买; 关闭开关即不消化; 试算不落表不ACK")
|
|
def _():
|
|
from app.services import signal_service as ss
|
|
fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
|
|
positions=[{"ts_code": "600000.SH", "total_qty": 6000,
|
|
"avail_qty": 6000, "avg_cost": 10.0}])
|
|
_install_signal_fakes(fake, intraday_msgs=[
|
|
("1-1", {"ts_code": "600000.SH", "action": "BUY", "confidence": "0.95"})])
|
|
r = ss.consume()
|
|
assert r["recorded"] == 1 and not r["exits"], r
|
|
assert not any(i["side"] == "buy" for i in fake.instructions.values())
|
|
assert any(x["action"] == "SIGNAL" for x in fake.ledger), fake.ledger
|
|
|
|
fake2 = install_fakes(prices={"600000.SH": 10.0},
|
|
params={"PMS_TOTAL_SCALE": "2000000", "PMS_SIGNAL_ENABLED": "false"},
|
|
positions=[{"ts_code": "600000.SH", "total_qty": 6000,
|
|
"avail_qty": 6000, "avg_cost": 10.0}])
|
|
by = _install_signal_fakes(fake2, sell_msgs=[_sell_msg("3-9", "600000.SH", 95)])
|
|
r2 = ss.consume()
|
|
assert "skipped" in r2 and not fake2.instructions, r2
|
|
|
|
fake3 = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
|
|
positions=[{"ts_code": "600000.SH", "total_qty": 6000,
|
|
"avail_qty": 6000, "avg_cost": 10.0}])
|
|
by3 = _install_signal_fakes(fake3, sell_msgs=[_sell_msg("3-10", "600000.SH", 95)])
|
|
r3 = ss.consume(dry_run=True)
|
|
assert r3["exits"] and r3["exits"][0]["dry_run"] is True, r3
|
|
assert not fake3.instructions and not fake3.ledger
|
|
assert by3[3].acked == [], "试算不该 ACK"
|
|
|
|
|
|
@case("装配·执行相关路由与调度接线到位")
|
|
def _():
|
|
from app.web.main import app
|
|
from app import scheduler as sch
|
|
paths = {r.path for r in app.routes}
|
|
for p in ("/api/ops/materialize", "/api/ops/exec-tick", "/api/ops/sweep-windows",
|
|
"/api/instructions/{instruction_id}/cancel", "/api/dispatch-mode",
|
|
"/api/ops/scan-proposals", "/api/ops/digest-signals", "/api/signal-status"):
|
|
assert p in paths, p
|
|
import inspect
|
|
src = inspect.getsource(sch.intraday_exec)
|
|
assert "executor" in src and "run_tick" in src, "调度器未接执行器"
|
|
assert "proposal_service" in src, "调度器未接自主提议扫描"
|
|
assert "signal_service" in inspect.getsource(sch.signal_digest), "调度器未接信号消化"
|
|
|
|
|
|
# ---------------------------------------------------------------- runner
|
|
def main():
|
|
# 静音日志: 本套里有好几条用例**故意**触发异常与告警来验证「守成」行为
|
|
# (调度守卫吞异常、外部成交告警、连续对账升级 ERROR、窗口耗尽告警),
|
|
# 这些 ERROR 栈打在测试输出里会被误读成失败。判定标准只看断言, 不看日志。
|
|
import logging
|
|
logging.disable(logging.CRITICAL)
|
|
passed, failed = 0, 0
|
|
for name, fn in RESULTS:
|
|
try:
|
|
fn()
|
|
print(f" PASS {name}")
|
|
passed += 1
|
|
except Exception:
|
|
print(f" FAIL {name}")
|
|
traceback.print_exc()
|
|
failed += 1
|
|
print("-" * 60)
|
|
if failed:
|
|
print(f"FAILED: {failed} / {passed + failed}")
|
|
sys.exit(1)
|
|
print(f"ALL PASS ({passed} cases)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|