From 67ea7763cd53e9c49145f0d64ae30f0ce1cd48b1 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Tue, 7 Jul 2026 10:05:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(trader):=20matcher.py=20A=E8=82=A1?= =?UTF-8?q?=E6=92=AE=E5=90=88(match=5Fsession/=E8=B4=B9=E7=8E=87/100?= =?UTF-8?q?=E8=82=A1/=E5=B0=81=E6=9D=BF)=20Issue#3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sanguo_trader/matcher.py | 92 ++++++++++++++ tests/trader/test_matcher.py | 234 +++++++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 sanguo_trader/matcher.py create mode 100644 tests/trader/test_matcher.py diff --git a/sanguo_trader/matcher.py b/sanguo_trader/matcher.py new file mode 100644 index 0000000..0a1cc7e --- /dev/null +++ b/sanguo_trader/matcher.py @@ -0,0 +1,92 @@ +"""A 股撮合纯函数(match_session / 费率 / 100 股 / 封板)。 + +match_bar 必须是 raw 价格(spec §3.3 / §6.2)。所有费率来自 AccountConfig。 +拒单返回 PaperReject;成交返回 PaperTrade。 +资金检查由 Account 在 apply 前负责(matcher 不看资金)。 +""" +import pandas as pd + +from .limit import is_locked_for_buy_symbol, is_locked_for_sell_symbol +from .models import ( + AccountConfig, + MatchSession, + OrderSide, + PaperOrder, + PaperReject, + PaperTrade, +) + +MIN_LOT = 100 + + +def cross_order( + order: PaperOrder, + match_bar: pd.Series, + prev_close_raw: float, + cfg: AccountConfig, + is_st: bool = False, +) -> PaperTrade | PaperReject: + """单笔订单撮合。 + + 步骤: + 1. 涨跌停封板拒单(raw,按板块幅度) + 2. 成交价(NEXT_OPEN=bar.open / CURRENT_CLOSE=bar.close) + 3. 限价单触价检查 + 4. 100 股取整(买入向下取整;卖出允许零股) + 5. 费用(佣金 min 5 元 / 印花税仅卖 / 过户费单边) + """ + symbol = order.symbol + bar_date = str(match_bar.get("date", "")) + + # 1. 涨跌停封板拒单(raw) + if order.side == OrderSide.BUY and is_locked_for_buy_symbol( + match_bar, symbol, prev_close_raw, cfg, is_st + ): + return PaperReject(order.strategy_id, symbol, "limit_up_locked", bar_date) + if order.side == OrderSide.SELL and is_locked_for_sell_symbol( + match_bar, symbol, prev_close_raw, cfg, is_st + ): + return PaperReject(order.strategy_id, symbol, "limit_down_locked", bar_date) + + # 2. 成交价(按 match_session) + if order.match_session == MatchSession.NEXT_OPEN: + fill_price = match_bar["open"] + elif order.match_session == MatchSession.CURRENT_CLOSE: + fill_price = match_bar["close"] + else: + return PaperReject( + order.strategy_id, symbol, "unsupported_match_session", bar_date + ) + + # 3. 限价单触价 + if not order.is_market: + if order.side == OrderSide.BUY and fill_price > order.price: + return PaperReject(order.strategy_id, symbol, "limit_not_touched", bar_date) + if order.side == OrderSide.SELL and fill_price < order.price: + return PaperReject(order.strategy_id, symbol, "limit_not_touched", bar_date) + + # 4. 100 股取整(买入向下取整;卖出不取整,允许零股退出) + volume = order.volume + if order.side == OrderSide.BUY: + volume = (volume // MIN_LOT) * MIN_LOT + if volume < MIN_LOT: + return PaperReject(order.strategy_id, symbol, "volume_below_min_lot", bar_date) + + # 5. 费用 + gross = volume * fill_price + commission = max(gross * cfg.rate, cfg.min_commission) + stamp_duty = gross * cfg.stamp_duty_rate if order.side == OrderSide.SELL else 0.0 + transfer_fee = gross * cfg.transfer_fee_rate # 单边;Account 算双向 ×2 + + return PaperTrade( + strategy_id=order.strategy_id, + symbol=symbol, + side=order.side, + price=fill_price, + volume=volume, + commission=commission, + stamp_duty=stamp_duty, + transfer_fee=transfer_fee, + bar_date=bar_date, + match_session=order.match_session, + ) diff --git a/tests/trader/test_matcher.py b/tests/trader/test_matcher.py new file mode 100644 index 0000000..baecb16 --- /dev/null +++ b/tests/trader/test_matcher.py @@ -0,0 +1,234 @@ +"""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_session_rejected(): + """CALL_AUCTION 首版预留,不支持。""" + o = buy(market=True, session=MatchSession.CALL_AUCTION) + r = cross_order(o, mkbar(10.5, 11, 10.2, 10.8), PREV, CFG) + assert isinstance(r, PaperReject) and r.reason == "unsupported_match_session" + + +# ---- 限价单触价 ---- +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 + + +# ---- 费用 ---- +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_single_sided_in_trade(): + t = cross_order(buy(market=True), mkbar(10, 10, 10, 10), PREV, CFG) + assert isinstance(t, PaperTrade) + # 单边 100*10*0.00001 = 0.01;trade 里存单边,Account 算 ×2 + assert t.transfer_fee == pytest.approx(0.01) + + +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"