feat(trader): C期分期项收尾—资金占用成本+分红送股+_restore_ledger修复
- 资金占用成本(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预拉日历)
This commit is contained in:
@@ -36,3 +36,6 @@ performance:
|
|||||||
max_retries: 3
|
max_retries: 3
|
||||||
fail_window: 100
|
fail_window: 100
|
||||||
fail_threshold: 0.8
|
fail_threshold: 0.8
|
||||||
|
|
||||||
|
# 资金占用成本归因(spec §195):年化无风险利率,每策略占用资金按此日扣归因到 PnL
|
||||||
|
risk_free_rate: 0.02
|
||||||
|
|||||||
@@ -194,6 +194,10 @@ def _run_replay(db, aid, req: PaperCreateRequest):
|
|||||||
s.name, strategy=strat, paper_cta_engine=cta, symbol=s.symbol,
|
s.name, strategy=strat, paper_cta_engine=cta, symbol=s.symbol,
|
||||||
max_allocation=(s.max_allocation if s.max_allocation is not None
|
max_allocation=(s.max_allocation if s.max_allocation is not None
|
||||||
else req.initial_capital)))
|
else req.initial_capital)))
|
||||||
|
from sanguo_data.dividend_source import build_dividend_calendar
|
||||||
|
div_calendar = build_dividend_calendar(req.symbols, req.start, req.end)
|
||||||
pe = PaperEngine(account, runners, _DataSourceWrapper(data_cfg), acc_cfg,
|
pe = PaperEngine(account, runners, _DataSourceWrapper(data_cfg), acc_cfg,
|
||||||
db, aid, req.symbols, req.start, req.end, req.interval)
|
db, aid, req.symbols, req.start, req.end, req.interval,
|
||||||
|
risk_free_rate=getattr(data_cfg, "risk_free_rate", 0.0),
|
||||||
|
dividends_by_date=div_calendar)
|
||||||
pe.run()
|
pe.run()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class DataConfig:
|
|||||||
data_sources: dict
|
data_sources: dict
|
||||||
validation: dict
|
validation: dict
|
||||||
performance: dict
|
performance: dict
|
||||||
|
risk_free_rate: float = 0.02 # 年化无风险利率(spec §195 资金占用成本归因)
|
||||||
|
|
||||||
def load_config(path: str) -> DataConfig:
|
def load_config(path: str) -> DataConfig:
|
||||||
try:
|
try:
|
||||||
@@ -27,6 +28,7 @@ def load_config(path: str) -> DataConfig:
|
|||||||
data_sources=raw.get("data_sources", {}),
|
data_sources=raw.get("data_sources", {}),
|
||||||
validation=raw.get("validation", {}),
|
validation=raw.get("validation", {}),
|
||||||
performance=raw.get("performance", {}),
|
performance=raw.get("performance", {}),
|
||||||
|
risk_free_rate=float(raw.get("risk_free_rate", 0.02)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""分红送股事件源(spec §295 C-S3)。
|
||||||
|
|
||||||
|
akshare `stock_history_dividend_detail(symbol, indicator="分红")` 拉取 A 股
|
||||||
|
分红送股明细。akshare 字段均为「每 10 股」口径,本模块统一转 per-share:
|
||||||
|
|
||||||
|
- 送股 + 转增(每 10 股 X 股)→ split_factor = 1 + (送股+转增)/10
|
||||||
|
- 派息(每 10 股 X 元) → cash_per_share = 派息/10
|
||||||
|
- 除权除息日:持仓调整日(当日开盘前持仓享权)
|
||||||
|
|
||||||
|
akshare 不可用/拉取失败 → 返回 [](事件源抽象,不抛异常避免阻断回测)。
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DividendEvent:
|
||||||
|
"""单次分红送股事件。"""
|
||||||
|
|
||||||
|
ex_date: str # 除权除息日 YYYY-MM-DD(持仓调整日)
|
||||||
|
symbol: str
|
||||||
|
split_factor: float # 1.0 = 无送转;1.5 = 10送5
|
||||||
|
cash_per_share: float # 每股现金分红(元);0.0 = 无现金分红
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_dividends(symbol: str, start: str, end: str) -> list[DividendEvent]:
|
||||||
|
"""拉取 symbol 在 [start, end] 除权除息日内的已实施分红送股事件。
|
||||||
|
|
||||||
|
akshare 未装/出错 → 返回 [](不抛异常)。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import akshare as ak
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("akshare 未安装,%s 分红事件返回空", symbol)
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
df = ak.stock_history_dividend_detail(symbol=symbol, indicator="分红")
|
||||||
|
except Exception as e: # noqa: BLE001 —— 数据源不可控,兜底
|
||||||
|
logger.warning("拉取 %s 分红失败,返回空: %s", symbol, e)
|
||||||
|
return []
|
||||||
|
return _parse_dividend_df(df, symbol, start, end)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dividend_df(df: pd.DataFrame, symbol: str,
|
||||||
|
start: str, end: str) -> list[DividendEvent]:
|
||||||
|
"""解析 akshare 分红明细 DataFrame → DividendEvent 列表。"""
|
||||||
|
if df is None or len(df) == 0:
|
||||||
|
return []
|
||||||
|
events: list[DividendEvent] = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
if str(row.get("进度", "")) != "实施":
|
||||||
|
continue
|
||||||
|
ex_date = _norm_date(row.get("除权除息日"))
|
||||||
|
if ex_date is None or not (start <= ex_date <= end):
|
||||||
|
continue
|
||||||
|
send = _to_float(row.get("送股", 0)) # 每 10 股送股
|
||||||
|
transfer = _to_float(row.get("转增", 0)) # 每 10 股转增
|
||||||
|
cash = _to_float(row.get("派息", 0)) # 每 10 股派息(元)
|
||||||
|
split_factor = 1.0 + (send + transfer) / 10.0
|
||||||
|
cash_per_share = cash / 10.0
|
||||||
|
if split_factor == 1.0 and cash_per_share == 0.0:
|
||||||
|
continue
|
||||||
|
events.append(DividendEvent(ex_date, symbol, split_factor, cash_per_share))
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def build_dividend_calendar(
|
||||||
|
symbols: list[str], start: str, end: str
|
||||||
|
) -> dict[str, dict[str, DividendEvent]]:
|
||||||
|
"""批量构建 {ex_date: {symbol: DividendEvent}} 日历(回测 preload 用)。"""
|
||||||
|
calendar: dict[str, dict[str, DividendEvent]] = {}
|
||||||
|
for sym in symbols:
|
||||||
|
for ev in fetch_dividends(sym, start, end):
|
||||||
|
calendar.setdefault(ev.ex_date, {})[sym] = ev
|
||||||
|
return calendar
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_date(val) -> str | None:
|
||||||
|
"""除权除息日归一化为 YYYY-MM-DD 字符串;NaT/缺失 → None。"""
|
||||||
|
if val is None or (isinstance(val, float) and pd.isna(val)):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
ts = pd.Timestamp(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
if pd.isna(ts):
|
||||||
|
return None
|
||||||
|
return ts.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(val, default: float = 0.0) -> float:
|
||||||
|
"""安全转 float;NaN/缺失 → default。"""
|
||||||
|
try:
|
||||||
|
f = float(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return default
|
||||||
|
return default if pd.isna(f) else f
|
||||||
@@ -46,10 +46,27 @@ class Account:
|
|||||||
for p in self.positions.values():
|
for p in self.positions.values():
|
||||||
p.unfreeze()
|
p.unfreeze()
|
||||||
|
|
||||||
def mark_to_market(self, bars_raw: dict[str, float]) -> None:
|
def apply_cash_dividend(self, symbol: str, per_share: float) -> None:
|
||||||
"""按 raw 收盘价重估合并持仓市值。bars_raw: {symbol: close_raw}。"""
|
"""现金分红到账:cash += per_share × 持仓量(spec §295)。
|
||||||
|
|
||||||
|
按 A 股惯例按除权日前一交易日持仓量派发;无持仓/非正值 no-op。
|
||||||
|
"""
|
||||||
|
if per_share <= 0:
|
||||||
|
return
|
||||||
|
pos = self.positions.get(symbol)
|
||||||
|
if pos is None or pos.volume <= 0:
|
||||||
|
return
|
||||||
|
self.cash += per_share * pos.volume
|
||||||
|
|
||||||
|
def mark_to_market(self, bars_raw: dict[str, float],
|
||||||
|
prev_close: dict[str, float] | None = None) -> None:
|
||||||
|
"""按 raw 收盘价重估合并持仓市值(spec §295 停牌盯市兜底)。
|
||||||
|
|
||||||
|
bar 缺失(停牌)→ 用前日 close 兜底,再退到 avg_price。
|
||||||
|
"""
|
||||||
|
prev_close = prev_close or {}
|
||||||
self.market_value = sum(
|
self.market_value = sum(
|
||||||
p.volume * bars_raw.get(sym, p.avg_price)
|
p.volume * bars_raw.get(sym, prev_close.get(sym, p.avg_price))
|
||||||
for sym, p in self.positions.items()
|
for sym, p in self.positions.items()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+43
-4
@@ -40,7 +40,8 @@ class PaperEngine:
|
|||||||
def __init__(self, account: Account, runners: list[StrategyRunner],
|
def __init__(self, account: Account, runners: list[StrategyRunner],
|
||||||
data_source, cfg, db_path: str, account_id: int,
|
data_source, cfg, db_path: str, account_id: int,
|
||||||
symbols: list[str], start: str, end: str,
|
symbols: list[str], start: str, end: str,
|
||||||
interval: str = "d") -> None:
|
interval: str = "d", risk_free_rate: float = 0.0,
|
||||||
|
dividends_by_date: dict | None = None) -> None:
|
||||||
self.account = account
|
self.account = account
|
||||||
self.runners = runners
|
self.runners = runners
|
||||||
self.data_source = data_source
|
self.data_source = data_source
|
||||||
@@ -51,6 +52,9 @@ class PaperEngine:
|
|||||||
self.start = start
|
self.start = start
|
||||||
self.end = end
|
self.end = end
|
||||||
self.interval = interval
|
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):
|
def step(self, bar_date, raw_bars, qfq_bars, prev_close, pending):
|
||||||
"""单根 bar 推进(回放 run 循环调;实走 live_step 调)。
|
"""单根 bar 推进(回放 run 循环调;实走 live_step 调)。
|
||||||
@@ -62,6 +66,8 @@ class PaperEngine:
|
|||||||
self.account.unfreeze_all()
|
self.account.unfreeze_all()
|
||||||
for r in self.runners:
|
for r in self.runners:
|
||||||
r.unfreeze_all()
|
r.unfreeze_all()
|
||||||
|
# 0. 除权除息日:分红送股调整(开盘前持仓享权,spec §295)
|
||||||
|
self._apply_dividends(bar_date)
|
||||||
# 1. 撮合上一根 pending(next_open,用当日 raw bar)
|
# 1. 撮合上一根 pending(next_open,用当日 raw bar)
|
||||||
if pending:
|
if pending:
|
||||||
for order, runner in pending:
|
for order, runner in pending:
|
||||||
@@ -77,19 +83,31 @@ class PaperEngine:
|
|||||||
pending.append((order, runner))
|
pending.append((order, runner))
|
||||||
else: # current_close 当根撮合(raw)
|
else: # current_close 当根撮合(raw)
|
||||||
self._match(order, runner, raw_bars, prev_close, bar_date)
|
self._match(order, runner, raw_bars, prev_close, bar_date)
|
||||||
# 3. 盯市 raw + 入库
|
# 3. 盯市 raw + 入库(停牌缺 bar 用 prev_close 兜底,spec §295)
|
||||||
closes = {s: raw_bars[s].close_price for s in raw_bars}
|
closes = {s: raw_bars[s].close_price for s in raw_bars}
|
||||||
self.account.mark_to_market(closes)
|
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(
|
save_daily_balance(
|
||||||
self.db_path, self.account_id, str(bar_date),
|
self.db_path, self.account_id, str(bar_date),
|
||||||
self.account.cash, self.account.market_value, self.account.equity,
|
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),
|
is_checkpoint=(self._bar_count % 500 == 0),
|
||||||
)
|
)
|
||||||
update_checkpoint(self.db_path, self.account_id, str(bar_date))
|
update_checkpoint(self.db_path, self.account_id, str(bar_date))
|
||||||
return pending, closes
|
return pending, closes
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
"""双源 zip(raw, qfq) 同日期对齐,逐根 step。"""
|
"""双源 zip(raw, qfq) 同日期对齐,逐根 step。
|
||||||
|
|
||||||
|
分红送股日历由调用方经 dividends_by_date 注入(见 __init__);
|
||||||
|
回测脚本可用 sanguo_data.dividend_source.build_dividend_calendar 预拉。
|
||||||
|
"""
|
||||||
prev_close: dict[str, float] = {}
|
prev_close: dict[str, float] = {}
|
||||||
pending: list = [] # [(order, runner)] next_open 待下根撮合
|
pending: list = [] # [(order, runner)] next_open 待下根撮合
|
||||||
raw_iter = self.data_source.iter_bars(
|
raw_iter = self.data_source.iter_bars(
|
||||||
@@ -102,6 +120,27 @@ class PaperEngine:
|
|||||||
pending, closes = self.step(rdate, raw_bars, qfq_bars, prev_close, pending)
|
pending, closes = self.step(rdate, raw_bars, qfq_bars, prev_close, pending)
|
||||||
prev_close = closes
|
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:
|
def _match(self, order, runner, bars, prev_close, bar_date) -> None:
|
||||||
if order.symbol not in bars:
|
if order.symbol not in bars:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -139,7 +139,8 @@ def live_step(db_path: str, account_id: int, data_source, cfg, today: str | None
|
|||||||
transfer_fee_rate=acc["transfer_fee_rate"], min_commission=acc["min_commission"],
|
transfer_fee_rate=acc["transfer_fee_rate"], min_commission=acc["min_commission"],
|
||||||
)
|
)
|
||||||
pe = PaperEngine(account, runners, data_source, acc_cfg, db_path, account_id,
|
pe = PaperEngine(account, runners, data_source, acc_cfg, db_path, account_id,
|
||||||
symbols, acc.get("start_date") or today, today, interval)
|
symbols, acc.get("start_date") or today, today, interval,
|
||||||
|
risk_free_rate=getattr(cfg, "risk_free_rate", 0.0))
|
||||||
pending_new, _closes = pe.step(today, bars, prev_close, pending)
|
pending_new, _closes = pe.step(today, bars, prev_close, pending)
|
||||||
|
|
||||||
# 6. 存状态(pending + positions)
|
# 6. 存状态(pending + positions)
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ mutable,被 Account / StrategyRunner 持有(spec §6.3 双层记账)。
|
|||||||
- 零股:卖出允许零股(退出持仓基本操作),买入由 matcher 保证 100 股整取
|
- 零股:卖出允许零股(退出持仓基本操作),买入由 matcher 保证 100 股整取
|
||||||
"""
|
"""
|
||||||
class PositionLedger:
|
class PositionLedger:
|
||||||
def __init__(self, symbol: str):
|
def __init__(self, symbol: str, volume: int = 0, frozen: int = 0, avg_price: float = 0.0):
|
||||||
self.symbol: str = symbol
|
self.symbol: str = symbol
|
||||||
self.volume: int = 0
|
self.volume: int = volume
|
||||||
self.frozen: int = 0 # T+1 当日买入冻结
|
self.frozen: int = frozen # T+1 当日买入冻结
|
||||||
self.avg_price: float = 0.0
|
self.avg_price: float = avg_price
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> int:
|
def available(self) -> int:
|
||||||
@@ -39,6 +39,19 @@ class PositionLedger:
|
|||||||
if self.volume == 0:
|
if self.volume == 0:
|
||||||
self.avg_price = 0.0
|
self.avg_price = 0.0
|
||||||
|
|
||||||
|
def apply_split(self, factor: float) -> None:
|
||||||
|
"""送股/转增:volume ×= factor,avg_price /= factor(总市值不变,spec §295)。
|
||||||
|
|
||||||
|
factor=(10+送转)/10,对 100 股整数倍持仓结果恒为整数。
|
||||||
|
factor<=0 非法;空持仓 no-op。
|
||||||
|
"""
|
||||||
|
if factor <= 0:
|
||||||
|
raise ValueError(f"split factor 必须为正: {factor}")
|
||||||
|
if self.volume <= 0:
|
||||||
|
return
|
||||||
|
self.volume = int(round(self.volume * factor))
|
||||||
|
self.avg_price = self.avg_price / factor
|
||||||
|
|
||||||
def unfreeze(self) -> None:
|
def unfreeze(self) -> None:
|
||||||
"""次日开盘前调用:frozen → available。"""
|
"""次日开盘前调用:frozen → available。"""
|
||||||
self.frozen = 0
|
self.frozen = 0
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ class StrategyRunner:
|
|||||||
for sym, p in self.positions.items() if p.volume > 0
|
for sym, p in self.positions.items() if p.volume > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def daily_borrow_cost(self, closes: dict[str, float] | None = None,
|
||||||
|
risk_free_rate: float = 0.0) -> float:
|
||||||
|
"""资金占用日成本 = 占用资金 × 年化无风险利率 / 365(spec §195 归因用)。
|
||||||
|
|
||||||
|
纯归因记账:返回值由 PaperEngine 计入 per_strategy_pnl,不扣 account.cash。
|
||||||
|
"""
|
||||||
|
return self.used_allocation(closes) * risk_free_rate / 365.0
|
||||||
|
|
||||||
def _position(self, symbol: str) -> PositionLedger:
|
def _position(self, symbol: str) -> PositionLedger:
|
||||||
if symbol not in self.positions:
|
if symbol not in self.positions:
|
||||||
self.positions[symbol] = PositionLedger(symbol)
|
self.positions[symbol] = PositionLedger(symbol)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""资金占用成本(borrow cost)归因测试(spec §195)。
|
||||||
|
|
||||||
|
每策略占用资金按无风险利率日扣,归因到 per_strategy_pnl;不影响账户总账净值。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sanguo_trader.account import Account
|
||||||
|
from sanguo_trader.cta_adapter import PaperCtaEngine
|
||||||
|
from sanguo_trader.engine import PaperEngine
|
||||||
|
from sanguo_trader.models import AccountConfig, MatchSession
|
||||||
|
from sanguo_trader.persistence import init_db, list_daily_balance, save_account
|
||||||
|
from sanguo_trader.position_ledger import PositionLedger
|
||||||
|
from sanguo_trader.strategy_runner import StrategyRunner
|
||||||
|
|
||||||
|
|
||||||
|
def _bar(date, o, h, l, c):
|
||||||
|
return SimpleNamespace(open_price=o, high_price=h, low_price=l,
|
||||||
|
close_price=c, datetime=date)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDataSource:
|
||||||
|
def __init__(self, sections):
|
||||||
|
self.sections = sections
|
||||||
|
|
||||||
|
def iter_bars(self, symbols, start, end, interval, adjust="qfq", cfg=None):
|
||||||
|
del symbols, start, end, interval, adjust, cfg
|
||||||
|
for date, bars in self.sections:
|
||||||
|
yield date, bars
|
||||||
|
|
||||||
|
|
||||||
|
class _NoOrderStrategy:
|
||||||
|
"""空策略:on_bar 不下单(仅驱动 step 走完盯市/归因路径)。"""
|
||||||
|
|
||||||
|
def __init__(self, engine, vt_symbol):
|
||||||
|
self.cta_engine = engine
|
||||||
|
self.vt_symbol = vt_symbol
|
||||||
|
|
||||||
|
def on_bar(self, bar):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _make_position(symbol, volume, avg_price):
|
||||||
|
pos = PositionLedger(symbol)
|
||||||
|
pos.volume = volume
|
||||||
|
pos.avg_price = avg_price
|
||||||
|
return pos
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_borrow_cost_calculation():
|
||||||
|
# Arrange:200 股 @10 → 占用 2000,rate=0.02 → 日成本 2000*0.02/365
|
||||||
|
runner = StrategyRunner("s1", symbol="600000")
|
||||||
|
runner.positions["600000"] = _make_position("600000", 200, 10.0)
|
||||||
|
closes = {"600000": 10.0}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
cost = runner.daily_borrow_cost(closes, risk_free_rate=0.02)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert cost == pytest.approx(2000 * 0.02 / 365)
|
||||||
|
|
||||||
|
|
||||||
|
def test_holding_cost_greater_than_empty():
|
||||||
|
# Arrange:持仓 runner vs 空仓 runner
|
||||||
|
runner_holding = StrategyRunner("s1", symbol="600000")
|
||||||
|
runner_holding.positions["600000"] = _make_position("600000", 200, 10.0)
|
||||||
|
runner_empty = StrategyRunner("s2", symbol="600000")
|
||||||
|
closes = {"600000": 10.0}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
cost_holding = runner_holding.daily_borrow_cost(closes, 0.02)
|
||||||
|
cost_empty = runner_empty.daily_borrow_cost(closes, 0.02)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert cost_empty == 0.0
|
||||||
|
assert cost_holding > 0
|
||||||
|
assert cost_holding > cost_empty
|
||||||
|
|
||||||
|
|
||||||
|
def test_borrow_cost_attributed_not_affecting_account(tmp_path):
|
||||||
|
# Arrange:预置持仓 200 股 @10(占用 2000),空策略不下单,rate=0.03
|
||||||
|
db = str(tmp_path / "borrow.db")
|
||||||
|
init_db(db)
|
||||||
|
aid = save_account(db, {"name": "t", "initial_capital": 1_000_000})
|
||||||
|
cfg = AccountConfig(initial_capital=1_000_000)
|
||||||
|
account = Account(1_000_000)
|
||||||
|
account.cash = 998000 # 已花 2000 买入
|
||||||
|
account.positions["600000"] = _make_position("600000", 200, 10.0)
|
||||||
|
cta = PaperCtaEngine("s1", match_session=MatchSession.CURRENT_CLOSE)
|
||||||
|
strat = _NoOrderStrategy(cta, "600000.SSE")
|
||||||
|
cta.set_strategy(strat)
|
||||||
|
runner = StrategyRunner("s1", strategy=strat, paper_cta_engine=cta,
|
||||||
|
symbol="600000")
|
||||||
|
runner.positions["600000"] = _make_position("600000", 200, 10.0)
|
||||||
|
sections = [("2024-01-01", {"600000": _bar("2024-01-01", 10.0, 10.5, 9.5, 10.0)})]
|
||||||
|
pe = PaperEngine(account, [runner], _FakeDataSource(sections), cfg, db, aid,
|
||||||
|
symbols=["600000"], start="2024-01-01", end="2024-01-31",
|
||||||
|
risk_free_rate=0.03)
|
||||||
|
cash_before = account.cash
|
||||||
|
|
||||||
|
# Act
|
||||||
|
pe.run()
|
||||||
|
|
||||||
|
# Assert:账户总账不受占用成本影响(仅归因记账)
|
||||||
|
assert account.cash == cash_before
|
||||||
|
balances = list_daily_balance(db, aid)
|
||||||
|
assert len(balances) == 1
|
||||||
|
psp = json.loads(balances[0]["per_strategy_pnl"])
|
||||||
|
assert "s1" in psp
|
||||||
|
assert psp["s1"]["borrow_cost"] == pytest.approx(2000 * 0.03 / 365)
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""分红送股事件测试(spec §295 C-S3)。
|
||||||
|
|
||||||
|
事件类型:
|
||||||
|
- 送股/转增:volume ×= factor,avg_price /= factor(总市值不变)
|
||||||
|
- 现金分红:cash += per_share × 持仓量
|
||||||
|
- 停牌盯市:缺 bar 用前日 close 兜底
|
||||||
|
"""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sanguo_data.dividend_source import DividendEvent, _parse_dividend_df
|
||||||
|
from sanguo_trader.account import Account
|
||||||
|
from sanguo_trader.engine import PaperEngine
|
||||||
|
from sanguo_trader.models import AccountConfig, MatchSession, OrderSide, PaperTrade
|
||||||
|
from sanguo_trader.persistence import init_db, save_account
|
||||||
|
from sanguo_trader.position_ledger import PositionLedger
|
||||||
|
|
||||||
|
|
||||||
|
def _buy(symbol="600000", price=10.0, volume=100):
|
||||||
|
return PaperTrade("s1", symbol, OrderSide.BUY, price, volume,
|
||||||
|
5.0, 0.0, 0.02, "", MatchSession.NEXT_OPEN)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- PositionLedger.apply_split ----------
|
||||||
|
|
||||||
|
def test_apply_split_increases_volume():
|
||||||
|
# 10送5 → factor 1.5
|
||||||
|
p = PositionLedger(symbol="600000")
|
||||||
|
p.apply_buy(10.0, 100)
|
||||||
|
p.unfreeze()
|
||||||
|
p.apply_split(1.5)
|
||||||
|
assert p.volume == 150
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_split_lowers_avg_price():
|
||||||
|
p = PositionLedger(symbol="600000")
|
||||||
|
p.apply_buy(10.0, 100)
|
||||||
|
p.unfreeze()
|
||||||
|
p.apply_split(1.5)
|
||||||
|
assert p.avg_price == pytest.approx(10.0 / 1.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_split_preserves_total_market_value():
|
||||||
|
# 总市值 = volume × avg_price 不变
|
||||||
|
p = PositionLedger(symbol="600000")
|
||||||
|
p.apply_buy(10.0, 100)
|
||||||
|
p.unfreeze()
|
||||||
|
before = p.volume * p.avg_price
|
||||||
|
p.apply_split(1.5)
|
||||||
|
after = p.volume * p.avg_price
|
||||||
|
assert after == pytest.approx(before)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_split_factor_one_is_noop():
|
||||||
|
p = PositionLedger(symbol="600000")
|
||||||
|
p.apply_buy(10.0, 100)
|
||||||
|
p.unfreeze()
|
||||||
|
p.apply_split(1.0)
|
||||||
|
assert p.volume == 100
|
||||||
|
assert p.avg_price == pytest.approx(10.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_split_noop_on_empty_position():
|
||||||
|
p = PositionLedger(symbol="600000")
|
||||||
|
p.apply_split(1.5)
|
||||||
|
assert p.volume == 0
|
||||||
|
assert p.avg_price == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_split_rejects_non_positive_factor():
|
||||||
|
p = PositionLedger(symbol="600000")
|
||||||
|
p.apply_buy(10.0, 100)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
p.apply_split(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Account.apply_cash_dividend ----------
|
||||||
|
|
||||||
|
def test_apply_cash_dividend_adds_cash():
|
||||||
|
acc = Account(1_000_000)
|
||||||
|
acc.apply_trade(_buy(volume=100))
|
||||||
|
acc.unfreeze_all()
|
||||||
|
cash_before = acc.cash
|
||||||
|
acc.apply_cash_dividend("600000", per_share=0.5)
|
||||||
|
assert acc.cash == pytest.approx(cash_before + 0.5 * 100)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_cash_dividend_no_position_is_noop():
|
||||||
|
acc = Account(1_000_000)
|
||||||
|
cash_before = acc.cash
|
||||||
|
acc.apply_cash_dividend("999999", per_share=0.5)
|
||||||
|
assert acc.cash == cash_before
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_cash_dividend_zero_per_share_is_noop():
|
||||||
|
acc = Account(1_000_000)
|
||||||
|
acc.apply_trade(_buy(volume=100))
|
||||||
|
acc.unfreeze_all()
|
||||||
|
cash_before = acc.cash
|
||||||
|
acc.apply_cash_dividend("600000", per_share=0.0)
|
||||||
|
assert acc.cash == cash_before
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Account.mark_to_market 停牌兜底 ----------
|
||||||
|
|
||||||
|
def test_mark_to_market_uses_today_close_when_available():
|
||||||
|
acc = Account(1_000_000)
|
||||||
|
acc.apply_trade(_buy(price=10.0, volume=100))
|
||||||
|
acc.unfreeze_all()
|
||||||
|
acc.mark_to_market({"600000": 12.0}, prev_close={"600000": 11.0})
|
||||||
|
assert acc.market_value == pytest.approx(100 * 12.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_to_market_falls_back_to_prev_close_on_suspension():
|
||||||
|
# 停牌:bars_raw 无该 symbol → 用 prev_close 兜底
|
||||||
|
acc = Account(1_000_000)
|
||||||
|
acc.apply_trade(_buy(price=10.0, volume=100))
|
||||||
|
acc.unfreeze_all()
|
||||||
|
acc.mark_to_market({}, prev_close={"600000": 11.0})
|
||||||
|
assert acc.market_value == pytest.approx(100 * 11.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_to_market_falls_back_to_avg_price_without_prev_close():
|
||||||
|
# 既无 bar 也无 prev_close → avg_price 兜底(向后兼容旧调用)
|
||||||
|
acc = Account(1_000_000)
|
||||||
|
acc.apply_trade(_buy(price=10.0, volume=100))
|
||||||
|
acc.unfreeze_all()
|
||||||
|
acc.mark_to_market({})
|
||||||
|
assert acc.market_value == pytest.approx(100 * 10.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- PaperEngine.step 除权日 ----------
|
||||||
|
|
||||||
|
def _bar(date, o, h, l, c):
|
||||||
|
return SimpleNamespace(open_price=o, high_price=h, low_price=l,
|
||||||
|
close_price=c, datetime=date)
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_step_applies_dividend_on_ex_date(tmp_path):
|
||||||
|
"""除权日:持仓自动 split + 现金分红到账(在 mark_to_market 前)。"""
|
||||||
|
# Arrange
|
||||||
|
db = str(tmp_path / "d.db")
|
||||||
|
init_db(db)
|
||||||
|
aid = save_account(db, {"name": "t", "initial_capital": 1_000_000})
|
||||||
|
cfg = AccountConfig(initial_capital=1_000_000)
|
||||||
|
account = Account(1_000_000)
|
||||||
|
account.apply_trade(_buy(price=10.0, volume=100)) # 预置 100 股 @ 10.0
|
||||||
|
account.unfreeze_all()
|
||||||
|
cash_before = account.cash
|
||||||
|
div_cal = {"2024-01-02": {"600000": DividendEvent(
|
||||||
|
ex_date="2024-01-02", symbol="600000",
|
||||||
|
split_factor=1.5, cash_per_share=0.5)}}
|
||||||
|
pe = PaperEngine(account, [], None, cfg, db, aid,
|
||||||
|
symbols=["600000"], start="2024-01-01", end="2024-12-31",
|
||||||
|
dividends_by_date=div_cal)
|
||||||
|
|
||||||
|
# Act:除权日 raw 价已下调(10 → 6.x)
|
||||||
|
bars = {"600000": _bar("2024-01-02", 6.8, 7.0, 6.6, 6.9)}
|
||||||
|
pe.step("2024-01-02", bars, bars, {"600000": 10.0}, [])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
pos = account.positions["600000"]
|
||||||
|
assert pos.volume == 150 # 100 × 1.5
|
||||||
|
assert pos.avg_price == pytest.approx(10.0 / 1.5)
|
||||||
|
# 现金分红按除权前持仓量(100 股)
|
||||||
|
assert account.cash == pytest.approx(cash_before + 0.5 * 100)
|
||||||
|
# 盯市 = 除权后 volume × 除权后 close
|
||||||
|
assert account.market_value == pytest.approx(150 * 6.9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_step_cash_only_dividend(tmp_path):
|
||||||
|
"""纯现金分红(无送转):持仓量不变,cash 增加。"""
|
||||||
|
db = str(tmp_path / "d.db")
|
||||||
|
init_db(db)
|
||||||
|
aid = save_account(db, {"name": "t", "initial_capital": 1_000_000})
|
||||||
|
account = Account(1_000_000)
|
||||||
|
account.apply_trade(_buy(price=10.0, volume=200))
|
||||||
|
account.unfreeze_all()
|
||||||
|
cash_before = account.cash
|
||||||
|
div_cal = {"2024-03-01": {"600000": DividendEvent(
|
||||||
|
ex_date="2024-03-01", symbol="600000",
|
||||||
|
split_factor=1.0, cash_per_share=0.42)}}
|
||||||
|
pe = PaperEngine(account, [], None, AccountConfig(initial_capital=1_000_000),
|
||||||
|
db, aid, symbols=["600000"], start="2024-01-01",
|
||||||
|
end="2024-12-31", dividends_by_date=div_cal)
|
||||||
|
bars = {"600000": _bar("2024-03-01", 9.9, 10.0, 9.8, 9.95)}
|
||||||
|
pe.step("2024-03-01", bars, bars, {}, [])
|
||||||
|
assert account.positions["600000"].volume == 200 # 不变
|
||||||
|
assert account.cash == pytest.approx(cash_before + 0.42 * 200)
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_step_no_dividend_unchanged(tmp_path):
|
||||||
|
"""非除权日:持仓/现金不变。"""
|
||||||
|
db = str(tmp_path / "d.db")
|
||||||
|
init_db(db)
|
||||||
|
aid = save_account(db, {"name": "t", "initial_capital": 1_000_000})
|
||||||
|
account = Account(1_000_000)
|
||||||
|
account.apply_trade(_buy(price=10.0, volume=100))
|
||||||
|
account.unfreeze_all()
|
||||||
|
cash_before = account.cash
|
||||||
|
pe = PaperEngine(account, [], None, AccountConfig(initial_capital=1_000_000),
|
||||||
|
db, aid, symbols=["600000"], start="2024-01-01",
|
||||||
|
end="2024-12-31", dividends_by_date={})
|
||||||
|
bars = {"600000": _bar("2024-01-02", 10.0, 10.2, 9.8, 10.1)}
|
||||||
|
pe.step("2024-01-02", bars, bars, {}, [])
|
||||||
|
assert account.positions["600000"].volume == 100
|
||||||
|
assert account.cash == cash_before
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- dividend_source 解析 ----------
|
||||||
|
|
||||||
|
def _mkdiv_df(send, transfer, cash, ex_date="2024-01-15", progress="实施"):
|
||||||
|
return pd.DataFrame([{
|
||||||
|
"公告日期": "2024-01-01", "送股": send, "转增": transfer, "派息": cash,
|
||||||
|
"进度": progress, "除权除息日": pd.Timestamp(ex_date),
|
||||||
|
"股权登记日": pd.Timestamp("2024-01-14"), "红股上市日": pd.NaT,
|
||||||
|
}])
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_dividend_df_per_10_shares_conversion():
|
||||||
|
# 送股/转增/派息 均为「每 10 股」→ 转 per-share
|
||||||
|
events = _parse_dividend_df(_mkdiv_df(5, 0, 2.0), "600000",
|
||||||
|
"2024-01-01", "2024-12-31")
|
||||||
|
assert len(events) == 1
|
||||||
|
ev = events[0]
|
||||||
|
assert ev.symbol == "600000"
|
||||||
|
assert ev.ex_date == "2024-01-15"
|
||||||
|
assert ev.split_factor == pytest.approx(1.5) # 1 + 5/10
|
||||||
|
assert ev.cash_per_share == pytest.approx(0.2) # 2.0/10
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_dividend_df_send_plus_transfer():
|
||||||
|
# 10送3转2 → factor 1.5
|
||||||
|
events = _parse_dividend_df(_mkdiv_df(3, 2, 0.0), "600000",
|
||||||
|
"2024-01-01", "2024-12-31")
|
||||||
|
assert events[0].split_factor == pytest.approx(1.5)
|
||||||
|
assert events[0].cash_per_share == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_dividend_df_skips_non_implemented():
|
||||||
|
events = _parse_dividend_df(_mkdiv_df(0, 0, 1.0, progress="预案"),
|
||||||
|
"600000", "2024-01-01", "2024-12-31")
|
||||||
|
assert events == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_dividend_df_filters_by_date_range():
|
||||||
|
events = _parse_dividend_df(_mkdiv_df(0, 0, 1.0, ex_date="2020-06-15"),
|
||||||
|
"600000", "2024-01-01", "2024-12-31")
|
||||||
|
assert events == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_dividend_df_handles_date_object():
|
||||||
|
# akshare 实测除权除息日可能返回 datetime.date
|
||||||
|
df = pd.DataFrame([{
|
||||||
|
"公告日期": "2024-01-01", "送股": 0, "转增": 0, "派息": 1.0,
|
||||||
|
"进度": "实施", "除权除息日": pd.Timestamp("2024-06-15").date(),
|
||||||
|
"股权登记日": pd.Timestamp("2024-06-14"), "红股上市日": pd.NaT,
|
||||||
|
}])
|
||||||
|
events = _parse_dividend_df(df, "600000", "2024-01-01", "2024-12-31")
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].ex_date == "2024-06-15"
|
||||||
Reference in New Issue
Block a user