152 lines
5.4 KiB
Python
152 lines
5.4 KiB
Python
"""Alpha Combos strategy signal (§3.20).
|
|
|
|
Weighted meta-signal combining all Tier 1 strategy signals into a single
|
|
composite alpha score. Each Tier 1 signal's normalized value is weighted
|
|
and summed. Weights are fixed (equal-weight baseline) but can be adjusted
|
|
based on strategy scorecard tracking over time.
|
|
|
|
Unlike multifactor.py (which computes its own factors from raw data),
|
|
alpha_combo operates on already-computed strategy signals — it's a
|
|
second-pass aggregation.
|
|
|
|
Reference: Kakushadze & Serur §3.20 — "Alpha Combos"
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
|
|
from tradingagents.strategies.base import BaseStrategy, StrategySignal
|
|
|
|
# Tier 1 strategy names and their normalization ranges
|
|
# value_range: (min_typical, max_typical) used to normalize to [-1, 1]
|
|
TIER1_CONFIG: dict[str, dict] = {
|
|
"momentum": {"weight": 0.20, "range": (-0.30, 0.30)},
|
|
"earnings_momentum": {"weight": 0.10, "range": (-3.0, 3.0)},
|
|
"value": {"weight": 0.15, "range": (0.0, 1.0)},
|
|
"volatility": {"weight": 0.10, "range": (0.10, 0.60)},
|
|
"multifactor": {"weight": 0.15, "range": (-1.0, 1.0)},
|
|
"mean_reversion": {"weight": 0.10, "range": (-3.0, 3.0)},
|
|
"moving_average": {"weight": 0.10, "range": (-1.0, 1.0)},
|
|
"sector_rotation": {"weight": 0.10, "range": (-0.30, 0.30)},
|
|
}
|
|
|
|
# Direction mapping: how each signal's direction maps to bullish/bearish
|
|
# 1 = SUPPORTS is bullish, -1 = SUPPORTS is bearish (inverted signals like vol)
|
|
DIRECTION_SIGN: dict[str, int] = {
|
|
"momentum": 1,
|
|
"earnings_momentum": 1,
|
|
"value": 1, # high value score = cheap = bullish
|
|
"volatility": -1, # high vol = bearish (low-vol anomaly)
|
|
"multifactor": 1,
|
|
"mean_reversion": -1, # high z-score = overbought = bearish
|
|
"moving_average": 1,
|
|
"sector_rotation": 1,
|
|
}
|
|
|
|
|
|
def _normalize(value: float, lo: float, hi: float) -> float:
|
|
"""Normalize value to [-1, 1] range given typical bounds."""
|
|
if hi == lo:
|
|
return 0.0
|
|
mid = (hi + lo) / 2
|
|
half = (hi - lo) / 2
|
|
return float(np.clip((value - mid) / half, -1, 1))
|
|
|
|
|
|
class AlphaComboStrategy(BaseStrategy):
|
|
|
|
@property
|
|
def interpretation_guide(self) -> str:
|
|
return "Usage: Ensemble of top-performing factor signals — diversified alpha source. Tips: Interpret as 'weight of evidence' — more factors agreeing = higher confidence. Individual factor weights shift over time. Strongest when combined with macro regime awareness."
|
|
|
|
name = "alpha_combo"
|
|
description = "Weighted meta-signal combining all Tier 1 strategy signals"
|
|
target_analysts = ["portfolio"]
|
|
|
|
def compute(self, ticker: str, date: str, **kwargs) -> StrategySignal:
|
|
"""Combine Tier 1 signals into a single alpha score.
|
|
|
|
kwargs:
|
|
tier1_signals: list[StrategySignal] — pre-computed Tier 1 signals
|
|
"""
|
|
tier1: list[StrategySignal] = kwargs.get("tier1_signals", [])
|
|
if not tier1:
|
|
return self._neutral(ticker, date)
|
|
|
|
# Index signals by name
|
|
by_name = {s["name"]: s for s in tier1 if s.get("name")}
|
|
|
|
weighted_sum = 0.0
|
|
total_weight = 0.0
|
|
contributions: dict[str, float] = {}
|
|
|
|
for name, cfg in TIER1_CONFIG.items():
|
|
sig = by_name.get(name)
|
|
if not sig or sig.get("signal") == "NEUTRAL":
|
|
continue
|
|
|
|
value = sig.get("value", 0.0)
|
|
lo, hi = cfg["range"]
|
|
normed = _normalize(value, lo, hi)
|
|
|
|
# Apply direction sign (e.g., high vol is bearish)
|
|
sign = DIRECTION_SIGN.get(name, 1)
|
|
normed *= sign
|
|
|
|
w = cfg["weight"]
|
|
weighted_sum += normed * w
|
|
total_weight += w
|
|
contributions[name] = round(normed * w, 4)
|
|
|
|
if total_weight == 0:
|
|
return self._neutral(ticker, date)
|
|
|
|
# Normalize by total weight used (handles missing signals)
|
|
alpha = weighted_sum / total_weight
|
|
|
|
# Classify
|
|
if alpha > 0.3:
|
|
signal = "STRONG"
|
|
elif alpha > 0.1:
|
|
signal = "MODERATE"
|
|
elif alpha > -0.1:
|
|
signal = "NEUTRAL"
|
|
elif alpha > -0.3:
|
|
signal = "WEAK"
|
|
else:
|
|
signal = "NEGATIVE"
|
|
|
|
direction = "SUPPORTS" if alpha > 0.1 else "CONTRADICTS" if alpha < -0.1 else "NEUTRAL"
|
|
|
|
# Top contributors
|
|
sorted_contrib = sorted(contributions.items(), key=lambda x: abs(x[1]), reverse=True)
|
|
top = [f"{n}={v:+.3f}" for n, v in sorted_contrib[:3]]
|
|
n_signals = len(contributions)
|
|
|
|
value_label = f"{alpha:+.3f} ({signal.lower()}, {n_signals}/{len(TIER1_CONFIG)} signals) [{', '.join(top)}]"
|
|
|
|
return StrategySignal(
|
|
name=self.name,
|
|
ticker=ticker,
|
|
date=date,
|
|
signal=signal,
|
|
value=round(alpha, 4),
|
|
value_label=value_label,
|
|
direction=direction,
|
|
detail={
|
|
"alpha": round(alpha, 4),
|
|
"contributions": contributions,
|
|
"n_signals": n_signals,
|
|
"total_weight": round(total_weight, 4),
|
|
},
|
|
)
|
|
|
|
def _neutral(self, ticker: str, date: str) -> StrategySignal:
|
|
return StrategySignal(
|
|
name=self.name, ticker=ticker, date=date,
|
|
signal="NEUTRAL", value=0.0,
|
|
value_label="N/A (no Tier 1 signals available)",
|
|
direction="NEUTRAL", detail={},
|
|
)
|