Files
sanguo_vnpy_v2/tests/trader/test_matcher.py
T
claude_dev ab703e93ba feat(matcher): 集合竞价CALL_AUCTION撮合(分期项)—开盘价(最大成交量原则→open)
CALL_AUCTION枚举原拒单(unsupported), 现撮合用开盘价(open, 集合竞价确定开盘价).
当前定价同NEXT_OPEN(均为open); 未来区分开盘/尾盘集合竞价需扩枚举.
test: call_auction fills@open(原rejected用例改).
2026-07-08 07:02:51 +08:00

332 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""matcher.py 撮合纯函数测试(match_session / 费率 / 100 股 / 封板)。
raw 价格:PREV=10.0 即 raw 前收。
拒单判断统一用 isinstance(r, PaperReject)(计划修正点:避免 walrus 表达式 bug)。
"""
import pandas as pd
import pytest
from sanguo_trader.matcher import cross_order
from sanguo_trader.models import (
AccountConfig,
MatchSession,
OrderSide,
PaperOrder,
PaperReject,
PaperTrade,
)
CFG = AccountConfig(initial_capital=1_000_000)
PREV = 10.0 # raw 前收
def mkbar(open, high, low, close):
return pd.Series({"open": open, "high": high, "low": low, "close": close})
def buy(price=0, volume=100, market=True, session=MatchSession.NEXT_OPEN, symbol="600000"):
return PaperOrder("s1", symbol, OrderSide.BUY, price, volume, market, session)
def sell(price=0, volume=100, market=True, session=MatchSession.NEXT_OPEN, symbol="600000"):
return PaperOrder("s1", symbol, OrderSide.SELL, price, volume, market, session)
# ---- 撮合时点 ----
def test_next_open_market_fill_uses_next_open():
t = cross_order(buy(market=True), mkbar(10.5, 11, 10.2, 10.8), PREV, CFG)
assert isinstance(t, PaperTrade)
assert t.price == 10.5
def test_current_close_fill_uses_current_close():
o = buy(market=True, session=MatchSession.CURRENT_CLOSE)
t = cross_order(o, mkbar(10.5, 11, 10.2, 10.8), PREV, CFG)
assert isinstance(t, PaperTrade)
assert t.price == 10.8
# ---- 涨跌停封板拒单 ----
def test_limit_up_one_word_rejects_buy():
up = 11.0 # 10*1.1
r = cross_order(buy(market=True), mkbar(up, up, up, up), PREV, CFG)
assert isinstance(r, PaperReject) and r.reason == "limit_up_locked"
def test_limit_up_t_lock_rejects_buy_conservatively():
up = 11.0
r = cross_order(buy(market=True), mkbar(up, up, 10.5, up), PREV, CFG)
assert isinstance(r, PaperReject) and r.reason == "limit_up_locked"
def test_limit_down_one_word_rejects_sell():
down = 9.0 # 10*0.9
r = cross_order(sell(market=True), mkbar(down, down, down, down), PREV, CFG)
assert isinstance(r, PaperReject) and r.reason == "limit_down_locked"
def test_gem_board_20pct_limit_up():
# 创业板 300750prev 10.00 → 涨停 12.0020%
r = cross_order(
PaperOrder("s1", "300750", OrderSide.BUY, 0, 100, True),
mkbar(12.0, 12.0, 12.0, 12.0),
10.0,
CFG,
)
assert isinstance(r, PaperReject) and r.reason == "limit_up_locked"
def test_star_board_20pct_limit_down():
# 科创板 688981prev 10.00 → 跌停 8.0020%
r = cross_order(
PaperOrder("s1", "688981", OrderSide.SELL, 0, 100, True),
mkbar(8.0, 8.0, 8.0, 8.0),
10.0,
CFG,
)
assert isinstance(r, PaperReject) and r.reason == "limit_down_locked"
def test_bse_board_30pct_limit_up():
# 北交所 830799prev 10.00 → 涨停 13.0030%
r = cross_order(
PaperOrder("s1", "830799", OrderSide.BUY, 0, 100, True),
mkbar(13.0, 13.0, 13.0, 13.0),
10.0,
CFG,
)
assert isinstance(r, PaperReject) and r.reason == "limit_up_locked"
def test_st_5pct_limit_up():
# ST 主板 600000prev 10.00 → 涨停 10.505%
r = cross_order(
buy(market=True, symbol="600000"),
mkbar(10.50, 10.50, 10.50, 10.50),
10.0,
CFG,
is_st=True,
)
assert isinstance(r, PaperReject) and r.reason == "limit_up_locked"
def test_call_auction_fills_at_open():
"""集合竞价撮合用开盘价(最大成交量原则 → open,分期项)。"""
o = buy(market=True, session=MatchSession.CALL_AUCTION)
t = cross_order(o, mkbar(10.5, 11, 10.2, 10.8), PREV, CFG)
assert isinstance(t, PaperTrade)
assert t.price == 10.5 # 集合竞价开盘价 = open
# ---- 限价单触价 ----
def test_limit_buy_not_touched_rejected():
o = PaperOrder("s1", "600000", OrderSide.BUY, 10.0, 100, is_market=False)
# open 10.5 > 委托 10.0 → 触不到
r = cross_order(o, mkbar(10.5, 11, 10.2, 10.8), PREV, CFG)
assert isinstance(r, PaperReject) and r.reason == "limit_not_touched"
def test_limit_buy_touched_fills():
o = PaperOrder("s1", "600000", OrderSide.BUY, 10.5, 100, is_market=False)
# open 10.5 <= 委托 10.5 → 成交
t = cross_order(o, mkbar(10.5, 11, 10.2, 10.8), PREV, CFG)
assert isinstance(t, PaperTrade) and t.price == 10.5
def test_limit_sell_not_touched_rejected():
o = PaperOrder(
"s1", "600000", OrderSide.SELL, 11.0, 100,
is_market=False, match_session=MatchSession.CURRENT_CLOSE,
)
# close 10.8 < 委托 11.0 → 卖不出
r = cross_order(o, mkbar(10.5, 11, 10.2, 10.8), PREV, CFG)
assert isinstance(r, PaperReject) and r.reason == "limit_not_touched"
# ---- 100 股取整(买入)----
def test_buy_rounds_down_to_100():
t = cross_order(
PaperOrder("s1", "600000", OrderSide.BUY, 0, 250, True),
mkbar(10, 10, 10, 10),
PREV,
CFG,
)
assert isinstance(t, PaperTrade) and t.volume == 200
def test_buy_below_100_rejected():
r = cross_order(
PaperOrder("s1", "600000", OrderSide.BUY, 0, 50, True),
mkbar(10, 10, 10, 10),
PREV,
CFG,
)
assert isinstance(r, PaperReject) and r.reason == "volume_below_min_lot"
def test_sell_odd_lot_allowed():
"""卖出允许零股(退出持仓基本操作),不取整。"""
t = cross_order(
PaperOrder("s1", "600000", OrderSide.SELL, 0, 50, True),
mkbar(10, 10, 10, 10),
PREV,
CFG,
)
assert isinstance(t, PaperTrade) and t.volume == 50
# ---- 科创板 200 股最小手数(分期项)----
def test_star_buy_below_200_rejected():
"""科创板 688981 买 150 股 < 200 → 拒单(200 股起)。"""
r = cross_order(
PaperOrder("s1", "688981", OrderSide.BUY, 0, 150, True),
mkbar(10, 10, 10, 10),
PREV,
CFG,
)
assert isinstance(r, PaperReject) and r.reason == "volume_below_min_lot"
def test_star_buy_250_not_rounded_to_200():
"""科创板 688981 买 250 股 → ≥200,1 股递增不取整,成交 250(非主板 100 整倍)。"""
t = cross_order(
PaperOrder("s1", "688981", OrderSide.BUY, 0, 250, True),
mkbar(10, 10, 10, 10),
PREV,
CFG,
)
assert isinstance(t, PaperTrade) and t.volume == 250
# ---- 费用 ----
def test_commission_uses_min_5_yuan():
# 100 股 × 10 元 × 0.0003 = 0.3 → 不足 5 元,收 5
t = cross_order(buy(market=True), mkbar(10, 10, 10, 10), PREV, CFG)
assert isinstance(t, PaperTrade)
assert t.commission == 5.0
def test_commission_above_min():
# 1000 股 × 10 元 × 0.0003 = 3 → 仍 < 5;用 5000 股 × 10 = 50000 × 0.0003 = 15
t = cross_order(
PaperOrder("s1", "600000", OrderSide.BUY, 0, 5000, True),
mkbar(10, 10, 10, 10),
PREV,
CFG,
)
assert isinstance(t, PaperTrade)
assert t.commission == pytest.approx(15.0)
def test_stamp_duty_only_on_sell():
t_buy = cross_order(buy(market=True), mkbar(10, 10, 10, 10), PREV, CFG)
assert isinstance(t_buy, PaperTrade)
assert t_buy.stamp_duty == 0.0
t_sell = cross_order(sell(market=True), mkbar(10, 10, 10, 10), PREV, CFG)
assert isinstance(t_sell, PaperTrade)
# 100*10*0.0005 = 0.5
assert t_sell.stamp_duty == pytest.approx(0.5)
def test_transfer_fee_double_sided_in_trade():
t = cross_order(buy(market=True), mkbar(10, 10, 10, 10), PREV, CFG)
assert isinstance(t, PaperTrade)
# 双向 100*10*0.00001*2 = 0.02matcher 直接出双向总额
assert t.transfer_fee == pytest.approx(0.02)
def test_trade_carries_match_session():
t = cross_order(
buy(market=True, session=MatchSession.CURRENT_CLOSE),
mkbar(10.5, 11, 10.2, 10.8),
PREV,
CFG,
)
assert isinstance(t, PaperTrade)
assert t.match_session == MatchSession.CURRENT_CLOSE
def test_trade_carries_strategy_id_and_symbol():
t = cross_order(
PaperOrder("my_strat", "300750", OrderSide.BUY, 0, 100, True),
mkbar(10, 10, 10, 10),
10.0,
CFG,
)
assert isinstance(t, PaperTrade)
assert t.strategy_id == "my_strat"
assert t.symbol == "300750"
# ---- H4: 限价超涨停 / 跌停未拒 ----
def test_limit_buy_above_limit_up_rejected():
"""限价买单价格超涨停 → reject price_above_limit(触价检查前先查涨跌停)。"""
# 主板 600000 prev 10.0 → 涨停 11.0;限价 11.50 超涨停
o = PaperOrder("s1", "600000", OrderSide.BUY, 11.50, 100, is_market=False)
r = cross_order(o, mkbar(10.5, 11, 10.2, 10.8), PREV, CFG)
assert isinstance(r, PaperReject) and r.reason == "price_above_limit"
def test_limit_sell_below_limit_down_rejected():
"""限价卖单价格低于跌停 → reject price_below_limit。"""
# 主板 600000 prev 10.0 → 跌停 9.0;限价 8.50 跌破
o = PaperOrder("s1", "600000", OrderSide.SELL, 8.50, 100, is_market=False)
r = cross_order(o, mkbar(10.5, 11, 10.2, 10.8), PREV, CFG)
assert isinstance(r, PaperReject) and r.reason == "price_below_limit"
def test_limit_buy_at_limit_up_not_rejected():
"""限价 == 涨停价 不拒(严格 >,等号允许)。"""
o = PaperOrder("s1", "600000", OrderSide.BUY, 11.0, 100, is_market=False)
t = cross_order(o, mkbar(10.8, 11.0, 10.5, 10.9), PREV, CFG)
assert isinstance(t, PaperTrade) and t.price == 10.8
def test_limit_buy_above_gem_limit_up_rejected():
"""创业板 20% 涨停边界:300750 prev 10.0 → 涨停 12.0;限价 12.50 拒单。"""
o = PaperOrder("s1", "300750", OrderSide.BUY, 12.50, 100, is_market=False)
r = cross_order(o, mkbar(10.5, 11, 10.2, 10.8), 10.0, CFG)
assert isinstance(r, PaperReject) and r.reason == "price_above_limit"
# ---- H2: slippage 应用到 fill_price ----
def test_slippage_added_to_buy_fill():
"""买单 fill_price += slippage。"""
cfg = AccountConfig(initial_capital=1_000_000, slippage=0.05)
t = cross_order(buy(market=True), mkbar(10.0, 10, 10, 10), PREV, cfg)
assert isinstance(t, PaperTrade)
assert t.price == pytest.approx(10.05)
def test_slippage_subtracted_from_sell_fill():
"""卖单 fill_price -= slippage。"""
cfg = AccountConfig(initial_capital=1_000_000, slippage=0.05)
t = cross_order(sell(market=True), mkbar(10.0, 10, 10, 10), PREV, cfg)
assert isinstance(t, PaperTrade)
assert t.price == pytest.approx(9.95)
def test_zero_slippage_preserves_fill_price():
"""默认 slippage=0 → fill_price 不变(向后兼容)。"""
t = cross_order(buy(market=True), mkbar(10.0, 10, 10, 10), PREV, CFG)
assert isinstance(t, PaperTrade)
assert t.price == 10.0
def test_slippage_affects_gross_for_fees():
"""slippage 后的 fill_price 用于费用计算(更真实)。"""
cfg = AccountConfig(initial_capital=1_000_000, slippage=0.05)
t = cross_order(sell(market=True), mkbar(10.0, 10, 10, 10), PREV, cfg)
assert isinstance(t, PaperTrade)
# 卖单:fill=9.95gross=100*9.95=995.0
# stamp_duty = 995.0 * 0.0005 = 0.4975
assert t.stamp_duty == pytest.approx(995.0 * 0.0005)
# ---- H3: 过户费双向(已有 test_transfer_fee_double_sided_in_trade 重命名断言更新)----
def test_transfer_fee_double_sided_on_sell_too():
"""卖单过户费同样双向(沪深一致)。"""
t = cross_order(sell(market=True), mkbar(10, 10, 10, 10), PREV, CFG)
assert isinstance(t, PaperTrade)
assert t.transfer_fee == pytest.approx(0.02)