TradingAgents/tradingagents/strategies/vol_targeting.py

177 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Volatility Targeting strategy signal (§6.5).
Scale position sizes to target a portfolio-level volatility budget.
Computes each position's marginal contribution to portfolio risk (MCTR)
and recommends sizing adjustments to keep portfolio vol near target.
Target vol: 15% annualized (typical balanced equity portfolio).
Positions contributing disproportionate vol → CONTRADICTS (reduce size).
Positions with low vol contribution → SUPPORTS (room to add).
Reference: Kakushadze & Serur §6.5 — "Volatility Targeting"
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import yfinance as yf
from tradingagents.strategies.base import BaseStrategy, StrategySignal
_ANNUALIZE = np.sqrt(252)
_TARGET_VOL = 0.15 # 15% annualized portfolio vol target
class VolTargetingStrategy(BaseStrategy):
@property
def interpretation_guide(self) -> str:
return "Usage: Position sizing recommendation based on target volatility. Tips: Reduces exposure in volatile markets, increases in calm markets. Not a directional signal — purely risk management. Apply after directional signals are determined."
name = "vol_targeting"
description = "Position sizing based on portfolio vol budget"
target_analysts = ["risk"]
def compute(self, ticker: str, date: str, **kwargs) -> StrategySignal:
portfolio_tickers = kwargs.get("portfolio_tickers", [])
if not portfolio_tickers or len(portfolio_tickers) < 2:
return self._single_ticker(ticker, date, kwargs)
end = pd.Timestamp(date)
start = end - pd.DateOffset(days=180)
start_str = start.strftime("%Y-%m-%d")
end_str = (end + pd.DateOffset(days=1)).strftime("%Y-%m-%d")
# Fetch returns for all portfolio tickers
returns_dict: dict[str, pd.Series] = {}
for t in portfolio_tickers:
try:
h = yf.Ticker(t).history(start=start_str, end=end_str)
if len(h) >= 30:
returns_dict[t] = h["Close"].pct_change().dropna()
except Exception:
pass
if ticker not in returns_dict or len(returns_dict) < 2:
return self._single_ticker(ticker, date, kwargs)
# Align returns to common dates
df = pd.DataFrame(returns_dict).dropna()
if len(df) < 30:
return self._single_ticker(ticker, date, kwargs)
n = len(df.columns)
# Equal-weight assumption (no position sizes available at strategy level)
weights = np.ones(n) / n
cov = df.cov().values * 252 # annualized covariance
# Portfolio vol
port_var = weights @ cov @ weights
port_vol = float(np.sqrt(port_var))
# Marginal contribution to risk (MCTR) for target ticker
ticker_idx = list(df.columns).index(ticker)
mctr = (cov @ weights) / port_vol # vector of MCTRs
ticker_mctr = float(mctr[ticker_idx])
# Contribution to risk (CTR) = weight × MCTR
ticker_ctr = float(weights[ticker_idx] * ticker_mctr)
# Proportional contribution
pct_contribution = ticker_ctr / port_vol if port_vol > 0 else 1 / n
# Vol scaling factor: how much to scale this position to hit target vol
vol_scale = _TARGET_VOL / port_vol if port_vol > 0 else 1.0
# Signal: is this position's vol contribution proportionate?
fair_share = 1.0 / n
if pct_contribution > fair_share * 1.5:
signal, direction = "NEGATIVE", "CONTRADICTS"
label = "overweight risk"
sizing = "REDUCE"
elif pct_contribution > fair_share * 1.2:
signal, direction = "WEAK", "NEUTRAL"
label = "slightly above fair share"
sizing = "TRIM"
elif pct_contribution < fair_share * 0.5:
signal, direction = "STRONG", "SUPPORTS"
label = "low risk contribution"
sizing = "ADD"
else:
signal, direction = "MODERATE", "NEUTRAL"
label = "proportionate risk"
sizing = "HOLD"
vol_label = "ABOVE" if port_vol > _TARGET_VOL else "BELOW" if port_vol < _TARGET_VOL * 0.8 else "ON"
value_label = (
f"MCTR {ticker_mctr:.1%}, {pct_contribution:.0%} of port risk ({label}) | "
f"Port vol {port_vol:.1%} ({vol_label} {_TARGET_VOL:.0%} target) → {sizing}"
)
return StrategySignal(
name=self.name,
ticker=ticker,
date=date,
signal=signal,
value=round(ticker_mctr, 4),
value_label=value_label,
direction=direction,
detail={
"mctr": round(ticker_mctr, 4),
"ctr": round(ticker_ctr, 4),
"pct_contribution": round(pct_contribution, 4),
"portfolio_vol": round(port_vol, 4),
"target_vol": _TARGET_VOL,
"vol_scale": round(vol_scale, 4),
"sizing": sizing,
"n_tickers": n,
},
)
def _single_ticker(self, ticker: str, date: str, kwargs: dict) -> StrategySignal:
"""Fallback when no portfolio context — just compare ticker vol to target."""
hist = kwargs.get("hist")
if hist is None:
end = pd.Timestamp(date)
start = end - pd.DateOffset(days=180)
try:
hist = yf.Ticker(ticker).history(
start=start.strftime("%Y-%m-%d"),
end=(end + pd.DateOffset(days=1)).strftime("%Y-%m-%d"),
)
except Exception:
return self._neutral(ticker, date)
if hist is None or hist.empty or len(hist) < 30:
return self._neutral(ticker, date)
ret = hist["Close"].pct_change().dropna()
vol = float(ret.iloc[-min(60, len(ret)):].std() * _ANNUALIZE)
vol_scale = _TARGET_VOL / vol if vol > 0 else 1.0
if vol > _TARGET_VOL * 1.5:
signal, direction, sizing = "NEGATIVE", "CONTRADICTS", "REDUCE"
elif vol > _TARGET_VOL:
signal, direction, sizing = "WEAK", "NEUTRAL", "TRIM"
elif vol < _TARGET_VOL * 0.5:
signal, direction, sizing = "STRONG", "SUPPORTS", "ADD"
else:
signal, direction, sizing = "MODERATE", "NEUTRAL", "HOLD"
value_label = f"Vol {vol:.1%} vs {_TARGET_VOL:.0%} target (scale {vol_scale:.2f}x) → {sizing}"
return StrategySignal(
name=self.name, ticker=ticker, date=date,
signal=signal, value=round(vol, 4), value_label=value_label,
direction=direction,
detail={"ticker_vol": round(vol, 4), "target_vol": _TARGET_VOL,
"vol_scale": round(vol_scale, 4), "sizing": sizing},
)
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={},
)