164690373f
- 资金占用成本(spec§195): StrategyRunner.daily_borrow_cost(used×risk_free/365) 归因per_strategy_pnl(不碰account总账, account.equity真实净值不变); config risk_free_rate=0.02; engine.step mark_to_market后计扣; =0向后兼容跳过 - 分红送股(spec§295): dividend_source.py(akshare stock_history_dividend_detail, 实测600000/000001纯现金分红); PositionLedger.apply_split(volume×factor/avg÷factor); Account.apply_cash_dividend; engine._apply_dividends(除权日调整,现金先split后); mark_to_market停牌prev_close兜底(今收→前收→均价); _run_replay注入dividends日历 - 修_restore_ledger预存bug: PositionLedger.__init__加volume/frozen/avg_price参数 (原只symbol, live_orchestrator跨日恢复4参数调用会TypeError, 首次step空仓未暴露) - 139 passed(119基准+20分红+3占用成本), 无回归 - live_step dividends注入待分期项(每日拉全市场分红慢, 需run_daily_update预拉日历)
188 lines
9.0 KiB
Python
188 lines
9.0 KiB
Python
"""PaperEngine 模拟盘主循环(逐根 bar 重放 + 双层记账 + 持久化,spec §4/§9)。
|
||
|
||
双源(分红除权准确方案,task #79 恢复):
|
||
- 撮合/涨跌停/盯市用 **raw**(真实价,涨跌停/成交真实)
|
||
- 策略 on_bar 信号用 **qfq**(前复权,无除权缺口 → MA 信号准)
|
||
run() 双迭代器 zip(raw, qfq) 同日期对齐;step(raw_bars, qfq_bars)。
|
||
"""
|
||
import logging
|
||
|
||
import pandas as pd
|
||
|
||
from .account import Account
|
||
from .matcher import cross_order
|
||
from .models import MatchSession, OrderSide, PaperTrade
|
||
from .persistence import save_daily_balance, save_trade, update_checkpoint
|
||
from .strategy_runner import StrategyRunner
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _to_series(bar) -> pd.Series:
|
||
return pd.Series({
|
||
"open": bar.open_price, "high": bar.high_price,
|
||
"low": bar.low_price, "close": bar.close_price,
|
||
"date": getattr(bar, "datetime", ""),
|
||
})
|
||
|
||
|
||
def _trade_to_dict(t: PaperTrade, bar_date) -> dict:
|
||
return {
|
||
"strategy_id": t.strategy_id, "symbol": t.symbol,
|
||
"direction": t.side.value, "price": t.price, "volume": t.volume,
|
||
"commission": t.commission, "stamp_duty": t.stamp_duty,
|
||
"transfer_fee": t.transfer_fee,
|
||
"bar_date": str(bar_date), "match_session": t.match_session.value,
|
||
}
|
||
|
||
|
||
class PaperEngine:
|
||
def __init__(self, account: Account, runners: list[StrategyRunner],
|
||
data_source, cfg, db_path: str, account_id: int,
|
||
symbols: list[str], start: str, end: str,
|
||
interval: str = "d", risk_free_rate: float = 0.0,
|
||
dividends_by_date: dict | None = None) -> None:
|
||
self.account = account
|
||
self.runners = runners
|
||
self.data_source = data_source
|
||
self.cfg = cfg
|
||
self.db_path = db_path
|
||
self.account_id = account_id
|
||
self.symbols = symbols
|
||
self.start = start
|
||
self.end = end
|
||
self.interval = interval
|
||
self.risk_free_rate = risk_free_rate # spec §195 资金占用成本归因
|
||
# spec §295 分红送股日历 {ex_date_str: {symbol: DividendEvent}}(缺省空=不处理)
|
||
self.dividends_by_date = dividends_by_date or {}
|
||
|
||
def step(self, bar_date, raw_bars, qfq_bars, prev_close, pending):
|
||
"""单根 bar 推进(回放 run 循环调;实走 live_step 调)。
|
||
|
||
撮合/盯市用 raw_bars(真实价);策略 on_bar 用 qfq_bars(信号准)。
|
||
返回 (新 pending, 当根 closes)。
|
||
"""
|
||
self._bar_count = getattr(self, "_bar_count", 0) + 1
|
||
self.account.unfreeze_all()
|
||
for r in self.runners:
|
||
r.unfreeze_all()
|
||
# 0. 除权除息日:分红送股调整(开盘前持仓享权,spec §295)
|
||
self._apply_dividends(bar_date)
|
||
# 1. 撮合上一根 pending(next_open,用当日 raw bar)
|
||
if pending:
|
||
for order, runner in pending:
|
||
self._match(order, runner, raw_bars, prev_close, bar_date)
|
||
pending = []
|
||
# 2. 喂策略 on_bar(qfq 信号)→ 收新单 → 当根撮合 raw / 缓冲 next_open
|
||
for runner in self.runners:
|
||
sym = runner.symbol
|
||
if sym and sym in qfq_bars:
|
||
runner.paper_cta_engine.on_bar(qfq_bars[sym])
|
||
for order in runner.paper_cta_engine.pop_orders():
|
||
if order.match_session == MatchSession.NEXT_OPEN:
|
||
pending.append((order, runner))
|
||
else: # current_close 当根撮合(raw)
|
||
self._match(order, runner, raw_bars, prev_close, bar_date)
|
||
# 3. 盯市 raw + 入库(停牌缺 bar 用 prev_close 兜底,spec §295)
|
||
closes = {s: raw_bars[s].close_price for s in raw_bars}
|
||
self.account.mark_to_market(closes, prev_close)
|
||
# 资金占用成本归因(spec §195):每策略持仓按无风险利率日扣,仅记 per_strategy_pnl
|
||
# 不扣 account.cash —— 总账净值保持真实,占用成本是分策略展示用归因
|
||
per_strategy_pnl: dict = {}
|
||
if self.risk_free_rate > 0:
|
||
for r in self.runners:
|
||
cost = r.daily_borrow_cost(closes, self.risk_free_rate)
|
||
per_strategy_pnl[r.strategy_id] = {"borrow_cost": cost}
|
||
save_daily_balance(
|
||
self.db_path, self.account_id, str(bar_date),
|
||
self.account.cash, self.account.market_value, self.account.equity,
|
||
per_strategy_pnl=per_strategy_pnl or None,
|
||
is_checkpoint=(self._bar_count % 500 == 0),
|
||
)
|
||
update_checkpoint(self.db_path, self.account_id, str(bar_date))
|
||
return pending, closes
|
||
|
||
def run(self) -> None:
|
||
"""双源 zip(raw, qfq) 同日期对齐,逐根 step。
|
||
|
||
分红送股日历由调用方经 dividends_by_date 注入(见 __init__);
|
||
回测脚本可用 sanguo_data.dividend_source.build_dividend_calendar 预拉。
|
||
"""
|
||
prev_close: dict[str, float] = {}
|
||
pending: list = [] # [(order, runner)] next_open 待下根撮合
|
||
raw_iter = self.data_source.iter_bars(
|
||
self.symbols, self.start, self.end, self.interval, "raw", None
|
||
)
|
||
qfq_iter = self.data_source.iter_bars(
|
||
self.symbols, self.start, self.end, self.interval, "qfq", None
|
||
)
|
||
for (rdate, raw_bars), (_qdate, qfq_bars) in zip(raw_iter, qfq_iter):
|
||
pending, closes = self.step(rdate, raw_bars, qfq_bars, prev_close, pending)
|
||
prev_close = closes
|
||
|
||
def _apply_dividends(self, bar_date) -> None:
|
||
"""除权除息日调整持仓(spec §295):现金分红按除权前持仓量,再 apply_split。
|
||
|
||
双层记账一致性:Account 与各 StrategyRunner 分户同步 split。
|
||
"""
|
||
events = self.dividends_by_date.get(str(bar_date))
|
||
if not events:
|
||
return
|
||
for sym, ev in events.items():
|
||
pos = self.account.positions.get(sym)
|
||
if pos is None or pos.volume <= 0:
|
||
continue
|
||
# 现金分红用除权前持仓量(apply_split 前的 volume)
|
||
self.account.apply_cash_dividend(sym, ev.cash_per_share)
|
||
pos.apply_split(ev.split_factor)
|
||
# 分户同步 split(保持总账=分户之和)
|
||
for r in self.runners:
|
||
rpos = r.positions.get(sym)
|
||
if rpos is not None and rpos.volume > 0:
|
||
rpos.apply_split(ev.split_factor)
|
||
|
||
def _match(self, order, runner, bars, prev_close, bar_date) -> None:
|
||
if order.symbol not in bars:
|
||
return
|
||
match_bar = _to_series(bars[order.symbol])
|
||
pc = prev_close.get(order.symbol, order.price)
|
||
result = cross_order(order, match_bar, pc, self.cfg)
|
||
if isinstance(result, PaperTrade):
|
||
if result.side == OrderSide.SELL:
|
||
# A 股不能做空:SELL 超过可卖持仓 → 拒单(不开空仓)
|
||
pos = self.account.positions.get(order.symbol)
|
||
if pos is None or pos.available < result.volume:
|
||
save_trade(self.db_path, self.account_id,
|
||
{"strategy_id": order.strategy_id, "symbol": order.symbol,
|
||
"bar_date": str(bar_date)},
|
||
rejected=True,
|
||
reject_reason="insufficient_position_no_short")
|
||
return
|
||
if self.account.cash_enough(result):
|
||
# 软限额:BUY 检查该策略已用额度(消除多策略撮合顺序依赖,spec §195)
|
||
if result.side == OrderSide.BUY:
|
||
closes = {sym: b.close_price for sym, b in bars.items()}
|
||
if (runner.used_allocation(closes) + result.price * result.volume
|
||
> runner.max_allocation):
|
||
save_trade(self.db_path, self.account_id,
|
||
{"strategy_id": order.strategy_id,
|
||
"symbol": order.symbol,
|
||
"bar_date": str(bar_date)},
|
||
rejected=True,
|
||
reject_reason="max_allocation_exceeded")
|
||
return
|
||
self.account.apply_trade(result)
|
||
runner.apply_trade(result)
|
||
save_trade(self.db_path, self.account_id,
|
||
_trade_to_dict(result, bar_date))
|
||
else:
|
||
save_trade(self.db_path, self.account_id,
|
||
{"strategy_id": order.strategy_id, "symbol": order.symbol,
|
||
"bar_date": str(bar_date)},
|
||
rejected=True, reject_reason="insufficient_cash")
|
||
else: # PaperReject
|
||
save_trade(self.db_path, self.account_id,
|
||
{"strategy_id": order.strategy_id, "symbol": order.symbol,
|
||
"bar_date": str(bar_date)},
|
||
rejected=True, reject_reason=result.reason)
|