"""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, **kw): return asyncio.run(b.buy(sec, amt, px, **kw)) def _sell(b: ShadowBroker, sec: str, amt: int, px: float | None = None, **kw): return asyncio.run(b.sell(sec, amt, px, **kw)) 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"] # ---- 市价单成交基准=实时行情(2026-08-24 双轨 4/6 红根因回归) ---- def test_market_buy_fills_at_quote_not_protective_price(): """市价单带的 price 是保护上限(策略侧行情×1.015),成交基准必须是实时行情。 08-24 实锤:momentum/small_cap 首次真实成交日,影子按保护价记账 → 25 票 价差紧聚 -150bps + 级联现金虚耗 → 002038 影子资金不足漏买,双轨 4/6 红。 BrokerBase 契约(bullet_trade/broker/base.py):market=True 时 price 亦视为 市价,可用作保护价/参考价——不是期望成交价。 """ b = _mk_broker(cash=1_000_000, slippage=0.0) oid = _buy(b, "600519.SH", 100, px=101.5, market=True) assert b.orders[oid]["status"] == "filled" assert b.orders[oid]["filled_price"] == pytest.approx(100.0) # 行情价,非 101.5 def test_market_sell_fills_at_quote_not_protective_floor(): """卖侧对称:保护下限不作成交价(08-24 实锤 510500 影子卖 7.64 vs 实盘 7.76)。""" b = _mk_broker(cash=1_000_000, slippage=0.0) _buy(b, "600519.SH", 100, px=100.0) b.before_open() oid = _sell(b, "600519.SH", 100, px=98.5, market=True) assert b.orders[oid]["status"] == "filled" assert b.orders[oid]["filled_price"] == pytest.approx(100.0) # 行情价,非 98.5 def test_market_buy_quote_beyond_protective_cap_fills_at_cap(): """行情已超保护上限(决策到成交间快速拉升):按上限成交(市价转限价语义),不追高。""" b = _mk_broker(cash=1_000_000, slippage=0.0, prices={"600519.SH": 105.0}) oid = _buy(b, "600519.SH", 100, px=101.5, market=True) assert b.orders[oid]["filled_price"] == pytest.approx(101.5) def test_market_order_no_quote_falls_back_to_protective_price(): """行情不可得:退回委托价(告警)而非拒单——可用性优先,影子不因缺行情停摆。""" b = _mk_broker(cash=1_000_000, slippage=0.0, prices={}) oid = _buy(b, "600519.SH", 100, px=101.5, market=True) assert b.orders[oid]["status"] == "filled" assert b.orders[oid]["filled_price"] == pytest.approx(101.5) def test_limit_order_keeps_passed_price_semantics(): """限价单(market=False)沿用传入价——触价语义不变。""" b = _mk_broker(cash=1_000_000, slippage=0.0) oid = _buy(b, "600519.SH", 100, px=99.0) assert b.orders[oid]["status"] == "filled" assert b.orders[oid]["filled_price"] == pytest.approx(99.0) # ---- 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