feat(shadow-desk): P1-b/c 影子柜台常驻进程+本地撮合broker+前端引擎选择: ShadowBroker(实时价±滑点即时成交/佣金印花最低佣金/A股整手/T+1日锁/资金不足拒单/均价加权/duck-typed BrokerBase协议); runner挂bullet_trade LiveEngine同实盘唯一差=broker_factory换影子(双轨对账基础),成交落paper_trades+30s快照落持仓净值; CLI单实例文件锁(python -m sanguo_trader.shadow); paper_accounts加engine列(eod_replay/shadow迁移); 前端:模拟盘新建组合卡撮合引擎单选+列表影子/日终徽标; 10 broker单测 [vps]
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
"""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"]
|
||||
|
||||
|
||||
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() == [] # 即时成交,无挂单
|
||||
Reference in New Issue
Block a user