c9a3b0eb4f
三日体检(2026-08-19)实锤三 bug,影子账户第1天后永久卡死:
①账本断连:supports_account_sync=False→引擎context.portfolio永远停在
初始100万/0持仓→8-19轮换零卖出+买单六连拒资金不足(账上真剩15.6万);
修=sync_account按_apply_account_snapshot契约推{cash,total,positions
[closeable_amount=T+1 held-今日买]}(引擎60s拉一次,策略从此看得见真实账本)
②净值断供(#88回归):get_account_info无as_of键→_should_write_balance恒
False→paper_daily_balance影子一行不写=前端模拟盘无收益率直接原因;
修=有持仓或有成交→as_of=今天,空账户→''不写垃圾行
③重启失忆:账本全内存,重启重置1M/0持仓与已落库成交断层;
修=restore_from_trades逐笔重放(cash=initial-Σ买-Σ费+Σ卖/持仓加权成本/
当日买入补T+1锁)+runner经_hist_trades_for_restore(list_trades)接线
+4回归(sync契约与T1两日/asof三态/重放手算基准996683/offset→side映射);
833绿 [vps]
343 lines
13 KiB
Python
343 lines
13 KiB
Python
"""ShadowBroker(影子柜台本地撮合)单元测试。
|
|
|
|
纯逻辑测试:不依赖 bullet_trade/xtquant,价格由固定 price_getter 注入。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import datetime
|
|
|
|
import pytest
|
|
|
|
from sanguo_trader.shadow.broker import ShadowBroker
|
|
|
|
|
|
def _mk_broker(cash: float = 100_000.0, **kw) -> ShadowBroker:
|
|
prices = kw.pop("prices", {"600519.SH": 100.0})
|
|
fixed_now = kw.pop("now", datetime(2026, 8, 14, 10, 0, 0))
|
|
return ShadowBroker(
|
|
initial_cash=cash,
|
|
price_getter=lambda s: prices.get(s),
|
|
now_provider=lambda: fixed_now,
|
|
**kw,
|
|
)
|
|
|
|
|
|
def _buy(b: ShadowBroker, sec: str, amt: int, px: float | None = None):
|
|
return asyncio.run(b.buy(sec, amt, px))
|
|
|
|
|
|
def _sell(b: ShadowBroker, sec: str, amt: int, px: float | None = None):
|
|
return asyncio.run(b.sell(sec, amt, px))
|
|
|
|
|
|
def test_buy_fills_with_commission_and_slippage():
|
|
b = _mk_broker(cash=100_000, slippage=0.001, commission_rate=0.0003, min_commission=5)
|
|
oid = _buy(b, "600519.SH", 100)
|
|
assert b.orders[oid]["status"] == "filled"
|
|
fill = b.orders[oid]["filled_price"]
|
|
assert fill == pytest.approx(100.0 * 1.001, abs=0.01) # 买入上浮滑点
|
|
# 现金扣减 = 全额 + 佣金(低于最低佣金取 5 元)
|
|
commission = max(100 * fill * 0.0003, 5.0)
|
|
assert b.cash == pytest.approx(100_000 - 100 * fill - commission)
|
|
assert b.positions["600519.SH"]["amount"] == 100
|
|
assert b.positions["600519.SH"]["avg_cost"] == pytest.approx(fill)
|
|
|
|
|
|
def test_sell_charges_stamp_duty_and_slippage_down():
|
|
b = _mk_broker(cash=100_000, slippage=0.001, stamp_duty_rate=0.001)
|
|
_buy(b, "600519.SH", 200, px=100.0) # 固定委托价,滑点仍生效
|
|
cash_after_buy = b.cash
|
|
# T+1:当日买入不可卖 → 先模拟次日(before_open 清锁)
|
|
b.before_open()
|
|
oid = _sell(b, "600519.SH", 200, px=100.0)
|
|
assert b.orders[oid]["status"] == "filled"
|
|
fill = b.orders[oid]["filled_price"]
|
|
assert fill == pytest.approx(100.0 * 0.999, abs=0.01) # 卖出下压滑点
|
|
gross = 200 * fill
|
|
commission = max(gross * 0.0003, 5.0)
|
|
stamp = gross * 0.001
|
|
assert b.cash == pytest.approx(cash_after_buy + gross - commission - stamp)
|
|
assert "600519.SH" not in b.positions # 清仓移除
|
|
|
|
|
|
def test_t1_blocks_same_day_sell():
|
|
b = _mk_broker()
|
|
_buy(b, "600519.SH", 200, px=100.0)
|
|
oid = _sell(b, "600519.SH", 200, px=100.0) # 当日卖 → 拒
|
|
assert b.orders[oid]["status"] == "rejected"
|
|
assert "T+1" in b.orders[oid]["reject_reason"]
|
|
# 次日可卖
|
|
b.before_open()
|
|
oid2 = _sell(b, "600519.SH", 200, px=100.0)
|
|
assert b.orders[oid2]["status"] == "filled"
|
|
|
|
|
|
def test_insufficient_cash_rejects():
|
|
b = _mk_broker(cash=5_000)
|
|
oid = _buy(b, "600519.SH", 100) # 需约 1 万
|
|
assert b.orders[oid]["status"] == "rejected"
|
|
assert "资金不足" in b.orders[oid]["reject_reason"]
|
|
assert b.cash == 5_000 # 拒单不动账
|
|
|
|
|
|
def test_odd_lot_floors_to_100():
|
|
b = _mk_broker(cash=1_000_000)
|
|
oid = _buy(b, "600519.SH", 250) # → 200
|
|
assert b.orders[oid]["status"] == "filled"
|
|
assert b.orders[oid]["filled_amount"] == 200
|
|
oid2 = _buy(b, "600519.SH", 50) # 不足一手 → 拒
|
|
assert b.orders[oid2]["status"] == "rejected"
|
|
|
|
|
|
def test_no_price_rejects():
|
|
b = _mk_broker(prices={})
|
|
oid = _buy(b, "600519.SH", 100)
|
|
assert b.orders[oid]["status"] == "rejected"
|
|
assert "无参考价" in b.orders[oid]["reject_reason"]
|
|
|
|
|
|
# ---- P1.3 涨跌停/停牌拒单(双轨对账:与实盘 QMT 约束对齐,减少对账噪音) ----
|
|
|
|
def _limit_map_getter(status: dict):
|
|
return lambda sec: status.get(sec)
|
|
|
|
|
|
def test_buy_rejected_when_limit_up():
|
|
b = _mk_broker(limit_getter=_limit_map_getter({
|
|
"600519.SH": {"is_limit_up": True, "is_limit_down": False, "is_paused": False},
|
|
}))
|
|
oid = _buy(b, "600519.SH", 100)
|
|
assert b.orders[oid]["status"] == "rejected"
|
|
assert "涨停" in b.orders[oid]["reject_reason"]
|
|
assert "600519.SH" not in b.positions
|
|
|
|
|
|
def test_sell_rejected_when_limit_down():
|
|
b = _mk_broker(limit_getter=_limit_map_getter({
|
|
"600519.SH": {"is_limit_up": False, "is_limit_down": True, "is_paused": False},
|
|
}))
|
|
_buy(b, "600519.SH", 100, px=100.0) # 买入时非跌停
|
|
oid = _sell(b, "600519.SH", 100)
|
|
assert b.orders[oid]["status"] == "rejected"
|
|
assert "跌停" in b.orders[oid]["reject_reason"]
|
|
assert b.positions["600519.SH"]["amount"] == 100
|
|
|
|
|
|
def test_buy_sell_rejected_when_paused():
|
|
b = _mk_broker(limit_getter=_limit_map_getter({
|
|
"600519.SH": {"is_limit_up": False, "is_limit_down": False, "is_paused": True},
|
|
}))
|
|
oid = _buy(b, "600519.SH", 100)
|
|
assert b.orders[oid]["status"] == "rejected"
|
|
assert "停牌" in b.orders[oid]["reject_reason"]
|
|
|
|
|
|
def test_limit_getter_failure_degrades_to_fill():
|
|
"""limit_getter 抛异常 → 降级放行(等价无涨跌停数据的旧行为),不崩柜台。"""
|
|
def boom(sec):
|
|
raise RuntimeError("boom")
|
|
b = _mk_broker(limit_getter=boom)
|
|
oid = _buy(b, "600519.SH", 100)
|
|
assert b.orders[oid]["status"] == "filled"
|
|
|
|
|
|
def test_no_limit_getter_keeps_old_behavior():
|
|
b = _mk_broker() # 不注入 limit_getter
|
|
oid = _buy(b, "600519.SH", 100)
|
|
assert b.orders[oid]["status"] == "filled"
|
|
|
|
|
|
# ---- build_limit_getter:provider 状态 → broker 语义映射 ----
|
|
|
|
def test_limit_getter_maps_live_current_limit_up():
|
|
from sanguo_trader.shadow.runner import build_limit_getter
|
|
|
|
class P:
|
|
def get_live_current(self, sec):
|
|
# last_price == high_limit → 涨停
|
|
return {"last_price": 11.0, "high_limit": 11.0,
|
|
"low_limit": 9.0, "paused": False}
|
|
|
|
g = build_limit_getter(P())
|
|
assert g("600519.SH") == {"is_limit_up": True, "is_limit_down": False,
|
|
"is_paused": False}
|
|
|
|
|
|
def test_limit_getter_falls_back_to_batch_when_no_live_current():
|
|
from sanguo_trader.shadow.runner import build_limit_getter
|
|
|
|
class P:
|
|
def get_limit_status_batch(self, codes, date):
|
|
return {c: {"is_limit_up": False, "is_limit_down": True,
|
|
"is_paused": False} for c in codes}
|
|
|
|
g = build_limit_getter(P())
|
|
assert g("600519.SH")["is_limit_down"] is True
|
|
|
|
|
|
def test_limit_getter_returns_none_without_any_source():
|
|
from sanguo_trader.shadow.runner import build_limit_getter
|
|
|
|
g = build_limit_getter(object()) # 两接口都没有
|
|
assert g("600519.SH") is None
|
|
|
|
|
|
def test_on_trade_callback_receives_fills():
|
|
seen: list[dict] = []
|
|
b = ShadowBroker(
|
|
initial_cash=100_000,
|
|
price_getter=lambda s: 10.0,
|
|
on_trade=seen.append,
|
|
)
|
|
_buy(b, "600519.SH", 100)
|
|
assert len(seen) == 1
|
|
t = seen[0]
|
|
assert t["side"] == "buy" and t["amount"] == 100 and t["price"] == pytest.approx(10.0)
|
|
|
|
|
|
def test_get_account_info_totals():
|
|
b = _mk_broker(cash=100_000)
|
|
_buy(b, "600519.SH", 100, px=100.0)
|
|
info = b.get_account_info()
|
|
assert info["available_cash"] == pytest.approx(100_000 - 100 * 100.0 - max(100 * 100 * 0.0003, 5))
|
|
assert info["market_value"] == pytest.approx(100 * 100.0) # price_getter=100
|
|
assert info["total_value"] == pytest.approx(info["available_cash"] + info["market_value"])
|
|
|
|
|
|
def test_avg_cost_weighted_on_second_buy():
|
|
b = _mk_broker(cash=1_000_000, slippage=0.0)
|
|
_buy(b, "600519.SH", 100, px=100.0)
|
|
_buy(b, "600519.SH", 100, px=110.0)
|
|
pos = b.positions["600519.SH"]
|
|
assert pos["amount"] == 200
|
|
assert pos["avg_cost"] == pytest.approx(105.0)
|
|
|
|
|
|
def test_cancel_always_false_and_open_orders_empty():
|
|
b = _mk_broker()
|
|
_buy(b, "600519.SH", 100, px=100.0)
|
|
assert asyncio.run(b.cancel_order("whatever")) is False
|
|
assert b.get_open_orders() == [] # 即时成交,无挂单
|
|
|
|
|
|
def test_live_engine_protocol_compat():
|
|
"""LiveEngine 0.9.2 duck-typed 协议完整性(2026-08-14 VPS 实况教训:
|
|
缺 supports_account_sync → 引擎 _start_background_jobs 启动即崩,影子进程崩溃循环)。"""
|
|
from sanguo_trader.shadow.broker import ShadowBroker
|
|
b = ShadowBroker()
|
|
for attr in (
|
|
"connect", "disconnect", "is_connected", "heartbeat",
|
|
"before_open", "after_close", "cleanup",
|
|
"supports_account_sync", "supports_orders_sync",
|
|
"sync_account", "sync_orders",
|
|
"get_account_info", "get_positions", "get_open_orders",
|
|
):
|
|
assert hasattr(b, attr), f"LiveEngine 需要 broker.{attr}, ShadowBroker 缺失"
|
|
# 2026-08-19 起:账户同步必须开(引擎 context.portfolio 只认 sync_account
|
|
# 快照,关闭=策略永远看到初始资金/0 持仓,次日轮换零卖出+买单全拒)
|
|
assert b.supports_account_sync() is True
|
|
assert b.supports_orders_sync() is False
|
|
assert b.sync_orders() == []
|
|
b.cleanup() # 不抛异常
|
|
|
|
|
|
def test_sync_account_pushes_ledger_to_engine_contract():
|
|
"""账户同步快照符合 _apply_account_snapshot 契约,T+1 买入锁定进 closeable。
|
|
|
|
2026-08-19 VPS 实况回归:断连导致影子第 2 天轮换六连拒"资金不足"。
|
|
"""
|
|
b = _mk_broker(cash=100_000, slippage=0.0, commission_rate=0.0, min_commission=0)
|
|
_buy(b, "600519.SH", 200, px=100.0)
|
|
_buy(b, "000001.SZ", 100, px=10.0)
|
|
|
|
assert b.supports_account_sync() is True
|
|
snap = b.sync_account()
|
|
assert snap["available_cash"] == pytest.approx(100_000 - 200 * 100 - 100 * 10)
|
|
by_sec = {p["security"]: p for p in snap["positions"]}
|
|
assert set(by_sec) == {"600519.SH", "000001.SZ"}
|
|
# 当日买入:T+1 全部锁定不可卖
|
|
assert by_sec["600519.SH"]["closeable_amount"] == 0
|
|
assert by_sec["600519.SH"]["amount"] == 200
|
|
assert by_sec["600519.SH"]["avg_cost"] == pytest.approx(100.0)
|
|
assert by_sec["600519.SH"]["market_value"] == pytest.approx(200 * 100.0)
|
|
# 次日(before_open 清锁)后可卖
|
|
b.before_open()
|
|
snap2 = b.sync_account()
|
|
by_sec2 = {p["security"]: p for p in snap2["positions"]}
|
|
assert by_sec2["600519.SH"]["closeable_amount"] == 200
|
|
|
|
|
|
def test_get_account_info_asof_drives_daily_balance():
|
|
"""as_of 判据:有持仓/有成交→今天(快照线程才肯写 paper_daily_balance),
|
|
空账户→''(不写垃圾行)。2026-08-19 VPS 实况回归:缺 as_of 键→净值一行不写。"""
|
|
today = "2026-08-14" # _mk_broker 固定 now
|
|
b = _mk_broker()
|
|
assert b.get_account_info().get("as_of") == "" # 新账户:无持仓无成交
|
|
|
|
_buy(b, "600519.SH", 100, px=100.0)
|
|
assert b.get_account_info()["as_of"] == today
|
|
|
|
b2 = _mk_broker() # 只有历史成交、已清仓:也应有 as_of(现金即净值)
|
|
b2.trades.append({"side": "sell", "security": "X", "amount": 1,
|
|
"price": 1.0, "commission": 0, "stamp_duty": 0,
|
|
"datetime": f"{today} 10:00:00"})
|
|
assert b2.get_account_info()["as_of"] == today
|
|
|
|
|
|
def test_restore_from_trades_rebuilds_cash_positions_and_t1():
|
|
"""重启恢复:从落库成交重放出现金/持仓/加权成本/当日 T+1 锁定。
|
|
|
|
手算基准:1,000,000 起步
|
|
buy 600519.SH 100@10 fee5 → -1005
|
|
buy 600519.SH 200@12 fee5 → -2405 (持仓300,avg=(1000+2400)/300)
|
|
sell 600519.SH 100@11 fee5+stamp2 → +1093
|
|
终态 cash=996,683,持仓 200@11.3333
|
|
"""
|
|
today = "2026-08-14"
|
|
b = _mk_broker(cash=1_000_000) # 固定 now=2026-08-14 10:00
|
|
trades = [
|
|
{"side": "buy", "security": "600519.SH", "amount": 100, "price": 10.0,
|
|
"commission": 5.0, "stamp_duty": 0.0, "datetime": f"{today} 09:35:00"},
|
|
{"side": "buy", "security": "600519.SH", "amount": 200, "price": 12.0,
|
|
"commission": 5.0, "stamp_duty": 0.0, "datetime": f"{today} 09:36:00"},
|
|
{"side": "sell", "security": "600519.SH", "amount": 100, "price": 11.0,
|
|
"commission": 5.0, "stamp_duty": 2.0, "datetime": f"{today} 13:45:00"},
|
|
]
|
|
b.restore_from_trades(trades)
|
|
|
|
assert b.cash == pytest.approx(1_000_000 - 1005 - 2405 + 1093)
|
|
assert b.positions["600519.SH"]["amount"] == 200
|
|
assert b.positions["600519.SH"]["avg_cost"] == pytest.approx(3400 / 300)
|
|
assert len(b.trades) == 3
|
|
# 当日买入共 300 股 → T+1 锁定后 closeable = 200-300 → 0
|
|
snap = b.sync_account()
|
|
pos = {p["security"]: p for p in snap["positions"]}["600519.SH"]
|
|
assert pos["closeable_amount"] == 0
|
|
# 隔日重放(成交日期非今天) → 不锁
|
|
b2 = _mk_broker()
|
|
b2.restore_from_trades([
|
|
{"side": "buy", "security": "600519.SH", "amount": 100, "price": 10.0,
|
|
"commission": 5.0, "stamp_duty": 0.0, "datetime": "2026-08-13 09:35:00"}])
|
|
snap2 = b2.sync_account()
|
|
assert snap2["positions"][0]["closeable_amount"] == 100
|
|
|
|
|
|
def test_hist_trades_mapping_offset_to_side():
|
|
"""runner 恢复链路契约:paper_trades 行(offset=open/close)→ buy/sell。"""
|
|
from sanguo_trader.shadow.runner import _hist_trades_for_restore
|
|
|
|
rows = [
|
|
{"symbol": "600519.XSHG", "offset": "open", "volume": 100,
|
|
"price": 10.0, "commission": 5.0, "stamp_duty": 0.0,
|
|
"datetime": "2026-08-18 09:35:00"},
|
|
{"symbol": "600519.XSHG", "offset": "close", "volume": 100,
|
|
"price": 11.0, "commission": 5.0, "stamp_duty": 2.0,
|
|
"datetime": "2026-08-19 13:45:00"},
|
|
]
|
|
hist = _hist_trades_for_restore(rows)
|
|
assert hist[0]["side"] == "buy"
|
|
assert hist[1]["side"] == "sell"
|
|
assert hist[0]["security"] == "600519.XSHG"
|
|
assert hist[1]["amount"] == 100
|