TradingAgents/tradingagents/strategies/multifactor.py

138 lines
5.5 KiB
Python

"""Multifactor Portfolio strategy signal (§3.6).
Combined momentum + value + quality + low-vol composite score.
Equal-weighted z-score combination of four factors:
1. Momentum: 12-1 month return
2. Value: composite B/M, E/P, CF/P
3. Quality: ROE + gross margin stability
4. Low-Vol: inverse realized volatility (low-vol anomaly)
Reference: Kakushadze & Serur §3.6 — "Multifactor Models"
"""
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)
class MultifactorStrategy(BaseStrategy):
@property
def interpretation_guide(self) -> str:
return "Usage: Composite signal — more robust than any single factor. Tips: When factors disagree (e.g. high momentum + poor value), confidence should be LOW. Strongest when 3+ factors align. Weight recent factor performance when interpreting."
name = "multifactor"
description = "Combined momentum + value + quality + low-vol composite"
target_analysts = ["portfolio"]
def compute(self, ticker: str, date: str, **kwargs) -> StrategySignal:
end = pd.Timestamp(date)
start = end - pd.DateOffset(months=13)
try:
tk = yf.Ticker(ticker)
hist = kwargs.get("hist")
if hist is None:
hist = tk.history(
start=start.strftime("%Y-%m-%d"),
end=(end + pd.DateOffset(days=1)).strftime("%Y-%m-%d"),
)
info = kwargs.get("info") or tk.info
except Exception:
return self._neutral(ticker, date)
if hist.empty or len(hist) < 22 or not info:
return self._neutral(ticker, date)
close = hist["Close"]
factors = {}
# 1. Momentum (12-1 month return)
ret_12m = (close.iloc[-1] / close.iloc[-min(252, len(close))]) - 1 if len(close) >= 22 else 0
ret_1m = (close.iloc[-1] / close.iloc[-min(22, len(close))]) - 1
factors["momentum"] = float(ret_12m - ret_1m)
# 2. Value (composite B/M, E/P, CF/P)
price = info.get("currentPrice") or info.get("regularMarketPrice") or info.get("previousClose") or 0
if price > 0:
bm = (info.get("bookValue") or 0) / price
eps = info.get("trailingEps") or 0
ep = eps / price if eps else 0
ocf = info.get("operatingCashflow") or 0
shares = info.get("sharesOutstanding") or 0
cfp = (ocf / shares) / price if shares and ocf else 0
vals = [v for v in (bm, ep, cfp) if v != 0]
factors["value"] = float(sum(vals) / len(vals)) if vals else 0.0
else:
factors["value"] = 0.0
# 3. Quality (ROE + gross margin)
roe = info.get("returnOnEquity") or 0
gm = info.get("grossMargins") or 0
quality_parts = [v for v in (roe, gm) if v != 0]
factors["quality"] = float(sum(quality_parts) / len(quality_parts)) if quality_parts else 0.0
# 4. Low-Vol (inverse realized vol — lower vol = higher score)
returns = close.pct_change().dropna()
if len(returns) >= 20:
vol = float(returns.iloc[-min(60, len(returns)):].std() * _ANNUALIZE)
factors["low_vol"] = -vol # negate so low vol = high score
else:
factors["low_vol"] = 0.0
# Composite: equal-weight average of normalized factors
# Simple approach: scale each to roughly [-1, 1] range then average
normed = {
"momentum": np.clip(factors["momentum"] / 0.30, -1, 1), # ±30% = ±1
"value": np.clip(factors["value"] / 0.15, -1, 1), # ±0.15 = ±1
"quality": np.clip(factors["quality"] / 0.30, -1, 1), # ±30% = ±1
"low_vol": np.clip((factors["low_vol"] + 0.25) / 0.15, -1, 1), # 10%-40% vol → [-1,1]
}
composite = float(np.mean(list(normed.values())))
# Signal classification
if composite > 0.4:
signal, label = "STRONG", "strong multifactor"
elif composite > 0.1:
signal, label = "MODERATE", "positive multifactor"
elif composite > -0.1:
signal, label = "NEUTRAL", "neutral"
elif composite > -0.4:
signal, label = "WEAK", "weak multifactor"
else:
signal, label = "NEGATIVE", "negative multifactor"
direction = "SUPPORTS" if composite > 0.1 else "CONTRADICTS" if composite < -0.1 else "NEUTRAL"
# Build value label with factor breakdown
parts = [f"mom={factors['momentum']:+.1%}", f"val={factors['value']:.3f}",
f"qual={factors['quality']:.1%}", f"vol={-factors['low_vol']:.1%}"]
value_label = f"{composite:+.2f} ({label}) [{', '.join(parts)}]"
return StrategySignal(
name=self.name,
ticker=ticker,
date=date,
signal=signal,
value=round(composite, 4),
value_label=value_label,
direction=direction,
detail={
"composite": round(composite, 4),
"factors_raw": {k: round(v, 4) for k, v in factors.items()},
"factors_normed": {k: round(v, 4) for k, v in normed.items()},
},
)
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={},
)