新增调仓事件记录功能,详细记录每次调仓的信息: 核心改进: 1. BacktestExecutor新增_apply_trade_cost_with_events方法 - 记录每次调仓的基本信息(持仓变化、调入调出标的) - 记录换手率、调仓成本、持仓天数、当日收益 2. 新增_enrich_rebalance_events方法 - 补充净值信息(调仓前净值、调仓后净值、净值变化%) 3. strategy.py保存调仓记录到CSV - 新增rebalances.csv文件 - 返回结果包含rebalance_events 调仓记录字段: - 调仓前持仓、调仓后持仓 - 调入标的、调出标的 - 换手率、调仓成本 - 持仓天数、当日收益 - 调仓前净值、调仓后净值、净值变化% 应用场景: - 分析每次调仓对收益的影响 - 评估调仓决策质量 - 统计调仓频率与效果
500 lines
18 KiB
Python
500 lines
18 KiB
Python
"""
|
||
执行层抽象接口(通用)
|
||
|
||
只提供抽象基类和Portfolio数据结构,具体执行器可扩展
|
||
"""
|
||
|
||
from abc import ABC, abstractmethod
|
||
from typing import Dict, List, Optional
|
||
import pandas as pd
|
||
import numpy as np
|
||
from datetime import datetime
|
||
|
||
from framework.risk import Position
|
||
|
||
|
||
class Portfolio:
|
||
"""
|
||
投资组合数据结构(通用)
|
||
|
||
用于管理持仓集合
|
||
"""
|
||
|
||
def __init__(self, initial_capital: float = 100000):
|
||
"""初始化投资组合"""
|
||
self.initial_capital = initial_capital
|
||
self.cash = initial_capital
|
||
self.positions: Dict[str, Position] = {}
|
||
self.trades: List[Dict] = []
|
||
self._net_value_history: List[float] = []
|
||
|
||
def add_position(self, code: str, price: float, quantity: float, time: datetime) -> None:
|
||
"""添加持仓"""
|
||
position = Position(
|
||
code=code,
|
||
entry_price=price,
|
||
current_price=price,
|
||
entry_time=time,
|
||
quantity=quantity
|
||
)
|
||
self.positions[code] = position
|
||
self.cash -= price * quantity
|
||
|
||
self.trades.append({
|
||
'action': 'BUY',
|
||
'code': code,
|
||
'price': price,
|
||
'quantity': quantity,
|
||
'time': time
|
||
})
|
||
|
||
def remove_position(self, code: str, price: float, time: datetime) -> float:
|
||
"""移除持仓"""
|
||
if code not in self.positions:
|
||
return 0
|
||
|
||
position = self.positions[code]
|
||
profit = (price - position.entry_price) * position.quantity
|
||
self.cash += price * position.quantity
|
||
|
||
del self.positions[code]
|
||
|
||
self.trades.append({
|
||
'action': 'SELL',
|
||
'code': code,
|
||
'price': price,
|
||
'quantity': position.quantity,
|
||
'time': time,
|
||
'profit': profit
|
||
})
|
||
|
||
return profit
|
||
|
||
def update_prices(self, prices: Dict[str, float]) -> None:
|
||
"""更新持仓价格"""
|
||
for code, price in prices.items():
|
||
if code in self.positions:
|
||
self.positions[code].current_price = price
|
||
|
||
def get_net_value(self) -> float:
|
||
"""计算净值"""
|
||
positions_value = sum(
|
||
pos.current_price * pos.quantity for pos in self.positions.values()
|
||
)
|
||
return self.cash + positions_value
|
||
|
||
def record_net_value(self) -> None:
|
||
"""记录当前净值"""
|
||
self._net_value_history.append(self.get_net_value())
|
||
|
||
def get_net_value_series(self) -> pd.Series:
|
||
"""获取净值序列"""
|
||
return pd.Series(self._net_value_history)
|
||
|
||
def get_weight(self, code: str) -> float:
|
||
"""计算持仓权重"""
|
||
if code not in self.positions:
|
||
return 0
|
||
|
||
position_value = self.positions[code].current_price * self.positions[code].quantity
|
||
return position_value / self.get_net_value()
|
||
|
||
def __repr__(self) -> str:
|
||
return f"Portfolio(capital={self.cash:.2f}, positions={len(self.positions)})"
|
||
|
||
|
||
class Executor(ABC):
|
||
"""
|
||
执行器抽象基类
|
||
|
||
所有执行器必须实现execute方法
|
||
"""
|
||
|
||
mode: str = "base"
|
||
|
||
def __init__(self, **params):
|
||
"""初始化执行器参数"""
|
||
self._params = params
|
||
|
||
@abstractmethod
|
||
def execute(self, signals: pd.DataFrame, data: pd.DataFrame) -> Portfolio:
|
||
"""
|
||
执行信号
|
||
|
||
Args:
|
||
signals: 信号DataFrame
|
||
data: OHLCV数据
|
||
|
||
Returns:
|
||
Portfolio对象
|
||
"""
|
||
pass
|
||
|
||
def __repr__(self) -> str:
|
||
params_str = ', '.join([f"{k}={v}" for k, v in self._params.items()])
|
||
return f"{self.__class__.__name__}({params_str})"
|
||
|
||
|
||
class BacktestExecutor(Executor):
|
||
"""
|
||
完整回测执行器(通用)
|
||
|
||
支持:
|
||
- 日收益率计算
|
||
- 交易成本扣除
|
||
- 净值计算(起点归一化)
|
||
- 基准对比
|
||
- 持仓跟踪
|
||
"""
|
||
|
||
mode = "backtest"
|
||
|
||
def __init__(
|
||
self,
|
||
initial_capital: float = 100000,
|
||
trade_cost: float = 0.001,
|
||
select_num: int = 1,
|
||
benchmark_data: Optional[pd.DataFrame] = None
|
||
):
|
||
super().__init__(
|
||
initial_capital=initial_capital,
|
||
trade_cost=trade_cost,
|
||
select_num=select_num,
|
||
benchmark_data=benchmark_data
|
||
)
|
||
self.initial_capital = initial_capital
|
||
self.trade_cost = trade_cost
|
||
self.select_num = select_num
|
||
self.benchmark_data = benchmark_data
|
||
|
||
def execute(self, signals: pd.DataFrame, data: pd.DataFrame) -> Portfolio:
|
||
"""
|
||
执行完整回测
|
||
|
||
Args:
|
||
signals: 信号DataFrame,包含signal或信号列
|
||
data: OHLCV数据和日收益率数据
|
||
|
||
Returns:
|
||
Portfolio对象(含净值序列、交易记录)
|
||
"""
|
||
portfolio = Portfolio(self.initial_capital)
|
||
|
||
# 支持中英文列名
|
||
signal_col = 'signal' if 'signal' in signals.columns else '信号'
|
||
|
||
# 删除空信号行
|
||
signals = signals.dropna(subset=[signal_col])
|
||
signals = signals[signals[signal_col] != '']
|
||
|
||
if signals.empty:
|
||
return portfolio
|
||
|
||
# 计算策略日收益率
|
||
result = self._calculate_daily_returns(signals, data, signal_col)
|
||
|
||
# 扣除交易成本(同时记录调仓事件)
|
||
result, rebalance_events = self._apply_trade_cost_with_events(result, signals, signal_col)
|
||
|
||
# 计算净值(起点归一化)
|
||
result = self._calculate_net_value(result)
|
||
|
||
# 计算基准净值
|
||
result = self._calculate_benchmark(result)
|
||
|
||
# 记录净值历史
|
||
for date in result.index:
|
||
portfolio.record_net_value()
|
||
|
||
# 存储回测结果
|
||
portfolio.backtest_result = result
|
||
portfolio.rebalance_events = rebalance_events # 新增:调仓事件记录
|
||
|
||
# 补充调仓事件的净值信息
|
||
if not rebalance_events.empty:
|
||
rebalance_events = self._enrich_rebalance_events(rebalance_events, result)
|
||
portfolio.rebalance_events = rebalance_events
|
||
|
||
return portfolio
|
||
|
||
def _calculate_daily_returns(self, signals: pd.DataFrame, data: pd.DataFrame, signal_col: str = 'signal') -> pd.DataFrame:
|
||
"""计算策略日收益率"""
|
||
result = signals.copy()
|
||
|
||
# 日收益率列名格式:日收益率_{code} 或 日收益率_{code}
|
||
return_cols = [col for col in data.columns if col.startswith('日收益率_')]
|
||
|
||
if self.select_num == 1:
|
||
# 单标的策略
|
||
def calc_return(row):
|
||
signal = row[signal_col]
|
||
if not signal or pd.isna(signal):
|
||
return 0.0
|
||
return data.loc[row.name, f'日收益率_{signal}'] if f'日收益率_{signal}' in data.columns else 0.0
|
||
|
||
result['策略日收益率'] = result.apply(calc_return, axis=1)
|
||
else:
|
||
# 多标的策略(等权组合)
|
||
# 按实际持仓数量等权分配:选出2只时每只50%,选出1只时100%
|
||
def calc_multi_return(row):
|
||
codes = [c for c in row[signal_col].split(',') if c]
|
||
if not codes:
|
||
return 0.0
|
||
returns = []
|
||
for c in codes:
|
||
ret = data.loc[row.name, f'日收益率_{c}'] if f'日收益率_{c}' in data.columns else None
|
||
if ret is not None and pd.notna(ret):
|
||
returns.append(ret)
|
||
return np.mean(returns) if returns else 0.0
|
||
|
||
result['策略日收益率'] = result.apply(calc_multi_return, axis=1)
|
||
|
||
return result
|
||
|
||
def _apply_trade_cost(self, result: pd.DataFrame, signals: pd.DataFrame, signal_col: str = 'signal') -> pd.DataFrame:
|
||
"""扣除交易成本"""
|
||
if self.trade_cost <= 0:
|
||
return result
|
||
|
||
prev_signal = signals[signal_col].shift(1)
|
||
|
||
if self.select_num == 1:
|
||
# 单标的策略:调仓时扣除固定成本
|
||
changed = (signals[signal_col] != prev_signal) & prev_signal.notna()
|
||
result.loc[changed, '策略日收益率'] -= self.trade_cost
|
||
else:
|
||
# 多标的策略:按换手率比例扣除成本
|
||
turnover_list = []
|
||
for curr, prev in zip(signals[signal_col], prev_signal):
|
||
if pd.isna(prev) or curr == prev:
|
||
turnover_list.append(0.0)
|
||
else:
|
||
old = set(prev.split(','))
|
||
new = set(curr.split(','))
|
||
swapped = len(old - new)
|
||
turnover = swapped / len(old) if old else 0.0
|
||
turnover_list.append(turnover)
|
||
|
||
result['换手率'] = turnover_list
|
||
result['策略日收益率'] -= result['换手率'] * self.trade_cost
|
||
|
||
return result
|
||
|
||
def _apply_trade_cost_with_events(self, result: pd.DataFrame, signals: pd.DataFrame, signal_col: str = 'signal') -> tuple:
|
||
"""
|
||
扣除交易成本并记录调仓事件
|
||
|
||
Returns:
|
||
(result, rebalance_events): 回测结果DataFrame和调仓事件DataFrame
|
||
"""
|
||
prev_signal = signals[signal_col].shift(1)
|
||
|
||
# 记录调仓事件
|
||
rebalance_events = []
|
||
last_rebalance_date = None
|
||
|
||
# 先计算累积收益率(用于计算调仓前后的净值)
|
||
cum_return_before_cost = result['策略日收益率'].copy()
|
||
|
||
if self.select_num == 1:
|
||
# 单标的策略
|
||
for i, (date, curr, prev) in enumerate(zip(signals.index, signals[signal_col], prev_signal)):
|
||
# 检查是否调仓
|
||
is_rebalance = False
|
||
turnover = 0.0
|
||
added = []
|
||
removed = []
|
||
|
||
if pd.notna(prev) and curr != prev:
|
||
is_rebalance = True
|
||
turnover = 1.0 if prev else 0.0
|
||
added = [curr] if curr else []
|
||
removed = [prev] if prev else []
|
||
# 扣除成本
|
||
result.loc[date, '策略日收益率'] -= self.trade_cost
|
||
|
||
# 记录调仓事件
|
||
if is_rebalance:
|
||
# 计算持仓天数
|
||
holding_days = 0
|
||
if last_rebalance_date is not None:
|
||
holding_days = (date - last_rebalance_date).days
|
||
|
||
event = {
|
||
'日期': date,
|
||
'调仓前持仓': prev if pd.notna(prev) else '',
|
||
'调仓后持仓': curr,
|
||
'调入标的': ','.join(added) if added else '',
|
||
'调出标的': ','.join(removed) if removed else '',
|
||
'换手率': turnover,
|
||
'调仓成本': self.trade_cost * turnover,
|
||
'持仓天数': holding_days,
|
||
'当日收益': result.loc[date, '策略日收益率'] + self.trade_cost * turnover, # 原始收益(扣除成本前)
|
||
}
|
||
rebalance_events.append(event)
|
||
last_rebalance_date = date
|
||
|
||
else:
|
||
# 多标的策略
|
||
turnover_list = []
|
||
for i, (date, curr, prev) in enumerate(zip(signals.index, signals[signal_col], prev_signal)):
|
||
# 检查是否调仓
|
||
is_rebalance = False
|
||
turnover = 0.0
|
||
added = []
|
||
removed = []
|
||
|
||
if pd.notna(prev) and curr != prev:
|
||
old = set(prev.split(',')) if prev else set()
|
||
new = set(curr.split(',')) if curr else set()
|
||
added = list(new - old)
|
||
removed = list(old - new)
|
||
swapped = len(removed)
|
||
turnover = swapped / len(old) if old else 0.0
|
||
is_rebalance = len(added) > 0 or len(removed) > 0
|
||
turnover_list.append(turnover)
|
||
# 扣除成本
|
||
result.loc[date, '策略日收益率'] -= turnover * self.trade_cost
|
||
else:
|
||
turnover_list.append(0.0)
|
||
|
||
# 记录调仓事件
|
||
if is_rebalance:
|
||
# 计算持仓天数
|
||
holding_days = 0
|
||
if last_rebalance_date is not None:
|
||
holding_days = (date - last_rebalance_date).days
|
||
|
||
event = {
|
||
'日期': date,
|
||
'调仓前持仓': prev if pd.notna(prev) else '',
|
||
'调仓后持仓': curr,
|
||
'调入标的': ','.join(added) if added else '',
|
||
'调出标的': ','.join(removed) if removed else '',
|
||
'换手率': turnover,
|
||
'调仓成本': self.trade_cost * turnover,
|
||
'持仓天数': holding_days,
|
||
'当日收益': result.loc[date, '策略日收益率'] + turnover * self.trade_cost, # 原始收益(扣除成本前)
|
||
}
|
||
rebalance_events.append(event)
|
||
last_rebalance_date = date
|
||
|
||
result['换手率'] = turnover_list
|
||
|
||
# 转换为DataFrame
|
||
rebalance_df = pd.DataFrame(rebalance_events) if rebalance_events else pd.DataFrame()
|
||
if not rebalance_df.empty:
|
||
rebalance_df['日期'] = pd.to_datetime(rebalance_df['日期'])
|
||
rebalance_df = rebalance_df.set_index('日期')
|
||
|
||
return result, rebalance_df
|
||
|
||
def _enrich_rebalance_events(self, rebalance_df: pd.DataFrame, result: pd.DataFrame) -> pd.DataFrame:
|
||
"""
|
||
补充调仓事件的净值信息
|
||
|
||
Args:
|
||
rebalance_df: 调仓事件DataFrame
|
||
result: 回测结果DataFrame(含净值序列)
|
||
|
||
Returns:
|
||
补充净值信息后的调仓事件DataFrame
|
||
"""
|
||
# 计算调仓前后净值变化
|
||
nav_before_list = []
|
||
nav_after_list = []
|
||
nav_change_list = []
|
||
|
||
for date in rebalance_df.index:
|
||
# 获取调仓日的净值
|
||
if date in result.index:
|
||
# 调仓前净值:前一天收盘净值
|
||
prev_date_idx = result.index.get_loc(date) - 1
|
||
if prev_date_idx >= 0:
|
||
nav_before = result['策略净值'].iloc[prev_date_idx]
|
||
else:
|
||
nav_before = 1.0
|
||
|
||
# 调仓后净值:当天收盘净值
|
||
nav_after = result.loc[date, '策略净值']
|
||
|
||
# 净值变化
|
||
nav_change = (nav_after / nav_before - 1) * 100
|
||
else:
|
||
nav_before = None
|
||
nav_after = None
|
||
nav_change = None
|
||
|
||
nav_before_list.append(nav_before)
|
||
nav_after_list.append(nav_after)
|
||
nav_change_list.append(nav_change)
|
||
|
||
# 添加净值信息列
|
||
rebalance_df['调仓前净值'] = nav_before_list
|
||
rebalance_df['调仓后净值'] = nav_after_list
|
||
rebalance_df['净值变化%'] = nav_change_list
|
||
|
||
return rebalance_df
|
||
|
||
def _calculate_net_value(self, result: pd.DataFrame) -> pd.DataFrame:
|
||
"""计算净值(起点归一化)"""
|
||
result['策略净值'] = (1 + result['策略日收益率']).cumprod()
|
||
|
||
# 归一化:确保净值起点为1.0
|
||
result['策略净值'] = result['策略净值'] / result['策略净值'].iloc[0]
|
||
|
||
return result
|
||
|
||
def _calculate_benchmark(self, result: pd.DataFrame) -> pd.DataFrame:
|
||
"""计算基准净值"""
|
||
if self.benchmark_data is None:
|
||
return result
|
||
|
||
# 获取基准收益率
|
||
if isinstance(self.benchmark_data, pd.DataFrame):
|
||
if 'close' in self.benchmark_data.columns:
|
||
bench_close = self.benchmark_data['close']
|
||
else:
|
||
bench_close = self.benchmark_data.iloc[:, 0]
|
||
else:
|
||
bench_close = self.benchmark_data
|
||
|
||
bench_ret = bench_close.pct_change().dropna()
|
||
common_dates = result.index.intersection(bench_ret.index)
|
||
bench_ret = bench_ret.loc[common_dates]
|
||
|
||
result['基准日收益率'] = bench_ret.reindex(result.index, fill_value=0)
|
||
result['基准净值'] = (1 + result['基准日收益率']).cumprod()
|
||
result['基准净值'] = result['基准净值'] / result['基准净值'].iloc[0]
|
||
|
||
return result
|
||
|
||
|
||
class DryRunExecutor(Executor):
|
||
"""
|
||
Dry-run执行器(通用)
|
||
|
||
用于模拟运行,不实际执行交易
|
||
"""
|
||
|
||
mode = "dry_run"
|
||
|
||
def __init__(self, verbose: bool = True):
|
||
super().__init__(verbose=verbose)
|
||
self.verbose = verbose
|
||
|
||
def execute(self, signals: pd.DataFrame, data: pd.DataFrame) -> Portfolio:
|
||
"""模拟执行"""
|
||
portfolio = Portfolio(100000)
|
||
|
||
for date in signals.index:
|
||
signal = signals.loc[date, 'signal']
|
||
|
||
if signal and self.verbose:
|
||
print(f"[{date}] Signal: {signal}")
|
||
|
||
return portfolio
|
||
|
||
|
||
# 导出抽象接口
|
||
__all__ = ['Portfolio', 'Executor', 'BacktestExecutor', 'DryRunExecutor'] |