From e5e4eef807bd407e6906192e7c8060aa2eb51a43 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Tue, 7 Jul 2026 11:50:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(trader):=20Account=E6=80=BB=E8=B4=A6+Strat?= =?UTF-8?q?egyRunner=E5=88=86=E6=88=B7(=E5=8F=8C=E5=B1=82=E8=AE=B0?= =?UTF-8?q?=E8=B4=A6/=E8=B5=84=E9=87=91T0/=E8=82=A1=E7=A5=A8T1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Account: cash资金T0/合并持仓/equity盯市/cash_enough买单检查 - StrategyRunner: 分户持仓+realized_pnl归因/unrealized_pnl - transfer_fee 直接用(matcher已双向,不再×2,review H3) - unfreeze_all 对称(总账+分户,T+1每日解冻) 7 tests passed. --- sanguo_trader/account.py | 58 +++++++++++++++++++++ sanguo_trader/strategy_runner.py | 51 +++++++++++++++++++ tests/trader/test_account.py | 87 ++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 sanguo_trader/account.py create mode 100644 sanguo_trader/strategy_runner.py create mode 100644 tests/trader/test_account.py diff --git a/sanguo_trader/account.py b/sanguo_trader/account.py new file mode 100644 index 0000000..4d33328 --- /dev/null +++ b/sanguo_trader/account.py @@ -0,0 +1,58 @@ +"""模拟盘总账:资金 T+0 / 合并持仓 / 净值盯市(spec §7 双层记账总账层)。 + +- cash 资金 T+0:卖出回笼资金当日即可再买(A 股硬规则) +- 股票 T+1:买入持仓由 PositionLedger.frozen 管,次日 unfreeze_all 解冻 +- transfer_fee:matcher 已出双向总额(review H3),Account 直接用,不再 ×2 +- 资金检查 cash_enough 在买单 apply 前由 PaperEngine 调用 +""" +from .models import OrderSide, PaperTrade +from .position_ledger import PositionLedger + + +class Account: + def __init__(self, initial_capital: float) -> None: + self.cash: float = float(initial_capital) + self.positions: dict[str, PositionLedger] = {} + self.market_value: float = 0.0 + + def _position(self, symbol: str) -> PositionLedger: + if symbol not in self.positions: + self.positions[symbol] = PositionLedger(symbol) + return self.positions[symbol] + + def cash_enough(self, trade: PaperTrade) -> bool: + """买单检查现金是否足够(卖出不检查,持仓检查在 PositionLedger)。""" + if trade.side != OrderSide.BUY: + return True + cost = trade.price * trade.volume + trade.commission + trade.transfer_fee + return self.cash >= cost + + def apply_trade(self, trade: PaperTrade) -> None: + pos = self._position(trade.symbol) + if trade.side == OrderSide.BUY: + cost = trade.price * trade.volume + trade.commission + trade.transfer_fee + self.cash -= cost + pos.apply_buy(trade.price, trade.volume) + else: + proceeds = ( + trade.price * trade.volume + - trade.commission - trade.stamp_duty - trade.transfer_fee + ) + self.cash += proceeds # 资金 T+0:卖出现金立即可用 + pos.apply_sell(trade.price, trade.volume) + + def unfreeze_all(self) -> None: + """每日开盘前:T+1 解冻所有持仓。""" + for p in self.positions.values(): + p.unfreeze() + + def mark_to_market(self, bars_raw: dict[str, float]) -> None: + """按 raw 收盘价重估合并持仓市值。bars_raw: {symbol: close_raw}。""" + self.market_value = sum( + p.volume * bars_raw.get(sym, p.avg_price) + for sym, p in self.positions.items() + ) + + @property + def equity(self) -> float: + return self.cash + self.market_value diff --git a/sanguo_trader/strategy_runner.py b/sanguo_trader/strategy_runner.py new file mode 100644 index 0000000..a111aab --- /dev/null +++ b/sanguo_trader/strategy_runner.py @@ -0,0 +1,51 @@ +"""模拟盘分户账:每策略持仓 + 已实现盈亏归因(spec §7 双层记账分户层)。 + +与 Account 共享同一笔 trade:Account 记合并总账,StrategyRunner 记该策略归因。 +""" +from .models import OrderSide, PaperTrade +from .position_ledger import PositionLedger + + +class StrategyRunner: + def __init__(self, strategy_id: str, strategy=None, paper_cta_engine=None) -> None: + self.strategy_id: str = strategy_id + self.strategy = strategy + self.paper_cta_engine = paper_cta_engine + self.positions: dict[str, PositionLedger] = {} + self.realized_pnl: float = 0.0 + self.commission_paid: float = 0.0 + + def _position(self, symbol: str) -> PositionLedger: + if symbol not in self.positions: + self.positions[symbol] = PositionLedger(symbol) + return self.positions[symbol] + + def apply_trade(self, trade: PaperTrade) -> None: + pos = self._position(trade.symbol) + if trade.side == OrderSide.BUY: + pos.apply_buy(trade.price, trade.volume) + self.commission_paid += trade.commission + trade.transfer_fee + else: + avg = pos.avg_price + realized = ( + (trade.price - avg) * trade.volume + - trade.commission - trade.stamp_duty - trade.transfer_fee + ) + self.realized_pnl += realized + self.commission_paid += ( + trade.commission + trade.stamp_duty + trade.transfer_fee + ) + pos.apply_sell(trade.price, trade.volume) + + def unfreeze_all(self) -> None: + """每日开盘前:T+1 解冻分户持仓(与 Account.unfreeze_all 对称)。""" + for p in self.positions.values(): + p.unfreeze() + + def unrealized_pnl(self, bars_raw: dict[str, float]) -> float: + """按 raw 收盘价算浮动盈亏(未实现)。""" + total = 0.0 + for sym, p in self.positions.items(): + if p.volume > 0: + total += (bars_raw.get(sym, p.avg_price) - p.avg_price) * p.volume + return total diff --git a/tests/trader/test_account.py b/tests/trader/test_account.py new file mode 100644 index 0000000..b4353a4 --- /dev/null +++ b/tests/trader/test_account.py @@ -0,0 +1,87 @@ +"""Account + StrategyRunner 双层记账测试(spec §7)。""" +import pytest +from sanguo_trader.account import Account +from sanguo_trader.strategy_runner import StrategyRunner +from sanguo_trader.models import MatchSession, OrderSide, PaperTrade + + +def mktrade(side, price=10.0, volume=100, commission=5.0, stamp=0.0, + transfer_fee=0.02, strategy_id="s1", symbol="600000"): + return PaperTrade( + strategy_id, symbol, side, price, volume, commission, + stamp, transfer_fee, "", MatchSession.NEXT_OPEN, + ) + + +def test_capital_t0_sell_then_buy_immediately(): + """资金 T+0:卖出现金立即可用于再买。""" + acc = Account(initial_capital=1_000_000) + acc.apply_trade(mktrade(OrderSide.BUY, price=10.0, volume=100)) + acc.unfreeze_all() + cash_after_buy = acc.cash + acc.apply_trade(mktrade(OrderSide.SELL, price=11.0, volume=100, stamp=0.55)) + assert acc.cash > cash_after_buy # 卖出后现金立即增加 + assert acc.cash_enough(mktrade(OrderSide.BUY, price=11.0, volume=50)) # 可立即再买 + + +def test_share_t1_buy_frozen_until_unfreeze(): + """股票 T+1:买入当日 frozen,unfreeze 后才可卖。""" + acc = Account(initial_capital=1_000_000) + acc.apply_trade(mktrade(OrderSide.BUY, volume=100)) + pos = acc.positions["600000"] + assert pos.frozen == 100 + assert pos.available == 0 + acc.unfreeze_all() + assert pos.available == 100 + + +def test_cash_enough_insufficient_rejected(): + acc = Account(initial_capital=1000) + # cost = 10*100 + 5 + 0.02 = 1005.02 > 1000 + assert acc.cash_enough(mktrade(OrderSide.BUY, price=10.0, volume=100)) is False + + +def test_transfer_fee_not_doubled_on_buy(): + """review H3:matcher 出双向 transfer_fee,Account 直接用不再 ×2。""" + acc = Account(initial_capital=1_000_000) + acc.apply_trade(mktrade(OrderSide.BUY, price=10.0, volume=100, transfer_fee=0.02)) + assert acc.cash == pytest.approx(1_000_000 - 1005.02) + + +def test_double_entry_account_equals_sum_of_runners(): + """双层记账一致性:总账持仓 = 各分户持仓之和。""" + acc = Account(initial_capital=1_000_000) + r1 = StrategyRunner("s1") + r2 = StrategyRunner("s2") + t1 = mktrade(OrderSide.BUY, volume=100, strategy_id="s1") + t2 = mktrade(OrderSide.BUY, volume=200, strategy_id="s2") + for t in (t1, t2): + acc.apply_trade(t) + r1.apply_trade(t1) + r2.apply_trade(t2) + assert acc.positions["600000"].volume == 300 + assert r1.positions["600000"].volume + r2.positions["600000"].volume == 300 + + +def test_runner_realized_pnl_on_sell(): + acc = Account(initial_capital=1_000_000) + runner = StrategyRunner("s1") + buy_t = mktrade(OrderSide.BUY, price=10.0, volume=100, strategy_id="s1") + acc.apply_trade(buy_t) + runner.apply_trade(buy_t) + acc.unfreeze_all() + runner.unfreeze_all() + sell_t = mktrade(OrderSide.SELL, price=12.0, volume=100, stamp=0.6, strategy_id="s1") + acc.apply_trade(sell_t) + runner.apply_trade(sell_t) + # (12-10)*100 - 5(佣) - 0.6(印) - 0.02(过) = 194.38 + assert runner.realized_pnl == pytest.approx(194.38) + + +def test_equity_mark_to_market(): + acc = Account(initial_capital=1_000_000) + acc.apply_trade(mktrade(OrderSide.BUY, price=10.0, volume=100)) + acc.unfreeze_all() + acc.mark_to_market({"600000": 11.0}) + assert acc.market_value == pytest.approx(1100.0) + assert acc.equity == pytest.approx(acc.cash + 1100.0)