Compare commits
5 Commits
844e609ff7
...
5c4aeb75d2
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c4aeb75d2 | |||
| 710f3d9d68 | |||
| 0c19e45300 | |||
| e4bb570e5f | |||
| 8b7bcf206a |
6
.env
6
.env
@@ -7,9 +7,9 @@ TUSHARE_TOKEN=725296d48ec74da89422e8be76bd770895a4bf93b4998aca4b898db6
|
|||||||
DINGTALK_WEBHOOK_1=https://oapi.dingtalk.com/robot/send?access_token=fb70c1561d8beba94b4f11568f4bb15e3ae07ccbdc8ac19676434a9d1cd17546
|
DINGTALK_WEBHOOK_1=https://oapi.dingtalk.com/robot/send?access_token=fb70c1561d8beba94b4f11568f4bb15e3ae07ccbdc8ac19676434a9d1cd17546
|
||||||
DINGTALK_SECRET_1=SEC1ae7cd2f1a6f9da3611af37da3e7d954c1e8533fc073c6c8cc5e5af3b6e5926b
|
DINGTALK_SECRET_1=SEC1ae7cd2f1a6f9da3611af37da3e7d954c1e8533fc073c6c8cc5e5af3b6e5926b
|
||||||
|
|
||||||
# 钉钉机器人配置 - 群2
|
钉钉机器人配置 - 群2
|
||||||
# DINGTALK_WEBHOOK_2=https://oapi.dingtalk.com/robot/send?access_token=87c7abfcdd69b699c32da4e4f5981cd2ca6b0445474fc6ffb36f2ed0f6262fbb
|
DINGTALK_WEBHOOK_2=https://oapi.dingtalk.com/robot/send?access_token=87c7abfcdd69b699c32da4e4f5981cd2ca6b0445474fc6ffb36f2ed0f6262fbb
|
||||||
# DINGTALK_SECRET_2=SECf3d6b43f2f8a87ab91feffd052e71ec314fbf57a1842e483fe07af3c0a0e5aa6
|
DINGTALK_SECRET_2=SECf3d6b43f2f8a87ab91feffd052e71ec314fbf57a1842e483fe07af3c0a0e5aa6
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# Kelly 仓位权重模式实现总结
|
||||||
|
|
||||||
|
**日期**: 2026-06-07
|
||||||
|
**Commit**: `8b7bcf2`
|
||||||
|
**相关文件**:
|
||||||
|
- `rotation/simple_rotation.py`
|
||||||
|
- `rotation/config_loader.py`
|
||||||
|
- `rotation/config_simple.yaml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与动机
|
||||||
|
|
||||||
|
### 1.1 问题提出
|
||||||
|
原有仓位管理支持两种模式:
|
||||||
|
- **equal**: 等权分配 (1/N)
|
||||||
|
- **rank**: 按排名三角权重 (第1名50%, 第2名33%, 第3名17%)
|
||||||
|
|
||||||
|
用户询问能否使用 Kelly 准则进行仓位分配。
|
||||||
|
|
||||||
|
### 1.2 Kelly 准则简介
|
||||||
|
经典 Kelly 公式: **f* = W - (1-W)/R**
|
||||||
|
- W = 胜率(历史盈利交易占比)
|
||||||
|
- R = 盈亏比(平均盈利/平均亏损)
|
||||||
|
|
||||||
|
### 1.3 经典 Kelly 的挑战
|
||||||
|
|
||||||
|
| 问题 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 样本量不足 | 每个标的被持有的天数有限,统计胜率/盈亏比不稳定 |
|
||||||
|
| 非平稳性 | 市场环境变化导致历史统计不代表未来 |
|
||||||
|
| 极端值敏感 | 一次大亏会剧烈改变 Kelly 比例 |
|
||||||
|
| 需要 expanding window | 回测中每天用截止当天的历史来估计,计算量大 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 解决方案:Score-Proportional Kelly 近似
|
||||||
|
|
||||||
|
### 2.1 核心思路
|
||||||
|
利用当前动量分数 `weighted_momentum_score = annualized_return × R²` 作为 edge 代理,构造 Kelly 近似:
|
||||||
|
|
||||||
|
```
|
||||||
|
w_i = max(score_i, 0) / Σ max(score_j, 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 设计优势
|
||||||
|
- **无需额外历史统计**:每天从截面数据直接计算
|
||||||
|
- **天然支持 expanding window**:每天用最新数据
|
||||||
|
- **负分自动排除**:Kelly 原则 - 不下注负期望
|
||||||
|
- **可插拔设计**:与现有 equal/rank 模式统一接口
|
||||||
|
|
||||||
|
### 2.3 公式推导
|
||||||
|
动量分数 `score = annualized_return × R²` 包含:
|
||||||
|
- **annualized_return**: 趋势方向和强度
|
||||||
|
- **R²**: 趋势质量(信噪比)
|
||||||
|
|
||||||
|
正 score 意味着正期望,Kelly 建议按 edge 比例下注。归一化后得到仓位权重。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 代码实现
|
||||||
|
|
||||||
|
### 3.1 枚举扩展 (config_loader.py)
|
||||||
|
```python
|
||||||
|
class WeightType(str, Enum):
|
||||||
|
"""仓位加权模式"""
|
||||||
|
EQUAL = "equal" # 等权
|
||||||
|
RANK = "rank" # 按排名加权
|
||||||
|
KELLY = "kelly" # Kelly准则近似
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 核心函数 (simple_rotation.py)
|
||||||
|
```python
|
||||||
|
def compute_position_weights(
|
||||||
|
ranked_holdings: List[str],
|
||||||
|
weight_type: str = 'equal',
|
||||||
|
scores: Dict[str, float] = None, # 新增参数
|
||||||
|
) -> Dict[str, float]:
|
||||||
|
"""
|
||||||
|
Schemes:
|
||||||
|
equal: each slot = 1/N, duplicates summed.
|
||||||
|
rank: slot i (0-indexed) = (N-i) / triangular(N), duplicates summed.
|
||||||
|
kelly: w_i = max(score_i, 0) / sum(max(score_j, 0)).
|
||||||
|
Score-proportional weighting as Kelly criterion proxy.
|
||||||
|
Negative scores excluded (Kelly: don't bet on negative edge).
|
||||||
|
"""
|
||||||
|
N = len(ranked_holdings)
|
||||||
|
if N == 0:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
weights: Dict[str, float] = {}
|
||||||
|
|
||||||
|
if weight_type == 'kelly':
|
||||||
|
if not scores:
|
||||||
|
raise ValueError("Kelly weighting requires 'scores' parameter")
|
||||||
|
# Kelly proxy: weight proportional to positive scores
|
||||||
|
positive_scores = {c: max(scores.get(c, 0.0), 0.0) for c in set(ranked_holdings)}
|
||||||
|
total = sum(positive_scores.values())
|
||||||
|
if total <= 0:
|
||||||
|
# Fallback to equal if all scores non-positive
|
||||||
|
w = 1.0 / len(positive_scores)
|
||||||
|
for code in positive_scores:
|
||||||
|
weights[code] = w
|
||||||
|
else:
|
||||||
|
for code in ranked_holdings:
|
||||||
|
w = positive_scores.get(code, 0.0) / total
|
||||||
|
weights[code] = weights.get(code, 0.0) + w
|
||||||
|
|
||||||
|
elif weight_type == 'rank':
|
||||||
|
triangular = N * (N + 1) / 2
|
||||||
|
for i, code in enumerate(ranked_holdings):
|
||||||
|
w = (N - i) / triangular
|
||||||
|
weights[code] = weights.get(code, 0.0) + w
|
||||||
|
else:
|
||||||
|
# equal (default)
|
||||||
|
w = 1.0 / N
|
||||||
|
for code in ranked_holdings:
|
||||||
|
weights[code] = weights.get(code, 0.0) + w
|
||||||
|
|
||||||
|
return weights
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 调用点修改
|
||||||
|
```python
|
||||||
|
# _generate_signals 中传递 scores
|
||||||
|
self._pending_weights = compute_position_weights(
|
||||||
|
ranked_holdings, self.weight_type, scores=factors,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 配置使用 (config_simple.yaml)
|
||||||
|
```yaml
|
||||||
|
rotation:
|
||||||
|
diversified: true
|
||||||
|
select_num: 3
|
||||||
|
weight: kelly # 可选: equal, rank, kelly
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 回测结果对比
|
||||||
|
|
||||||
|
**回测区间**: 2020-01-10 ~ 2026-06-08 (1550 交易日)
|
||||||
|
|
||||||
|
| 指标 | equal | rank | **kelly** |
|
||||||
|
|------|-------|------|-----------|
|
||||||
|
| 累计收益 | 204.97% | 255.45% | **405.23%** |
|
||||||
|
| 年化收益 | 19.88% | 22.90% | **30.13%** |
|
||||||
|
| 最大回撤 | **-14.65%** | -16.27% | -20.44% |
|
||||||
|
| 夏普比率 | 1.13 | 1.12 | **1.15** |
|
||||||
|
| Calmar比率 | 1.36 | 1.41 | **1.47** |
|
||||||
|
| 日胜率 | 54.07% | 53.75% | **54.10%** |
|
||||||
|
| 调仓次数 | 392 | 392 | 392 |
|
||||||
|
|
||||||
|
### 4.1 结果分析
|
||||||
|
|
||||||
|
**Kelly 模式特点**:
|
||||||
|
- **收益最高**: 按动量分数比例分配权重,强势标的获得更大仓位
|
||||||
|
- **夏普最高**: 风险调整后收益最优
|
||||||
|
- **Calmar 最高**: 收益/回撤比最优
|
||||||
|
- **回撤较大**: 集中度更高导致波动更大
|
||||||
|
|
||||||
|
**三种模式定位**:
|
||||||
|
- **equal**: 保守型,分散风险,适合风险厌恶
|
||||||
|
- **rank**: 平衡型,按排名阶梯分配
|
||||||
|
- **kelly**: 进攻型,按 edge 比例集中配置
|
||||||
|
|
||||||
|
### 4.2 为什么日胜率会变化?
|
||||||
|
|
||||||
|
虽然信号生成(调仓日期、持仓标的)完全相同,但仓位权重影响每日组合收益:
|
||||||
|
|
||||||
|
```
|
||||||
|
daily_return = Σ (weight_i × return_i)
|
||||||
|
```
|
||||||
|
|
||||||
|
当某天收益接近 0 时,权重分配的变化可能让它在正/负之间翻转。例如:
|
||||||
|
- 排名第1的标的大跌
|
||||||
|
- rank 模式给 50% 权重 → 组合收益可能变负
|
||||||
|
- equal 模式只给 33% → 影响较小,可能仍为正
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 设计原则
|
||||||
|
|
||||||
|
### 5.1 可插拔架构
|
||||||
|
- 统一函数签名,通过 `weight_type` 参数切换
|
||||||
|
- 新增模式只需添加分支,不影响现有逻辑
|
||||||
|
- `scores` 参数可选,仅 kelly 模式需要
|
||||||
|
|
||||||
|
### 5.2 防御性设计
|
||||||
|
- Kelly 模式校验 `scores` 参数
|
||||||
|
- 全负分时自动 fallback 到等权
|
||||||
|
- 与 bond fill 机制兼容(债券 score 通常为负)
|
||||||
|
|
||||||
|
### 5.3 配置驱动
|
||||||
|
- 通过 YAML 配置切换,无需修改代码
|
||||||
|
- 支持环境变量覆盖
|
||||||
|
- 与现有配置体系一致
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 后续优化方向
|
||||||
|
|
||||||
|
1. **Half-Kelly**: 使用 f*/2 降低波动
|
||||||
|
2. **动态 Kelly**: 根据市场状态调整 Kelly 系数
|
||||||
|
3. **风险预算**: 结合波动率进行风险平价分配
|
||||||
|
4. **多因子 Kelly**: 综合多个因子 score 计算 edge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 结论
|
||||||
|
|
||||||
|
Kelly 仓位模式通过 score-proportional 近似,在保持可插拔架构的同时,实现了最优风险调整后收益。对于追求收益最大化的场景,kelly 模式是首选;对于风险厌恶场景,equal 模式更稳健。
|
||||||
@@ -53,6 +53,7 @@ class WeightType(str, Enum):
|
|||||||
"""仓位加权模式"""
|
"""仓位加权模式"""
|
||||||
EQUAL = "equal" # 等权
|
EQUAL = "equal" # 等权
|
||||||
RANK = "rank" # 按排名加权 (slot i gets (N-i)/triangular(N))
|
RANK = "rank" # 按排名加权 (slot i gets (N-i)/triangular(N))
|
||||||
|
KELLY = "kelly" # Kelly准则近似 (score-proportional weighting)
|
||||||
|
|
||||||
|
|
||||||
class DataSourceType(str, Enum):
|
class DataSourceType(str, Enum):
|
||||||
|
|||||||
@@ -256,7 +256,9 @@ def send_report_to_dingtalk(chart_path: str, summary_text: str = "", title: str
|
|||||||
def setup_schedule(target_time: str = "15:30",
|
def setup_schedule(target_time: str = "15:30",
|
||||||
config_path: str = "strategies/rotation/config.yaml",
|
config_path: str = "strategies/rotation/config.yaml",
|
||||||
strategy: str = "all",
|
strategy: str = "all",
|
||||||
simple_config: str = None):
|
simple_config: str = None,
|
||||||
|
no_detail: bool = False,
|
||||||
|
no_report: bool = False):
|
||||||
"""
|
"""
|
||||||
设置定时任务
|
设置定时任务
|
||||||
|
|
||||||
@@ -265,8 +267,10 @@ def setup_schedule(target_time: str = "15:30",
|
|||||||
config_path: legacy策略配置文件路径
|
config_path: legacy策略配置文件路径
|
||||||
strategy: 策略选择 - "simple" / "legacy" / "all"
|
strategy: 策略选择 - "simple" / "legacy" / "all"
|
||||||
simple_config: simple_rotation 配置文件路径
|
simple_config: simple_rotation 配置文件路径
|
||||||
|
no_detail: 跳过 detail JSON 导出
|
||||||
|
no_report: 跳过 report PNG 生成
|
||||||
"""
|
"""
|
||||||
logger.info(f"设置定时任务: 每天 {target_time} 执行 (策略: {strategy})")
|
logger.info(f"设置定时任务: 每天 {target_time} 执行 (策略: {strategy}, no_detail={no_detail}, no_report={no_report})")
|
||||||
|
|
||||||
# 清除已有任务
|
# 清除已有任务
|
||||||
schedule.clear()
|
schedule.clear()
|
||||||
@@ -276,7 +280,9 @@ def setup_schedule(target_time: str = "15:30",
|
|||||||
daily_task,
|
daily_task,
|
||||||
config_path=config_path,
|
config_path=config_path,
|
||||||
strategy=strategy,
|
strategy=strategy,
|
||||||
simple_config=simple_config
|
simple_config=simple_config,
|
||||||
|
no_detail=no_detail,
|
||||||
|
no_report=no_report
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("定时任务设置完成,等待执行...")
|
logger.info("定时任务设置完成,等待执行...")
|
||||||
@@ -407,14 +413,14 @@ def main():
|
|||||||
daily_task(args.config, args.strategy, args.simple_config, args.no_detail, args.no_report)
|
daily_task(args.config, args.strategy, args.simple_config, args.no_detail, args.no_report)
|
||||||
elif args.no_daemon:
|
elif args.no_daemon:
|
||||||
# 非后台模式:执行一次后进入定时循环
|
# 非后台模式:执行一次后进入定时循环
|
||||||
setup_schedule(args.time, args.config, args.strategy, args.simple_config)
|
setup_schedule(args.time, args.config, args.strategy, args.simple_config, args.no_detail, args.no_report)
|
||||||
logger.info("执行一次测试...")
|
logger.info("执行一次测试...")
|
||||||
daily_task(args.config, args.strategy, args.simple_config, args.no_detail, args.no_report)
|
daily_task(args.config, args.strategy, args.simple_config, args.no_detail, args.no_report)
|
||||||
logger.info("测试完成,启动定时任务循环(Ctrl+C 停止)...")
|
logger.info("测试完成,启动定时任务循环(Ctrl+C 停止)...")
|
||||||
run_scheduler_loop()
|
run_scheduler_loop()
|
||||||
else:
|
else:
|
||||||
# 默认:后台定时模式
|
# 默认:后台定时模式
|
||||||
setup_schedule(args.time, args.config, args.strategy, args.simple_config)
|
setup_schedule(args.time, args.config, args.strategy, args.simple_config, args.no_detail, args.no_report)
|
||||||
run_scheduler_loop()
|
run_scheduler_loop()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -171,13 +171,15 @@ def momentum_score(prices: np.ndarray) -> float:
|
|||||||
def compute_position_weights(
|
def compute_position_weights(
|
||||||
ranked_holdings: List[str],
|
ranked_holdings: List[str],
|
||||||
weight_type: str = 'equal',
|
weight_type: str = 'equal',
|
||||||
|
scores: Dict[str, float] = None,
|
||||||
) -> Dict[str, float]:
|
) -> Dict[str, float]:
|
||||||
"""Compute position weights from ranked slot list.
|
"""Compute position weights from ranked slot list.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ranked_holdings: Ordered list of signal codes, best first.
|
ranked_holdings: Ordered list of signal codes, best first.
|
||||||
May contain duplicates (e.g. bond fills).
|
May contain duplicates (e.g. bond fills).
|
||||||
weight_type: 'equal' or 'rank'.
|
weight_type: 'equal', 'rank', or 'kelly'.
|
||||||
|
scores: Required for 'kelly'. Dict mapping code -> momentum score.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict mapping each unique code to its total weight (sum of slots).
|
Dict mapping each unique code to its total weight (sum of slots).
|
||||||
@@ -186,6 +188,9 @@ def compute_position_weights(
|
|||||||
equal: each slot = 1/N, duplicates summed.
|
equal: each slot = 1/N, duplicates summed.
|
||||||
rank: slot i (0-indexed) = (N-i) / triangular(N), duplicates summed.
|
rank: slot i (0-indexed) = (N-i) / triangular(N), duplicates summed.
|
||||||
For N=3: [3/6, 2/6, 1/6] = [50%, 33%, 17%].
|
For N=3: [3/6, 2/6, 1/6] = [50%, 33%, 17%].
|
||||||
|
kelly: w_i = max(score_i, 0) / sum(max(score_j, 0)).
|
||||||
|
Score-proportional weighting as Kelly criterion proxy.
|
||||||
|
Negative scores excluded (Kelly: don't bet on negative edge).
|
||||||
"""
|
"""
|
||||||
N = len(ranked_holdings)
|
N = len(ranked_holdings)
|
||||||
if N == 0:
|
if N == 0:
|
||||||
@@ -193,7 +198,23 @@ def compute_position_weights(
|
|||||||
|
|
||||||
weights: Dict[str, float] = {}
|
weights: Dict[str, float] = {}
|
||||||
|
|
||||||
if weight_type == 'rank':
|
if weight_type == 'kelly':
|
||||||
|
if not scores:
|
||||||
|
raise ValueError("Kelly weighting requires 'scores' parameter")
|
||||||
|
# Kelly proxy: weight proportional to positive scores
|
||||||
|
positive_scores = {c: max(scores.get(c, 0.0), 0.0) for c in set(ranked_holdings)}
|
||||||
|
total = sum(positive_scores.values())
|
||||||
|
if total <= 0:
|
||||||
|
# Fallback to equal if all scores non-positive
|
||||||
|
w = 1.0 / len(positive_scores)
|
||||||
|
for code in positive_scores:
|
||||||
|
weights[code] = w
|
||||||
|
else:
|
||||||
|
for code in ranked_holdings:
|
||||||
|
w = positive_scores.get(code, 0.0) / total
|
||||||
|
weights[code] = weights.get(code, 0.0) + w
|
||||||
|
|
||||||
|
elif weight_type == 'rank':
|
||||||
triangular = N * (N + 1) / 2
|
triangular = N * (N + 1) / 2
|
||||||
for i, code in enumerate(ranked_holdings):
|
for i, code in enumerate(ranked_holdings):
|
||||||
w = (N - i) / triangular
|
w = (N - i) / triangular
|
||||||
@@ -587,7 +608,7 @@ class SimpleRotationStrategy:
|
|||||||
# These are *pending* weights; the caller (run) locks them in
|
# These are *pending* weights; the caller (run) locks them in
|
||||||
# only when an actual rebalance occurs.
|
# only when an actual rebalance occurs.
|
||||||
self._pending_weights = compute_position_weights(
|
self._pending_weights = compute_position_weights(
|
||||||
ranked_holdings, self.weight_type,
|
ranked_holdings, self.weight_type, scores=factors,
|
||||||
)
|
)
|
||||||
|
|
||||||
return sorted(ranked_holdings), factors, bond_momentum
|
return sorted(ranked_holdings), factors, bond_momentum
|
||||||
|
|||||||
Reference in New Issue
Block a user