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