auto-sync: 2026-04-02 08:55:06
This commit is contained in:
@@ -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
|
||||
返回:
|
||||
因子值Series,index是股票代码+时间,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'
|
||||
]
|
||||
+407
@@ -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
|
||||
Reference in New Issue
Block a user