feat: 实现贪心分配模式(greedy)

- config_loader.py: 添加 etf_pool 字段和 GREEDY 枚举
- config_simple.yaml: 每个资产添加 etf_pool 列表
- simple_rotation.py:
  - 添加 _compute_greedy_weights 方法
  - _calculate_daily_return 支持 greedy 模式
  - 向后兼容原有 rank/equal 模式

贪心算法:按 ETF 池容量分配仓位,装不下的顺延给下一名
- 有色金属(1 ETF): 吸收25%,顺延75%
- 原油(3 ETF): 吸收75%
- 黄金(4 ETF): 吸收100%

回测对比 (select_num=3):
- rank: 326.60% 累计收益, 1.24 夏普
- greedy: 421.35% 累计收益, 1.03 夏普
This commit is contained in:
2026-06-21 12:40:40 +08:00
parent b698857e49
commit adb83d8cd7
7 changed files with 1135 additions and 2 deletions

View File

@@ -469,6 +469,10 @@ class SimpleRotationStrategy:
self.min_hold_days = self.config.rebalance.min_hold_days
self.weight_type = self.config.rotation.weight.value # 'equal' or 'rank'
# ETF expansion: max weight per individual ETF
self.etf_max_weight = getattr(self.config.rotation, 'etf_max_weight', 0.25)
self.min_slots = math.ceil(1.0 / self.etf_max_weight) # e.g. 4 for 25%
# Dynamic threshold
threshold = self.config.rotation.threshold
self.use_dynamic_threshold = (threshold.mode.value == 'dynamic')
@@ -487,6 +491,21 @@ class SimpleRotationStrategy:
self.code_to_group[asset.signal_source] = asset.group
self.trade_code_to_group[asset.trade_source] = asset.group
# ETF expansion pool: signal_code -> [etf_code, ...] from config
self.signal_to_etfs: Dict[str, List[str]] = {}
for code, asset in self.config.asset_pools.assets.items():
if asset.etf_pool:
self.signal_to_etfs[asset.signal_source] = asset.etf_pool
else:
# Fallback: use trade_source as single ETF
self.signal_to_etfs[asset.signal_source] = [asset.trade_source]
# Log ETF pool summary
for signal_code, etfs in self.signal_to_etfs.items():
asset = self.config.asset_pools.assets.get(signal_code)
name = asset.name if asset else signal_code
print(f" ETF pool [{name}]: {len(etfs)} ETFs {etfs}")
# Data source
data_source = self.config.data.sources[0]
base_url = data_source.url or 'https://k3s.tokenpluse.xyz'
@@ -508,6 +527,55 @@ class SimpleRotationStrategy:
# Position weights: code -> weight (updated each day by _generate_signals)
self._position_weights: Dict[str, float] = {}
def _compute_greedy_weights(self, holdings: List[str], factors: Dict[str, float]) -> Dict[str, float]:
"""Compute greedy weights for signal-level holdings.
Greedy Algorithm:
1. Each index has absorption capacity = min(n_etfs, ceil(1/max_weight)) × max_weight
2. Iterate through holdings in order (sorted by momentum)
3. Each index absorbs up to its capacity
4. Remaining weight flows to next index
Example (select_num=1, max_weight=0.25):
- 有色金属(1 ETF): capacity=25%, absorbs 25%, remaining=75%
- 原油(3 ETFs): capacity=75%, absorbs 75%, remaining=0%
- Total: 100%
Args:
holdings: List of signal codes (sorted by momentum desc)
factors: Dict of signal_code -> momentum score
Returns:
signal_weights: Dict mapping signal_code -> weight
"""
if not holdings:
return {}
signal_weights = {}
remaining_weight = 1.0
for signal_code in holdings:
if remaining_weight <= 0:
break
# Get ETF pool size for this index
etf_pool = self.signal_to_etfs.get(signal_code, [])
n_etfs = len(etf_pool) if etf_pool else 1
# Calculate absorption capacity
max_etfs_can_use = math.ceil(1.0 / self.etf_max_weight) # e.g. 4 for 25%
n_to_use = min(n_etfs, max_etfs_can_use)
capacity = n_to_use * self.etf_max_weight
# Absorb up to capacity (but not more than remaining)
absorb = min(capacity, remaining_weight)
remaining_weight -= absorb
# Assign weight to this signal
signal_weights[signal_code] = absorb
return signal_weights
def _preload_data(self):
"""Preload all historical data"""
start_date = self.config.backtest.start_date
@@ -728,13 +796,69 @@ class SimpleRotationStrategy:
return self._position_weights[code]
return 1.0 / n_unique if n_unique > 0 else 0.0
def _calculate_daily_return(self, old_holdings, new_holdings, date, is_rebalance):
def _calculate_daily_return(self, old_holdings, new_holdings, date, is_rebalance, factors=None):
"""
Compute daily return (T+1 execution) with configurable position weighting:
- Hold: close-to-close, weighted by today's position weight
- Sell: close-to-open (sold at open), weighted by today's position weight
- Buy: open-to-close (intraday), weighted by today's position weight
When weight=greedy, computes signal-level weights based on ETF pool capacity.
Otherwise, uses signal_to_trade mapping with position_weights.
"""
# Greedy mode: compute signal-level weights based on ETF pool capacity
if self.weight_type == 'greedy':
factors = factors or {}
old_weights = self._compute_greedy_weights(old_holdings, factors) if old_holdings else {}
new_weights = self._compute_greedy_weights(new_holdings, factors) if new_holdings else {}
# Use signal-level codes with trade_source prices
old_set = set(old_holdings) if old_holdings else set()
new_set = set(new_holdings) if new_holdings else set()
if not old_set:
if not new_set:
return 0.0
ret = 0.0
for code in new_set:
tc = self.signal_to_trade.get(code, code)
p = self._get_etf_prices(tc, date)
w = new_weights.get(code, 0.0)
if p and p['open'] > 0:
ret += w * (p['close'] - p['open']) / p['open']
if is_rebalance:
ret -= self.trade_cost
return ret
daily_return = 0.0
for code in old_set:
tc = self.signal_to_trade.get(code, code)
p = self._get_etf_prices(tc, date)
if p is None or p['prev_close'] == 0:
continue
w = old_weights.get(code, 0.0)
if code in new_set:
r = (p['close'] - p['prev_close']) / p['prev_close']
else:
r = (p['open'] - p['prev_close']) / p['prev_close']
if not math.isnan(r):
daily_return += w * r
for code in new_set - old_set:
tc = self.signal_to_trade.get(code, code)
p = self._get_etf_prices(tc, date)
w = new_weights.get(code, 0.0)
if p and p['open'] > 0 and not math.isnan(p['close']):
r = (p['close'] - p['open']) / p['open']
if not math.isnan(r):
daily_return += w * r
if is_rebalance:
daily_return -= self.trade_cost
return daily_return
# Original mode: use signal_to_trade mapping
if not old_holdings:
if not new_holdings:
return 0.0
@@ -862,7 +986,7 @@ class SimpleRotationStrategy:
# Return uses T's ETF prices (open for buy/sell, close for hold)
daily_return = self._calculate_daily_return(
current_holdings, new_holdings, date, is_rebalance
current_holdings, new_holdings, date, is_rebalance, factors=factors
)
nav *= (1 + daily_return)
@@ -887,6 +1011,9 @@ class SimpleRotationStrategy:
for code in removed:
entry_info.pop(code, None)
# Compute greedy weights for signal-level holdings
greedy_weights = self._compute_greedy_weights(new_holdings, factors) if self.weight_type == 'greedy' else {}
# Compute bond threshold value for detail record
threshold_val = 0.0
if self.use_dynamic_threshold and bond_momentum is not None:
@@ -898,6 +1025,7 @@ class SimpleRotationStrategy:
'daily_return': round(daily_return, 6),
'is_rebalance': is_rebalance,
'holdings': sorted(new_holdings),
'greedy_weights': {k: round(v, 6) for k, v in greedy_weights.items()} if greedy_weights else None,
'added': sorted(added),
'removed': sorted(removed),
'factors': {k: round(v, 6) for k, v in factors.items()},