TradingAgents/tradingagents/strategies/alpha_combo.py

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={},
)