fix(trader): matcher slippage/过户费双向/限价超涨停拒单 (H2/H3/H4)
H2: fill_price 应用 slippage(买+/卖-,默认0不影响) H3: 过户费改双向(matcher 直接×2,Account 不再×2,删单边注释) H4: 限价单超涨停价拒单(price_above_limit/price_below_limit) 69 tests passed.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user