Files
etf/strategies/shared/signals/selectors.py
aszerW c15e418ead feat: 短债动态阈值仓位分配机制
设计理念:
- 每份仓位 = 1/select_num
- 每个选中标的持有基础份额 1/select_num
- 被排除标的的份额归短债(BOND)继承

信号生成:
- generate()返回signal和signal_ranks
- _grouped_selection_with_ranks()返回标的和排名

仓位分配:
- DynamicThresholdAllocator.allocate()计算权重
- 短债继承被排除标的的份额

示例(短债排名2,select_num=3):
- NDX排名1 → 1/3(基础)
- 短债排名2 → 1/3(基础)+ 1/3(继承)= 2/3
- 排名3的份额被短债继承
2026-05-18 23:07:21 +08:00

412 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
定制信号生成器实现
这些信号生成器继承framework.core.signals.SignalGenerator
"""
from framework.signals import SignalGenerator
import pandas as pd
import numpy as np
from typing import Dict, List, Optional, Any, Tuple
class TopNSelector(SignalGenerator):
"""
Top N选股器定制实现
用于轮动策略:
- 按因子值排序选出Top N标的
- 支持分组选股(先类内竞争,再跨类排序)
- 支持调仓阈值检查(新组合得分需超过当前组合一定比例才调仓)
参数:
- select_num: 选中数量默认3
- group_by: 分组键名(可选,如'market'
- group_mapping: 分组映射字典(可选,{code: group}
- top_per_group: 每组选中数量默认1
- min_score: 最小得分阈值可选如0表示过滤负分
- rebalance_threshold: 调仓阈值可选新组合得分需超过当前组合X%才调仓)
- rebalance_days: 最低调仓周期可选持仓至少N天才能调仓
"""
mode = "top_n"
def __init__(
self,
select_num: int = 3,
group_by: Optional[str] = None,
group_mapping: Optional[Dict[str, str]] = None,
top_per_group: int = 1,
min_score: Optional[float] = None,
rebalance_threshold: float = 0.0,
rebalance_days: int = 1
):
super().__init__(
select_num=select_num,
group_by=group_by,
group_mapping=group_mapping,
top_per_group=top_per_group,
min_score=min_score,
rebalance_threshold=rebalance_threshold,
rebalance_days=rebalance_days
)
self.select_num = select_num
self.group_by = group_by
self.group_mapping = group_mapping or {}
self.top_per_group = top_per_group
self.min_score = min_score
self.rebalance_threshold = rebalance_threshold
self.rebalance_days = rebalance_days
def generate(self, factor_data: pd.DataFrame) -> pd.DataFrame:
"""生成Top N选股信号支持调仓周期控制
返回DataFrame包含:
- signal: 选中标的列表(逗号分隔)
- signal_ranks: 选中标的的排名列表(逗号分隔)
"""
result = pd.DataFrame(index=factor_data.index)
factor_cols = self._get_factor_columns(factor_data)
if not factor_cols:
result['signal'] = ''
result['signal_ranks'] = ''
return result
# Step 1: 每日目标组合(不考虑调仓周期)
daily_target = []
daily_ranks = []
for date in factor_data.index:
row = factor_data.loc[date]
# 提取得分
scores = {}
for col in factor_cols:
score = row[col]
if pd.notna(score):
scores[col] = score
# 最小得分过滤(如过滤负分)
if self.min_score is not None:
scores = {k: v for k, v in scores.items() if v >= self.min_score}
# 分组选股或全局选股
if self.group_mapping:
selected, ranks = self._grouped_selection_with_ranks(scores)
else:
selected, ranks = self._global_top_n_with_ranks(scores)
daily_target.append(','.join(selected) if selected else '')
daily_ranks.append(','.join(str(r) for r in ranks) if ranks else '')
result['signal_raw'] = daily_target
result['ranks_raw'] = daily_ranks
signals = self._apply_rebalance_control(daily_target, factor_data)
result['signal'] = signals
# T+1执行信号向后移位1天
result['signal'] = result['signal'].shift(1)
return result
def _get_factor_columns(self, data: pd.DataFrame) -> List[str]:
"""获取因子列名"""
exclude_cols = ['signal', 'signal_raw', 'ranks_raw', 'group_info', 'combined', 'open', 'high', 'low', 'close', 'volume']
return [col for col in data.columns if col not in exclude_cols and not col.endswith('_weighted')]
def _global_top_n_with_ranks(self, scores: Dict[str, float]) -> Tuple[List[str], List[int]]:
"""全局Top N选股返回标的和排名"""
if not scores:
return [], []
# 计算全局排名
all_sorted = sorted(scores.items(), key=lambda x: x[1], reverse=True)
rank_map = {code: rank + 1 for rank, (code, _) in enumerate(all_sorted)}
# 选出Top N
selected = [item[0] for item in all_sorted[:self.select_num]]
ranks = [rank_map[code] for code in selected]
return selected, ranks
def _grouped_selection_with_ranks(self, scores: Dict[str, float]) -> Tuple[List[str], List[int]]:
"""分组选股:返回标的和排名
设计理念:短债作为"动态过滤阈值"
- 短债正常参与动量排序,没有任何特殊处理
- 短债排名 <= select_num → 短债被选中,比短债弱的标的被排除
- 短债排名 > select_num → 短债被排除(有更好的选择)
- effective_threshold = min(短债排名, select_num)
"""
if not scores:
return [], []
# 建立 group -> (code, score) 的映射
group_champions = {}
for code, score in scores.items():
group = self.group_mapping.get(code, 'default')
if group not in group_champions or score > group_champions[group][1]:
group_champions[group] = (code, score)
# 计算全局动量排名
all_sorted = sorted(scores.items(), key=lambda x: x[1], reverse=True)
rank_map = {code: rank + 1 for rank, (code, _) in enumerate(all_sorted)}
# ⭐ 找出短债(BOND大类)的排名位置
bond_rank = None
for group, (code, score) in group_champions.items():
if group == 'BOND':
bond_rank = rank_map.get(code, len(all_sorted) + 1)
break
# ⭐ 确定有效排名阈值
if bond_rank is not None and bond_rank <= self.select_num:
effective_threshold = bond_rank
else:
effective_threshold = self.select_num
# ⭐ 大类冠军过滤
valid_champions = []
for group, (code, score) in group_champions.items():
rank = rank_map.get(code, len(all_sorted) + 1)
if score >= self.min_score and rank <= effective_threshold:
valid_champions.append((code, score, rank))
# 对有效冠军按得分排序选出Top N
sorted_champions = sorted(valid_champions, key=lambda x: x[1], reverse=True)
selected = [code for code, score, rank in sorted_champions[:self.select_num]]
ranks = [rank for code, score, rank in sorted_champions[:self.select_num]]
return selected, ranks
def _apply_rebalance_control(self, daily_target: List[str], factor_data: pd.DataFrame) -> List[str]:
"""应用调仓周期控制"""
signals = []
current_held = None
last_rebalance_idx = 0
for i, target in enumerate(daily_target):
# 初始持仓为空,等待第一个有效信号
if current_held is None:
if not target:
signals.append('')
continue
current_held = target
last_rebalance_idx = i
signals.append(current_held)
continue
# 检查调仓周期
days_since = i - last_rebalance_idx
if days_since < self.rebalance_days:
# 未达到最低调仓周期,保持当前持仓
signals.append(current_held)
continue
# 检查是否应该调仓
if target: # 目标信号有效
should = self._check_rebalance(
factor_data.iloc[i],
current_held,
target,
self._get_factor_columns(factor_data)
)
if should:
current_held = target
last_rebalance_idx = i
else:
# 目标信号为空所有标的动量得分低于min_score清仓
# 不继续持有负动量标的,转为空仓
current_held = ''
last_rebalance_idx = i
signals.append(current_held)
return signals
def _check_rebalance(
self,
row: pd.Series,
current_held: str,
target: str,
factor_cols: List[str]
) -> bool:
"""检查是否应该调仓(得分阈值检查)"""
# 提取当前持仓和目标持仓的代码
old_codes = [c for c in current_held.split(',') if c]
new_codes = [c for c in target.split(',') if c]
if not new_codes or not old_codes:
return True
if set(new_codes) == set(old_codes):
return False # 组合完全相同,不调仓
# 计算新旧组合的总得分
old_total = sum(float(row.get(col, 0)) for col in factor_cols if col in old_codes)
new_total = sum(float(row.get(col, 0)) for col in factor_cols if col in new_codes)
# 新组合得分需超过当前组合一定比例才调仓
# 即使 threshold=0也要确保 new_total >= old_total
if old_total > 0:
return (new_total / old_total - 1) >= self.rebalance_threshold
return new_total > 0
def _grouped_selection(self, scores: Dict[str, float]) -> List[str]:
"""分组选股先类内竞争每大类选Top1再跨类排序
设计理念:短债作为"动态过滤阈值"
1. 短债正常参与动量排序,没有任何特殊处理
2. 短债排名 <= select_num → 短债被选中,比短债弱的标的被排除
3. 短债排名 > select_num → 短债被排除(有更好的选择)
4. 实际持仓数量 = min(短债排名, select_num),动态调整
"""
if not scores:
return []
# 建立 group -> (code, score) 的映射
group_champions = {}
for code, score in scores.items():
group = self.group_mapping.get(code, 'default')
if group not in group_champions or score > group_champions[group][1]:
group_champions[group] = (code, score)
# 计算全局动量排名
all_sorted = sorted(scores.items(), key=lambda x: x[1], reverse=True)
rank_map = {code: rank + 1 for rank, (code, _) in enumerate(all_sorted)}
# ⭐ 找出短债(BOND大类)的排名位置
bond_rank = None
for group, (code, score) in group_champions.items():
if group == 'BOND':
bond_rank = rank_map.get(code, len(all_sorted) + 1)
break
# ⭐ 确定有效排名阈值
# 如果短债排名在select_num内则以短债排名为阈值
# 如果短债排名超过select_num则以select_num为阈值
if bond_rank is not None and bond_rank <= self.select_num:
# 短债在Top select_num内以短债排名为阈值
# 比短债弱的标的(排名 > 短债排名)被排除
effective_threshold = bond_rank
else:
# 短债不在Top select_num内使用正常select_num阈值
effective_threshold = self.select_num
# ⭐ 大类冠军过滤
valid_champions = []
for group, (code, score) in group_champions.items():
rank = rank_map.get(code, len(all_sorted) + 1)
# 过滤条件:
# 1. 得分 >= min_score过滤负动量
# 2. 排名 <= effective_threshold动态阈值
if score >= self.min_score and rank <= effective_threshold:
valid_champions.append((code, score, rank))
# 对有效冠军按得分排序选出Top N
sorted_champions = sorted(valid_champions, key=lambda x: x[1], reverse=True)
return [code for code, score, rank in sorted_champions[:self.select_num]]
class TrendFollower(SignalGenerator):
"""趋势跟随器(定制实现)"""
mode = "trend"
def __init__(self, entry_threshold: float = 0.02, exit_threshold: float = -0.02, select_num: int = 1):
super().__init__(entry_threshold=entry_threshold, exit_threshold=exit_threshold, select_num=select_num)
self.entry_threshold = entry_threshold
self.exit_threshold = exit_threshold
self.select_num = select_num
def generate(self, factor_data: pd.DataFrame) -> pd.DataFrame:
"""生成趋势跟随信号"""
result = pd.DataFrame(index=factor_data.index)
factor_cols = self._get_factor_columns(factor_data)
for col in factor_cols:
trend_strength = factor_data[col]
result[f'{col}_entry'] = trend_strength > self.entry_threshold
result[f'{col}_exit'] = trend_strength < self.exit_threshold
signals = []
for date in result.index:
entry_signals = []
for col in factor_cols:
if result.loc[date, f'{col}_entry']:
score = factor_data.loc[date, col]
if pd.notna(score):
entry_signals.append((col, score))
entry_signals.sort(key=lambda x: x[1], reverse=True)
selected = [item[0] for item in entry_signals[:self.select_num]]
signals.append(','.join(selected) if selected else '')
result['signal'] = signals
result['signal'] = result['signal'].shift(1)
return result
def _get_factor_columns(self, data: pd.DataFrame) -> List[str]:
"""获取因子列名"""
exclude_cols = ['signal', 'signal_raw', 'combined', 'open', 'high', 'low', 'close', 'volume']
return [col for col in data.columns if col not in exclude_cols and not col.endswith('_weighted')]
class ReversalTrader(SignalGenerator):
"""反转交易器(定制实现)"""
mode = "reversal"
def __init__(self, overbought: float = 70, oversold: float = 30, reversal_threshold: float = 0.1):
super().__init__(overbought=overbought, oversold=oversold, reversal_threshold=reversal_threshold)
self.overbought = overbought
self.oversold = oversold
self.reversal_threshold = reversal_threshold
def generate(self, factor_data: pd.DataFrame) -> pd.DataFrame:
"""生成反转交易信号"""
result = pd.DataFrame(index=factor_data.index)
factor_cols = self._get_factor_columns(factor_data)
for col in factor_cols:
reversal_signal = factor_data[col]
result[f'{col}_buy'] = reversal_signal > self.reversal_threshold
result[f'{col}_sell'] = reversal_signal < -self.reversal_threshold
signals = []
for date in result.index:
buy_signals = []
sell_signals = []
for col in factor_cols:
if result.loc[date, f'{col}_buy']:
buy_signals.append(col)
if result.loc[date, f'{col}_sell']:
sell_signals.append(col)
if buy_signals:
signals.append(f"BUY:{','.join(buy_signals)}")
elif sell_signals:
signals.append(f"SELL:{','.join(sell_signals)}")
else:
signals.append('')
result['signal'] = signals
result['signal'] = result['signal'].shift(1)
return result
def _get_factor_columns(self, data: pd.DataFrame) -> List[str]:
"""获取因子列名"""
exclude_cols = ['signal', 'signal_raw', 'combined', 'open', 'high', 'low', 'close', 'volume']
return [col for col in data.columns if col not in exclude_cols and not col.endswith('_weighted')]