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:
@@ -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()},
|
||||
|
||||
Reference in New Issue
Block a user