138 lines
5.5 KiB
Python
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={},
|
|
)
|