"""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(): # 创业板 300750,prev 10.00 → 涨停 12.00(20%) 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(): # 科创板 688981,prev 10.00 → 跌停 8.00(20%) 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(): # 北交所 830799,prev 10.00 → 涨停 13.00(30%) 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 主板 600000,prev 10.00 → 涨停 10.50(5%) 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.02;matcher 直接出双向总额 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.95,gross=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)