feat(trader): Account总账+StrategyRunner分户(双层记账/资金T0/股票T1)

- Account: cash资金T0/合并持仓/equity盯市/cash_enough买单检查
- StrategyRunner: 分户持仓+realized_pnl归因/unrealized_pnl
- transfer_fee 直接用(matcher已双向,不再×2,review H3)
- unfreeze_all 对称(总账+分户,T+1每日解冻)
7 tests passed.
This commit is contained in:
2026-07-07 11:50:40 +08:00
parent 05d74fc2c1
commit e5e4eef807
3 changed files with 196 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
"""模拟盘总账:资金 T+0 / 合并持仓 / 净值盯市(spec §7 双层记账总账层)。
- cash 资金 T+0:卖出回笼资金当日即可再买(A 股硬规则)
- 股票 T+1:买入持仓由 PositionLedger.frozen 管,次日 unfreeze_all 解冻
- transfer_feematcher 已出双向总额(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
+51
View File
@@ -0,0 +1,51 @@
"""模拟盘分户账:每策略持仓 + 已实现盈亏归因(spec §7 双层记账分户层)。
与 Account 共享同一笔 tradeAccount 记合并总账,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
+87
View File
@@ -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:买入当日 frozenunfreeze 后才可卖。"""
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 H3matcher 出双向 transfer_feeAccount 直接用不再 ×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)