TradingAgents/tradingagents/strategies/value.py

110 lines
3.9 KiB
Python

"""Value strategy signal (§3.3).
Composite value score from Book-to-Market, Earnings/Price, and
Cash-Flow/Price ratios. High composite = deep value; low = expensive.
Reference: Kakushadze & Serur §3.3 — "Value"
"""
from __future__ import annotations
import yfinance as yf
from tradingagents.strategies.base import BaseStrategy, StrategySignal
class ValueStrategy(BaseStrategy):
@property
def interpretation_guide(self) -> str:
return "Usage: Best for identifying long-term mean reversion candidates. Tips: Value traps are common — always check cash flow and debt levels. Works best in rising-rate environments. Combine with quality metrics (ROE, debt/equity) to filter traps."
name = "value"
description = "Composite value score: B/M, E/P, CF/P"
target_analysts = ["fundamentals"]
def compute(self, ticker: str, date: str, **kwargs) -> StrategySignal:
info = kwargs.get("info")
if info is None:
try:
info = yf.Ticker(ticker).info
except Exception:
return self._neutral(ticker, date)
if not info:
return self._neutral(ticker, date)
price = info.get("currentPrice") or info.get("regularMarketPrice") or info.get("previousClose") or 0
if price <= 0:
return self._neutral(ticker, date)
# Book-to-Market (B/M)
book_ps = info.get("bookValue") or 0
bm = book_ps / price if book_ps > 0 else 0.0
# Earnings/Price (E/P) — inverse of trailing P/E
trailing_eps = info.get("trailingEps") or 0
ep = trailing_eps / price if trailing_eps != 0 else 0.0
# Cash-Flow/Price (CF/P)
ocf = info.get("operatingCashflow") or 0
shares = info.get("sharesOutstanding") or 0
cfp = (ocf / shares) / price if shares > 0 and ocf != 0 else 0.0
# Count how many ratios we have
components = {"B/M": bm, "E/P": ep, "CF/P": cfp}
valid = {k: v for k, v in components.items() if v != 0.0}
if not valid:
return self._neutral(ticker, date)
# Composite: equal-weight average of available ratios (each z-scored
# relative to typical ranges would be ideal, but we use simple
# percentile-style thresholds for single-stock context)
composite = sum(valid.values()) / len(valid)
# Signal: higher composite = cheaper (more value)
if composite > 0.15:
signal = "STRONG"
label = "deep value"
elif composite > 0.06:
signal = "MODERATE"
label = "value"
elif composite > 0.02:
signal = "WEAK"
label = "fair"
elif composite > 0:
signal = "NEUTRAL"
label = "growth-priced"
else:
signal = "NEGATIVE"
label = "expensive/negative earnings"
direction = "SUPPORTS" if composite > 0.06 else "CONTRADICTS" if composite < 0.02 else "NEUTRAL"
parts = [f"{k}={v:.3f}" for k, v in components.items() if v != 0.0]
value_label = f"{composite:.3f} ({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),
"book_to_market": round(bm, 4),
"earnings_to_price": round(ep, 4),
"cashflow_to_price": round(cfp, 4),
"components_used": len(valid),
},
)
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={},
)