TradingAgents/tradingagents/strategies/volatility.py

147 lines
5.8 KiB
Python

"""Low-Volatility Anomaly strategy signal (§3.4).
Realized volatility ranking — annualized from daily returns over trailing
60 trading days. Low-vol stocks historically outperform on risk-adjusted
basis (low-vol anomaly). Flags high-vol positions for position sizing
and low-vol for potential overweight.
Reference: Kakushadze & Serur §3.4 — "Low-Volatility Investing"
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import yfinance as yf
from tradingagents.strategies.base import BaseStrategy, StrategySignal
# Annualization factor (√252 trading days)
_ANNUALIZE = np.sqrt(252)
class VolatilityStrategy(BaseStrategy):
@property
def interpretation_guide(self) -> str:
return "Usage: Low-vol stocks historically outperform on risk-adjusted basis (low-vol anomaly). Tips: Signal inverts during market stress — low-vol stocks can gap down sharply. Use as position sizing input, not directional signal. Combine with momentum for 'low-vol + trending' filter."
name = "volatility"
description = "Realized vol ranking, low-vol anomaly flag"
target_analysts = ["risk"]
def compute(self, ticker: str, date: str, **kwargs) -> StrategySignal:
hist = kwargs.get("hist")
if hist is None:
end = pd.Timestamp(date)
start = end - pd.DateOffset(days=120) # ~60 trading days + buffer
hist = yf.Ticker(ticker).history(
start=start.strftime("%Y-%m-%d"),
end=(end + pd.DateOffset(days=1)).strftime("%Y-%m-%d"),
)
if hist.empty or len(hist) < 20:
return self._neutral(ticker, date)
close = hist["Close"]
returns = close.pct_change().dropna()
if len(returns) < 20:
return self._neutral(ticker, date)
# Realized vol (annualized) — trailing 60 days or available
trail = returns.iloc[-min(60, len(returns)):]
realized_vol = float(trail.std() * _ANNUALIZE)
# Compare to SPY as market benchmark
spy_vol = self._spy_vol(date, kwargs)
# Vol ratio: >1 means more volatile than market
vol_ratio = realized_vol / spy_vol if spy_vol > 0 else 1.0
# Low-vol anomaly flag
low_vol = vol_ratio < 0.8
high_vol = vol_ratio > 1.5
# Signal: low-vol = SUPPORTS overweight (anomaly), high-vol = CONTRADICTS (risk)
if low_vol:
signal, label, direction = "STRONG", "low-vol anomaly", "SUPPORTS"
elif vol_ratio <= 1.0:
signal, label, direction = "MODERATE", "below-market vol", "SUPPORTS"
elif vol_ratio <= 1.5:
signal, label, direction = "WEAK", "above-market vol", "NEUTRAL"
else:
signal, label, direction = "NEGATIVE", "high-vol", "CONTRADICTS"
# Rank within portfolio if provided
rank, total = None, None
portfolio_tickers = kwargs.get("portfolio_tickers", [])
if portfolio_tickers and len(portfolio_tickers) > 1:
vols = {}
for t in portfolio_tickers:
if t == ticker:
vols[t] = realized_vol
else:
try:
end_ts = pd.Timestamp(date)
t_hist = yf.Ticker(t).history(
start=(end_ts - pd.DateOffset(days=120)).strftime("%Y-%m-%d"),
end=(end_ts + pd.DateOffset(days=1)).strftime("%Y-%m-%d"),
)
if len(t_hist) >= 20:
t_ret = t_hist["Close"].pct_change().dropna()
vols[t] = float(t_ret.iloc[-min(60, len(t_ret)):].std() * _ANNUALIZE)
except Exception:
pass
if vols:
# Rank by vol ascending (lowest vol = rank 1 = best for low-vol anomaly)
ranked = sorted(vols, key=lambda k: vols[k])
rank = ranked.index(ticker) + 1 if ticker in ranked else None
total = len(ranked)
rank_label = f" (rank {rank}/{total})" if rank and total else ""
value_label = f"{realized_vol:.1%} ann. vol, {vol_ratio:.2f}x market ({label}){rank_label}"
return StrategySignal(
name=self.name,
ticker=ticker,
date=date,
signal=signal,
value=round(realized_vol, 4),
value_label=value_label,
direction=direction,
detail={
"realized_vol": round(realized_vol, 4),
"spy_vol": round(spy_vol, 4),
"vol_ratio": round(vol_ratio, 4),
"low_vol_anomaly": low_vol,
"high_vol": high_vol,
"rank": rank,
"total": total,
},
)
def _spy_vol(self, date: str, kwargs: dict) -> float:
"""Get SPY realized vol as market benchmark."""
spy_hist = kwargs.get("spy_hist")
if spy_hist is None:
try:
end = pd.Timestamp(date)
spy_hist = yf.Ticker("SPY").history(
start=(end - pd.DateOffset(days=120)).strftime("%Y-%m-%d"),
end=(end + pd.DateOffset(days=1)).strftime("%Y-%m-%d"),
)
except Exception:
return 0.16 # ~16% long-run avg
if spy_hist is None or spy_hist.empty or len(spy_hist) < 20:
return 0.16
ret = spy_hist["Close"].pct_change().dropna()
trail = ret.iloc[-min(60, len(ret)):]
return float(trail.std() * _ANNUALIZE)
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 (insufficient data)",
direction="NEUTRAL", detail={},
)