105 lines
3.5 KiB
Python
105 lines
3.5 KiB
Python
"""PaperBroker —— 回测中的虚拟撮合 / 账户。
|
||
|
||
建模 A 股规则:
|
||
- 100 股整数手(1手=100股)
|
||
- T+1:当日买入次日才可卖(fast_mode 关闭此约束)
|
||
- 印花税(卖出)、过户费(双边)、佣金(万1 最低5元)、滑点
|
||
- 撮合价:以当根 bar 收盘价近似(阶段1 接 VWAP / 限价单)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
|
||
from ..commission import CostSchedule, DEFAULT, buy_cost, sell_cost
|
||
from ..domain import Fill, Side
|
||
|
||
LOT = 100 # A股 1 手 = 100 股
|
||
|
||
|
||
@dataclass
|
||
class PaperBroker:
|
||
initial_cash: float = 100000.0
|
||
schedule: CostSchedule = field(default_factory=lambda: DEFAULT)
|
||
enable_costs: bool = True
|
||
enable_t_plus_1: bool = True
|
||
|
||
cash: float = field(init=False)
|
||
holdings: float = 0.0 # 可卖数量
|
||
locked: float = 0.0 # 当日买入(T+1 锁定)
|
||
avg_price: float = 0.0
|
||
fills: list[Fill] = field(default_factory=list)
|
||
|
||
def __post_init__(self) -> None:
|
||
self.cash = self.initial_cash
|
||
|
||
@property
|
||
def position(self) -> float:
|
||
return self.holdings + self.locked
|
||
|
||
def equity(self, price: float) -> float:
|
||
return self.cash + self.position * price
|
||
|
||
@staticmethod
|
||
def _to_lots(qty: float) -> int:
|
||
return int(qty // LOT) * LOT
|
||
|
||
def buy_max(self, ts, price: float) -> Fill | None:
|
||
"""用当前现金买尽可能多的整手。"""
|
||
if price <= 0:
|
||
return None
|
||
rate = (
|
||
self.schedule.commission_rate
|
||
+ self.schedule.transfer_fee_rate
|
||
+ self.schedule.slippage_rate
|
||
) if self.enable_costs else 0.0
|
||
affordable_qty = self.cash / (price * (1 + rate))
|
||
qty = self._to_lots(affordable_qty)
|
||
if qty <= 0:
|
||
return None
|
||
return self._execute_buy(ts, price, qty)
|
||
|
||
def sell_all(self, ts, price: float) -> Fill | None:
|
||
"""卖出全部可卖持仓(整手)。"""
|
||
qty = self._to_lots(self.holdings)
|
||
if qty <= 0:
|
||
return None
|
||
return self._execute_sell(ts, price, qty)
|
||
|
||
def _execute_buy(self, ts, price: float, qty: int) -> Fill:
|
||
if self.enable_costs:
|
||
fp, comm, tf = buy_cost(price, qty, self.schedule)
|
||
else:
|
||
fp, comm, tf = price, 0.0, 0.0
|
||
cost = fp * qty + comm + tf
|
||
prev_pos = self.position
|
||
new_pos = prev_pos + qty
|
||
self.avg_price = (self.avg_price * prev_pos + fp * qty) / new_pos if new_pos else 0.0
|
||
if self.enable_t_plus_1:
|
||
self.locked += qty
|
||
else:
|
||
self.holdings += qty
|
||
self.cash -= cost
|
||
f = Fill(ts=ts, side=Side.BUY, price=fp, qty=qty, commission=comm, transfer_fee=tf)
|
||
self.fills.append(f)
|
||
return f
|
||
|
||
def _execute_sell(self, ts, price: float, qty: int) -> Fill:
|
||
if self.enable_costs:
|
||
fp, comm, tf, sd = sell_cost(price, qty, self.schedule)
|
||
else:
|
||
fp, comm, tf, sd = price, 0.0, 0.0, 0.0
|
||
proceeds = fp * qty - comm - tf - sd
|
||
self.holdings -= qty
|
||
self.cash += proceeds
|
||
if self.position == 0:
|
||
self.avg_price = 0.0
|
||
f = Fill(ts=ts, side=Side.SELL, price=fp, qty=qty, commission=comm, stamp_duty=sd, transfer_fee=tf)
|
||
self.fills.append(f)
|
||
return f
|
||
|
||
def release_t_plus_1(self) -> None:
|
||
"""每根 bar 结束时调用:当日锁定转为次日可卖。"""
|
||
if self.enable_t_plus_1:
|
||
self.holdings += self.locked
|
||
self.locked = 0.0
|