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)

This commit is contained in:
Clayton Brown 2026-04-21 08:37:38 +10:00
parent 1b1710b509
commit 4e97302e22
9 changed files with 546 additions and 0 deletions

View File

@ -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)}",
)

View File

@ -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}",
)

View File

@ -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)}",
)

View File

@ -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%}",
)

View File

@ -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}",
)

View File

@ -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}",
)

View File

@ -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}",
)

View File

@ -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)}",
)

View File

@ -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",
)