From 8b7bcf206aea4ec5b62c0b9b9c67152e2e5a849e Mon Sep 17 00:00:00 2001 From: aszerW Date: Mon, 8 Jun 2026 23:04:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(weight):=20=E5=AE=9E=E7=8E=B0=20Kelly=20?= =?UTF-8?q?=E4=BB=93=E4=BD=8D=E6=9D=83=E9=87=8D=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config_loader.py: WeightType 枚举新增 KELLY - simple_rotation.py: compute_position_weights 新增 kelly 分支 - 公式: w_i = max(score_i, 0) / sum(max(score_j, 0)) - 负分自动排除 (Kelly: 不下注负期望) - 全负分时 fallback 到等权 - _generate_signals 传递 scores 给 kelly 模式 - config_simple.yaml: weight 改为 kelly - 新增策略总结文档: kelly_weight.md 回测对比 (2020-2026): - equal: 年化 19.88%, 夏普 1.13, 回撤 -14.65% - rank: 年化 22.90%, 夏普 1.12, 回撤 -16.27% - kelly: 年化 30.13%, 夏普 1.15, 回撤 -20.44% --- .../strategy_summary_20260607_kelly_weight.md | 213 ++++++++++++++++++ rotation/config_loader.py | 1 + rotation/config_simple.yaml | 2 +- rotation/simple_rotation.py | 27 ++- 4 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 docs/strategy_summaries/strategy_summary_20260607_kelly_weight.md diff --git a/docs/strategy_summaries/strategy_summary_20260607_kelly_weight.md b/docs/strategy_summaries/strategy_summary_20260607_kelly_weight.md new file mode 100644 index 0000000..368ea91 --- /dev/null +++ b/docs/strategy_summaries/strategy_summary_20260607_kelly_weight.md @@ -0,0 +1,213 @@ +# Kelly 仓位权重模式实现总结 + +**日期**: 2026-06-07 +**Commit**: `06d12f2` +**相关文件**: +- `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 模式更稳健。 diff --git a/rotation/config_loader.py b/rotation/config_loader.py index 81ac0a4..4f74304 100644 --- a/rotation/config_loader.py +++ b/rotation/config_loader.py @@ -53,6 +53,7 @@ class WeightType(str, Enum): """仓位加权模式""" EQUAL = "equal" # 等权 RANK = "rank" # 按排名加权 (slot i gets (N-i)/triangular(N)) + KELLY = "kelly" # Kelly准则近似 (score-proportional weighting) class DataSourceType(str, Enum): diff --git a/rotation/config_simple.yaml b/rotation/config_simple.yaml index 3111bb1..e56e096 100644 --- a/rotation/config_simple.yaml +++ b/rotation/config_simple.yaml @@ -108,7 +108,7 @@ rebalance: rotation: diversified: true select_num: 3 - weight: rank + weight: kelly threshold: dynamic: fallback_enabled: true diff --git a/rotation/simple_rotation.py b/rotation/simple_rotation.py index cf9f415..53532db 100644 --- a/rotation/simple_rotation.py +++ b/rotation/simple_rotation.py @@ -171,13 +171,15 @@ def momentum_score(prices: np.ndarray) -> float: def compute_position_weights( ranked_holdings: List[str], weight_type: str = 'equal', + scores: Dict[str, float] = None, ) -> Dict[str, float]: """Compute position weights from ranked slot list. Args: ranked_holdings: Ordered list of signal codes, best first. 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: 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. rank: slot i (0-indexed) = (N-i) / triangular(N), duplicates summed. 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) if N == 0: @@ -193,7 +198,23 @@ def compute_position_weights( 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 for i, code in enumerate(ranked_holdings): w = (N - i) / triangular @@ -587,7 +608,7 @@ class SimpleRotationStrategy: # These are *pending* weights; the caller (run) locks them in # only when an actual rebalance occurs. 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