diff --git a/sanguo_trader/matcher.py b/sanguo_trader/matcher.py index 0a1cc7e..7cb3c11 100644 --- a/sanguo_trader/matcher.py +++ b/sanguo_trader/matcher.py @@ -3,10 +3,21 @@ match_bar 必须是 raw 价格(spec §3.3 / §6.2)。所有费率来自 AccountConfig。 拒单返回 PaperReject;成交返回 PaperTrade。 资金检查由 Account 在 apply 前负责(matcher 不看资金)。 + +费用口径(review H3 修正): +- transfer_fee 直接出**双向总额**(沪深买卖均收),Account 不再 ×2。 +- stamp_duty 仅卖;commission 最低 5 元。 """ import pandas as pd -from .limit import is_locked_for_buy_symbol, is_locked_for_sell_symbol +from .limit import ( + get_board, + is_locked_for_buy_symbol, + is_locked_for_sell_symbol, + limit_down_price, + limit_ratio, + limit_up_price, +) from .models import ( AccountConfig, MatchSession, @@ -31,9 +42,10 @@ def cross_order( 步骤: 1. 涨跌停封板拒单(raw,按板块幅度) 2. 成交价(NEXT_OPEN=bar.open / CURRENT_CLOSE=bar.close) - 3. 限价单触价检查 + 3. 限价单触价检查(含 H4:限价超涨停/跌停先拒) 4. 100 股取整(买入向下取整;卖出允许零股) - 5. 费用(佣金 min 5 元 / 印花税仅卖 / 过户费单边) + 5. 滑点(买 += slippage,卖 -= slippage;H2) + 6. 费用(佣金 min 5 元 / 印花税仅卖 / 过户费双向 H3) """ symbol = order.symbol bar_date = str(match_bar.get("date", "")) @@ -50,16 +62,26 @@ def cross_order( # 2. 成交价(按 match_session) if order.match_session == MatchSession.NEXT_OPEN: - fill_price = match_bar["open"] + fill_price = float(match_bar["open"]) elif order.match_session == MatchSession.CURRENT_CLOSE: - fill_price = match_bar["close"] + fill_price = float(match_bar["close"]) else: return PaperReject( order.strategy_id, symbol, "unsupported_match_session", bar_date ) - # 3. 限价单触价 + # 3. 限价单:先查涨跌停边界(H4),再查触价 if not order.is_market: + board = get_board(symbol) + ratio = limit_ratio(board, is_st) + up = limit_up_price(prev_close_raw, ratio, cfg.pricetick) + down = limit_down_price(prev_close_raw, ratio, cfg.pricetick) + # H4:限价超涨停 / 跌停拒单(== 边界允许,> / < 才拒) + if order.side == OrderSide.BUY and order.price > up: + return PaperReject(order.strategy_id, symbol, "price_above_limit", bar_date) + if order.side == OrderSide.SELL and order.price < down: + return PaperReject(order.strategy_id, symbol, "price_below_limit", bar_date) + # 触价(用原始 fill_price,slippage 不影响触价判断) 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: @@ -72,11 +94,17 @@ def cross_order( if volume < MIN_LOT: return PaperReject(order.strategy_id, symbol, "volume_below_min_lot", bar_date) - # 5. 费用 + # 5. 滑点(H2;默认 0 不影响) + if order.side == OrderSide.BUY: + fill_price += cfg.slippage + else: + fill_price -= cfg.slippage + + # 6. 费用(transfer_fee 双向,H3) 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 + transfer_fee = gross * cfg.transfer_fee_rate * 2 # 沪深双向 return PaperTrade( strategy_id=order.strategy_id, diff --git a/tests/trader/test_matcher.py b/tests/trader/test_matcher.py index baecb16..451f6fc 100644 --- a/tests/trader/test_matcher.py +++ b/tests/trader/test_matcher.py @@ -204,11 +204,11 @@ def test_stamp_duty_only_on_sell(): assert t_sell.stamp_duty == pytest.approx(0.5) -def test_transfer_fee_single_sided_in_trade(): +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 = 0.01;trade 里存单边,Account 算 ×2 - assert t.transfer_fee == pytest.approx(0.01) + # 双向 100*10*0.00001*2 = 0.02;matcher 直接出双向总额 + assert t.transfer_fee == pytest.approx(0.02) def test_trade_carries_match_session(): @@ -232,3 +232,76 @@ def test_trade_carries_strategy_id_and_symbol(): 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)