feat(trader): matcher.py A股撮合(match_session/费率/100股/封板) Issue#3
This commit is contained in:
@@ -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,
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user