Compare commits

..

3 Commits

Author SHA1 Message Date
444dc0e751 refactor(execution): 改为固定仓位分配逻辑
- 原逻辑: 按实际持仓数量等权(选出2只时权重50%)
- 新逻辑: 按select_num固定等权(选出2只时权重33.3%+现金33.3%)
- 缺失仓位用现金替代,收益为0
- 交易成本按固定仓位比例计算
- 目的: 保持稳定风险敞口,避免仓位不足时波动放大
2026-05-16 00:18:19 +08:00
07463f68e1 fix(strategy): 消除pandas pct_change弃用警告
- 添加 fill_method=None 参数避免 FutureWarning
- pandas 未来版本将移除默认 fill_method='pad' 行为
2026-05-15 23:38:45 +08:00
80c7fe0ba8 refactor(log): 优化回测日志输出格式
- strategy.py: 在数据获取前打印回测配置区间说明
- flask_api_source.py: 使用API返回的实际数据范围(date_range)
- 原问题: 日志显示请求参数的start_date,而非实际数据范围
- 修改后: 各标的显示实际数据时间周期(如创业板2010年开始)
2026-05-15 23:34:52 +08:00
3 changed files with 37 additions and 9 deletions

View File

@@ -138,6 +138,11 @@ class FlaskAPIDataSource:
# 确保列名标准化 # 确保列名标准化
df = df[['open', 'high', 'low', 'close', 'volume']] df = df[['open', 'high', 'low', 'close', 'volume']]
# 使用 API 返回的实际数据范围(而非请求参数)
actual_start = data.get('date_range', {}).get('start', start_date)
actual_end = data.get('date_range', {}).get('end', end_date)
actual_count = data.get('count', len(df))
# 缓存 info 信息(如果有) # 缓存 info 信息(如果有)
if 'info' in data: if 'info' in data:
df.attrs['info'] = data['info'] df.attrs['info'] = data['info']
@@ -166,7 +171,7 @@ class FlaskAPIDataSource:
if 'premium_stats' in data: if 'premium_stats' in data:
df.attrs['premium_stats'] = data['premium_stats'] df.attrs['premium_stats'] = data['premium_stats']
print(f"{code}: {len(df)} 条数据 ({start_date} ~ {end_date})") print(f"{code}: {actual_count} 条数据 ({actual_start} ~ {actual_end})")
return df return df
except requests.exceptions.Timeout: except requests.exceptions.Timeout:

View File

@@ -228,17 +228,29 @@ class BacktestExecutor(Executor):
result['策略日收益率'] = result.apply(calc_return, axis=1) result['策略日收益率'] = result.apply(calc_return, axis=1)
else: else:
# 多标的策略(等权组合 # 多标的策略(固定仓位分配
# 核心逻辑按select_num固定分配仓位缺失标的用现金替代
# 例如select_num=3选出2只标的 → 权重=1/3+1/3现金权重=1/3收益为0
def calc_multi_return(row): def calc_multi_return(row):
codes = [c for c in row[signal_col].split(',') if c] codes = [c for c in row[signal_col].split(',') if c]
if not codes: if not codes:
# 空仓全部现金收益为0
return 0.0 return 0.0
returns = []
# 固定仓位权重:每只标的权重 = 1 / select_num
unit_weight = 1.0 / self.select_num
# 计算实际持仓收益缺失标的用现金替代收益为0
total_return = 0.0
for c in codes: for c in codes:
ret = data.loc[row.name, f'日收益率_{c}'] if f'日收益率_{c}' in data.columns else None ret = data.loc[row.name, f'日收益率_{c}'] if f'日收益率_{c}' in data.columns else None
if ret is not None and pd.notna(ret): if ret is not None and pd.notna(ret):
returns.append(ret) total_return += ret * unit_weight
return np.mean(returns) if returns else 0.0 # 如果数据缺失视为现金收益为0不累加
# 缺失标的的仓位自动变成现金收益为0
# 总收益 = sum(实际持仓收益) + 0 * (缺失仓位)
return total_return
result['策略日收益率'] = result.apply(calc_multi_return, axis=1) result['策略日收益率'] = result.apply(calc_multi_return, axis=1)
@@ -256,7 +268,9 @@ class BacktestExecutor(Executor):
changed = (signals[signal_col] != prev_signal) & prev_signal.notna() changed = (signals[signal_col] != prev_signal) & prev_signal.notna()
result.loc[changed, '策略日收益率'] -= self.trade_cost result.loc[changed, '策略日收益率'] -= self.trade_cost
else: else:
# 多标的策略:按换手率比例扣除成本 # 多标的策略:按固定仓位比例扣除成本
# 核心逻辑每只标的权重固定为1/select_num
# 换手率 = (调出数量 + 调入数量) / select_num
turnover_list = [] turnover_list = []
for curr, prev in zip(signals[signal_col], prev_signal): for curr, prev in zip(signals[signal_col], prev_signal):
if pd.isna(prev) or curr == prev: if pd.isna(prev) or curr == prev:
@@ -264,8 +278,13 @@ class BacktestExecutor(Executor):
else: else:
old = set(prev.split(',')) old = set(prev.split(','))
new = set(curr.split(',')) new = set(curr.split(','))
swapped = len(old - new) # 调出的标的数量(这些仓位需要卖出)
turnover = swapped / len(old) if old else 0.0 exit_count = len(old - new)
# 调入的标的数量(这些仓位需要买入)
enter_count = len(new - old)
# 换手率 = (卖出 + 买入) / select_num
# 每次调仓涉及的仓位比例
turnover = (exit_count + enter_count) / self.select_num
turnover_list.append(turnover) turnover_list.append(turnover)
result['换手率'] = turnover_list result['换手率'] = turnover_list

View File

@@ -157,6 +157,10 @@ class RotationStrategy(StrategyBase):
else: else:
print(f"✓ Flask API 服务正常 (SSH: {health.get('ssh_configured', False)})") print(f"✓ Flask API 服务正常 (SSH: {health.get('ssh_configured', False)})")
# 打印回测时间区间说明
print(f"\n回测配置区间: {self.start_date} ~ {self.end_date}")
print("注: 各标的实际数据范围可能因上市时间/数据源限制而不同")
# 获取指数代码列表 # 获取指数代码列表
index_codes = list(code_list_config.keys()) index_codes = list(code_list_config.keys())
@@ -413,7 +417,7 @@ class RotationStrategy(StrategyBase):
for idx_code in valid_codes: for idx_code in valid_codes:
etf_code = etf_code_map.get(idx_code, idx_code) etf_code = etf_code_map.get(idx_code, idx_code)
if etf_code in etf_data.columns: if etf_code in etf_data.columns:
returns_data[f'日收益率_{idx_code}'] = etf_data[etf_code].pct_change() returns_data[f'日收益率_{idx_code}'] = etf_data[etf_code].pct_change(fill_method=None)
returns_df = pd.DataFrame(returns_data) returns_df = pd.DataFrame(returns_data)
else: else:
# 回退到指数收盘价数据 # 回退到指数收盘价数据