From 4e97302e22788e65dde1d8e4fd62fdeccf7b653c Mon Sep 17 00:00:00 2001 From: Clayton Brown Date: Tue, 21 Apr 2026 08:37:38 +1000 Subject: [PATCH] feat(028-strategy-signals-contrib): add 9 enhanced strategies (implied_vol, residual_momentum, pairs, trend_following, alpha_combo, dispersion, event_driven, vol_targeting, tax_optimization) --- tradingagents/strategies/alpha_combo.py | 63 ++++++++++++++ tradingagents/strategies/dispersion.py | 58 +++++++++++++ tradingagents/strategies/event_driven.py | 79 +++++++++++++++++ tradingagents/strategies/implied_vol.py | 55 ++++++++++++ tradingagents/strategies/pairs.py | 86 +++++++++++++++++++ tradingagents/strategies/residual_momentum.py | 56 ++++++++++++ tradingagents/strategies/tax_optimization.py | 52 +++++++++++ tradingagents/strategies/trend_following.py | 47 ++++++++++ tradingagents/strategies/vol_targeting.py | 50 +++++++++++ 9 files changed, 546 insertions(+) create mode 100644 tradingagents/strategies/alpha_combo.py create mode 100644 tradingagents/strategies/dispersion.py create mode 100644 tradingagents/strategies/event_driven.py create mode 100644 tradingagents/strategies/implied_vol.py create mode 100644 tradingagents/strategies/pairs.py create mode 100644 tradingagents/strategies/residual_momentum.py create mode 100644 tradingagents/strategies/tax_optimization.py create mode 100644 tradingagents/strategies/trend_following.py create mode 100644 tradingagents/strategies/vol_targeting.py diff --git a/tradingagents/strategies/alpha_combo.py b/tradingagents/strategies/alpha_combo.py new file mode 100644 index 00000000..015fe5d9 --- /dev/null +++ b/tradingagents/strategies/alpha_combo.py @@ -0,0 +1,63 @@ +"""Alpha Combo strategy signal (§3.15 — Alpha Combination / Factor Ensemble). + +Ensemble of top-performing factor signals: momentum, value, mean-reversion. + +Reference: + Kakushadze & Serur, "151 Trading Strategies", §3.15 +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from .base import BaseStrategy, StrategySignal +from ._data import get_ohlcv, get_info + + +class AlphaComboStrategy(BaseStrategy): + name = "Alpha Combo (§3.15)" + roles = ["researcher", "risk"] + + def compute(self, ticker: str, date: str, context: dict[str, Any] | None = None) -> StrategySignal | None: + df = get_ohlcv(ticker, date, context) + if df is None or len(df) < 252: + return None + + close = df["Close"].values + factors: list[float] = [] + details: list[str] = [] + + # Momentum: 12-1 month return + mom = (close[-21] - close[-252]) / close[-252] + factors.append(max(-1.0, min(1.0, mom))) + details.append(f"mom={mom:+.2%}") + + # Mean reversion: 20d z-score (inverted) + recent = close[-20:] + z = (recent[-1] - float(np.mean(recent))) / max(float(np.std(recent)), 1e-8) + factors.append(max(-1.0, min(1.0, -z / 3.0))) + details.append(f"mr_z={z:+.1f}") + + # Value: inverse PE if available + info = get_info(ticker, context) + if info: + pe = info.get("trailingPE") + if pe and pe > 0: + val = min(1.0 / pe / 0.15, 1.0) * 2 - 1 + factors.append(max(-1.0, min(1.0, val))) + details.append(f"val_pe={pe:.1f}") + + strength = round(sum(factors) / len(factors), 4) + strength = max(-1.0, min(1.0, strength)) + direction = "bullish" if strength > 0.05 else ("bearish" if strength < -0.05 else "neutral") + + return StrategySignal( + name=self.name, + ticker=ticker, + date=date, + signal_strength=strength, + direction=direction, + detail=f"Alpha ensemble ({len(factors)} factors): {', '.join(details)}", + ) diff --git a/tradingagents/strategies/dispersion.py b/tradingagents/strategies/dispersion.py new file mode 100644 index 00000000..bac77415 --- /dev/null +++ b/tradingagents/strategies/dispersion.py @@ -0,0 +1,58 @@ +"""Dispersion strategy signal (§4.2 — Cross-Sectional Return Dispersion). + +Measures cross-sectional return dispersion across sector ETFs to detect +high/low dispersion regimes (high dispersion favors stock-picking alpha). + +Reference: + Kakushadze & Serur, "151 Trading Strategies", §4.2 +""" + +from __future__ import annotations + +import logging +from typing import Any + +import numpy as np + +from .base import BaseStrategy, StrategySignal +from ._data import get_ohlcv + +logger = logging.getLogger(__name__) + +_SECTOR_ETFS = ["XLK", "XLV", "XLF", "XLY", "XLP", "XLE", "XLI", "XLB", "XLU", "XLRE", "XLC"] + + +class DispersionStrategy(BaseStrategy): + name = "Dispersion (§4.2)" + roles = ["researcher", "risk"] + + def compute(self, ticker: str, date: str, context: dict[str, Any] | None = None) -> StrategySignal | None: + returns: list[float] = [] + for etf in _SECTOR_ETFS: + df = get_ohlcv(etf, date) + if df is not None and len(df) >= 21: + close = df["Close"].values + returns.append((close[-1] - close[-21]) / close[-21]) + + if len(returns) < 5: + return None + + disp = float(np.std(returns)) + # High dispersion → more alpha opportunity → mildly bullish for active strategies + # Normalize: 0.02 = low, 0.08 = high + strength = max(-1.0, min(1.0, (disp - 0.05) / 0.05)) + if disp > 0.06: + direction, label = "bullish", "high dispersion (stock-picking favored)" + elif disp < 0.03: + direction, label = "bearish", "low dispersion (index-like)" + else: + direction, label = "neutral", "moderate dispersion" + + return StrategySignal( + name=self.name, + ticker=ticker, + date=date, + signal_strength=round(strength, 4), + direction=direction, + detail=f"{label}: sector return dispersion={disp:.4f}", + ) diff --git a/tradingagents/strategies/event_driven.py b/tradingagents/strategies/event_driven.py new file mode 100644 index 00000000..46e11896 --- /dev/null +++ b/tradingagents/strategies/event_driven.py @@ -0,0 +1,79 @@ +"""Event-Driven strategy signal (§5.1 — Event-Driven / Earnings & Dividend Proximity). + +Flags proximity to upcoming earnings or ex-dividend dates as event catalysts. + +Reference: + Kakushadze & Serur, "151 Trading Strategies", §5.1 +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from .base import BaseStrategy, StrategySignal +from ._data import get_info + + +class EventDrivenStrategy(BaseStrategy): + name = "Event-Driven (§5.1)" + roles = ["fundamentals", "news", "researcher"] + + def compute(self, ticker: str, date: str, context: dict[str, Any] | None = None) -> StrategySignal | None: + info = get_info(ticker, context) + if not info: + return None + + try: + ref = datetime.strptime(date, "%Y-%m-%d") + except ValueError: + return None + + events: list[str] = [] + days_to_event: int | None = None + + # Check earnings date proximity + for key in ("earningsDate", "nextEarningsDate"): + raw = info.get(key) + if raw is None: + continue + # yfinance may return a timestamp or list + if isinstance(raw, (list, tuple)) and raw: + raw = raw[0] + try: + dt = datetime.fromtimestamp(int(raw)) if isinstance(raw, (int, float)) else datetime.strptime(str(raw)[:10], "%Y-%m-%d") + delta = (dt - ref).days + if 0 <= delta <= 30: + events.append(f"earnings in {delta}d") + days_to_event = min(days_to_event, delta) if days_to_event is not None else delta + except Exception: + continue + + # Check ex-dividend date + ex_div = info.get("exDividendDate") + if ex_div: + try: + dt = datetime.fromtimestamp(int(ex_div)) if isinstance(ex_div, (int, float)) else datetime.strptime(str(ex_div)[:10], "%Y-%m-%d") + delta = (dt - ref).days + if 0 <= delta <= 30: + events.append(f"ex-div in {delta}d") + days_to_event = min(days_to_event, delta) if days_to_event is not None else delta + except Exception: + pass + + if not events: + return None + + # Closer event → stronger signal (event risk / catalyst) + # Neutral direction — events are catalysts, not directional + proximity = max(0.0, 1.0 - (days_to_event or 30) / 30.0) + strength = round(proximity * 0.5, 4) # cap at 0.5 — events are informational + + return StrategySignal( + name=self.name, + ticker=ticker, + date=date, + signal_strength=strength, + direction="neutral", + detail=f"Upcoming: {', '.join(events)}", + ) diff --git a/tradingagents/strategies/implied_vol.py b/tradingagents/strategies/implied_vol.py new file mode 100644 index 00000000..455caba9 --- /dev/null +++ b/tradingagents/strategies/implied_vol.py @@ -0,0 +1,55 @@ +"""Implied Volatility strategy signal (§3.5 — Volatility Premium/Discount). + +Compares implied volatility to realized volatility to detect IV premium or discount. + +Reference: + Kakushadze & Serur, "151 Trading Strategies", §3.5 +""" + +from __future__ import annotations + +import logging +from typing import Any + +import numpy as np + +from .base import BaseStrategy, StrategySignal +from ._data import get_ohlcv, get_info + +logger = logging.getLogger(__name__) + + +class ImpliedVolStrategy(BaseStrategy): + name = "Implied Volatility (§3.5)" + roles = ["risk", "market", "researcher"] + + def compute(self, ticker: str, date: str, context: dict[str, Any] | None = None) -> StrategySignal | None: + df = get_ohlcv(ticker, date, context) + if df is None or len(df) < 63: + return None + + info = get_info(ticker, context) + iv = info.get("impliedVolatility") if info else None + if iv is None or iv <= 0: + return None + + # Realized vol (63d annualized) + close = df["Close"].values[-63:] + rv = float(np.std(np.diff(np.log(close))) * np.sqrt(252)) + if rv <= 0: + return None + + # IV premium: IV > RV → options expensive → bearish bias (mean-revert expectation) + premium = (iv - rv) / rv + strength = max(-1.0, min(1.0, -premium)) # high premium → bearish + direction = "bearish" if premium > 0.2 else ("bullish" if premium < -0.2 else "neutral") + label = "premium" if premium > 0 else "discount" + + return StrategySignal( + name=self.name, + ticker=ticker, + date=date, + signal_strength=round(strength, 4), + direction=direction, + detail=f"IV={iv:.1%} vs RV={rv:.1%}, {label}={premium:+.1%}", + ) diff --git a/tradingagents/strategies/pairs.py b/tradingagents/strategies/pairs.py new file mode 100644 index 00000000..23077998 --- /dev/null +++ b/tradingagents/strategies/pairs.py @@ -0,0 +1,86 @@ +"""Pairs Trading strategy signal (§3.8 — Pairs Trading / Statistical Arbitrage). + +Cointegration-based spread signal using price ratio z-score vs a correlated peer. + +Reference: + Kakushadze & Serur, "151 Trading Strategies", §3.8 +""" + +from __future__ import annotations + +import logging +from typing import Any + +import numpy as np + +from .base import BaseStrategy, StrategySignal +from ._data import get_ohlcv, get_info + +logger = logging.getLogger(__name__) + +# Simple sector-based peer mapping (one representative peer per sector) +_SECTOR_PEERS: dict[str, str] = { + "Technology": "MSFT", + "Healthcare": "JNJ", + "Financial Services": "JPM", + "Financials": "JPM", + "Consumer Cyclical": "AMZN", + "Consumer Defensive": "PG", + "Energy": "XOM", + "Industrials": "HON", + "Basic Materials": "LIN", + "Utilities": "NEE", + "Real Estate": "PLD", + "Communication Services": "GOOGL", +} + + +class PairsStrategy(BaseStrategy): + name = "Pairs Trading (§3.8)" + roles = ["market", "researcher"] + + def compute(self, ticker: str, date: str, context: dict[str, Any] | None = None) -> StrategySignal | None: + info = get_info(ticker, context) + if not info: + return None + + sector = info.get("sector", "") + peer = _SECTOR_PEERS.get(sector) + if not peer or peer.upper() == ticker.upper(): + return None + + df = get_ohlcv(ticker, date, context) + peer_df = get_ohlcv(peer, date) + if df is None or peer_df is None or len(df) < 60 or len(peer_df) < 60: + return None + + # Price ratio z-score over 60 days + stock_close = df["Close"].values[-60:] + peer_close = peer_df["Close"].values[-60:] + if np.any(peer_close == 0): + return None + + ratio = stock_close / peer_close + mean = float(np.mean(ratio)) + std = float(np.std(ratio)) + if std == 0: + return None + + z = (ratio[-1] - mean) / std + # High z → stock overvalued vs peer → bearish; low z → bullish + strength = max(-1.0, min(1.0, -z / 2.5)) + if z > 1.5: + direction, label = "bearish", "overvalued vs peer" + elif z < -1.5: + direction, label = "bullish", "undervalued vs peer" + else: + direction, label = "neutral", "fair vs peer" + + return StrategySignal( + name=self.name, + ticker=ticker, + date=date, + signal_strength=round(strength, 4), + direction=direction, + detail=f"{label}: {ticker}/{peer} ratio z={z:+.2f}", + ) diff --git a/tradingagents/strategies/residual_momentum.py b/tradingagents/strategies/residual_momentum.py new file mode 100644 index 00000000..29b3d281 --- /dev/null +++ b/tradingagents/strategies/residual_momentum.py @@ -0,0 +1,56 @@ +"""Residual Momentum strategy signal (§3.7 — Residual Momentum). + +Momentum after removing market beta exposure, isolating stock-specific trend. + +Reference: + Kakushadze & Serur, "151 Trading Strategies", §3.7 +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from .base import BaseStrategy, StrategySignal +from ._data import get_ohlcv + + +class ResidualMomentumStrategy(BaseStrategy): + name = "Residual Momentum (§3.7)" + roles = ["market", "researcher"] + + def compute(self, ticker: str, date: str, context: dict[str, Any] | None = None) -> StrategySignal | None: + df = get_ohlcv(ticker, date, context) + spy_df = get_ohlcv("SPY", date) + if df is None or spy_df is None or len(df) < 252 or len(spy_df) < 252: + return None + + # Daily log returns over past 252 days + stock_ret = np.diff(np.log(df["Close"].values[-253:])) + mkt_ret = np.diff(np.log(spy_df["Close"].values[-253:])) + if len(stock_ret) != len(mkt_ret): + return None + + # OLS beta: cov(stock, mkt) / var(mkt) + mkt_var = float(np.var(mkt_ret)) + if mkt_var == 0: + return None + beta = float(np.cov(stock_ret, mkt_ret)[0, 1]) / mkt_var + + # Residual returns = stock - beta * market + residuals = stock_ret - beta * mkt_ret + # Cumulative residual momentum (skip last 21 days for reversal) + res_mom = float(np.sum(residuals[:-21])) + + strength = max(-1.0, min(1.0, res_mom * 5)) + direction = "bullish" if strength > 0.05 else ("bearish" if strength < -0.05 else "neutral") + + return StrategySignal( + name=self.name, + ticker=ticker, + date=date, + signal_strength=round(strength, 4), + direction=direction, + detail=f"Residual momentum (beta-adj): {res_mom:+.4f}, beta={beta:.2f}", + ) diff --git a/tradingagents/strategies/tax_optimization.py b/tradingagents/strategies/tax_optimization.py new file mode 100644 index 00000000..ca2e5391 --- /dev/null +++ b/tradingagents/strategies/tax_optimization.py @@ -0,0 +1,52 @@ +"""Tax Optimization strategy signal (§7.1 — Tax-Loss Harvesting). + +Scores tax-loss harvesting opportunity based on unrealized loss from recent highs. + +Reference: + Kakushadze & Serur, "151 Trading Strategies", §7.1 +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from .base import BaseStrategy, StrategySignal +from ._data import get_ohlcv + + +class TaxOptimizationStrategy(BaseStrategy): + name = "Tax Optimization (§7.1)" + roles = ["risk", "researcher"] + + def compute(self, ticker: str, date: str, context: dict[str, Any] | None = None) -> StrategySignal | None: + df = get_ohlcv(ticker, date, context) + if df is None or len(df) < 252: + return None + + close = df["Close"].values[-252:] + price = float(close[-1]) + high_252 = float(np.max(close)) + if high_252 <= 0: + return None + + drawdown = (price - high_252) / high_252 # negative when below high + + # Larger drawdown → stronger harvesting opportunity + if drawdown > -0.05: + return None # no meaningful loss to harvest + + # Map drawdown: -5% → 0, -30%+ → 1.0 opportunity score + opportunity = min(1.0, abs(drawdown) / 0.30) + # Bearish signal: suggests selling to harvest loss + strength = round(-opportunity, 4) + + return StrategySignal( + name=self.name, + ticker=ticker, + date=date, + signal_strength=strength, + direction="bearish", + detail=f"Tax-loss harvest opportunity: drawdown={drawdown:.1%} from 252d high={high_252:.2f}", + ) diff --git a/tradingagents/strategies/trend_following.py b/tradingagents/strategies/trend_following.py new file mode 100644 index 00000000..bb7d82f8 --- /dev/null +++ b/tradingagents/strategies/trend_following.py @@ -0,0 +1,47 @@ +"""Trend Following strategy signal (§3.10 — Time-Series Momentum / Trend Following). + +Multi-timeframe trend strength using short, medium, and long lookbacks. + +Reference: + Kakushadze & Serur, "151 Trading Strategies", §3.10 +""" + +from __future__ import annotations + +from typing import Any + +from .base import BaseStrategy, StrategySignal +from ._data import get_ohlcv + + +class TrendFollowingStrategy(BaseStrategy): + name = "Trend Following (§3.10)" + roles = ["market", "researcher"] + + def compute(self, ticker: str, date: str, context: dict[str, Any] | None = None) -> StrategySignal | None: + df = get_ohlcv(ticker, date, context) + if df is None or len(df) < 252: + return None + + close = df["Close"].values + scores: list[float] = [] + details: list[str] = [] + + for label, period in [("21d", 21), ("63d", 63), ("252d", 252)]: + ret = (close[-1] - close[-period]) / close[-period] + s = max(-1.0, min(1.0, ret * (252 / period) ** 0.5)) # vol-scale + scores.append(s) + details.append(f"{label}={ret:+.1%}") + + strength = round(sum(scores) / len(scores), 4) + strength = max(-1.0, min(1.0, strength)) + direction = "bullish" if strength > 0.05 else ("bearish" if strength < -0.05 else "neutral") + + return StrategySignal( + name=self.name, + ticker=ticker, + date=date, + signal_strength=strength, + direction=direction, + detail=f"Multi-TF trend: {', '.join(details)}", + ) diff --git a/tradingagents/strategies/vol_targeting.py b/tradingagents/strategies/vol_targeting.py new file mode 100644 index 00000000..99fb5f50 --- /dev/null +++ b/tradingagents/strategies/vol_targeting.py @@ -0,0 +1,50 @@ +"""Vol Targeting strategy signal (§6.1 — Volatility Targeting / Position Sizing). + +Suggests position size scaling based on target volatility vs realized volatility. + +Reference: + Kakushadze & Serur, "151 Trading Strategies", §6.1 +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from .base import BaseStrategy, StrategySignal +from ._data import get_ohlcv + +_TARGET_VOL = 0.15 # 15% annualized target + + +class VolTargetingStrategy(BaseStrategy): + name = "Vol Targeting (§6.1)" + roles = ["risk", "researcher"] + + def compute(self, ticker: str, date: str, context: dict[str, Any] | None = None) -> StrategySignal | None: + df = get_ohlcv(ticker, date, context) + if df is None or len(df) < 63: + return None + + close = df["Close"].values[-63:] + rv = float(np.std(np.diff(np.log(close))) * np.sqrt(252)) + if rv <= 0: + return None + + # Scale factor: target / realized + scale = _TARGET_VOL / rv + scale = min(scale, 2.0) # cap leverage at 2x + + # High vol → reduce position (bearish sizing), low vol → increase (bullish sizing) + strength = max(-1.0, min(1.0, (scale - 1.0))) + direction = "bullish" if scale > 1.1 else ("bearish" if scale < 0.9 else "neutral") + + return StrategySignal( + name=self.name, + ticker=ticker, + date=date, + signal_strength=round(strength, 4), + direction=direction, + detail=f"Vol target={_TARGET_VOL:.0%}, realized={rv:.1%}, scale={scale:.2f}x", + )