Files
sanguo_vnpy_v2/tests/trader/test_shadow_broker.py
T

241 lines
8.5 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 缺失"
assert b.supports_account_sync() is False # 本地柜台,引擎跳过账户同步
assert b.supports_orders_sync() is False
assert b.sync_orders() == []
b.cleanup() # 不抛异常