2071 lines
102 KiB
Python
2071 lines
102 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.cash_flows = []
|
||
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, *, command_id, cmd_class, cmd_type, ts_code, params, status,
|
||
issued_by=None, note=None, progress=None):
|
||
kw = {"command_id": command_id, "cmd_class": cmd_class, "cmd_type": cmd_type,
|
||
"ts_code": ts_code, "params": params, "status": status,
|
||
"issued_by": issued_by, "note": note, "progress": progress}
|
||
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 ---
|
||
# --- pms_cash_flow (第 14 张表; 费用只进现金账, 绝不摊成本 —— 协议 §5.5) ---
|
||
def insert_cash_flow(self, *, ymd, kind, amount, ts_code=None, estimated=0,
|
||
trade_no=None, instruction_id=None, note=None):
|
||
self.cash_flows.append({"ymd": ymd, "kind": kind, "amount": float(amount),
|
||
"ts_code": ts_code, "estimated": int(bool(estimated)),
|
||
"trade_no": trade_no, "instruction_id": instruction_id,
|
||
"note": note})
|
||
return 1
|
||
|
||
def insert_instruction(self, *, instruction_id, origin_type, origin_id, ts_code, action,
|
||
side, qty, limit_price=None, window_tdays=3, status="PROPOSED",
|
||
progress=None):
|
||
kw = {"instruction_id": instruction_id, "origin_type": origin_type,
|
||
"origin_id": origin_id, "ts_code": ts_code, "action": action, "side": side,
|
||
"qty": qty, "limit_price": limit_price, "window_tdays": window_tdays,
|
||
"status": status, "progress": progress or {}}
|
||
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)
|
||
|
||
# **签名必须跟真 repo 一模一样**, 不许写成 `**kw`。2026-08-03 实机: executor 多传了
|
||
# 一个 limit_price=None, 真 repo 抛 TypeError、单子已发出而本端没记账, 而这里 `**kw`
|
||
# 全收所以单测一路绿。桩比真依赖宽松 = 单测在替真依赖打掩护。
|
||
# test_batch10 的 [M] 组会扫这一条, 别改回 **kw。
|
||
def update_instruction(self, iid, *, status=None, exec_qty=None, exec_avg_price=None,
|
||
dispatch_ref=None, progress=None):
|
||
i = self.instructions.get(iid)
|
||
if not i:
|
||
return 0
|
||
for k, v in (("status", status), ("exec_qty", exec_qty),
|
||
("exec_avg_price", exec_avg_price), ("dispatch_ref", dispatch_ref),
|
||
("progress", progress)):
|
||
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, *, proposal_id, ts_code, action, qty, hard_numbers, expire_at,
|
||
judge_verdict=None, judge_reason=None, status="WAIT_USER"):
|
||
kw = {"proposal_id": proposal_id, "ts_code": ts_code, "action": action, "qty": qty,
|
||
"hard_numbers": hard_numbers, "expire_at": expire_at,
|
||
"judge_verdict": judge_verdict, "judge_reason": judge_reason, "status": status}
|
||
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, *, ts_code, action, arbiter, verdict, price_at, hard_numbers=None,
|
||
failed_checks=None, reason=None, ref_id=None):
|
||
kw = {"ts_code": ts_code, "action": action, "arbiter": arbiter, "verdict": verdict,
|
||
"price_at": price_at, "hard_numbers": hard_numbers or {},
|
||
"failed_checks": failed_checks or [], "reason": reason, "ref_id": ref_id,
|
||
"decided_at": datetime.now()}
|
||
self.ledger.append(kw)
|
||
return 1
|
||
|
||
def list_ledger(self, *, ts_code=None, limit=200):
|
||
return self.ledger[-limit:]
|
||
|
||
def rule_rejected_today(self, since):
|
||
# 内存桩里所有留痕都算"今天"; 只认规则闸的 REJECT (研判结论会变, 不参与当日去重)
|
||
return {(r["ts_code"], r["action"]) for r in self.ledger
|
||
if r.get("verdict") == "REJECT" and r.get("arbiter") == "rule"}
|
||
|
||
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 = {}
|
||
self.inbox = {} # seq → 上行消息行 (pms_qmt_inbox)
|
||
self.snapshots = [] # snapshot 上行 (对账的事实源, §5.7)
|
||
|
||
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,
|
||
# 真表的列名是 parent_instruction_id, consume_ws_trades 反查用的是它。
|
||
# 桩里两个键都放, 少一个的话 SMOKE_ 那道闸在单测里永远"看起来没生效"。
|
||
"parent_instruction_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 get_order(self, instruction_id):
|
||
return self.orders.get(instruction_id)
|
||
|
||
# ---- pms_qmt_inbox ----
|
||
def put_trade(self, *, seq, instruction_id, ts_code, side, qty, price, fee=0.0,
|
||
trade_no=None, processed=0):
|
||
"""塞一笔 trade 上行。测试专用, 真 repo 里对应的是 runner 的落库路径。"""
|
||
self.inbox[int(seq)] = {
|
||
"seq": int(seq), "msg_type": "trade", "processed": int(processed),
|
||
"process_note": None,
|
||
"payload": {"instruction_id": instruction_id, "ts_code": ts_code, "side": side,
|
||
"qty": int(qty), "price": float(price),
|
||
"amount": round(float(price) * int(qty), 2), "fee": float(fee),
|
||
"trade_no": trade_no or f"T-{seq}"}}
|
||
return self.inbox[int(seq)]
|
||
|
||
def inbox_pending(self, limit=500):
|
||
rows = [r for r in sorted(self.inbox.values(), key=lambda x: x["seq"])
|
||
if int(r["processed"]) == 0]
|
||
return [dict(r, payload=dict(r["payload"])) for r in rows[:int(limit)]]
|
||
|
||
def inbox_mark(self, seqs, processed=1, note=None):
|
||
n = 0
|
||
for s in (seqs or []):
|
||
if int(s) in self.inbox:
|
||
self.inbox[int(s)].update({"processed": int(processed), "process_note": note})
|
||
n += 1
|
||
return n
|
||
|
||
def inbox_orphan_count(self):
|
||
return sum(1 for r in self.inbox.values() if int(r["processed"]) == 3)
|
||
|
||
def put_snapshot(self, *, kind="positions", items=None, data=None, age_sec=0, seq=None):
|
||
"""塞一条 snapshot 上行 (§5.7)。age_sec 用来造"快照过期"。"""
|
||
seq = int(seq if seq is not None else (max(self.inbox) + 1 if self.inbox else 1000))
|
||
pl = {"kind": kind, "as_of": 1769500000000}
|
||
if items is not None:
|
||
pl["items"] = items
|
||
if data is not None:
|
||
pl["data"] = data
|
||
self.snapshots.append({"seq": seq, "payload": pl, "age_sec": float(age_sec),
|
||
"received_at": datetime.now()})
|
||
return pl
|
||
|
||
def latest_snapshot(self, kind, *, scan=60):
|
||
for s in sorted(self.snapshots, key=lambda x: x["seq"], reverse=True):
|
||
if str(s["payload"].get("kind") or "").lower() == str(kind).lower():
|
||
return dict(s)
|
||
return None
|
||
|
||
|
||
def install_fakes(prices=None, positions=None, params=None, high5=None, prev_close=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", "get_order", "inbox_pending", "inbox_mark",
|
||
"inbox_orphan_count", "latest_snapshot"):
|
||
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)
|
||
# plan_price 是规划期定价 (实时价 → 昨收兜底)。桩不接库, 一律按"有实时价"处理;
|
||
# 昨收那条路单独给 prev_close 参数的用例覆盖。
|
||
market.get_last_close = lambda c: (prev_close or {}).get(c)
|
||
market.plan_price = lambda c: (
|
||
{"price": (prices or {}).get(c), "source": "realtime"} if (prices or {}).get(c)
|
||
else ({"price": (prev_close or {}).get(c), "source": "prev_close"}
|
||
if (prev_close or {}).get(c) else {"price": None, "source": "none"}))
|
||
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.category_columns = lambda force=False: {
|
||
"columns": None, "code_col": None, "industry_col": None,
|
||
"error": "单测桩: 未接行业表"}
|
||
from app.repo import industry_repo
|
||
industry_repo.probe = lambda force=False: {"form": None, "error": "单测桩: 未接 gp_hybk",
|
||
"columns": None, "tried": []}
|
||
industry_repo.primary_industry_map = lambda codes, level="l3": {}
|
||
industry_repo.invalidate = lambda: 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")
|
||
# 上游选股计划接口在单测里一律停用 (base 为空 → plan_feed 立即抛 PlanFeedError,
|
||
# 不会发出任何 HTTP 请求)。要测候选池的用例自己 stub plan_feed.candidates。
|
||
fake.params.setdefault("PMS_PLAN_API_BASE", "")
|
||
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.plan_feed",
|
||
"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",
|
||
"/api/upstream/plan", "/api/ops/plan-refresh"]
|
||
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 == {"plan_pull", "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",
|
||
"pms.plan_pull"):
|
||
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 datetime import date
|
||
from app.repo import pms_repo
|
||
from app.services import proposal_service as ps
|
||
fake = install_fakes(positions=[{"ts_code": "600000.SH", "total_qty": 6000,
|
||
"avail_qty": 6000, "base_qty": 6000, "avg_cost": 10.0}])
|
||
# 三个计数器此前只被读、从没被写过 —— 设计 §6 的三条一次性约束等于一直没生效
|
||
ps.bump_once_guards("600000.SH", "FILL", {})
|
||
assert fake.positions["600000.SH"]["fill_count"] == 1
|
||
ps.bump_once_guards("600000.SH", "ADD", {})
|
||
assert fake.positions["600000.SH"]["last_add_date"] == date.today()
|
||
ps.bump_once_guards("600000.SH", "DCA", {"stage": 2})
|
||
assert fake.positions["600000.SH"]["dca_count"] == 2, "dca_count 记的是档序不是次数"
|
||
ps.bump_once_guards("600000.SH", "TRIM", {}) # 减持无一次性约束, 不该动任何列
|
||
assert fake.positions["600000.SH"]["fill_count"] == 1
|
||
|
||
# 当日已被规则闸拒过的 (代码, 动作) 要进 skip —— 否则超上限时每分钟重评一次、
|
||
# 每分钟往评审账本灌一行, 判分锚被自己的噪声埋掉
|
||
pms_repo.insert_ledger(ts_code="600000.SH", action="DCA", arbiter="rule",
|
||
verdict="REJECT", price_at=10.0, reason="超总仓上限")
|
||
pms_repo.insert_ledger(ts_code="000001.SZ", action="ADD", arbiter="judge",
|
||
verdict="REJECT", price_at=10.0, reason="研判驳回")
|
||
keys = ps._rejected_today_keys()
|
||
assert ("600000.SH", "DCA") in keys, keys
|
||
assert ("000001.SZ", "ADD") not in keys, "研判驳回不该进当日去重 —— 研判结论会变"
|
||
|
||
|
||
@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, param_store
|
||
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"
|
||
assert r["streak"] == 1, r
|
||
|
||
# 每跑一趟对账下游都比账本多 100 股 —— 这样每一趟都真的"不一致", 隔离出
|
||
# 「同一天多趟到底加不加」这一个变量
|
||
nxt = [1500]
|
||
|
||
def _drift():
|
||
nxt[0] += 100
|
||
return {"rows": [{"ts_code": "600000.SH", "qty": nxt[0]}],
|
||
"columns": {"qty": "current_qty"}, "raw_count": 1}
|
||
downstream_repo.fetch_positions = _drift
|
||
|
||
# 同一交易日内再对账多少趟, 连续天数都**不动** —— 盘中轻对账每分钟跑一次,
|
||
# 按次累加的话"连续 3 日"三分钟就到了, 日报还会写出"连续 175 日"这种数。
|
||
for _ in range(3):
|
||
r = ls.reconcile()
|
||
assert r["diffs"] and r["streak"] == 1 and r["severity"] == "WARN", r
|
||
|
||
# 跨交易日才推进: 把"上次推进日"往前拨, 等价于隔了一天再跑。
|
||
# 必须走 set_param (不能直接改 fake.params) —— 一是要过 ParamStore 的缓存失效,
|
||
# 二是**顺带守住白名单**: STREAK_YMD 键当初就是漏在白名单外, set_param 静默拒写、
|
||
# prev_ymd 永远读回 0, 按日推进形同虚设。这里 ok 断言就是那道锁。
|
||
for _i in range(2):
|
||
w = param_store.set_param(ls.STREAK_YMD_KEY, 20260701 + _i, "test")
|
||
assert w["ok"], w
|
||
r = ls.reconcile()
|
||
assert r["streak"] == 3, r # 连续第 3 日
|
||
assert r["severity"] == "ERROR", r
|
||
finally:
|
||
downstream_repo.fetch_positions = orig
|
||
|
||
|
||
@case("账本服务·下游读空绝不清账 (2026-07-29 实际发生过的 22 只全核销)")
|
||
def _():
|
||
from app.services import ledger_service as ls
|
||
from app.repo import downstream_repo
|
||
fake = install_fakes(prices={f"6000{i:02d}.SH": 10.0 for i in range(22)})
|
||
for i in range(22):
|
||
code = f"6000{i:02d}.SH"
|
||
fake.insert_lot(ts_code=code, lot_type="BASE", qty=1000, open_price=10.0,
|
||
open_date="2026-07-01")
|
||
fake.update_position(code, total_qty=1000, avail_qty=1000)
|
||
orig = downstream_repo.fetch_positions
|
||
try:
|
||
# 对端模拟环境重启, 下游持仓表读回空集。「一夜清仓」与「读空」在数据上完全同形,
|
||
# 但代价不对称: 拦错了只是晚一天修账, 放错了是 22 个持仓的批次结构不可逆地没了。
|
||
downstream_repo.fetch_positions = lambda: {
|
||
"rows": [], "columns": {"code": "stock_code", "qty": "current_qty"},
|
||
"raw_count": 0}
|
||
r = ls.reconcile()
|
||
assert r.get("blocked"), "下游读空必须拦截, 不能照着清账"
|
||
assert r["severity"] == "ERROR", r["severity"]
|
||
assert not r["fixes"], "拦截时不得产生任何修正"
|
||
for i in range(22):
|
||
assert fake.positions[f"6000{i:02d}.SH"]["total_qty"] == 1000, "持仓不得被动过"
|
||
r2 = ls.reconcile(force=True) # 人工确认下游读数无误后放行
|
||
assert not r2.get("blocked"), "force 应绕过限制"
|
||
finally:
|
||
downstream_repo.fetch_positions = orig
|
||
|
||
|
||
@case("账本服务·日常小幅漂移仍照常自动修正 (不被爆炸半径误伤)")
|
||
def _():
|
||
from app.services import ledger_service as ls
|
||
from app.repo import downstream_repo
|
||
fake = install_fakes(prices={f"6000{i:02d}.SH": 10.0 for i in range(22)})
|
||
for i in range(22):
|
||
code = f"6000{i:02d}.SH"
|
||
fake.insert_lot(ts_code=code, lot_type="BASE", qty=1000, open_price=10.0,
|
||
open_date="2026-07-01")
|
||
fake.update_position(code, total_qty=1000, avail_qty=1000)
|
||
orig = downstream_repo.fetch_positions
|
||
try:
|
||
rows = [{"ts_code": f"6000{i:02d}.SH", "qty": 1000} for i in range(22)]
|
||
rows[0]["qty"] = 1500 # 22 只里只有 1 只对不上
|
||
downstream_repo.fetch_positions = lambda: {
|
||
"rows": rows, "columns": {"qty": "current_qty"}, "raw_count": 22}
|
||
r = ls.reconcile()
|
||
assert not r.get("blocked"), "小幅漂移不该被拦 —— 那正是对账的价值"
|
||
assert fake.positions["600000.SH"]["total_qty"] == 1500, "以下游为准"
|
||
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",
|
||
progress={"deadline": "2026-07-27", "is_command": True,
|
||
"children": []})
|
||
# exec_qty 不是 insert_instruction 的形参 (真 repo 没有), 造数据直接改桩里的字段
|
||
fake.instructions["INS_F"]["exec_qty"] = 2000
|
||
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",
|
||
progress={"deadline": "2026-07-20", "is_command": True,
|
||
"children": []})
|
||
fake.instructions["INS_P"]["exec_qty"] = 500
|
||
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",
|
||
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 app.services import portfolio
|
||
fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"})
|
||
t = portfolio.positions_view()["totals"]
|
||
assert t["cash_source"] == "estimate" and t["cash_avail"] is None, t
|
||
assert t["cash_est"] == 2_000_000.0, t # scale − 市值, 是个估算值
|
||
|
||
fake.qmt.put_snapshot(kind="funds", data={"total_asset": 981448.56,
|
||
"available_cash": 971251.56,
|
||
"sell_return_today": 48900.0})
|
||
v = portfolio.positions_view()
|
||
t = v["totals"]
|
||
assert t["cash_source"] == "ws", t
|
||
# 当日卖出回笼 T+0 可用, 必须算进去 —— 不算就把"卖一只买另一只"判成资金不足
|
||
assert t["cash_avail"] == round(971251.56 + 48900.0, 2), t
|
||
assert t["total_asset"] == 981448.56, t
|
||
# 这一行是整件事的由来: 按 scale 估出来 200 万, 账户真钱只有 102 万, 差着一倍
|
||
assert t["cash_est"] > t["cash_avail"], (t["cash_est"], t["cash_avail"])
|
||
# caps_ctx 要把真钱与来源一起带给规则闸, 否则闸门只能降级
|
||
caps = portfolio.caps_ctx(v, ts_code="600000.SH")
|
||
assert caps["cash_source"] == "ws" and caps["cash_avail"] == t["cash_avail"], caps
|
||
|
||
|
||
@case("对账事实源·ws 快照新鲜 → 以 ws 为准建账 (表空集不再当事实)")
|
||
def _():
|
||
from app.services import ledger_service as ls
|
||
fake = install_fakes(prices={"600000.SH": 10.0})
|
||
fake.qmt.put_snapshot(items=[{"ts_code": "600000.SH", "total_qty": 1100,
|
||
"avail_qty": 1100, "cost_price": 9.273}])
|
||
src = ls.positions_source()
|
||
assert src["source"] == ls.SRC_WS, src
|
||
assert src["rows"][0]["qty"] == 1100 and src["rows"][0]["cost"] == 9.273, src["rows"]
|
||
assert not src["alerts"], src["alerts"] # 表是空集, 不算"不一致"
|
||
# 账本空 + ws 有持仓 = 首次建账, 爆炸半径闸放行, 成本价取下游 cost_price
|
||
r = ls.reconcile(apply_fix=True)
|
||
assert r["source"] == "ws" and not r.get("blocked"), r
|
||
assert fake.positions["600000.SH"]["total_qty"] == 1100, fake.positions
|
||
assert abs(float(fake.positions["600000.SH"]["avg_cost"]) - 9.273) < 1e-6, \
|
||
"开仓价必须取下游 cost_price —— 拿现价当成本会让安全垫齐刷刷是 0"
|
||
|
||
|
||
@case("对账事实源·前缀式代码归一成点式 (不归一会造出一轮双向全量重写)")
|
||
def _():
|
||
from app.services import ledger_service as ls
|
||
fake = install_fakes(prices={"600000.SH": 10.0})
|
||
fake.qmt.put_snapshot(items=[{"stock_code": "SH600000", "total_quantity": 600,
|
||
"cost_price": 10.0}])
|
||
src = ls.positions_source()
|
||
assert src["rows"][0]["ts_code"] == "600000.SH", src["rows"]
|
||
|
||
|
||
@case("对账事实源·ws 与表对不上 → 用 ws 但必须留痕告警")
|
||
def _():
|
||
from app.repo import downstream_repo
|
||
from app.services import ledger_service as ls
|
||
fake = install_fakes(prices={"600000.SH": 10.0})
|
||
fake.qmt.put_snapshot(items=[{"ts_code": "600000.SH", "total_qty": 1100}])
|
||
downstream_repo.fetch_positions = lambda: {
|
||
"rows": [{"ts_code": "600000.SH", "qty": 700, "avail_qty": 700, "cost": 9.0,
|
||
"frozen": 0, "price": 10.0}],
|
||
"columns": {"qty": "total_quantity"}, "raw_count": 1}
|
||
src = ls.positions_source()
|
||
assert src["source"] == ls.SRC_WS, src["source"]
|
||
assert src["rows"][0]["qty"] == 1100, "以 ws 为准"
|
||
codes = [a["code"] for a in src["alerts"]]
|
||
assert "SOURCE_DISAGREE" in codes, src["alerts"]
|
||
|
||
|
||
@case("对账事实源·ws 快照过期 → 退回表并说明退回原因")
|
||
def _():
|
||
from app.repo import downstream_repo
|
||
from app.services import ledger_service as ls
|
||
fake = install_fakes(prices={"600000.SH": 10.0},
|
||
params={"PMS_RECON_WS_SNAPSHOT_MAX_AGE_SEC": "900"})
|
||
fake.qmt.put_snapshot(items=[{"ts_code": "600000.SH", "total_qty": 1100}], age_sec=5000)
|
||
downstream_repo.fetch_positions = lambda: {
|
||
"rows": [{"ts_code": "600000.SH", "qty": 700, "avail_qty": 700, "cost": 9.0,
|
||
"frozen": 0, "price": 10.0}],
|
||
"columns": {"qty": "total_quantity"}, "raw_count": 1}
|
||
src = ls.positions_source()
|
||
assert src["source"] == ls.SRC_TABLE, src["source"]
|
||
assert src["rows"][0]["qty"] == 700
|
||
msg = " ".join(a["message"] for a in src["alerts"])
|
||
assert "过期" in msg, src["alerts"]
|
||
|
||
|
||
@case("对账事实源·表应答空集仍算有效应答 → 归 table (空集是数据, 不是缺数据)")
|
||
def _():
|
||
from app.services import ledger_service as ls
|
||
install_fakes(prices={"600000.SH": 10.0}) # 默认桩: fetch_positions 成功返回 rows=[]
|
||
src = ls.positions_source()
|
||
# 这一条守着 force 的生路: 归 table 才会走到爆炸半径闸, 人工确认后 force 能放行;
|
||
# 若把"应答了空"误判成 source=none, 对账入口就把 force 挡死了 (曾经真挡死过)
|
||
assert src["source"] == ls.SRC_TABLE, src
|
||
assert src["rows"] == [], src
|
||
|
||
|
||
@case("对账事实源·两个源都**无应答** + 本端有持仓 → 入口就拒, **force 也不放行**")
|
||
def _():
|
||
from app.repo import downstream_repo
|
||
from app.services import ledger_service as ls
|
||
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)
|
||
|
||
def boom():
|
||
raise RuntimeError("Can't connect to MySQL server on '192.168.16.153'")
|
||
downstream_repo.fetch_positions = boom # 没塞快照 + 表查询异常 = 谁都没应答
|
||
src = ls.positions_source()
|
||
assert src["source"] == ls.SRC_NONE, src
|
||
# force 的语义是「人工已确认下游读数正确」, 而这里根本没有读数可供确认 —— 没有任何数字,
|
||
# 人也无从确认。真要清账走 reset_ledger.py, 别拿空壳子当"下游事实"去核销批次。
|
||
for force in (False, True):
|
||
r = ls.reconcile(apply_fix=True, force=force)
|
||
assert r.get("blocked"), f"force={force} 也必须拒绝对账"
|
||
assert r["severity"] == "ERROR" and not r["fixes"], r
|
||
assert fake.positions["600000.SH"]["total_qty"] == 1000, "持仓一股都不许被动"
|
||
|
||
|
||
@case("对账事实源·两个源都无应答 + 账本也空 → 不报 ERROR (刚清账等对端装持仓)")
|
||
def _():
|
||
from app.repo import downstream_repo
|
||
from app.services import ledger_service as ls
|
||
install_fakes()
|
||
|
||
def boom():
|
||
raise RuntimeError("库不可达")
|
||
downstream_repo.fetch_positions = boom
|
||
r = ls.reconcile(apply_fix=True)
|
||
# 盘中轻对账每分钟一跳, 这个状态下反复刷 ERROR 只会把真告警埋掉
|
||
assert not r.get("blocked") and r["severity"] == "OK", r
|
||
assert "无可对之账" in (r.get("note") or ""), r
|
||
|
||
|
||
@case("ws 入账·三分流: 真单入账 / 联调单跳过 / **孤儿成交挂起不入账**")
|
||
def _():
|
||
from app.repo import qmt_repo
|
||
from app.services import ledger_service as ls
|
||
fake = install_fakes(prices={"600000.SH": 10.0})
|
||
fake.insert_instruction(instruction_id="INS_R", origin_type="plan", origin_id="P1",
|
||
ts_code="600000.SH", action="OPEN", side="buy", qty=100,
|
||
status="DISPATCHED")
|
||
# 真单: 出口表有行、父指令不带 SMOKE_
|
||
fake.qmt.enqueue_order(instruction_id="INS_R-1", parent_id="INS_R", ts_code="600000.SH",
|
||
side="buy", qty=100, limit_price=10.0, valid_until=0)
|
||
fake.qmt.put_trade(seq=1, instruction_id="INS_R-1", ts_code="600000.SH", side="buy",
|
||
qty=100, price=10.0, fee=5.0)
|
||
# 联调单: 出口表有行, 父指令带 SMOKE_
|
||
fake.qmt.enqueue_order(instruction_id="INS_S-1", parent_id="SMOKE_INS_S",
|
||
ts_code="600000.SH", side="buy", qty=100, limit_price=9.0,
|
||
valid_until=0)
|
||
fake.qmt.put_trade(seq=2, instruction_id="INS_S-1", ts_code="600000.SH", side="buy",
|
||
qty=100, price=9.0)
|
||
# 孤儿: 出口表里根本没有这个 instruction_id (上下游停机后遗留的旧 inbox 行)
|
||
fake.qmt.put_trade(seq=3, instruction_id="INS_GHOST-1", ts_code="600183.SH", side="buy",
|
||
qty=100, price=88.0)
|
||
r = ls.consume_ws_trades()
|
||
assert r["trades"] == 1, f"只有真单该进入账流程: {r}"
|
||
assert r.get("smoke_skipped") == 1 and r.get("orphan_held") == 1, r
|
||
assert fake.qmt.inbox[1]["processed"] == qmt_repo.PROC_BOOKED, fake.qmt.inbox[1]
|
||
assert fake.qmt.inbox[2]["processed"] == qmt_repo.PROC_DIGESTED, fake.qmt.inbox[2]
|
||
# 关键: 孤儿标 3 (挂起) 而不是 2 (已消化) —— 2 的语义是"确认过不用管", 混了就分不出漏账
|
||
assert fake.qmt.inbox[3]["processed"] == qmt_repo.PROC_ORPHAN, fake.qmt.inbox[3]
|
||
# 账本只认那一笔真单; 孤儿的标的一股都不许长出来
|
||
assert [l["ts_code"] for l in fake.lots] == ["600000.SH"], fake.lots
|
||
assert fake.positions["600000.SH"]["total_qty"] == 100
|
||
assert "600183.SH" not in fake.positions or \
|
||
fake.positions["600183.SH"]["total_qty"] == 0, fake.positions
|
||
# 告警必须活着传出来 —— mapped["alerts"] 曾经直接赋值把它冲掉过
|
||
assert any(a.get("type") == "ORPHAN_WS_TRADE" for a in r["alerts"]), r["alerts"]
|
||
assert qmt_repo.inbox_orphan_count() == 1
|
||
# 费用只出现金流水、不进成本 (§5.5): 真单那 5 元记一条 FEE, 摊薄成本仍是 10.0
|
||
assert r["fees"] == 1 and len(fake.cash_flows) == 1, fake.cash_flows
|
||
assert fake.cash_flows[0]["kind"] == "FEE" and fake.cash_flows[0]["amount"] == -5.0
|
||
assert abs(float(fake.positions["600000.SH"]["avg_cost"]) - 10.0) < 1e-6, fake.positions
|
||
|
||
|
||
@case("ws 入账·出口表反查报错时一笔都不判 (留在待入账, 下一跳重来)")
|
||
def _():
|
||
from app.repo import qmt_repo
|
||
from app.services import ledger_service as ls
|
||
fake = install_fakes(prices={"600000.SH": 10.0})
|
||
fake.qmt.put_trade(seq=9, instruction_id="INS_X-1", ts_code="600000.SH", side="buy",
|
||
qty=100, price=10.0)
|
||
|
||
def boom(_iid):
|
||
raise RuntimeError("Lost connection to MySQL server during query")
|
||
qmt_repo.get_order = boom
|
||
r = ls.consume_ws_trades()
|
||
# 原先这里把异常当"查不到"→ 当真单入账: 一次连接超时就能凭空造出一笔外部成交。
|
||
assert not r["ok"] and r["errors"], r
|
||
assert r["trades"] == 0 and not r.get("orphan_held"), r
|
||
assert fake.qmt.inbox[9]["processed"] == qmt_repo.PROC_PENDING, fake.qmt.inbox[9]
|
||
assert not fake.lots, fake.lots
|
||
|
||
|
||
@case("ws 入账·孤儿成交在通道状态里露出数字 (挂起的账不能没人报)")
|
||
def _():
|
||
from app.services import dispatcher
|
||
fake = install_fakes()
|
||
assert dispatcher.channel_status()["orphan_held"] == 0
|
||
fake.qmt.put_trade(seq=5, instruction_id="INS_GHOST-2", ts_code="600000.SH", side="sell",
|
||
qty=100, price=10.0, processed=3)
|
||
assert dispatcher.channel_status()["orphan_held"] == 1
|
||
|
||
|
||
@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 _real_industry():
|
||
"""把被 install_fakes 打过桩的 industry 恢复成真实实现 (reload 就地替换属性)。"""
|
||
import importlib
|
||
from app.services import industry as ind
|
||
importlib.reload(ind)
|
||
ind.invalidate()
|
||
return ind
|
||
|
||
|
||
def _real_downstream():
|
||
"""同上, 恢复 downstream_repo —— install_fakes 把 category_columns 也打了桩。"""
|
||
import importlib
|
||
from app.repo import downstream_repo as dr
|
||
importlib.reload(dr)
|
||
return dr
|
||
|
||
|
||
@case("行业源·gp_stock_category 查得到才算 ready (参数填了不等于数据源能用)")
|
||
def _():
|
||
from app.repo import downstream_repo
|
||
fake = install_fakes(params={"PMS_SECTOR_SOURCE": "gp_stock_category"})
|
||
ind = _real_industry()
|
||
orig = downstream_repo.probe_category
|
||
try:
|
||
downstream_repo.probe_category = lambda c: {
|
||
"value": "电力设备", "code_col": "stock_code", "industry_col": "industry",
|
||
"columns": ["stock_code", "industry"], "error": None}
|
||
assert ind.ready() is True
|
||
st = ind.status()
|
||
assert st["ready"] is True and st["count"] == st["probe"]["tried"] > 0, st
|
||
assert "命中" in st["hint"], st["hint"]
|
||
assert ind.get("600000.SH") == "电力设备"
|
||
finally:
|
||
downstream_repo.probe_category = orig
|
||
_ = fake
|
||
|
||
|
||
@case("行业源·一只都查不到 → ready=False, 约束按未配置停用 (绝不静默失效)")
|
||
def _():
|
||
from app.repo import downstream_repo
|
||
install_fakes(params={"PMS_SECTOR_SOURCE": "gp_stock_category"})
|
||
ind = _real_industry()
|
||
orig = downstream_repo.probe_category
|
||
try:
|
||
# ① 表里没有这些票 (无报错)
|
||
downstream_repo.probe_category = lambda c: {
|
||
"value": None, "code_col": None, "industry_col": None, "columns": None,
|
||
"error": None}
|
||
assert ind.ready() is False
|
||
st = ind.status()
|
||
assert st["ready"] is False and "一只都没查到" in st["hint"], st["hint"]
|
||
assert "表里没有这些票" in st["hint"] and "导出下游表结构" in st["hint"], st["hint"]
|
||
# ② 表根本查不了 (有报错) —— 错误必须冒到 hint 上, 不能吞
|
||
ind.invalidate()
|
||
downstream_repo.probe_category = lambda c: {
|
||
"value": None, "code_col": None, "industry_col": None, "columns": None,
|
||
"error": "stock_code: ProgrammingError: Table doesn't exist"}
|
||
assert ind.ready() is False
|
||
assert "Table doesn't exist" in ind.status()["hint"]
|
||
finally:
|
||
downstream_repo.probe_category = orig
|
||
|
||
|
||
@case("行业源·gp_hybk: 三级 884 / 每票只认 bk_code 最小的主行业 / 代码写法自适应")
|
||
def _():
|
||
from app.repo import industry_repo as ir
|
||
install_fakes(params={"PMS_SECTOR_SOURCE": "gp_hybk"})
|
||
ind = _real_industry()
|
||
import importlib
|
||
importlib.reload(ir)
|
||
orig = ir.fetch_all
|
||
try:
|
||
# 表里用前缀式 SH600000; 一票挂 二级×1 + 三级×2
|
||
TBL = [
|
||
{"gp_code": "SH600000", "bk_code": 881155, "bk_name": "银行(二级)"},
|
||
{"gp_code": "SH600000", "bk_code": 884219, "bk_name": "股份制银行"},
|
||
{"gp_code": "SH600000", "bk_code": 884101, "bk_name": "全国性银行"},
|
||
{"gp_code": "SZ000001", "bk_code": 884219, "bk_name": "股份制银行"},
|
||
]
|
||
calls = []
|
||
|
||
def _fa(sql, params=None, source=None):
|
||
assert source == "index", source # 必须走 199, 不是 153 代理
|
||
want = set((params or {}).values())
|
||
calls.append(sorted(want))
|
||
return [r for r in TBL if r["gp_code"] in want]
|
||
ir.fetch_all = _fa
|
||
p = ir.probe(force=True)
|
||
assert p["form"] == "prefix", p # 点式先试没命中, 自动落到前缀式
|
||
m = ir.fetch_industries(["600000.SH", "000001.SZ"], level="l3")
|
||
assert m["600000.SH"] == [("884101", "全国性银行"), ("884219", "股份制银行")], m
|
||
# 主行业 = bk_code 升序第一个 (884101 < 884219), 二级 881 不能混进来
|
||
pm = ir.primary_industry_map(["600000.SH", "000001.SZ"], level="l3")
|
||
assert pm == {"600000.SH": "全国性银行", "000001.SZ": "股份制银行"}, pm
|
||
assert ir.primary_industry_map(["600000.SH"], level="l2") == {"600000.SH": "银行(二级)"}
|
||
# 服务层: ready 有证据, get_many 批量且按日缓存
|
||
ind.invalidate()
|
||
assert ind.ready() is True
|
||
assert ind.get_many(["600000.SH", "000001.SZ"]) == {
|
||
"600000.SH": "全国性银行", "000001.SZ": "股份制银行"}
|
||
n = len(calls)
|
||
ind.get_many(["600000.SH", "000001.SZ"]) # 二次不再打库
|
||
assert len(calls) == n, calls[n:]
|
||
assert ind.get("600000.SH") == "全国性银行"
|
||
st = ind.status()
|
||
assert st["ready"] is True and st["level"] == "l3" and "884" in st["hint"], st
|
||
finally:
|
||
ir.fetch_all = orig
|
||
ir.invalidate()
|
||
|
||
|
||
@case("行业源·gp_hybk 三种写法都不命中 → ready=False, 错误说清是表能查还是查不了")
|
||
def _():
|
||
from app.repo import industry_repo as ir
|
||
install_fakes(params={"PMS_SECTOR_SOURCE": "gp_hybk"})
|
||
ind = _real_industry()
|
||
import importlib
|
||
importlib.reload(ir)
|
||
orig = ir.fetch_all
|
||
try:
|
||
ir.fetch_all = lambda sql, params=None, source=None: [] # 能查, 但没有这些票
|
||
ind.invalidate()
|
||
assert ind.ready() is False
|
||
st = ind.status()
|
||
assert st["ready"] is False and "用不了" in st["hint"], st["hint"]
|
||
assert "三种代码写法都没命中" in st["hint"], st["hint"]
|
||
assert "DB_MYSQL_URL" in st["hint"], st["hint"] # 指到该改哪儿
|
||
# 查不了的情况: 错误原文要冒上来
|
||
ir.invalidate()
|
||
|
||
def _boom(sql, params=None, source=None):
|
||
raise RuntimeError("Table 'db_gp_cj.gp_hybk' doesn't exist")
|
||
ir.fetch_all = _boom
|
||
ind.invalidate()
|
||
assert ind.ready() is False
|
||
assert "doesn't exist" in ind.status()["hint"], ind.status()["hint"]
|
||
finally:
|
||
ir.fetch_all = orig
|
||
ir.invalidate()
|
||
|
||
|
||
@case("行业源·先 SHOW COLUMNS 认列再查: 没有代码列时直接给可执行的报错")
|
||
def _():
|
||
install_fakes(params={"PMS_SECTOR_SOURCE": "gp_stock_category"})
|
||
ind = _real_industry()
|
||
dr = _real_downstream() # category_columns 也被 install_fakes 打过桩
|
||
orig_fa, orig_fo = dr.fetch_all, dr.fetch_one
|
||
try:
|
||
# ① 实机遇到的情况: 表在, 但没有 stock_code 这类代码列
|
||
dr._cat_cols.update({"at": 0.0, "data": None})
|
||
dr.fetch_all = lambda sql, p=None: [{"Field": "gp_name"}, {"Field": "sw_l1"}]
|
||
meta = dr.category_columns(force=True)
|
||
assert meta["code_col"] is None and "没有可识别的代码列" in meta["error"], meta
|
||
assert "gp_name" in meta["error"], meta["error"] # 把实际列名报出来才可执行
|
||
assert dr.probe_category("600000.SH")["value"] is None
|
||
assert ind.ready() is False
|
||
# ② 列认出来了: 代码写法逐个试, 命中即返回
|
||
dr._cat_cols.update({"at": 0.0, "data": None})
|
||
dr.fetch_all = lambda sql, p=None: [{"Field": "ts_code"}, {"Field": "sw_l1"}]
|
||
seen = []
|
||
|
||
def _one(sql, p=None):
|
||
seen.append((p or {}).get("code"))
|
||
return {"ts_code": "SH600000", "sw_l1": "银行"} if (p or {}).get("code") == "SH600000" else None
|
||
dr.fetch_one = _one
|
||
dr.category_columns(force=True)
|
||
r = dr.probe_category("600000.SH")
|
||
assert r["value"] == "银行" and r["industry_col"] == "sw_l1", r
|
||
assert seen == ["600000.SH", "SH600000"], seen # 点式先试, 再前缀式
|
||
ind.invalidate()
|
||
assert ind.ready() is True
|
||
finally:
|
||
dr.fetch_all, dr.fetch_one = orig_fa, orig_fo
|
||
dr._cat_cols.update({"at": 0.0, "data": None})
|
||
|
||
|
||
@case("行业源·custom_table 映射表为空同样不算 ready (空表 = 全票 None = 约束失效)")
|
||
def _():
|
||
fake = install_fakes(params={"PMS_SECTOR_SOURCE": "custom_table"})
|
||
ind = _real_industry()
|
||
assert ind.ready() is False
|
||
assert "是空的" in ind.status()["hint"], ind.status()["hint"]
|
||
fake.industry = {}
|
||
ind.invalidate()
|
||
from app.repo import pms_repo
|
||
orig = pms_repo.list_industry
|
||
try:
|
||
pms_repo.list_industry = lambda limit=5000: [
|
||
{"ts_code": "600000.SH", "industry": "银行", "updated_at": ""}]
|
||
assert ind.ready() is True
|
||
assert ind.get("600000.SH") == "银行"
|
||
finally:
|
||
pms_repo.list_industry = orig
|
||
|
||
|
||
# ================================================================ 候选池 (上游 /plan)
|
||
def _stub_sp(csvc, mapping):
|
||
"""替换 effective_stock_params, 返回 restore 闭包。"""
|
||
orig = csvc.effective_stock_params
|
||
csvc.effective_stock_params = lambda: dict(mapping)
|
||
return lambda: setattr(csvc, "effective_stock_params", orig)
|
||
|
||
|
||
@case("候选池·plan_api 独占: 价格现取 / 无价剔除 / 黑名单与持仓剔除 / 白名单压过计划票")
|
||
def _():
|
||
from app.services import command_service as csvc, plan_feed
|
||
install_fakes(prices={"600418.SH": 12.5, "300952.SZ": 30.0, "600000.SH": 10.0,
|
||
"600104.SH": 18.0},
|
||
params={"PMS_TOTAL_SCALE": "980000", "PMS_CANDIDATE_SOURCE": "plan_api"})
|
||
restore_sp = _stub_sp(csvc, {"600000.SH": {"white": True}, "600104.SH": {"black": True}})
|
||
orig = plan_feed.candidates
|
||
try:
|
||
plan_feed.candidates = lambda **kw: {
|
||
"date": "2026-07-29", "considered": 4, "eligible": 4, "dropped": {},
|
||
"items": [{"ts_code": "600418.SH", "score": 242.24, "theme": "整车"},
|
||
{"ts_code": "688717.SH", "score": 242.05, "theme": "储能"},
|
||
{"ts_code": "300952.SZ", "score": 241.91, "theme": "传感器"},
|
||
{"ts_code": "600104.SH", "score": 241.18, "theme": "整车"}]}
|
||
pool = csvc._candidates({"held": [{"ts_code": "605598.SH"}]})
|
||
finally:
|
||
plan_feed.candidates = orig
|
||
restore_sp()
|
||
by = {c["ts_code"]: c for c in pool}
|
||
# 688717.SH 行情里没有价 → 必须剔除 (planner 对 price<=0 只是静默跳过, 那样少票看不出来)
|
||
assert "688717.SH" not in by, by
|
||
assert "600104.SH" not in by, "黑名单票不得进池"
|
||
assert by["600418.SH"]["price"] == 12.5 and by["600418.SH"]["src"] == "plan_api"
|
||
assert by["600418.SH"]["theme"] == "整车" and by["600418.SH"]["sector"] is None
|
||
# 白名单是用户点名的票, 分必须压过任何计划票 —— planner 只认 score 一把尺子
|
||
assert by["600000.SH"]["src"] == "whitelist"
|
||
plan_max = max(c["score"] for c in pool if c["src"] == "plan_api")
|
||
assert by["600000.SH"]["score"] > plan_max, (by["600000.SH"]["score"], plan_max)
|
||
|
||
|
||
@case("候选池·盘前无实时价回落昨收 (db13 存的是当日分钟线, 盘前那张 key 不存在)")
|
||
def _():
|
||
from app.services import command_service as csvc, plan_feed
|
||
install_fakes(prices={}, prev_close={"600418.SH": 12.5, "300952.SZ": 30.0},
|
||
params={"PMS_CANDIDATE_SOURCE": "plan_api"})
|
||
restore_sp = _stub_sp(csvc, {})
|
||
orig = plan_feed.candidates
|
||
try:
|
||
plan_feed.candidates = lambda **kw: {
|
||
"date": "2026-07-30", "considered": 3, "eligible": 3, "dropped": {},
|
||
"items": [{"ts_code": "600418.SH", "score": 242.24, "theme": "整车"},
|
||
{"ts_code": "300952.SZ", "score": 241.91, "theme": "传感器"},
|
||
{"ts_code": "688717.SH", "score": 242.05, "theme": "储能"}]}
|
||
pool = csvc._candidates({"held": []})
|
||
finally:
|
||
plan_feed.candidates = orig
|
||
restore_sp()
|
||
by = {c["ts_code"]: c for c in pool}
|
||
# 有昨收的进池并标明价格来源; 实时价与昨收都没有的才剔除
|
||
assert set(by) == {"600418.SH", "300952.SZ"}, by
|
||
assert by["600418.SH"]["price"] == 12.5
|
||
assert by["600418.SH"]["price_source"] == "prev_close", by["600418.SH"]
|
||
assert "688717.SH" not in by
|
||
|
||
|
||
@case("候选池·上游计划不可用: 池为空, 绝不静默回退旧 trading_buy_plan 表")
|
||
def _():
|
||
from app.repo import downstream_repo
|
||
from app.services import command_service as csvc, plan_feed
|
||
install_fakes(prices={"601111.SH": 9.0}, params={"PMS_CANDIDATE_SOURCE": "plan_api"})
|
||
restore_sp = _stub_sp(csvc, {})
|
||
orig_c, orig_bp = plan_feed.candidates, downstream_repo.fetch_buy_plans
|
||
|
||
def _boom(**kw):
|
||
raise plan_feed.PlanFeedError("接口超时")
|
||
try:
|
||
plan_feed.candidates = _boom
|
||
downstream_repo.fetch_buy_plans = lambda **kw: [
|
||
{"ts_code": "601111.SH", "price": 9.0, "score": 0.8}]
|
||
pool = csvc._candidates({"held": []})
|
||
finally:
|
||
plan_feed.candidates, downstream_repo.fetch_buy_plans = orig_c, orig_bp
|
||
restore_sp()
|
||
assert pool == [], f"上游拿不到时必须让候选池为空, 实际拿到 {pool}"
|
||
|
||
|
||
@case("候选池·both: 计划票与旧表票取并集, 同码只留计划那份")
|
||
def _():
|
||
from app.repo import downstream_repo
|
||
from app.services import command_service as csvc, plan_feed
|
||
install_fakes(prices={"600418.SH": 12.5, "601111.SH": 9.0},
|
||
params={"PMS_CANDIDATE_SOURCE": "both"})
|
||
restore_sp = _stub_sp(csvc, {})
|
||
orig_c, orig_bp = plan_feed.candidates, downstream_repo.fetch_buy_plans
|
||
try:
|
||
plan_feed.candidates = lambda **kw: {
|
||
"date": "2026-07-29", "considered": 1, "eligible": 1, "dropped": {},
|
||
"items": [{"ts_code": "600418.SH", "score": 242.24, "theme": "整车"}]}
|
||
downstream_repo.fetch_buy_plans = lambda **kw: [
|
||
{"ts_code": "600418.SH", "price": 11.0, "score": 0.9}, # 同码, 计划优先
|
||
{"ts_code": "601111.SH", "price": 9.0, "score": 0.8}]
|
||
pool = csvc._candidates({"held": []})
|
||
finally:
|
||
plan_feed.candidates, downstream_repo.fetch_buy_plans = orig_c, orig_bp
|
||
restore_sp()
|
||
by = {c["ts_code"]: c for c in pool}
|
||
assert set(by) == {"600418.SH", "601111.SH"}, by
|
||
assert by["600418.SH"]["src"] == "plan_api" and by["600418.SH"]["price"] == 12.5
|
||
assert by["601111.SH"]["src"] == "buy_plan" and by["601111.SH"]["price"] == 9.0
|
||
|
||
|
||
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()
|