diff --git a/sanguo_trader/models.py b/sanguo_trader/models.py new file mode 100644 index 0000000..93cf6db --- /dev/null +++ b/sanguo_trader/models.py @@ -0,0 +1,80 @@ +"""模拟盘数据模型(immutable DTOs)。 + +数据类承载配置与订单/成交/拒单状态,跨模块传递时保持不可变。 +费率默认值见 §6.4(Issue #3 费率参数化)。 +""" +from dataclasses import dataclass +from enum import Enum + + +class MatchSession(str, Enum): + """撮合时点(spec §6.1)。 + + - NEXT_OPEN:下一根 bar 的 open(收盘型策略,安全) + - CURRENT_CLOSE:当根 bar 的 close(尾盘抓涨停型,策略不得用当根 OHLC) + - CALL_AUCTION:集合竞价(首版预留) + """ + + NEXT_OPEN = "next_open" + CURRENT_CLOSE = "current_close" + CALL_AUCTION = "call_auction" + + +class OrderSide(str, Enum): + BUY = "buy" + SELL = "sell" + + +@dataclass(frozen=True) +class AccountConfig: + """账户费率/撮合参数(Issue #3 全字段可配)。""" + + initial_capital: float + rate: float = 0.0003 # 佣金率 + min_commission: float = 5.0 # 最低佣金 5 元 + stamp_duty_rate: float = 0.0005 # 印花税(仅卖,2023.8.28 起 0.05%) + transfer_fee_rate: float = 0.00001 # 过户费率(沪深双向 ×2,由 Account 计算) + slippage: float = 0.0 + pricetick: float = 0.01 + size: float = 1.0 + + +@dataclass(frozen=True) +class PaperOrder: + """策略下单请求。match_session 决定撮合时点。""" + + strategy_id: str + symbol: str + side: OrderSide + price: float + volume: int + is_market: bool = True + match_session: MatchSession = MatchSession.NEXT_OPEN + + +@dataclass(frozen=True) +class PaperTrade: + """已成交记录(含费用拆分)。transfer_fee 为单边,Account 扣款时 ×2。""" + + strategy_id: str + symbol: str + side: OrderSide + price: float + volume: int + commission: float + stamp_duty: float + transfer_fee: float + bar_date: str + match_session: MatchSession + + +@dataclass(frozen=True) +class PaperReject: + """拒单记录。reason 枚举:limit_up_locked / limit_down_locked / + limit_not_touched / volume_below_min_lot / unsupported_match_session / + insufficient_cash / blocked_by_strategy=。""" + + strategy_id: str + symbol: str + reason: str + bar_date: str diff --git a/tests/trader/__init__.py b/tests/trader/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/trader/test_models.py b/tests/trader/test_models.py new file mode 100644 index 0000000..84f6f14 --- /dev/null +++ b/tests/trader/test_models.py @@ -0,0 +1,89 @@ +"""PaperAccount/Order/Trade/Reject 数据类测试。""" +from sanguo_trader.models import ( + AccountConfig, + PaperOrder, + PaperTrade, + PaperReject, + MatchSession, + OrderSide, +) + + +def test_account_config_defaults(): + cfg = AccountConfig(initial_capital=1_000_000) + assert cfg.initial_capital == 1_000_000 + assert cfg.rate == 0.0003 + assert cfg.min_commission == 5.0 + assert cfg.stamp_duty_rate == 0.0005 + assert cfg.transfer_fee_rate == 0.00001 + assert cfg.slippage == 0 + assert cfg.pricetick == 0.01 + + +def test_account_config_is_frozen(): + import dataclasses + + cfg = AccountConfig(initial_capital=1_000_000) + assert dataclasses.is_dataclass(cfg) + # frozen=True → 不可变 + try: + cfg.rate = 0.001 # type: ignore[misc] + except dataclasses.FrozenInstanceError: + pass + else: + raise AssertionError("AccountConfig 应为 frozen dataclass") + + +def test_paper_order_defaults_next_open(): + o = PaperOrder( + strategy_id="s1", + symbol="600000", + side=OrderSide.BUY, + price=10.0, + volume=100, + is_market=True, + ) + assert o.match_session == MatchSession.NEXT_OPEN + + +def test_paper_order_explicit_current_close(): + o = PaperOrder( + strategy_id="s1", + symbol="600000", + side=OrderSide.SELL, + price=10.0, + volume=100, + is_market=False, + match_session=MatchSession.CURRENT_CLOSE, + ) + assert o.match_session == MatchSession.CURRENT_CLOSE + assert o.is_market is False + + +def test_paper_trade_fields(): + t = PaperTrade( + strategy_id="s1", + symbol="600000", + side=OrderSide.BUY, + price=10.0, + volume=100, + commission=5.0, + stamp_duty=0.0, + transfer_fee=0.01, + bar_date="2024-01-02", + match_session=MatchSession.NEXT_OPEN, + ) + assert t.volume == 100 + assert t.commission == 5.0 + assert t.bar_date == "2024-01-02" + + +def test_paperReject_records_reason(): + r = PaperReject( + strategy_id="s1", + symbol="600000", + reason="limit_up_locked", + bar_date="2024-01-02", + ) + assert r.reason == "limit_up_locked" + assert r.symbol == "600000"