diff --git a/sanguo_trader/cta_adapter.py b/sanguo_trader/cta_adapter.py new file mode 100644 index 0000000..2e6a81d --- /dev/null +++ b/sanguo_trader/cta_adapter.py @@ -0,0 +1,88 @@ +"""PaperCtaEngine:vnpy_ctastrategy CtaTemplate 的纸面适配器(spec §5)。 + +实现 CtaTemplate 所需的 cta_engine 接口(send_order/cancel_order), +拦截 send_order → 构造 PaperOrder → 收集到 pending_orders, +供 PaperEngine 撮合。参考 vnpy_ctastrategy BacktestingEngine 的策略桥接 +(它也是假 cta_engine)。 + +vnpy_ctastrategy 是 pip 依赖(容器有,本机可能无)——本模块不顶部 import 它, +只在 load_strategy 时 lazy import;本机用 mock 策略测 send_order 收集逻辑。 +""" +import logging + +from .models import MatchSession, OrderSide, PaperOrder + +logger = logging.getLogger(__name__) + + +def _direction_to_side(direction) -> OrderSide: + """vnpy Direction.LONG/SHORT(或 '多'/'空')→ OrderSide。""" + s = str(direction) + if "LONG" in s or "多" in s: + return OrderSide.BUY + if "SHORT" in s or "空" in s: + return OrderSide.SELL + return OrderSide.BUY + + +class PaperCtaEngine: + """纸面 cta_engine:拦截 CtaTemplate.send_order 转 PaperOrder。""" + + def __init__(self, strategy_id: str, + match_session: MatchSession | str = MatchSession.NEXT_OPEN, + listing_days: int = 0) -> None: + self.strategy_id = strategy_id + self.match_session = MatchSession(match_session) if isinstance(match_session, str) else match_session + self.listing_days = listing_days + self.strategy = None + self.pending_orders: list[PaperOrder] = [] + + def set_strategy(self, strategy) -> None: + self.strategy = strategy + + def send_order(self, strategy, direction, offset, price, volume, + stop: bool = False, lock: bool = False, net: bool = False) -> list[str]: + """拦截 CtaTemplate.send_order → 收集 PaperOrder。返回假 vt_orderids。""" + del stop, lock, net # 未用(首版不支持 stop/lock/net) + vt_symbol = getattr(strategy, "vt_symbol", "") + symbol = vt_symbol.split(".")[0] if vt_symbol else getattr(strategy, "symbol", "") + order = PaperOrder( + strategy_id=self.strategy_id, + symbol=symbol, + side=_direction_to_side(direction), + price=float(price), + volume=int(volume), + is_market=True, + match_session=self.match_session, + listing_days=self.listing_days, + ) + self.pending_orders.append(order) + return [f"paper.{self.strategy_id}.{len(self.pending_orders)}"] + + def cancel_order(self, vt_orderid) -> None: + """首版简化:取消从 pending 移除(未撮合的)。""" + if isinstance(vt_orderid, (list, tuple)): + for oid in vt_orderid: + self._cancel_one(oid) + else: + self._cancel_one(vt_orderid) + + def _cancel_one(self, vt_orderid: str) -> None: + try: + idx = int(vt_orderid.rsplit(".", 1)[-1]) - 1 + if 0 <= idx < len(self.pending_orders): + self.pending_orders.pop(idx) + except (ValueError, IndexError): + logger.warning("cancel_order 找不到 %s", vt_orderid) + + def on_bar(self, bar) -> None: + """逐根 bar:清空上一根 pending,转发策略 on_bar(策略内调 send_order 收集新单)。""" + self.pending_orders = [] + if self.strategy is not None: + self.strategy.on_bar(bar) + + def pop_orders(self) -> list[PaperOrder]: + """PaperEngine 撮合后取走 pending_orders。""" + orders = self.pending_orders + self.pending_orders = [] + return orders diff --git a/tests/trader/test_cta_adapter.py b/tests/trader/test_cta_adapter.py new file mode 100644 index 0000000..67c80de --- /dev/null +++ b/tests/trader/test_cta_adapter.py @@ -0,0 +1,65 @@ +"""PaperCtaEngine 适配器测试(mock 策略,不依赖 vnpy_ctastrategy,spec §5)。""" +import pytest +from sanguo_trader.cta_adapter import PaperCtaEngine, _direction_to_side +from sanguo_trader.models import MatchSession, OrderSide, PaperOrder + + +class MockStrategy: + """模拟 CtaTemplate:on_bar 调 cta_engine.send_order。""" + def __init__(self, engine, vt_symbol="600000.SSE"): + self.cta_engine = engine + self.vt_symbol = vt_symbol + + def on_bar(self, bar): + # 模拟策略:每根 bar 买 100 股 @10.0 + self.cta_engine.send_order(self, "LONG", "OPEN", 10.0, 100) + + +def test_send_order_collects_paper_order(): + engine = PaperCtaEngine("s1") + strat = MockStrategy(engine) + engine.set_strategy(strat) + engine.on_bar(None) # 触发策略 on_bar → send_order + orders = engine.pop_orders() + assert len(orders) == 1 + assert isinstance(orders[0], PaperOrder) + assert orders[0].strategy_id == "s1" + assert orders[0].symbol == "600000" + assert orders[0].side == OrderSide.BUY + assert orders[0].price == 10.0 + + +def test_match_session_propagates(): + engine = PaperCtaEngine("s1", match_session="current_close") + strat = MockStrategy(engine) + engine.set_strategy(strat) + engine.on_bar(None) + orders = engine.pop_orders() + assert orders[0].match_session == MatchSession.CURRENT_CLOSE + + +def test_on_bar_clears_previous_pending(): + engine = PaperCtaEngine("s1") + engine.pending_orders.append(PaperOrder("s1", "600000", OrderSide.BUY, 10, 100)) + strat = MockStrategy(engine) + engine.set_strategy(strat) + engine.on_bar(None) # 应清空上一根,只留本根 1 单 + assert len(engine.pending_orders) == 1 + + +def test_cancel_order_removes_pending(): + engine = PaperCtaEngine("s1") + strat = MockStrategy(engine) + engine.set_strategy(strat) + engine.on_bar(None) + oids = engine.send_order(strat, "LONG", "OPEN", 11.0, 200) # 第 2 单 + assert len(engine.pending_orders) == 2 + engine.cancel_order(oids[0]) + assert len(engine.pending_orders) == 1 + + +def test_direction_mapping(): + assert _direction_to_side("LONG") == OrderSide.BUY + assert _direction_to_side("SHORT") == OrderSide.SELL + assert _direction_to_side("多") == OrderSide.BUY + assert _direction_to_side("空") == OrderSide.SELL