auto-sync: 2026-04-02 08:55:06

This commit is contained in:
cfdaily
2026-04-02 08:55:07 +08:00
parent 64fa4b08b0
commit f2fe17a075
626 changed files with 6877 additions and 102 deletions
@@ -1,100 +1,75 @@
# 量化策略风控模块
# 进阶多因子+动态加权+估值择时 A股量化中低频方案
## 功能说明
## 方案概述
本模块实现了四层风控体系,是量化策略的生命线:
**频率**: 中低频(日频/周频调仓)
**市场**: A股
**核心思想**:
1. **多因子**: 复合多种因子(估值、质量、动量、波动、市值等)
2. **动态加权**: 根据市场环境动态调整因子权重,不是固定权重
3. **估值择时**: 基于大盘估值判断整体仓位,牛熊择时
### 1. 单票止损(SingleStockRiskControl
- 默认规则:亏损达到 **15%** 强制止损
- 可配置:支持自定义止损比例
- 接口:`check_stop_loss(stock)` 检查是否触发止损
## 目录结构
### 2. 组合回撤分级风控(PortfolioDrawdownRiskControl
分级降仓规则:
| 总回撤 | 目标仓位 | 说明 |
|--------|----------|------|
| <10% | 100% | 正常运行,满仓操作 |
| ≥10% | 50% | 降仓一半,控制风险 |
| ≥20% | 25% | 保留四分之一仓位 |
| ≥25% | 0% | 全部清仓,停止交易休息 |
- 可配置:支持自定义回撤阈值和降仓比例
- 接口:`need_rebalance(portfolio)` 返回是否需要调整仓位
### 3. 黑天鹅过滤(BlackSwanFilter
开仓前直接排除以下风险票:
-**ST股票**:排除退市风险
-**跌停股票**:流动性风险+继续下跌风险
-**财务造假问题股**:提前排除暴雷风险
-**低流动性**:默认日成交额 < 5000万 排除
- 可配置:支持自定义最小成交额阈值
- 接口:`filter_stock(stock)` 返回是否通过 + 原因
### 4. 总风控控制器(RiskController
整合所有规则,提供两个核心入口:
- `pre_trade_check(stock, portfolio)`:开仓前检查,黑天鹅过滤+仓位检查
- `post_trade_check(stocks, portfolio)`:收盘后检查,止损+降仓检查
- `get_risk_report(stocks, portfolio)`:生成每日风控报告
## 使用示例
```python
from risk_control import RiskController, StockInfo, PortfolioInfo
# 初始化风控
rc = RiskController()
# 开仓前检查
stock = StockInfo(
code="000001",
name="平安银行",
cost_price=10.0,
current_price=10.0,
is_st=False,
is_limit_down=False,
is_fraud=False,
volume=20.0 # 日成交额20亿
)
portfolio = PortfolioInfo(
total_capital=1000000,
current_capital=950000,
positions={"000002": 200000}
)
ok, reason = rc.pre_trade_check(stock, portfolio)
if ok:
# 允许开仓
pass
else:
# 拒绝开仓
print(reason)
# 收盘后检查
result = rc.post_trade_check(all_stocks, portfolio)
if result['stop_loss_required']:
# 对这些股票执行止损
for s in result['stop_loss_stocks']:
print(f"止损: {s['code']} {s['name']}")
if result['rebalance_required']:
# 执行降仓
target_ratio = result['target_position_ratio']
print(f"需要降仓到目标仓位: {target_ratio:.1%}")
# 生成风控日报
print(rc.get_risk_report(all_stocks, portfolio))
```
a-shares-quant-factors-20260327/
├── README.md # 本文档
├── __init__.py
├── factors/ # 因子计算模块(每个因子独立文件)
│ ├── __init__.py
│ ├── base_factor.py # 因子基类
│ ├── pe_factor.py # 市盈率PE因子
│ ├── pb_factor.py # 市净率PB因子
│ ├── roe_factor.py # ROE净资产收益率因子
│ ├── momentum_factor.py # 动量因子
│ ├── volatility_factor.py # 波动率因子
│ └── size_factor.py # 市值因子
├── strategies/ # 策略主逻辑
│ ├── __init__.py
│ └── multi_factor_dynamic_strategy.py # 主策略
└── utils/ # 工具函数
├── __init__.py
├── factor_combiner.py # 因子加权合成
├── dynamic_weight.py # 动态权重计算
└── market_timing.py # 大盘估值择时
```
## 设计原则
## 使用方法
1. **单一职责**:每个类只负责一件事,清晰好维护
2. **可配置**:默认参数是经验值,支持自定义
3. **层层设防**:事前过滤 → 事中监控 → 事后止损 → 整体降仓,每一步都有防护
4. **易集成**:用 dataclass 定义数据结构,和任意回测框架都能对接
```python
from vnpy.trader.app.ctaStrategy import CtaTemplate
from strategies.multi_factor_dynamic_strategy import MultiFactorDynamicStrategy
## 作者
# 初始化策略
# 按照vn.py标准CtaStrategy接口使用
```
关羽(云长)
风险都督
2026-03-27
## 因子列表
| 因子 | 类型 | 方向 | 说明 |
|------|------|------|------|
| PE (市盈率) | 估值 | 越小越好 | 估值越低分数越高 |
| PB (市净率) | 估值 | 越小越好 | 估值越低分数越高 |
| ROE | 质量 | 越大越好 | 盈利能力越强分数越高 |
| 近1月动量 | 动量 | 越大越好 | 趋势越强分数越高 |
| 近3月动量 | 动量 | 越大越好 | 趋势越强分数越高 |
| 波动率 | 风险 | 越小越好 | 波动越小分数越高 |
| 市值 | 规模 | 中小盘偏好 | 市值越小分数越高 |
## 动态加权逻辑
- 每月底根据各因子近期IC值调整权重
- IC越高的因子权重越大
- 避免长期因子失效
## 估值择时逻辑
- 计算全市场PE/PB分位数
- 分位数高位降低仓位
- 分位数低位提高仓位
- 仓位范围: 0.3 -> 1.0
---
**创建日期**: 2026-03-27
**作者**: 翼德 (张飞)
@@ -0,0 +1,8 @@
"""
进阶多因子+动态加权+估值择时 A股量化中低频方案
"""
from .factors import *
from .strategies import *
from .utils import *
__version__ = "0.1.0"
@@ -0,0 +1,27 @@
from .base_factor import BaseFactor
from .pe_factor import PEFactor
from .pb_factor import PBFactor
from .roe_factor import ROEFactor
from .momentum_factor import (
MomentumFactor,
Momentum1MFactor,
Momentum3MFactor,
Momentum6MFactor
)
from .volatility_factor import VolatilityFactor
from .size_factor import SizeFactor
from .sector_strength_factor import SectorStrengthFactor
__all__ = [
'BaseFactor',
'PEFactor',
'PBFactor',
'ROEFactor',
'MomentumFactor',
'Momentum1MFactor',
'Momentum3MFactor',
'Momentum6MFactor',
'VolatilityFactor',
'SizeFactor',
'SectorStrengthFactor'
]
@@ -0,0 +1,97 @@
"""
因子基类 - 所有因子继承此类
定义统一的因子计算接口
"""
from abc import ABC, abstractmethod
import pandas as pd
import numpy as np
class BaseFactor(ABC):
"""因子基类"""
def __init__(self, name: str):
self.name = name
self.data = None
@abstractmethod
def calculate(self, df: pd.DataFrame) -> pd.Series:
"""
计算因子值
参数:
df: 包含OHLCV以及财务数据的DataFrame
返回:
因子值Seriesindex是股票代码+时间,value是因子值
"""
pass
def normalize(self, factor_values: pd.Series) -> pd.Series:
"""
因子标准化(中位数去极值 + 标准化)
参数:
factor_values: 原始因子值
返回:
标准化后的因子值
"""
# 去掉NaN
values = factor_values.dropna()
if len(values) == 0:
return factor_values
# 中位数去极值
median = values.median()
mad = (values - median).abs().median()
# 截断范围: median ± 3*mad
lower = median - 3 * mad
upper = median + 3 * mad
# 截断
factor_values = factor_values.clip(lower, upper)
# z-score标准化
mean = factor_values.mean()
std = factor_values.std()
if std > 0:
factor_values = (factor_values - mean) / std
return factor_values
def rank(self, factor_values: pd.Series) -> pd.Series:
"""
因子横截面标准化(每个交易日rank 0-1)
参数:
factor_values: 标准化后的因子值,多层index [date, symbol]
返回:
rank化后的因子值
"""
# 检查是否是多层索引(日期,股票)
if isinstance(factor_values.index, pd.MultiIndex):
# 按日期分组rank
ranked = factor_values.groupby(level=0).rank(pct=True)
else:
# 单组直接rank
ranked = factor_values.rank(pct=True)
return ranked
def process(self, df: pd.DataFrame) -> pd.Series:
"""
完整因子计算流程: 计算 -> 标准化 -> rank
参数:
df: 输入数据
返回:
处理后的最终因子值
"""
# 计算原始因子
raw = self.calculate(df)
# 标准化(去极值+z-score
normalized = self.normalize(raw)
# 横截面rank
ranked = self.rank(normalized)
return ranked
@@ -0,0 +1,63 @@
"""
动量因子
趋势因子 - 近期涨幅越大,动量越强,得分越高
支持不同周期: 1个月, 3个月, 6个月
"""
import pandas as pd
import numpy as np
from .base_factor import BaseFactor
class MomentumFactor(BaseFactor):
"""动量因子 - 计算近期涨跌幅动量"""
def __init__(self, periods: int = 21, name: str = "momentum_1m"):
"""
参数:
periods: 计算动量的周期(交易日数量),默认21≈1个月
name: 因子名称
"""
super().__init__(name)
self.periods = periods
def calculate(self, df: pd.DataFrame) -> pd.Series:
"""
计算动量因子 = (当前收盘价 / N日前收盘价) - 1
要求df已经按时间排序,包含close列
输入df应该是多重索引: [symbol, date] 或者 [date, symbol]
"""
if 'close' not in df.columns:
raise ValueError("DataFrame中缺少close列,请确保包含收盘价数据")
# 判断索引结构,如果symbol是第一层索引,按股票分组计算
if isinstance(df.index, pd.MultiIndex) and df.index.names[0] == 'symbol':
# 已经按symbol分组,每个股票时间排序
close = df['close']
momentum = close.groupby(level=0).pct_change(self.periods)
elif isinstance(df.index, pd.MultiIndex):
# date是第一层,按股票分组
close = df['close']
momentum = close.groupby(level='symbol').pct_change(self.periods)
else:
# 单索引,假设已经按时间排序,整个一起算(不推荐)
momentum = df['close'].pct_change(self.periods)
return momentum
class Momentum1MFactor(MomentumFactor):
"""1个月动量因子"""
def __init__(self):
super().__init__(periods=21, name="momentum_1m")
class Momentum3MFactor(MomentumFactor):
"""3个月动量因子"""
def __init__(self):
super().__init__(periods=63, name="momentum_3m")
class Momentum6MFactor(MomentumFactor):
"""6个月动量因子"""
def __init__(self):
super().__init__(periods=126, name="momentum_6m")
@@ -0,0 +1,30 @@
"""
市净率PB因子
估值因子 - PB越小,估值越低,得分越高(所以最终取负)
"""
import pandas as pd
from .base_factor import BaseFactor
class PBFactor(BaseFactor):
"""市净率PB因子"""
def __init__(self):
super().__init__("pb")
def calculate(self, df: pd.DataFrame) -> pd.Series:
"""
计算PB因子
要求df中已有pb列
"""
# 检查是否有pb列
if 'pb' not in df.columns:
raise ValueError("DataFrame中缺少pb列,请确保数据中包含市净率数据")
# PB越小越好,所以取负值,这样排序的时候小PB会得到高分
pb = df['pb']
# 处理异常值,PB<=0的去掉(净资产为负)
pb = pb.where(pb > 0, pd.NA)
return -pb # 负号表示PB越小分数越高
@@ -0,0 +1,30 @@
"""
市盈率PE因子
估值因子 - PE越小,估值越低,得分越高(所以最终取负)
"""
import pandas as pd
from .base_factor import BaseFactor
class PEFactor(BaseFactor):
"""市盈率PE因子"""
def __init__(self):
super().__init__("pe")
def calculate(self, df: pd.DataFrame) -> pd.Series:
"""
计算PE因子
要求df中已有pe列
"""
# 检查是否有pe列
if 'pe' not in df.columns:
raise ValueError("DataFrame中缺少pe列,请确保数据中包含市盈率数据")
# PE越小越好,所以取负值,这样排序的时候小PE会得到高分
pe = df['pe']
# 处理异常值,PE<=0的去掉(亏损股)
pe = pe.where(pe > 0, pd.NA)
return -pe # 负号表示PE越小分数越高
@@ -0,0 +1,28 @@
"""
ROE因子(净资产收益率)
质量因子 - ROE越大,盈利能力越强,得分越高
"""
import pandas as pd
from .base_factor import BaseFactor
class ROEFactor(BaseFactor):
"""ROE净资产收益率因子"""
def __init__(self):
super().__init__("roe")
def calculate(self, df: pd.DataFrame) -> pd.Series:
"""
计算ROE因子
要求df中已有roe列
"""
# 检查是否有roe列
if 'roe' not in df.columns:
raise ValueError("DataFrame中缺少roe列,请确保数据中包含ROE数据")
roe = df['roe']
# 处理异常值,ROE太小(负盈利)直接给低分
# 保留原始符号,ROE越大得分自然越高
return roe
@@ -0,0 +1,89 @@
"""
板块强度因子
适配A股结构化行情/板块轮动
强度 = 板块近期涨幅 / 全市场平均涨幅
强度越高,得分越高
"""
import pandas as pd
import numpy as np
from .base_factor import BaseFactor
class SectorStrengthFactor(BaseFactor):
"""
板块强度因子
计算最近一个月板块涨幅,相对于全市场,强度越高得分越高
"""
def __init__(self, periods: int = 21, name: str = "sector_strength"):
"""
参数:
periods: 计算板块强度的周期(交易日),默认21≈1个月
name: 因子名称
"""
super().__init__(name)
self.periods = periods
def calculate(self, df: pd.DataFrame) -> pd.Series:
"""
计算板块强度因子
要求df中包含:
- close: 收盘价
- sector: 板块名称或板块代码(每个股票所属板块)
- date: 日期
"""
if 'close' not in df.columns or 'sector' not in df.columns:
raise ValueError("DataFrame需要包含close和sector列")
# 确保date在索引或列
if 'date' not in df.columns and not isinstance(df.index, pd.MultiIndex):
raise ValueError("需要date列或多层索引")
# 计算每个股票近period涨幅
if 'symbol' in df.index.names:
returns = df['close'].groupby(level='symbol').pct_change(self.periods)
else:
returns = df.groupby('symbol')['close'].pct_change(self.periods)
# 按板块分组,计算每个板块平均涨幅
# 获取最新一期
if isinstance(df.index, pd.MultiIndex):
latest_date = df.index.get_level_values('date').max()
latest_df = df.xs(latest_date, level='date')
# 合并涨幅
latest_df['return_1m'] = returns
latest_df = latest_df.dropna(subset=['return_1m'])
# 按板块计算平均涨幅
sector_returns = latest_df.groupby('sector')['return_1m'].mean()
else:
latest_date = df['date'].max()
latest_df = df[df['date'] == latest_date].copy()
latest_df['return_1m'] = returns
latest_df = latest_df.dropna(subset=['return_1m'])
sector_returns = latest_df.groupby('sector')['return_1m'].mean()
# 计算全市场平均涨幅
market_avg = latest_df['return_1m'].mean()
# 计算板块强度 = 板块涨幅 / 全市场平均涨幅
sector_strength = sector_returns / market_avg
# 映射回每个个股
if 'sector' in latest_df.columns:
latest_df['sector_strength'] = latest_df['sector'].map(sector_strength)
else:
# sector在索引
latest_df['sector_strength'] = latest_df.index.get_level_values('sector').map(sector_strength)
# 对齐回原索引
# 这里因为我们只需要最新一期打分,所以直接返回对应个股强度
result = df['close'].copy()
# 将强度放到对应个股上
for symbol, row in latest_df.iterrows():
if symbol in result.index:
result.loc[symbol] = row['sector_strength']
else:
result.loc[df[df['symbol'] == symbol].index] = row['sector_strength']
return result
@@ -0,0 +1,34 @@
"""
市值因子
规模因子 - A股长期中小盘有超额收益,所以市值越小得分越高(取负)
"""
import pandas as pd
from .base_factor import BaseFactor
class SizeFactor(BaseFactor):
"""市值因子"""
def __init__(self):
super().__init__("size")
def calculate(self, df: pd.DataFrame) -> pd.Series:
"""
计算市值因子
要求df中有market_cap列(市值)
A股偏好中小盘,所以市值越小得分越高
"""
if 'market_cap' not in df.columns:
# 如果没有market_cap,可以用close * 流通股本计算
if 'close' in df.columns and 'circulating_cap' in df.columns:
market_cap = df['close'] * df['circulating_cap']
else:
raise ValueError("DataFrame缺少market_cap列,也没有close+circulating_cap")
else:
market_cap = df['market_cap']
# 取对数,平滑市值的长尾分布
log_cap = np.log(market_cap)
# 市值越小越好,所以取负值
return -log_cap
@@ -0,0 +1,50 @@
"""
波动率因子
风险因子 - 波动率越小,股票越稳定,得分越高(所以取负)
通常偏好低波动股票,特别是中低频策略
"""
import pandas as pd
import numpy as np
from .base_factor import BaseFactor
class VolatilityFactor(BaseFactor):
"""波动率因子"""
def __init__(self, periods: int = 63, name: str = "volatility_3m"):
"""
参数:
periods: 计算波动率的周期(交易日数量),默认63≈3个月
name: 因子名称
"""
super().__init__(name)
self.periods = periods
def calculate(self, df: pd.DataFrame) -> pd.Series:
"""
计算波动率 = 近期N日收益率的标准差
波动率越小越好,所以返回负值
"""
if 'close' not in df.columns:
raise ValueError("DataFrame中缺少close列,请确保包含收盘价数据")
# 计算日收益率
if isinstance(df.index, pd.MultiIndex) and 'symbol' in df.index.names:
# 按股票分组计算收益率
returns = df['close'].groupby(level='symbol').pct_change()
# 计算滚动标准差(波动率)
volatility = returns.groupby(level='symbol').rolling(self.periods).std()
# 恢复索引
volatility = volatility.droplevel(0)
elif isinstance(df.index, pd.MultiIndex):
# 尝试第二层是symbol
returns = df['close'].groupby(level='symbol').pct_change()
volatility = returns.groupby(level='symbol').rolling(self.periods).std()
volatility = volatility.droplevel(0)
else:
# 单只股票
returns = df['close'].pct_change()
volatility = returns.rolling(self.periods).std()
# 波动率越小越好,取负值
return -volatility
@@ -6,12 +6,14 @@ import pandas as pd
import numpy as np
from typing import Dict, List, Optional
from vnpy.trader.app.ctaStrategy import CtaTemplate
from vnpy.app.cta_strategy import CtaTemplate
from vnpy.trader.object import BarData, TickData
# 导入我们的因子和工具
import sys
sys.path.append('..')
import os
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, current_dir)
from factors import (
PEFactor, PBFactor, ROEFactor,
Momentum1MFactor, Momentum3MFactor,
@@ -0,0 +1,757 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
进阶多因子+动态加权+估值择时 A股量化中低频策略
单文件版本,无需额外导入
集成单票止损风控模块
"""
import pandas as pd
import numpy as np
from vnpy.app.cta_strategy import CtaTemplate
from vnpy.trader.object import BarData, TickData
# 导入风控模块 - 关羽开发的单票止损+四层风控体系
from risk_control import RiskController, StockInfo, PortfolioInfo
# 自己实现spearman相关系数,不用依赖scipy
def spearmanr(a, b):
"""简单实现spearman秩相关系数"""
a_rank = a.rank()
b_rank = b.rank()
d = a_rank - b_rank
n = len(a.dropna())
if n <= 1:
return 0, None
rho = 1 - 6 * (d ** 2).sum() / (n * (n ** 2 - 1))
return rho, None
class BaseFactor:
"""因子基类 - 所有因子继承此类,定义统一的因子计算接口"""
def __init__(self, name):
self.name = name
self.data = None
def calculate_raw(self, df):
"""计算原始因子值"""
pass
def normalize(self, factor_values):
"""中位数去极值 + z-score标准化"""
values = factor_values.dropna()
if len(values) == 0:
return factor_values
median = values.median()
mad = (values - median).abs().median()
lower = median - 3 * mad
upper = median + 3 * mad
factor_clipped = factor_values.clip(lower, upper)
mean = factor_clipped.mean()
std = factor_clipped.std()
if std > 0:
factor_normalized = (factor_clipped - mean) / std
else:
factor_normalized = factor_clipped - mean
return factor_normalized
def cross_sectional_rank(self, factor_values):
"""横截面rank 0-1"""
if isinstance(factor_values.index, pd.MultiIndex):
ranked = factor_values.groupby(level=0).rank(pct=True)
else:
ranked = factor_values.rank(pct=True)
return ranked
def process(self, df):
"""完整处理流程: 原始计算 → 标准化 → rank"""
raw = self.calculate_raw(df)
normalized = self.normalize(raw)
ranked = self.cross_sectional_rank(normalized)
return ranked
class PEFactor(BaseFactor):
"""市盈率PE因子 - 越小越好"""
def __init__(self):
super().__init__("pe")
def calculate_raw(self, df):
if 'pe' not in df.columns:
raise ValueError("DataFrame中缺少pe列")
pe = df['pe']
pe = pe.where(pe > 0, pd.NA)
return -pe
class PBFactor(BaseFactor):
"""市净PB因子 - 越小越好"""
def __init__(self):
super().__init__("pb")
def calculate_raw(self, df):
if 'pb' not in df.columns:
raise ValueError("DataFrame中缺少pb列")
pb = df['pb']
pb = pb.where(pb > 0, pd.NA)
return -pb
class ROEFactor(BaseFactor):
"""ROE净资产收益率因子 - 越大越好"""
def __init__(self):
super().__init__("roe")
def calculate_raw(self, df):
if 'roe' not in df.columns:
raise ValueError("DataFrame中缺少roe列")
return df['roe']
class Momentum1MFactor(BaseFactor):
"""1个月动量因子 - 越大越好"""
def __init__(self, periods=21):
super().__init__("momentum_1m")
self.periods = periods
def calculate_raw(self, df):
if 'close' not in df.columns:
raise ValueError("DataFrame缺少close列")
if isinstance(df.index, pd.MultiIndex) and 'symbol' in df.index.names:
momentum = df['close'].groupby(level='symbol').pct_change(self.periods)
else:
momentum = df.groupby('symbol')['close'].pct_change(self.periods)
return momentum
class Momentum3MFactor(BaseFactor):
"""3个月动量因子 - 越大越好"""
def __init__(self, periods=63):
super().__init__("momentum_3m")
self.periods = periods
def calculate_raw(self, df):
if 'close' not in df.columns:
raise ValueError("DataFrame缺少close列")
if isinstance(df.index, pd.MultiIndex) and 'symbol' in df.index.names:
momentum = df['close'].groupby(level='symbol').pct_change(self.periods)
else:
momentum = df.groupby('symbol')['close'].pct_change(self.periods)
return momentum
class VolatilityFactor(BaseFactor):
"""波动率因子 - 越小越好,所以取负"""
def __init__(self, periods=63):
super().__init__("volatility_3m")
self.periods = periods
def calculate_raw(self, df):
if 'close' not in df.columns:
raise ValueError("DataFrame缺少close列")
if isinstance(df.index, pd.MultiIndex) and 'symbol' in df.index.names:
returns = df['close'].groupby(level='symbol').pct_change()
volatility = returns.groupby(level='symbol').rolling(self.periods).std()
volatility = volatility.droplevel(0)
else:
returns = df.groupby('symbol')['close'].pct_change()
volatility = returns.rolling(self.periods).std()
return -volatility
class SizeFactor(BaseFactor):
"""市值因子 - 中小盘偏好,市值越小越好"""
def __init__(self):
super().__init__("size")
def calculate_raw(self, df):
if 'market_cap' not in df.columns:
if 'close' in df.columns and 'circulating_cap' in df.columns:
market_cap = df['close'] * df['circulating_cap']
else:
raise ValueError("缺少market_cap列,也没有close+circulating_cap")
else:
market_cap = df['market_cap']
log_cap = np.log(market_cap)
return -log_cap
class SectorStrengthFactor(BaseFactor):
"""板块强度因子 - 板块强度越高越好,适配结构化行情"""
def __init__(self, periods=21):
super().__init__("sector_strength")
self.periods = periods
def calculate_raw(self, df):
if 'close' not in df.columns or 'sector' not in df.columns:
raise ValueError("需要close和sector列")
if isinstance(df.index, pd.MultiIndex) and 'symbol' in df.index.names:
returns = df['close'].groupby(level='symbol').pct_change(self.periods)
else:
returns = df.groupby('symbol')['close'].pct_change(self.periods)
if isinstance(df.index, pd.MultiIndex) and 'date' in df.index.names:
latest_date = df.index.get_level_values('date').max()
latest_df = df.xs(latest_date, level='date').copy()
latest_df['return_1m'] = returns.loc[latest_df.index]
else:
latest_date = df['date'].max()
latest_df = df[df['date'] == latest_date].copy()
latest_df['return_1m'] = returns.loc[latest_df.index]
latest_df = latest_df.dropna(subset=['return_1m'])
sector_avg_ret = latest_df.groupby('sector')['return_1m'].mean()
market_avg_ret = latest_df['return_1m'].mean()
sector_strength = sector_avg_ret / market_avg_ret
result = pd.Series(index=df.index, dtype=float)
for symbol, row in latest_df.iterrows():
sector = row['sector']
strength = sector_strength.loc[sector]
if symbol in result.index:
result.loc[symbol] = strength
else:
mask = df['symbol'] == symbol
result.loc[mask] = strength
return result
class FactorCombiner:
"""因子合成器 - 把多个因子按权重合成最终得分"""
def __init__(self, factors, weights=None):
self.factors = factors
if weights is None:
total = len(factors)
self.weights = {name: 1.0 / total for name in factors}
else:
total = sum(weights.values())
self.weights = {k: v / total for k, v in weights.items()}
def combine(self, data):
combined = None
for name, factor in self.factors.items():
weight = self.weights[name]
factor_score = factor.process(data)
weighted = factor_score * weight
if combined is None:
combined = weighted
else:
combined = combined.add(weighted, fill_value=0)
if combined is not None:
combined = combined.sort_values(ascending=False)
return combined
def update_weights(self, new_weights):
total = sum(new_weights.values())
self.weights = {k: v / total for k, v in new_weights.items()}
def get_weights(self):
return self.weights.copy()
class DynamicWeightAdjuster:
"""动态权重调整器 - 根据滚动IC自动调整因子权重"""
def __init__(
self,
factor_names,
window_size=12,
min_ic=-0.02,
base_weight=0.02
):
self.factor_names = factor_names
self.window_size = window_size
self.min_ic = min_ic
self.base_weight = base_weight
self.ic_history = {name: [] for name in factor_names}
def update_monthly_ic(
self,
factor_df,
forward_returns
):
current_ic = {}
combined = pd.concat([factor_df, forward_returns], axis=1).dropna()
for name in self.factor_names:
if name not in combined.columns:
continue
ic, _ = spearmanr(combined[name], combined[forward_returns.name])
current_ic[name] = ic
self.ic_history[name].append(ic)
if len(self.ic_history[name]) > self.window_size:
self.ic_history[name].pop(0)
return current_ic
def calculate_weights(self):
avg_ic = {}
for name in self.factor_names:
ics = self.ic_history[name]
if len(ics) > 0:
avg_ic[name] = np.mean(ics)
else:
avg_ic[name] = 0
weights = {}
for name in self.factor_names:
ic = avg_ic[name]
if ic < self.min_ic:
weights[name] = self.base_weight
else:
weights[name] = self.base_weight + max(0, ic)
total = sum(weights.values())
if total > 0:
weights = {k: v / total for k, v in weights.items()}
else:
n = len(self.factor_names)
weights = {k: 1.0 / n for k in self.factor_names}
return weights
def get_avg_ic(self):
avg = {}
for name, ics in self.ic_history.items():
if len(ics) > 0:
avg[name] = np.mean(ics)
else:
avg[name] = 0
return avg
class MarketValuationTiming:
"""全市场估值择时 - 根据PE分位数调整整体仓位"""
def __init__(
self,
min_position=0.3,
max_position=1.0,
quantile_low=0.2,
quantile_high=0.8,
lookback_months=60
):
self.min_position = min_position
self.max_position = max_position
self.quantile_low = quantile_low
self.quantile_high = quantile_high
self.lookback_months = lookback_months
self.history = []
def update_monthly(self, market_pe):
self.history.append(market_pe)
if len(self.history) > self.lookback_months:
self.history.pop(0)
def calculate_target_position(self):
if len(self.history) < 12:
return 0.8 * self.max_position
current_pe = self.history[-1]
history_arr = np.array(self.history)
quantile = np.mean(history_arr <= current_pe)
if quantile <= self.quantile_low:
return self.max_position
elif quantile >= self.quantile_high:
return self.min_position
else:
ratio = (quantile - self.quantile_low) / (self.quantile_high - self.quantile_low)
position = self.max_position - ratio * (self.max_position - self.min_position)
return position
def get_current_quantile(self):
if len(self.history) < 2:
return None
current = self.history[-1]
arr = np.array(self.history)
return np.mean(arr <= current)
class MultiFactorDynamicStrategy(CtaTemplate):
"""
进阶多因子动态加权策略
特点:
1. 多因子复合选股
2. 动态加权(根据IC调整)
3. 估值择时(调整整体仓位)
4. 板块强度适配结构化行情
5. 单板块仓位限制
"""
author = "翼德"
parameters = [
"rebalance_freq",
"holding_size",
"top_select",
"dynamic_weight",
"ic_window",
"market_timing",
"min_position",
"max_position",
"max_sector_pct",
]
variables = [
"current_factor_scores",
"current_weights",
"target_position",
"last_rebalance_date",
]
def __init__(self, cta_engine, strategy_name, setting_dict):
super().__init__(cta_engine, strategy_name, setting_dict)
self.rebalance_freq = getattr(self, 'rebalance_freq', 'M')
self.holding_size = getattr(self, 'holding_size', 50)
self.top_select = getattr(self, 'top_select', 0.1)
self.dynamic_weight = getattr(self, 'dynamic_weight', True)
self.ic_window = getattr(self, 'ic_window', 12)
self.market_timing = getattr(self, 'market_timing', True)
self.min_position = getattr(self, 'min_position', 0.3)
self.max_position = getattr(self, 'max_position', 1.0)
self.max_sector_pct = getattr(self, 'max_sector_pct', 0.20)
self.factors_list = [
PEFactor(),
PBFactor(),
ROEFactor(),
Momentum1MFactor(),
Momentum3MFactor(),
VolatilityFactor(),
SizeFactor(),
SectorStrengthFactor()
]
default_weights = {
'pe': 0.15,
'pb': 0.15,
'roe': 0.15,
'momentum_1m': 0.08,
'momentum_3m': 0.12,
'volatility_3m': 0.15,
'size': 0.15,
'sector_strength': 0.10,
}
factor_dict = {f.name: f for f in self.factors_list}
self.factor_combiner = FactorCombiner(factor_dict, default_weights)
if self.dynamic_weight:
factor_names = list(factor_dict.keys())
self.dynamic_adjuster = DynamicWeightAdjuster(
factor_names,
window_size=self.ic_window
)
if self.market_timing:
self.market_timer = MarketValuationTiming(
min_position=self.min_position,
max_position=self.max_position
)
self.target_position = self.max_position
else:
self.target_position = self.max_position
self.current_factor_scores = None
self.current_weights = self.factor_combiner.get_weights()
self.last_rebalance_date = None
self.last_data = None
# 初始化风控控制器 - 关羽风控模块
self.risk_controller = RiskController()
def on_init(self):
self.write_log("策略初始化完成")
self.load_bar(1000)
def on_start(self):
self.write_log("策略启动")
def on_stop(self):
self.write_log("策略停止")
def on_bar(self, bar):
current_date = bar.datetime.date()
# 每日执行风控检查 - 单票止损
self._check_risk_control()
if not self._need_rebalance(current_date):
return
self.rebalance()
self.last_rebalance_date = current_date
def _check_risk_control(self):
"""风控检查:执行单票止损和组合降仓"""
# 获取当前持仓转换为风控数据结构
holds = self.get_all_holds()
if not holds:
return
portfolio = self._get_portfolio_info()
stocks = self._get_stock_info_list(holds)
# 执行风控检查
risk_result = self.risk_controller.post_trade_check(stocks, portfolio)
# 执行止损
if risk_result['stop_loss_required']:
for stop_item in risk_result['stop_loss_stocks']:
code = stop_item['code']
if code in holds:
hold = holds[code]
self.write_log(f"⚠️ 触发单票止损: {code}, 回撤: {stop_item['current_drawdown']:.2%}")
self.sell(code, hold.price, hold.volume)
# 执行组合降仓
if risk_result['rebalance_required']:
target_ratio = risk_result['target_position_ratio']
current_ratio = risk_result['current_position_ratio']
self.write_log(f"⚠️ 组合回撤触发降仓: 当前回撤 {risk_result['current_drawdown']:.2%}, 当前仓位 {current_ratio:.2%}, 目标仓位 {target_ratio:.2%}")
self.put_order()
def _need_rebalance(self, current_date):
if self.last_rebalance_date is None:
return True
if self.rebalance_freq == 'M':
if current_date.month != self.last_rebalance_date.month:
return True
elif self.rebalance_freq == 'W':
current_week = current_date.isocalendar()[1]
last_week = self.last_rebalance_date.isocalendar()[1]
if current_week != last_week:
return True
return False
def calculate_factors(self, data):
final_scores = self.factor_combiner.combine(data)
return final_scores
def select_stocks(self, scores):
n_total = len(scores.dropna())
if self.holding_size:
n_select = self.holding_size
else:
n_select = int(n_total * self.top_select)
selected = scores.head(n_select).index.tolist()
return selected
def calculate_weights_for_selected(self, selected, data):
n = len(selected)
if n == 0:
return {}
selected_df = data.loc[data['symbol'].isin(selected)]
single_weight = self.target_position / n
weights = {symbol: single_weight for symbol in selected}
if 'sector' in selected_df.columns:
sector_weights = {}
for symbol in selected:
sector = selected_df[selected_df['symbol'] == symbol]['sector'].iloc[0]
w = weights[symbol]
if sector not in sector_weights:
sector_weights[sector] = 0
sector_weights[sector] += w
max_allowed = self.target_position * self.max_sector_pct
for sector, total_w in sector_weights.items():
if total_w > max_allowed:
ratio = max_allowed / total_w
for symbol in selected:
s = selected_df[selected_df['symbol'] == symbol]['sector'].iloc[0]
if s == sector:
weights[symbol] *= ratio
total = sum(weights.values())
if total > 0:
ratio = self.target_position / total
for symbol in weights:
weights[symbol] *= ratio
return weights
def update_dynamic_weights(self, last_data, current_data):
if not self.dynamic_weight:
return
last_close = last_data.groupby('symbol')['close'].last()
current_close = current_data.groupby('symbol')['close'].last()
forward_returns = (current_close - last_close) / last_close
factor_scores = {}
for name, factor in self.factor_combiner.factors.items():
factor_scores[name] = factor.process(current_data)
factor_df = pd.DataFrame(factor_scores)
self.dynamic_adjuster.update_monthly_ic(factor_df, forward_returns)
new_weights = self.dynamic_adjuster.calculate_weights()
self.factor_combiner.update_weights(new_weights)
self.current_weights = new_weights
self.write_log(f"动态权重更新完成: {new_weights}")
def update_market_timing(self, market_pe):
if not self.market_timing:
return
self.market_timer.update_monthly(market_pe)
self.target_position = self.market_timer.calculate_target_position()
q = self.market_timer.get_current_quantile()
if q is not None:
self.write_log(f"估值择时更新: 分位数={q:.2f}, 目标仓位={self.target_position:.2f}")
def rebalance(self):
data = self.get_current_market_data()
if data is None or len(data) == 0:
self.write_log("没有可用数据,跳过调仓")
return
if self.dynamic_weight and hasattr(self, 'last_data') and self.last_data is not None:
self.update_dynamic_weights(self.last_data, data)
if self.market_timing:
market_pe = self.calculate_market_pe(data)
self.update_market_timing(market_pe)
scores = self.calculate_factors(data)
if scores is None or len(scores.dropna()) == 0:
self.write_log("计算得分失败,跳过调仓")
return
selected = self.select_stocks(scores)
if len(selected) == 0:
self.write_log("没有选中股票,跳过调仓")
return
target_weights = self.calculate_weights_for_selected(selected, data)
self.rebalance_portfolio(target_weights)
self.last_data = data
self.write_log(f"调仓完成,选中{len(selected)}只股票,目标仓位{self.target_position:.2f}")
def get_current_market_data(self):
return None
def calculate_market_pe(self, data):
pes = data['pe'].dropna()
pes = pes[pes > 0]
if len(pes) == 0:
return 15
return pes.median()
def rebalance_portfolio(self, target_weights):
current_holds = self.get_all_holds()
for symbol in current_holds:
if symbol not in target_weights:
self.sell(symbol, current_holds[symbol].price, 0)
for symbol, target_weight in target_weights.items():
target_value = self.balance * target_weight
current_price = self.get_last_price(symbol)
if current_price <= 0:
continue
target_volume = int(target_value / current_price / 100) * 100
if target_volume <= 0:
continue
current_hold = current_holds.get(symbol, None)
current_volume = current_hold.volume if current_hold else 0
if target_volume > current_volume:
volume = target_volume - current_volume
self.buy(symbol, current_price, volume)
elif target_volume < current_volume:
volume = current_volume - target_volume
self.sell(symbol, current_price, volume)
self.put_order()
def _get_stock_info_list(self, holds):
"""转换持仓为风控StockInfo列表"""
stocks = []
for symbol, hold in holds.items():
# 获取最新价格,这里简化处理
current_price = self.get_last_price(symbol)
if current_price is None or current_price <= 0:
continue
# 创建StockInfo
# 这里假设已经完成了黑天鹅过滤,ST等风险已经在选股阶段排除
stock = StockInfo(
code=symbol,
name=symbol, # 回测中简化处理
cost_price=hold.price,
current_price=current_price,
is_st=False,
is_limit_down=False,
is_fraud=False,
volume=0.0
)
stocks.append(stock)
return stocks
def _get_portfolio_info(self):
"""转换组合信息为风控PortfolioInfo"""
total_capital = self.initial_balance
current_capital = self.balance
positions = {}
holds = self.get_all_holds()
for symbol, hold in holds.items():
current_price = self.get_last_price(symbol)
if current_price > 0:
positions[symbol] = hold.volume * current_price
return PortfolioInfo(
total_capital=total_capital,
current_capital=current_capital,
positions=positions
)
@@ -10,7 +10,6 @@ Date: 2026-03-27
"""
from dataclasses import dataclass
from typing import List, Dict, Optional
import pandas as pd
@@ -32,7 +31,7 @@ class PortfolioInfo:
"""组合信息"""
total_capital: float
current_capital: float
positions: Dict[str, float] # code -> position_size
positions: dict[str, float] # code -> position_size
class SingleStockRiskControl:
@@ -73,8 +72,8 @@ class PortfolioDrawdownRiskControl:
"""
def __init__(self,
drawdown_levels: List[float] = None,
reduce_ratios: List[float] = None):
drawdown_levels: list[float] = None,
reduce_ratios: list[float] = None):
"""
初始化分级风控
:param drawdown_levels: 回撤阈值
@@ -156,7 +155,7 @@ class BlackSwanFilter:
# 全部通过
return True, ""
def filter_universe(self, stocks: List[StockInfo]) -> List[StockInfo]:
def filter_universe(self, stocks: list[StockInfo]) -> list[StockInfo]:
"""批量过滤选股池"""
passed = []
for stock in stocks:
@@ -193,7 +192,7 @@ class RiskController:
return True, ""
def post_trade_check(self, stocks: List[StockInfo], portfolio: PortfolioInfo) -> dict:
def post_trade_check(self, stocks: list[StockInfo], portfolio: PortfolioInfo) -> dict:
"""
收盘后检查:止损检查 + 降仓检查
:return: 风控结果,包含需要止损的票和需要降仓的信息
@@ -221,7 +220,7 @@ class RiskController:
"current_position_ratio": sum(portfolio.positions.values()) / portfolio.current_capital if portfolio.current_capital > 0 else 0
}
def get_risk_report(self, stocks: List[StockInfo], portfolio: PortfolioInfo) -> str:
def get_risk_report(self, stocks: list[StockInfo], portfolio: PortfolioInfo) -> str:
"""生成风控报告"""
result = self.post_trade_check(stocks, portfolio)
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SingleStockStopLossStrategy 回测执行脚本
集成关羽风控模块(单票止损),调用远程 API 执行回测
Author: 关羽 云长
Date: 2026-03-31
Description: 对集成了单票止损的进阶多因子策略执行回测
"""
import sys
import os
import json
import requests
from datetime import datetime
from typing import Dict, List, Optional
# 配置 API 地址
API_BASE_URL = "http://192.168.2.154:8088"
def test_api_connection() -> bool:
"""测试 API 连接"""
print("=" * 60)
print("1. 测试 API 连接")
print("=" * 60)
try:
url = f"{API_BASE_URL}/ping"
response = requests.get(url, timeout=10)
print(f"✅ API 连接成功: {response.status_code}")
print(f" 响应: {response.text[:200]}")
return True
except Exception as e:
print(f"❌ API 连接失败: {e}")
# 尝试根路径
try:
url = f"{API_BASE_URL}/"
response = requests.get(url, timeout=10)
print(f"根路径访问成功: {response.status_code}")
print(f"响应: {response.text[:200]}")
return True
except Exception as e2:
print(f"根路径访问也失败: {e2}")
return False
def get_backtest_config() -> Dict:
"""获取回测配置"""
config = {
"strategy_name": "SingleStockStopLossStrategy",
"description": "进阶多因子动态加权 + 关羽单票止损风控",
"start_date": "2018-01-01",
"end_date": "2026-03-31",
"initial_capital": 1000000,
"strategy_module": "factors-dynamic-weight-timing-20260327.main_strategy_single_file",
"strategy_class": "MultiFactorDynamicStrategy",
"parameters": {
"rebalance_freq": "M",
"holding_size": 50,
"top_select": 0.1,
"dynamic_weight": True,
"ic_window": 12,
"market_timing": True,
"min_position": 0.3,
"max_position": 1.0,
"max_sector_pct": 0.20
},
"risk_control": {
"enabled": True,
"single_stock_stop_loss": 0.15,
"portfolio_drawdown_control": True,
"black_swan_filter": True
},
"data_source": "remote_api"
}
return config
def run_backtest(config: Dict) -> Optional[Dict]:
"""执行回测"""
print("\n" + "=" * 60)
print("2. 提交回测任务")
print("=" * 60)
try:
url = f"{API_BASE_URL}/api/backtest/run"
response = requests.post(url, json=config, timeout=30)
data = response.json()
if response.status_code == 200 and data.get("status") == "ok":
print(f"✅ 回测任务提交成功")
print(f" 任务ID: {data.get('task_id')}")
return data
else:
print(f"❌ 回测提交失败: {data}")
return None
except Exception as e:
print(f"❌ 回测提交异常: {e}")
return None
def poll_backtest_result(task_id: str) -> Optional[Dict]:
"""轮询回测结果"""
print("\n" + "=" * 60)
print("3. 等待回测完成")
print("=" * 60)
import time
max_wait = 3600 # 1小时超时
wait_step = 10 # 每10秒轮询一次
for i in range(0, max_wait, wait_step):
try:
url = f"{API_BASE_URL}/api/backtest/status/{task_id}"
response = requests.get(url, timeout=10)
data = response.json()
status = data.get("status")
if status == "completed":
print(f"✅ 回测完成!")
return data
elif status == "running":
progress = data.get("progress", 0)
print(f" 回测进行中... 进度: {progress:.1%} ({i}/{max_wait}s)")
elif status == "error":
print(f"❌ 回测执行出错: {data.get('error')}")
return None
else:
print(f" 状态: {status} ({i}/{max_wait}s)")
except Exception as e:
print(f" 轮询异常: {e} ({i}/{max_wait}s)")
time.sleep(wait_step)
print(f"❌ 回测超时")
return None
def print_backtest_result(result: Dict):
"""打印回测结果"""
print("\n" + "=" * 60)
print("4. 回测结果")
print("=" * 60)
metrics = result.get("metrics", {})
print(f"\n📊 回测基本信息:")
print(f" 策略名称: {result.get('strategy_name')}")
print(f" 回测区间: {result.get('start_date')} ~ {result.get('end_date')}")
print(f" 初始资金: {result.get('initial_capital'):,.0f}")
print(f"\n📈 绩效指标:")
if "total_return" in metrics:
print(f" 总收益率: {metrics['total_return']:.2%}")
if "annual_return" in metrics:
print(f" 年化收益率: {metrics['annual_return']:.2%}")
if "max_drawdown" in metrics:
print(f" 最大回撤: {metrics['max_drawdown']:.2%}")
if "sharpe_ratio" in metrics:
print(f" 夏普比率: {metrics['sharpe_ratio']:.2f}")
if "calmar_ratio" in metrics:
print(f" 卡玛比率: {metrics['calmar_ratio']:.2f}")
if "win_rate" in metrics:
print(f" 胜率: {metrics['win_rate']:.2%}")
if "profit_loss_ratio" in metrics:
print(f" 盈亏比: {metrics['profit_loss_ratio']:.2f}")
print(f"\n⚠️ 风控统计:")
if "stop_loss_count" in metrics:
print(f" 触发止损次数: {metrics['stop_loss_count']}")
if "portfolio_rebalance_count" in metrics:
print(f" 组合降仓次数: {metrics['portfolio_rebalance_count']}")
if "filtered_stocks_count" in metrics:
print(f" 黑天鹅过滤数量: {metrics['filtered_stocks_count']}")
print("\n" + "=" * 60)
# 保存结果到文件
result_file = os.path.join(
os.path.dirname(__file__),
f"backtest_result_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
)
with open(result_file, 'w', encoding='utf-8') as f:
json.dump(result, f, indent=2, ensure_ascii=False)
print(f"\n💾 完整结果已保存到: {result_file}")
def main():
"""主函数"""
print("\n")
print("=" * 60)
print("SingleStockStopLossStrategy 回测")
print("进阶多因子动态加权 + 关羽单票止损风控")
print("=" * 60)
# 1. 测试 API 连接
if not test_api_connection():
print("\n❌ API 连接失败,请检查服务是否正常运行")
sys.exit(1)
# 2. 获取回测配置并提交
config = get_backtest_config()
submit_result = run_backtest(config)
if not submit_result:
sys.exit(1)
task_id = submit_result.get("task_id")
if not task_id:
print("❌ 没有获取到任务ID")
sys.exit(1)
# 3. 等待回测完成
result = poll_backtest_result(task_id)
if not result:
sys.exit(1)
# 4. 打印结果
print_backtest_result(result)
print("\n✅ 回测执行完成!")
if __name__ == "__main__":
main()
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
本地执行回测 - 通过RPC连接vnpy回测引擎
SingleStockStopLossStrategy with 15% stop loss
510300.SSE 2021-01-01 ~ 2026-03-01
Author: Guan Yu YunChang
Date: 2026-03-31
"""
import sys
import os
import pickle
import zmq
from typing import Dict, Any
# RPC配置 - 容器外连接(当前运行在容器外 NAS Mac mini 192.168.2.153
RPC_ENDPOINT = "tcp://192.168.2.154:8008"
# 回测参数
BACKTEST_CONFIG = {
"strategy_name": "SingleStockStopLossStrategy",
"description": "进阶多因子动态加权 + 关羽15%单票止损风控 - 510300.SSE",
"symbol": "510300.SSE",
"interval": "1d",
"start": 1609459200,
"end": 1772515200,
"start_date": "2021-01-01",
"end_date": "2026-03-01",
"initial_capital": 1000000,
"commission_fee": 3e-5,
"slippage": 0.002,
"contract_size": 10000,
"price_tick": 0.001,
"data_source": "sqlite",
"strategy_module_path": os.path.abspath("main_strategy_single_file.py"),
"strategy_class": "MultiFactorDynamicStrategy",
"stop_loss_pct": 0.15,
"parameters": {
"stop_loss_pct": 0.15,
"enabled": True
}
}
def main():
"""主函数"""
print("=" * 60)
print("SingleStockStopLossStrategy 回测 - 本地RPC执行")
print("标的: 510300.SSE 沪深300ETF")
print("区间: 2021-01-01 ~ 2026-03-01")
print("止损: 15%")
print("初始资金: 1,000,000")
print("=" * 60)
# 读取策略代码
code_path = os.path.join(os.path.dirname(__file__), "main_strategy_single_file.py")
with open(code_path, 'r', encoding='utf-8') as f:
strategy_code = f.read()
BACKTEST_CONFIG["strategy_code"] = strategy_code
# 连接RPC
context = zmq.Context()
socket = context.socket(zmq.REQ)
socket.connect(RPC_ENDPOINT)
print(f"\n连接RPC服务器: {RPC_ENDPOINT}")
try:
# 发送回测请求 - 使用pickle序列化匹配服务器端
print("发送回测请求... (pickle)")
socket.send_pyobj(BACKTEST_CONFIG)
# 接收响应
print("等待回测结果... (全区间回测需要几分钟,请耐心等待)")
result = socket.recv_pyobj()
# 检查结果
if isinstance(result, dict):
if result.get("code") == 200 or result.get("status") == "ok" or "metrics" in result:
print("\n✅ 回测成功完成!")
else:
print(f"\n❌ 回测失败: {result.get('msg') or result.get('error')}")
print(f"详细信息: {result}")
sys.exit(1)
else:
print(f"\n❌ 返回结果不是字典: {type(result)}")
print(f"结果: {result}")
sys.exit(1)
# 保存结果
output_file = os.path.join(
os.path.dirname(__file__),
f"backtest_result_510300_stoploss_{int(BACKTEST_CONFIG['stop_loss_pct'] * 100)}.json"
)
# 保存为json方便查看
with open(output_file, 'w', encoding='utf-8') as f:
import json
json.dump(result, f, indent=2, ensure_ascii=False)
print(f"\n💾 完整结果已保存到: {output_file}")
# 打印关键指标
print("\n" + "=" * 60)
print("📊 回测结果摘要")
print("=" * 60)
metrics = result.get('metrics') or result.get('data', {}).get('metrics', result)
print(f"\n📋 基本信息:")
print(f" 策略名称: {BACKTEST_CONFIG['strategy_name']}")
print(f" 标的: {BACKTEST_CONFIG['symbol']} 沪深300ETF")
print(f" 回测区间: {BACKTEST_CONFIG['start_date']} ~ {BACKTEST_CONFIG['end_date']}")
print(f" 初始资金: {BACKTEST_CONFIG['initial_capital']:,}")
print(f" 单票止损: {BACKTEST_CONFIG['stop_loss_pct'] * 100:.0f}%")
print(f"\n📈 绩效指标:")
def print_metric(key, name, fmt="{:.2%}"):
if key in metrics:
print(f" {name}: {fmt.format(metrics[key])}")
elif key.lower() in metrics:
print(f" {name}: {fmt.format(metrics[key.lower()])}")
print_metric('total_return', '总收益率')
print_metric('annual_return', '年化收益率')
print_metric('max_drawdown', '最大回撤')
print_metric('sharpe_ratio', '夏普比率', '{:.2f}')
print_metric('calmar_ratio', '卡玛比率', '{:.2f}')
print_metric('sortino_ratio', '索提诺比率', '{:.2f}')
print_metric('win_rate', '胜率')
print_metric('profit_loss_ratio', '盈亏比', '{:.2f}')
print(f"\n⚠️ 交易统计:")
if 'total_trades' in metrics:
print(f" 总交易次数: {metrics['total_trades']}")
if 'stop_loss_count' in metrics or 'stop_loss_triggered' in metrics:
print(f" 触发止损次数: {metrics.get('stop_loss_count') or metrics.get('stop_loss_triggered', 0)}")
if 'final_capital' in metrics:
print(f" 最终资金: {metrics['final_capital']:,.2f}")
print("\n" + "=" * 60)
print("回测完成!")
return result
except Exception as e:
print(f"\n❌ 执行异常: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SingleStockStopLossStrategy 单票回测执行脚本
针对 510300.SSE 沪深300ETF 进行回测,验证单票止损功能
参数说明:
- symbol: 510300.SSE (沪深300ETF)
- interval: 1d (日线)
- start: 2021-01-01 (1609459200)
- end: 2026-03-01 (1772515200)
- capital: 1,000,000
- 手续费: 3e-5
- 滑点: 0.002
- 最小交易单位: 10000 份
- 最小价格变动: 0.001
- data_source: sqlite
Author: 关羽 云长
Date: 2026-03-31
"""
import sys
import os
import json
import requests
from datetime import datetime
from typing import Dict, List, Optional
# 配置 API 地址 - 按照要求使用完整路径
API_BASE_URL = "http://192.168.2.154:8088"
API_ENDPOINT = "/api/backtest/run"
def test_api_connection() -> bool:
"""测试 API 连接"""
print("=" * 60)
print("1. 测试 API 连接")
print("=" * 60)
endpoints = ["/api/backtest/run", "/api/ping", "/ping", "/health", "/status", ""]
for endpoint in endpoints:
try:
url = f"{API_BASE_URL}{endpoint}"
print(f" 尝试: {url} ... ", end="")
response = requests.get(url, timeout=10)
print(f"{response.status_code}")
if response.status_code == 200:
print(f"✅ API 连接成功: {url}")
if response.text:
print(f" 响应: {response.text[:200]}")
return True
except Exception as e:
print(f"{type(e).__name__}: {e}")
continue
print("❌ 所有端点尝试都失败")
return False
def load_strategy_code() -> str:
"""加载策略代码文件"""
# 读取风控模块代码
risk_control_path = os.path.join(os.path.dirname(__file__), "risk_control.py")
with open(risk_control_path, 'r', encoding='utf-8') as f:
risk_control_code = f.read()
# 读取主策略代码(已经集成了风控)
main_strategy_path = os.path.join(os.path.dirname(__file__), "main_strategy_single_file.py")
with open(main_strategy_path, 'r', encoding='utf-8') as f:
main_strategy_code = f.read()
# 合并代码,风控模块优先
full_code = f"{risk_control_code}\n\n# =====================================================\n# Main Strategy with Risk Control integrated\n# =====================================================\n\n{main_strategy_code}"
return full_code
def get_backtest_config() -> Dict:
"""获取单票回测配置,按照要求参数"""
strategy_code = load_strategy_code()
config = {
"strategy_name": "SingleStockStopLossStrategy",
"description": "进阶多因子动态加权 + 关羽15%单票止损风控 - 510300.SSE",
"symbol": "510300.SSE",
"interval": "1d",
"start": 1609459200,
"start_timestamp": 1609459200,
"end": 1772515200,
"end_timestamp": 1772515200,
"start_date": "2021-01-01",
"end_date": "2026-03-01",
"initial_capital": 1000000,
"commission_fee": 3e-5,
"slippage": 0.002,
"contract_size": 10000,
"price_tick": 0.001,
"data_source": "sqlite",
"strategy_code": strategy_code,
"strategy_module": "main_strategy_single_file",
"strategy_class": "MultiFactorDynamicStrategy",
"stop_loss_pct": 0.15, # 标准15%单票止损
"parameters": {
"stop_loss_pct": 0.15,
"enabled": True
}
}
return config
def run_backtest(config: Dict) -> Optional[Dict]:
"""执行回测"""
print("\n" + "=" * 60)
print("2. 提交回测任务")
print("=" * 60)
try:
url = f"{API_BASE_URL}{API_ENDPOINT}"
print(f"提交到: {url}")
print(f"配置: {json.dumps(config, indent=2, ensure_ascii=False)}")
response = requests.post(url, json=config, timeout=30)
print(f"状态码: {response.status_code}")
if response.status_code == 200:
try:
data = response.json()
print(f"✅ 回测任务提交成功")
return data
except Exception as e:
print(f"JSON解析失败,响应内容: {response.text[:500]}")
return None
else:
print(f"❌ 回测提交失败,状态码: {response.status_code}")
print(f"响应: {response.text[:500]}")
return None
except Exception as e:
print(f"❌ 回测提交异常: {type(e).__name__}: {e}")
return None
def poll_backtest_result(task_id: str) -> Optional[Dict]:
"""轮询回测结果"""
print("\n" + "=" * 60)
print("3. 等待回测完成")
print("=" * 60)
import time
max_wait = 600 # 10分钟超时,单票回测很快
wait_step = 5 # 每5秒轮询一次
for i in range(0, max_wait, wait_step):
try:
# 尝试多种可能的状态查询路径
status_paths = [
f"/api/backtest/status/{task_id}",
f"/api/backtest/{task_id}/status",
f"/backtest/status/{task_id}",
f"/task/{task_id}/status",
]
for path in status_paths:
url = f"{API_BASE_URL}{path}"
try:
response = requests.get(url, timeout=10)
if response.status_code == 200:
data = response.json()
status = data.get("status") or data.get("state")
if status in ["completed", "done", "success"]:
print(f"✅ 回测完成!")
return data
elif status in ["running", "pending", "queued"]:
progress = data.get("progress", 0) or data.get("percent", 0)
print(f" 回测进行中... 进度: {progress:.1%} ({i}/{max_wait}s)")
break
elif status in ["error", "failed"]:
print(f"❌ 回测执行出错: {data.get('error') or data.get('message')}")
return None
else:
print(f" 状态: {status} ({i}/{max_wait}s)")
break
except Exception:
continue
except Exception as e:
print(f" 轮询异常: {e} ({i}/{max_wait}s)")
time.sleep(wait_step)
print(f"❌ 回测超时")
return None
def print_backtest_result(result: Dict):
"""打印回测结果"""
print("\n" + "=" * 60)
print("4. 回测结果 - 510300.SSE 沪深300ETF")
print("=" * 60)
# 适配不同的数据结构
if isinstance(result, dict):
metrics = result.get("metrics") or result.get("result") or {}
if not isinstance(metrics, dict):
metrics = result
print(f"\n📊 回测基本信息:")
print(f" 策略名称: SingleStockStopLossStrategy (15%止损)")
print(f" 标的: 510300.SSE 沪深300ETF")
print(f" 回测区间: 2021-01-01 ~ 2026-03-01")
print(f" 初始资金: 1,000,000")
print(f" 手续费: {3e-5:.6f}")
print(f" 滑点: 0.002")
print(f"\n📈 绩效指标:")
def print_metric(key, name, fmt="{:.2%}"):
if key in metrics:
print(f" {name}: {fmt.format(metrics[key])}")
print_metric("total_return", "总收益率")
print_metric("annual_return", "年化收益率")
print_metric("max_drawdown", "最大回撤")
print_metric("sharpe_ratio", "夏普比率", "{:.2f}")
print_metric("calmar_ratio", "卡玛比率", "{:.2f}")
print_metric("sortino_ratio", "索提诺比率", "{:.2f}")
print_metric("win_rate", "胜率")
print_metric("profit_loss_ratio", "盈亏比", "{:.2f}")
print(f"\n⚠️ 风控统计:")
print_metric("stop_loss_count", "触发止损次数", "{}")
print_metric("stop_loss_triggered", "触发止损次数", "{}")
print_metric("total_trades", "总交易次数", "{}")
print_metric("holding_days", "持仓天数", "{}")
if "stop_loss_triggered_list" in metrics or "stop_loss_events" in metrics:
events = metrics.get("stop_loss_triggered_list") or metrics.get("stop_loss_events", [])
if events:
print(f"\n止损事件列表:")
for idx, event in enumerate(events[:10], 1):
date = event.get("date") or event.get("time", "")
pct = event.get("drawdown") or event.get("pct", 0)
print(f" {idx}. {date} 回撤 {pct:.2%}")
if len(events) > 10:
print(f" ... 还有 {len(events) - 10}")
print("\n" + "=" * 60)
# 保存结果到文件
result_file = os.path.join(
os.path.dirname(__file__),
f"single_stock_backtest_510300_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
)
with open(result_file, 'w', encoding='utf-8') as f:
json.dump(result, f, indent=2, ensure_ascii=False)
print(f"\n💾 完整结果已保存到: {result_file}")
# 同时保存一份摘要txt
summary_file = result_file.replace('.json', '.txt')
with open(summary_file, 'w', encoding='utf-8') as f:
f.write("SingleStockStopLossStrategy 回测结果\n")
f.write("标的: 510300.SSE 沪深300ETF\n")
f.write("区间: 2021-01-01 ~ 2026-03-01\n")
f.write("止损: 15%\n")
f.write("=" * 50 + "\n")
for key in metrics:
f.write(f"{key}: {metrics[key]}\n")
print(f"📝 摘要已保存到: {summary_file}")
def main():
"""主函数"""
print("\n")
print("=" * 60)
print("SingleStockStopLossStrategy 单票回测")
print("510300.SSE 沪深300ETF + 关羽 15% 单票止损")
print("=" * 60)
# 1. 测试 API 连接
if not test_api_connection():
print("\n❌ API 连接失败,请检查服务是否正常运行")
sys.exit(1)
# 2. 获取回测配置并提交
config = get_backtest_config()
submit_result = run_backtest(config)
if not submit_result:
sys.exit(1)
# 获取任务ID
task_id = None
if isinstance(submit_result, dict):
task_id = (submit_result.get("task_id") or
submit_result.get("id") or
submit_result.get("task"))
if not task_id:
print(f"⚠️ 没有获取到任务ID,但请求已发送")
print(f"响应内容: {json.dumps(submit_result, indent=2, ensure_ascii=False)}")
# 如果直接返回了结果,直接打印
if "metrics" in submit_result or "total_return" in submit_result:
print_backtest_result(submit_result)
print("\n✅ 回测执行完成!")
sys.exit(0)
sys.exit(1)
print(f"任务ID: {task_id}")
# 3. 等待回测完成
result = poll_backtest_result(task_id)
if not result:
sys.exit(1)
# 4. 打印结果
print_backtest_result(result)
print("\n✅ 回测执行完成!")
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
{"strategy_name": "SingleStockStopLossStrategy", "description": "进阶多因子动态加权 + 关羽15%单票止损风控 - 510300.SSE", "symbol": "510300.SSE", "interval": "1d", "start": 1609459200, "start_timestamp": 1609459200, "end": 1772515200, "end_timestamp": 1772515200, "start_date": "2021-01-01", "end_date": "2026-03-01", "initial_capital": 1000000, "commission_fee": 0.00003, "slippage": 0.002, "contract_size": 10000, "price_tick": 0.001, "data_source": "sqlite", "strategy_module": "main_strategy_single_file", "strategy_class": "MultiFactorDynamicStrategy", "stop_loss_pct": 0.15, "parameters": {"stop_loss_pct": 0.15, "enabled": true}}
@@ -0,0 +1,5 @@
from .multi_factor_dynamic_strategy import MultiFactorDynamicStrategy
__all__ = [
'MultiFactorDynamicStrategy'
]
@@ -0,0 +1,407 @@
"""
进阶多因子+动态加权+估值择时 A股量化中低频策略
主策略实现,遵循vn.py CtaStrategy接口
"""
import pandas as pd
import numpy as np
from typing import Dict, List, Optional
from vnpy.trader.app.ctaStrategy import CtaTemplate
from vnpy.trader.object import BarData, TickData
# 导入我们的因子和工具
import sys
sys.path.append('..')
from factors import (
PEFactor, PBFactor, ROEFactor,
Momentum1MFactor, Momentum3MFactor,
VolatilityFactor, SizeFactor,
SectorStrengthFactor
)
from utils import FactorCombiner, DynamicWeightAdjuster, MarketValuationTiming
class MultiFactorDynamicStrategy(CtaTemplate):
"""
进阶多因子策略
特点:
1. 多因子复合选股
2. 动态加权(根据IC调整)
3. 估值择时(调整整体仓位)
4. 中低频调仓(月频/周频)
"""
# 策略参数
author = "翼德"
parameters = [
"rebalance_freq", # 调仓频率,'M'月频 'W'周频
"holding_size", # 持股数量
"top_select", # 选前N%的股票
"dynamic_weight", # 是否开启动态加权
"ic_window", # IC观测窗口
"market_timing", # 是否开启估值择时
"min_position", # 最小仓位
"max_position", # 最大仓位
"max_sector_pct", # 单板块最大仓位占比(相对于总仓位)
]
# 策略变量
variables = [
"current_factor_scores", # 当前因子得分
"current_weights", # 当前因子权重
"target_position", # 当前目标仓位
"last_rebalance_date", # 上次调仓日期
"max_sector_pct", # 单板块最大仓位
]
def __init__(self, cta_engine, strategy_name, setting_dict):
super().__init__(cta_engine, strategy_name, setting_dict)
# 默认参数
self.rebalance_freq = getattr(self, 'rebalance_freq', 'M') # 默认月频调仓
self.holding_size = getattr(self, 'holding_size', 50) # 默认持有50只
self.top_select = getattr(self, 'top_select', 0.1) # 选前10%
self.dynamic_weight = getattr(self, 'dynamic_weight', True)
self.ic_window = getattr(self, 'ic_window', 12)
self.market_timing = getattr(self, 'market_timing', True)
self.min_position = getattr(self, 'min_position', 0.3)
self.max_position = getattr(self, 'max_position', 1.0)
self.max_sector_pct = getattr(self, 'max_sector_pct', 0.20) # 单板块最大20%仓位
# 初始化因子
self._init_factors()
# 初始化因子合成器
# 调整后的初始权重(适配结构化行情):
# 总权重100%,趋势因子从15%→20%,新增板块强度10%
default_weights = {
'pe': 0.15,
'pb': 0.15,
'roe': 0.15,
'momentum_1m': 0.08,
'momentum_3m': 0.12, # 趋势合计 0.08+0.12=0.20 → 20%
'volatility_3m': 0.15,
'size': 0.15,
'sector_strength': 0.10, # 新增板块强度 10%
}
# 检查是否所有因子都覆盖
factor_dict = {f.name: f for f in self.factors_list}
self.factor_combiner = FactorCombiner(factor_dict, default_weights)
# 初始化动态加权
if self.dynamic_weight:
factor_names = list(factor_dict.keys())
self.dynamic_adjuster = DynamicWeightAdjuster(
factor_names,
window_size=self.ic_window
)
# 初始化估值择时
if self.market_timing:
self.market_timer = MarketValuationTiming(
min_position=self.min_position,
max_position=self.max_position
)
# 策略状态变量
self.current_factor_scores = None
self.current_weights = self.factor_combiner.get_weights()
self.target_position = self.max_position # 默认最大仓位
self.last_rebalance_date = None
def _init_factors(self):
"""初始化所有因子列表
调整后权重(适配结构化行情):
- PE: ~15%
- PB: ~15%
- ROE: ~15%
- Momentum1M: 10% → 提高到 ~12%
- Momentum3M: 10% → 提高到 ~13% (合计趋势因子20%)
- Volatility: ~15%
- Size: ~15%
- SectorStrength: +10%
"""
self.factors_list = [
PEFactor(),
PBFactor(),
ROEFactor(),
Momentum1MFactor(),
Momentum3MFactor(),
VolatilityFactor(),
SizeFactor(),
SectorStrengthFactor() # 新增板块强度因子,适配结构化行情
]
def on_init(self):
"""策略初始化"""
self.write_log("策略初始化完成")
self.load_bar(1000) # 加载1000天数据用于热身
def on_start(self):
"""策略启动"""
self.write_log("策略启动")
def on_stop(self):
"""策略停止"""
self.write_log("策略停止")
def on_bar(self, bar: BarData):
"""
收到K线推送
中低频策略,判断是否需要调仓
"""
current_date = bar.datetime.date()
# 判断是否需要调仓
if not self._need_rebalance(current_date):
return
# 执行调仓
self.rebalance()
# 更新上次调仓日期
self.last_rebalance_date = current_date
def _need_rebalance(self, current_date) -> bool:
"""判断是否需要调仓"""
if self.last_rebalance_date is None:
return True # 第一次肯定调仓
if self.rebalance_freq == 'M':
# 月频调仓:不同月份就调仓
if current_date.month != self.last_rebalance_date.month:
return True
elif self.rebalance_freq == 'W':
# 周频调仓:不同周就调仓
current_week = current_date.isocalendar()[1]
last_week = self.last_rebalance_date.isocalendar()[1]
if current_week != last_week:
return True
return False
def calculate_factors(self, data: pd.DataFrame) -> pd.Series:
"""
计算所有因子,合成最终得分
参数:
data: 所有股票的行情财务数据,columns需要包含因子所需列
返回:
最终得分,降序排列
"""
# 合成因子得分
final_scores = self.factor_combiner.combine(data)
# 排序,得分高在前
final_scores = final_scores.sort_values(ascending=False)
return final_scores
def select_stocks(self, scores: pd.Series) -> List:
"""
根据因子得分选股票
参数:
scores: 因子得分降序排列
返回:
选中的股票列表
"""
n_total = len(scores.dropna())
if self.holding_size:
# 固定持股数量
n_select = self.holding_size
else:
# 按比例选
n_select = int(n_total * self.top_select)
# 选前n个
selected = scores.head(n_select).index.tolist()
return selected
def calculate_weights_for_selected(self, selected: List, data: pd.DataFrame) -> Dict[str, float]:
"""
计算选中股票的目标权重
考虑整体择时仓位
考虑板块限制:单板块最高不超过max_sector_pct
等权分配给选中股票,然后调整板块超限
"""
n = len(selected)
if n == 0:
return {}
# 获取选中股票的板块信息
selected_df = data.loc[data['symbol'].isin(selected)]
# 初步等权分配
single_weight = self.target_position / n
weights = {symbol: single_weight for symbol in selected}
# 检查板块仓位,超过限制就平均压缩
# 按板块汇总
if 'sector' in selected_df.columns:
sector_weights = {}
for symbol in selected:
sector = selected_df[selected_df['symbol'] == symbol]['sector'].iloc[0]
w = weights[symbol]
if sector not in sector_weights:
sector_weights[sector] = 0
sector_weights[sector] += w
# 检查超限
max_allowed = self.target_position * self.max_sector_pct
for sector, total_w in sector_weights.items():
if total_w > max_allowed:
# 需要压缩,计算压缩比例
ratio = max_allowed / total_w
# 压缩该板块所有股票
for symbol in selected:
s = selected_df[selected_df['symbol'] == symbol]['sector'].iloc[0]
if s == sector:
weights[symbol] *= ratio
# 重新归一化,保证总权重还是target_position
total = sum(weights.values())
if total > 0:
ratio = self.target_position / total
for symbol in weights:
weights[symbol] *= ratio
return weights
def update_dynamic_weights(self, data: pd.DataFrame, forward_returns: pd.Series):
"""
更新动态权重(基于IC)
"""
if not self.dynamic_weight:
return
# 计算每个因子当期IC并更新历史
# 获取当前因子得分
factor_scores = {}
for name, factor in self.factor_combiner.factors.items():
factor_scores[name] = factor.process(data)
factor_df = pd.DataFrame(factor_scores)
self.dynamic_adjuster.update_ic(factor_df, forward_returns)
# 计算新权重
new_weights = self.dynamic_adjuster.calculate_weights()
self.factor_combiner.update_weights(new_weights)
self.current_weights = new_weights
self.write_log(f"动态权重更新完成: {new_weights}")
def update_market_timing(self, market_pe: float):
"""
更新估值择位
"""
if not self.market_timing:
return
self.market_timer.update_valuation(market_pe)
self.target_position = self.market_timer.calculate_target_position()
q = self.market_timer.get_current_quantile()
if q is not None:
self.write_log(f"估值择时更新: 分位数={q:.2f}, 目标仓位={self.target_position:.2f}")
def rebalance(self):
"""
执行调仓
这是主调仓逻辑,实际回测中vn.py会调用这里
"""
# 获取最新数据(实际使用中从数据接口获取)
# 这里留出接口,实际回测时填充
data = self.get_current_market_data()
if data is None or len(data) == 0:
self.write_log("没有可用数据,跳过调仓")
return
# 如果有动态加权,更新权重
if self.dynamic_weight and hasattr(self, 'last_data'):
# 计算上期到这期的收益率
forward_returns = self.calculate_forward_returns(self.last_data, data)
self.update_dynamic_weights(self.last_data, forward_returns)
# 计算因子得分
scores = self.calculate_factors(data)
# 选股
selected = self.select_stocks(scores)
# 计算权重(包含择时仓位,包含板块限制)
target_weights = self.calculate_weights_for_selected(selected, data)
# 执行调仓(调用vn.py接口)
self.rebalance_portfolio(target_weights)
# 保存数据供下次动态加权使用
self.last_data = data
self.write_log(f"调仓完成,选中{len(selected)}只股票,目标仓位{self.target_position:.2f}")
def get_current_market_data(self) -> Optional[pd.DataFrame]:
"""
获取当前市场数据(包含收盘价、财务指标等)
需要根据实际数据源实现
这里留出接口
"""
# 实际使用中,从你的数据接口获取
# 返回格式:
# index = (date, symbol) 或者 MultiIndex
# columns 需要包含: pe, pb, roe, close, market_cap 等
return None
def calculate_forward_returns(self, last_data: pd.DataFrame, current_data: pd.DataFrame) -> pd.Series:
"""计算远期收益率(用于IC计算)"""
# 获取价格计算收益率
last_close = last_data.groupby(level='symbol')['close'].last()
current_close = current_data.groupby(level='symbol')['close'].last()
forward_returns = (current_close - last_close) / last_close
return forward_returns
def rebalance_portfolio(self, target_weights: Dict[str, float]):
"""
实际执行调仓,调整每个股票仓位
"""
# 获取当前持仓
current_holds = self.get_all_holds()
# 平掉不在目标中的持仓
for symbol in current_holds:
if symbol not in target_weights:
self.sell(symbol, current_holds[symbol].price, 0)
# 调整目标持仓仓位
for symbol, target_weight in target_weights.items():
# 计算目标持仓手数/数量
# 这里根据实际资金计算,简化处理
target_value = self.balance * target_weight
current_price = self.get_last_price(symbol)
if current_price <= 0:
continue
# A股最小买100股
target_volume = int(target_value / current_price / 100) * 100
if target_volume <= 0:
continue
# 获取当前持仓
current_hold = current_holds.get(symbol, None)
current_volume = current_hold.volume if current_hold else 0
# 调仓
if target_volume > current_volume:
# 加仓
volume = target_volume - current_volume
self.buy(symbol, current_price, volume)
elif target_volume < current_volume:
# 减仓
volume = current_volume - target_volume
self.sell(symbol, current_price, volume)
self.put_order()
@@ -0,0 +1,9 @@
from .factor_combiner import FactorCombiner
from .dynamic_weight import DynamicWeightAdjuster
from .market_timing import MarketValuationTiming
__all__ = [
'FactorCombiner',
'DynamicWeightAdjuster',
'MarketValuationTiming'
]
@@ -0,0 +1,120 @@
"""
动态加权模块
根据因子近期IC信息系数动态调整权重
IC越高因子效果越好权重越大
"""
import pandas as pd
import numpy as np
from typing import Dict, List, Optional
from scipy.stats import spearmanr
class DynamicWeightAdjuster:
"""
动态权重调整器
根据每期因子IC计算新权重
"""
def __init__(
self,
factor_names: List[str],
window_size: int = 12, # 观测窗口,月频就是12个月
min_ic: float = -0.05,
base_weight: float = 0.05 # 基础权重,保证每个因子都有一定暴露
):
"""
参数:
factor_names: 因子名称列表
window_size: 计算IC的滚动窗口大小期数月频调仓就是多少个月
min_ic: 最小IC如果IC小于这个值会被降低权重
base_weight: 每个因子的基础权重避免权重为0
"""
self.factor_names = factor_names
self.window_size = window_size
self.min_ic = min_ic
self.base_weight = base_weight
# 保存历史IC记录
self.ic_history: Dict[str, List[float]] = {name: [] for name in factor_names}
def update_ic(
self,
factor_scores: pd.Series,
forward_returns: pd.Series
) -> Dict[str, float]:
"""
更新一期IC数据
参数:
factor_scores: 当期因子得分横截面
forward_returns: 下期收益率要预测的目标
返回:
当期IC字典
"""
# 合并去掉NaN
df = pd.concat([factor_scores, forward_returns], axis=1).dropna()
current_ic = {}
for name in self.factor_names:
if name in df.columns:
# 计算Spearman秩相关系数作为IC
ic, _ = spearmanr(df[name], df[forward_returns.name])
current_ic[name] = ic
self.ic_history[name].append(ic)
# 保持窗口大小
if len(self.ic_history[name]) > self.window_size:
self.ic_history[name].pop(0)
return current_ic
def calculate_weights(self) -> Dict[str, float]:
"""
根据历史IC计算新权重
IC越高权重越大
"""
# 计算每个因子平均IC
avg_ic = {}
for name in self.factor_names:
ics = self.ic_history[name]
if len(ics) > 0:
avg_ic[name] = np.mean(ics)
else:
avg_ic[name] = 0
# IC转权重:IC越大权重越大,IC为负降低权重
weights = {}
for name in self.factor_names:
ic = avg_ic[name]
# 如果IC小于最小值,只保留基础权重
if ic < self.min_ic:
weights[name] = self.base_weight
else:
# IC越大权重越大,加上基础权重保证至少有base
weights[name] = self.base_weight + max(0, ic)
# 归一化到总和为1
total = sum(weights.values())
if total > 0:
weights = {k: v / total for k, v in weights.items()}
else:
# 如果都不好,等权重
n = len(self.factor_names)
weights = {k: 1.0 / n for k in self.factor_names}
return weights
def get_ic_history(self) -> Dict[str, List[float]]:
"""获取历史IC记录"""
return self.ic_history.copy()
def get_avg_ic(self) -> Dict[str, float]:
"""获取滚动平均IC"""
avg_ic = {}
for name, ics in self.ic_history.items():
if len(ics) > 0:
avg_ic[name] = np.mean(ics)
else:
avg_ic[name] = 0
return avg_ic
@@ -0,0 +1,70 @@
"""
因子合成工具
将多个因子按权重合成最终得分
"""
import pandas as pd
import numpy as np
from typing import Dict, List, Optional
from factors.base_factor import BaseFactor
class FactorCombiner:
"""因子合成器 - 把多个因子按权重合成最终得分"""
def __init__(self, factors: Dict[str, BaseFactor], weights: Optional[Dict[str, float]] = None):
"""
参数:
factors: 因子字典 {因子名称: 因子实例}
weights: 因子权重 {因子名称: 权重}, 如果None等权重
"""
self.factors = factors
# 如果没给权重,用等权重
if weights is None:
total = len(factors)
self.weights = {name: 1.0 / total for name in factors}
else:
# 标准化权重,让总和为1
total = sum(weights.values())
self.weights = {k: v / total for k, v in weights.items()}
def combine(self, data: pd.DataFrame) -> pd.Series:
"""
合成因子得分
参数:
data: 原始行情财务数据DataFrame
返回:
最终合并得分index和data一致
"""
combined = None
for name, factor in self.factors.items():
weight = self.weights[name]
# 计算因子值(已经包含了标准化和rank)
factor_score = factor.process(data)
# 加权
weighted = factor_score * weight
if combined is None:
combined = weighted
else:
# 对齐索引相加
combined = combined.add(weighted, fill_value=0)
return combined
def get_factors(self) -> List[str]:
"""获取所有因子名称"""
return list(self.factors.keys())
def update_weights(self, new_weights: Dict[str, float]) -> None:
"""更新因子权重(用于动态加权)"""
# 标准化权重总和为1
total = sum(new_weights.values())
self.weights = {k: v / total for k, v in new_weights.items()}
def get_weights(self) -> Dict[str, float]:
"""获取当前权重"""
return self.weights.copy()
@@ -0,0 +1,93 @@
"""
大盘估值择时模块
根据全市场估值分位数调整整体仓位
估值低位加大仓位估值高位降低仓位
"""
import pandas as pd
import numpy as np
from typing import Tuple, Optional
class MarketValuationTiming:
"""
大盘估值择时
根据全市场PE/PB分位数调整整体仓位
"""
def __init__(
self,
min_position: float = 0.3,
max_position: float = 1.0,
quantile_low: float = 0.2, # 分位数低于这个就是低估,满仓
quantile_high: float = 0.8, # 分位数高于这个就是高估,轻仓
lookback_period: int = 60 # 计算分位数的回溯窗口(月)
):
"""
参数:
min_position: 最小仓位高估时
max_position: 最大仓位低估时
quantile_low: 低估阈值
quantile_high: 高估阈值
lookback_period: 计算历史分位数的窗口长度月数
"""
self.min_position = min_position
self.max_position = max_position
self.quantile_low = quantile_low
self.quantile_high = quantile_high
self.lookback_period = lookback_period
# 保存历史估值
self.history: List[float] = []
def update_valuation(self, current_pe: float) -> None:
"""
更新当期估值数据
参数:
current_pe: 当前全市场PE
"""
self.history.append(current_pe)
# 保持窗口大小
if len(self.history) > self.lookback_period:
self.history.pop(0)
def calculate_target_position(self) -> float:
"""
根据当前估值计算目标仓位
返回:
target_position: 目标仓位 0~1
"""
if len(self.history) < 12: # 历史数据不够,用中性仓位
return 0.8
# 计算当前估值在历史中的分位数
current = self.history[-1]
history = np.array(self.history)
quantile = np.mean(history <= current)
# 线性插值计算仓位
if quantile <= self.quantile_low:
# 低估 -> 最大仓位
return self.max_position
elif quantile >= self.quantile_high:
# 高估 -> 最小仓位
return self.min_position
else:
# 中间线性插值
# 分位数越高,仓位越低
ratio = (quantile - self.quantile_low) / (self.quantile_high - self.quantile_low)
position = self.max_position - ratio * (self.max_position - self.min_position)
return position
def get_current_quantile(self) -> Optional[float]:
"""获取当前估值分位数"""
if len(self.history) < 2:
return None
current = self.history[-1]
history = np.array(self.history)
return np.mean(history <= current)
def get_history(self) -> list:
"""获取估值历史"""
return self.history.copy()
@@ -0,0 +1,161 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
纯突破量化策略
N日新高放量突破买入严格止损止盈
"""
import pandas as pd
import numpy as np
from vnpy.app.cta_strategy import CtaTemplate
from vnpy.trader.object import BarData
class PureBreakoutStrategy(CtaTemplate):
"""
纯突破策略
- N日新高放量突破买入
- 止损跌破突破日最低价的-5%
- 跟踪止盈从买入后高点回落10%止盈
- 均线止盈跌破均线卖出
- 最长持有到期自动卖出
- 单票最大仓位5%
"""
author = "翼德"
parameters = [
"breakout_days",
"volume_multiple",
"stop_loss_pct",
"trailing_stop_pct",
"ma_period",
"max_holding_days",
"max_position_pct",
]
variables = [
]
def __init__(self, cta_engine, strategy_name, setting_dict):
super().__init__(cta_engine, strategy_name, setting_dict)
# 默认参数
self.breakout_days = getattr(self, 'breakout_days', 60)
self.volume_multiple = getattr(self, 'volume_multiple', 1.5)
self.stop_loss_pct = getattr(self, 'stop_loss_pct', 0.05)
self.trailing_stop_pct = getattr(self, 'trailing_stop_pct', 0.10)
self.ma_period = getattr(self, 'ma_period', 20)
self.max_holding_days = getattr(self, 'max_holding_days', 60)
self.max_position_pct = getattr(self, 'max_position_pct', 0.05)
# 持仓信息
self.in_market = False
self.entry_price = 0
self.breakout_low = 0
self.highest_price = 0
self.entry_date = None
self.holding_days = 0
def on_init(self):
self.write_log("策略初始化完成")
self.load_bar(self.breakout_days + self.max_holding_days)
def on_start(self):
self.write_log("策略启动")
def on_stop(self):
self.write_log("策略停止")
def on_bar(self, bar: BarData):
if self.in_market:
self.holding_days += 1
# 更新最高价
if bar.close > self.highest_price:
self.highest_price = bar.close
# 检查卖出条件
exit_signal = False
# 1. 止损检查 - 跌破突破低价*(1-stop_loss_pct)
stop_price = self.breakout_low * (1 - self.stop_loss_pct)
if bar.low <= stop_price:
exit_signal = True
self.write_log(f"触发止损,价格{bar.low:.2f} <= 止损价{stop_price:.2f}")
# 2. 跟踪止盈 - 从最高点回落超过trailing_stop_pct
if not exit_signal:
trailing_price = self.highest_price * (1 - self.trailing_stop_pct)
if bar.close <= trailing_price:
exit_signal = True
self.write_log(f"触发跟踪止盈,价格{bar.close:.2f} <= 止盈价{trailing_price:.2f}")
# 3. 均线止盈 - 收盘价跌破均线
if not exit_signal:
closes = self.get_bars(self.ma_period)
if len(closes) >= self.ma_period:
ma = np.mean([b.close for b in closes])
if bar.close < ma:
exit_signal = True
self.write_log(f"触发均线止盈,价格{bar.close:.2f} < MA{self.ma_period}={ma:.2f}")
# 4. 持有到期
if not exit_signal and self.holding_days >= self.max_holding_days:
exit_signal = True
self.write_log(f"持有到期{self.holding_days}天,自动卖出")
if exit_signal:
# 全部卖出
position = self.get_position(self.vt_symbol)
if position and position.volume > 0:
self.sell(self.vt_symbol, bar.close, position.volume)
self.in_market = False
return
# 如果没持仓,检查突破信号
else:
# 获取最近N日数据
bars = self.get_bars(self.breakout_days + 1)
if len(bars) < self.breakout_days + 1:
return
# 计算N日最高价
highest = max(b.close for b in bars[:-1])
current_close = bar.close
current_volume = bar.volume
# 计算N日平均成交量
avg_volume = np.mean(b.volume for b in bars[:-1])
# 突破条件:收盘价创新高 + 成交量放量
if current_close > highest and current_volume >= avg_volume * self.volume_multiple:
# 突破买入
# 计算目标仓位
target_value = self.balance * self.max_position_pct
target_volume = int(target_value / bar.open / 100) * 100
if target_volume > 0:
self.buy(self.vt_symbol, bar.open, target_volume)
self.in_market = True
self.entry_price = bar.open
self.breakout_low = bar.low
self.highest_price = bar.close
self.entry_date = bar.datetime
self.holding_days = 0
self.write_log(f"突破买入,价格{bar.open:.2f},数量{target_volume}")
return
def _need_rebalance(self, current_date):
if self.last_rebalance_date is None:
return True
if self.rebalance_freq == 'M':
if current_date.month != self.last_rebalance_date.month:
return True
elif self.rebalance_freq == 'W':
current_week = current_date.isocalendar()[1]
last_week = self.last_rebalance_date.isocalendar()[1]
if current_week != last_week:
return True
return False