268 lines
11 KiB
Python
268 lines
11 KiB
Python
"""Portfolio-level agents: Theme Substitution Engine, Position Replacement Agent.
|
|
|
|
These run after scoring, before the debate phase. They use the deep-thinking LLM
|
|
to evaluate the stock in context — is it the best expression of its theme? Should
|
|
it replace an existing holding?
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any, Dict, List
|
|
|
|
import yfinance as yf
|
|
|
|
from tradingagents.models import (
|
|
PositionReplacementOutput,
|
|
ThemeStock,
|
|
ThemeSubstitutionOutput,
|
|
invoke_structured,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _fetch_peer_basics(tickers: List[str]) -> List[dict]:
|
|
"""Fetch basic yfinance data for a list of peer tickers."""
|
|
peers = []
|
|
for sym in tickers[:8]: # cap at 8 to keep prompt manageable
|
|
try:
|
|
info = yf.Ticker(sym.upper()).info or {}
|
|
peers.append({
|
|
"ticker": sym.upper(),
|
|
"company_name": info.get("longName") or info.get("shortName") or sym,
|
|
"market_cap": info.get("marketCap"),
|
|
"current_price": info.get("currentPrice") or info.get("regularMarketPrice"),
|
|
"trailing_pe": info.get("trailingPE"),
|
|
"forward_pe": info.get("forwardPE"),
|
|
"revenue_growth": info.get("revenueGrowth"),
|
|
"profit_margins": info.get("profitMargins"),
|
|
"return_on_equity": info.get("returnOnEquity"),
|
|
"52w_range_pct": _range_pct(info),
|
|
})
|
|
except Exception:
|
|
peers.append({"ticker": sym.upper(), "error": "fetch failed"})
|
|
return peers
|
|
|
|
|
|
def _range_pct(info: dict) -> float | None:
|
|
hi = info.get("fiftyTwoWeekHigh")
|
|
lo = info.get("fiftyTwoWeekLow")
|
|
price = info.get("currentPrice") or info.get("regularMarketPrice")
|
|
if hi and lo and price and (hi - lo) > 0:
|
|
return round((price - lo) / (hi - lo) * 100, 1)
|
|
return None
|
|
|
|
|
|
def _summarize_for_theme(state: Dict[str, Any]) -> str:
|
|
"""Compact summary of the candidate stock for theme comparison."""
|
|
card = state.get("company_card") or {}
|
|
macro = state.get("macro") or {}
|
|
bq = state.get("business_quality") or {}
|
|
inst = state.get("institutional_flow") or {}
|
|
val = state.get("valuation") or {}
|
|
er = state.get("earnings_revisions") or {}
|
|
arch = state.get("archetype") or {}
|
|
|
|
return "\n".join([
|
|
f"Ticker: {card.get('ticker', '?')} | {card.get('company_name', '?')}",
|
|
f"Sector: {card.get('sector', '?')} | Industry: {card.get('industry', '?')}",
|
|
f"Market Cap: {card.get('market_cap_formatted', 'N/A')}",
|
|
f"Archetype: {arch.get('archetype', 'N/A')}",
|
|
f"Master Score: {state.get('master_score', 'N/A')}",
|
|
f"Adjusted Score: {state.get('adjusted_score', 'N/A')}",
|
|
f"Position Role: {state.get('position_role', 'N/A')}",
|
|
f"Macro Regime: {macro.get('regime_label', '?')} | Risk: {macro.get('risk_appetite', '?')} | Liq: {macro.get('liquidity_regime', '?')}",
|
|
f"Business Quality: {bq.get('score_0_to_10', 'N/A')} | Moat: {bq.get('competitive_moat', '?')}",
|
|
f"Inst Flow: {inst.get('score_0_to_10', 'N/A')} | Smart Money: {inst.get('smart_money_signal', '?')}",
|
|
f"Valuation: {val.get('score_0_to_10', 'N/A')} | Verdict: {val.get('valuation_verdict', '?')}",
|
|
f"Earnings Rev: {er.get('score_0_to_10', 'N/A')} | Direction: {er.get('eps_revision_direction', '?')}",
|
|
])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Theme Substitution Engine
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def create_theme_substitution_node(llm):
|
|
"""Identifies whether the stock is the best expression of its theme."""
|
|
|
|
def node(state: Dict[str, Any]) -> Dict[str, Any]:
|
|
ticker = state["ticker"]
|
|
card = state.get("company_card") or {}
|
|
summary = _summarize_for_theme(state)
|
|
master_score = state.get("master_score", 0)
|
|
|
|
# Use yfinance to find peers in the same industry
|
|
try:
|
|
t = yf.Ticker(ticker.upper())
|
|
info = t.info or {}
|
|
industry = info.get("industry", "")
|
|
sector = info.get("sector", "")
|
|
except Exception:
|
|
industry = card.get("industry", "")
|
|
sector = card.get("sector", "")
|
|
|
|
# Fetch competitor/peer data to ground the LLM's comparison
|
|
competitors = card.get("competitors") or []
|
|
peer_data = _fetch_peer_basics(competitors) if competitors else []
|
|
peer_summary = ""
|
|
if peer_data:
|
|
lines = []
|
|
for p in peer_data:
|
|
if p.get("error"):
|
|
continue
|
|
rg = p.get("revenue_growth")
|
|
rg_str = f"{rg*100:.1f}%" if rg else "N/A"
|
|
pm = p.get("profit_margins")
|
|
pm_str = f"{pm*100:.1f}%" if pm else "N/A"
|
|
lines.append(
|
|
f" {p['ticker']}: P/E={p.get('trailing_pe', 'N/A')}, "
|
|
f"Fwd P/E={p.get('forward_pe', 'N/A')}, "
|
|
f"RevGrowth={rg_str}, "
|
|
f"Margins={pm_str}, "
|
|
f"52W={p.get('52w_range_pct', 'N/A')}%"
|
|
)
|
|
peer_summary = "\n".join(lines)
|
|
|
|
theme_prompt = f"""You are a Theme Substitution Analyst. Your job: determine if {ticker} is the BEST
|
|
expression of its investment theme, or if better alternatives exist.
|
|
|
|
CANDIDATE STOCK:
|
|
{summary}
|
|
|
|
{f'PEER FUNDAMENTALS (live data):{chr(10)}{peer_summary}' if peer_summary else 'No live peer data available — use your knowledge of these companies.'}
|
|
|
|
INSTRUCTIONS — do this in order:
|
|
|
|
1. IDENTIFY THE THEME: What macro/sector theme does {ticker} express?
|
|
Examples: "AI infrastructure buildout", "GLP-1 obesity drugs", "defense spending ramp",
|
|
"EV supply chain", "cloud migration", "reshoring/nearshoring".
|
|
Name it clearly in theme_name.
|
|
|
|
2. LIST THEME PEERS: Name 3-6 other publicly traded stocks that express the SAME theme.
|
|
Use the peer data above if available. These should be the strongest competitors
|
|
for capital allocation in this theme.
|
|
For each peer, score master_score_estimate (0-10) based on fundamentals, momentum,
|
|
and positioning vs {ticker}.
|
|
|
|
3. RANK WITHIN THEME: Rank all stocks (including {ticker}) by investment quality.
|
|
The stock with the best combination of: business quality, valuation, momentum,
|
|
and institutional positioning should rank #1.
|
|
|
|
4. DETERMINE BEST EXPRESSION:
|
|
- Set best_expression_of_theme=true if {ticker} is rank #1 or close (#1-2).
|
|
- Set best_expression_of_theme=false if clearly better alternatives exist.
|
|
- List stronger_alternatives (tickers that rank above {ticker}).
|
|
- Set relative_score_gap: how many score points {ticker} trails the best alternative
|
|
(0 if {ticker} is best, positive number if it trails).
|
|
|
|
5. PORTFOLIO OVERLAP: Flag if {ticker} has high correlation with common holdings.
|
|
Set portfolio_overlap_warning if this stock would add redundant exposure.
|
|
|
|
Be honest and rigorous. A stock can score well absolutely but still not be the best
|
|
way to express its theme."""
|
|
|
|
try:
|
|
result = invoke_structured(llm, ThemeSubstitutionOutput, theme_prompt)
|
|
except Exception as e:
|
|
logger.warning("ThemeSubstitution LLM failed: %s", e)
|
|
result = ThemeSubstitutionOutput(
|
|
theme_name="Unknown",
|
|
best_expression_of_theme=True,
|
|
reasoning="Theme analysis unavailable",
|
|
)
|
|
|
|
return {"theme_substitution": result.model_dump()}
|
|
|
|
return node
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Position Replacement Agent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def create_position_replacement_node(llm):
|
|
"""Identifies when a new stock is a better use of capital than alternatives."""
|
|
|
|
def node(state: Dict[str, Any]) -> Dict[str, Any]:
|
|
ticker = state["ticker"]
|
|
summary = _summarize_for_theme(state)
|
|
master_score = state.get("master_score", 0)
|
|
theme = state.get("theme_substitution") or {}
|
|
|
|
# Get the strongest alternative from theme analysis
|
|
stronger = theme.get("stronger_alternatives", [])
|
|
theme_stocks = theme.get("theme_stocks_ranked", [])
|
|
theme_name = theme.get("theme_name", "Unknown")
|
|
|
|
# If no stronger alternatives, this IS the best — skip deep comparison
|
|
if not stronger and theme.get("best_expression_of_theme", True):
|
|
result = PositionReplacementOutput(
|
|
replace_candidate=ticker,
|
|
replace_with="",
|
|
score_difference=0.0,
|
|
theme_overlap=theme_name,
|
|
replacement_reason=f"{ticker} is the best expression of the '{theme_name}' theme.",
|
|
conviction_level="high",
|
|
should_replace=False,
|
|
)
|
|
return {"position_replacement": result.model_dump()}
|
|
|
|
# Format theme peers for comparison
|
|
peer_lines = []
|
|
for ts in theme_stocks[:6]:
|
|
if isinstance(ts, dict):
|
|
peer_lines.append(
|
|
f" {ts.get('ticker', '?')}: est. score {ts.get('master_score_estimate', '?')}/10 "
|
|
f"— advantage: {ts.get('key_advantage', 'N/A')}, weakness: {ts.get('key_weakness', 'N/A')}"
|
|
)
|
|
|
|
prompt = f"""You are a Position Replacement Analyst. Determine if {ticker} should be replaced
|
|
by a stronger alternative in the same theme.
|
|
|
|
CANDIDATE STOCK:
|
|
{summary}
|
|
|
|
THEME: {theme_name}
|
|
Best expression: {'Yes' if theme.get('best_expression_of_theme') else 'No'}
|
|
Score gap vs best: {theme.get('relative_score_gap', 0):.1f}
|
|
|
|
THEME PEERS:
|
|
{chr(10).join(peer_lines) or 'No peers available'}
|
|
|
|
STRONGER ALTERNATIVES: {', '.join(stronger) if stronger else 'None'}
|
|
|
|
INSTRUCTIONS:
|
|
1. Compare {ticker} to the strongest alternative in the theme.
|
|
2. Assess on these dimensions: master score, earnings revisions, institutional flow,
|
|
risk profile, valuation, entry timing.
|
|
3. Set replace_with to the best alternative ticker (empty if none).
|
|
4. Set score_difference: how much better the replacement is (positive = replacement is stronger).
|
|
5. Set conviction_level: high / medium / low.
|
|
- high: replacement is clearly better on 3+ dimensions.
|
|
- medium: replacement is better on 1-2 dimensions, mixed on others.
|
|
- low: marginal difference, keep current.
|
|
6. Set should_replace=true only if conviction_level is high.
|
|
7. List what the replacement is stronger_on and weaker_on vs {ticker}.
|
|
|
|
Be conservative. Don't recommend replacement for marginal differences."""
|
|
|
|
try:
|
|
result = invoke_structured(llm, PositionReplacementOutput, prompt)
|
|
except Exception as e:
|
|
logger.warning("PositionReplacement LLM failed: %s", e)
|
|
result = PositionReplacementOutput(
|
|
replace_candidate=ticker,
|
|
should_replace=False,
|
|
replacement_reason="Position replacement analysis unavailable",
|
|
)
|
|
|
|
result.replace_candidate = ticker
|
|
result.theme_overlap = theme_name
|
|
|
|
return {"position_replacement": result.model_dump()}
|
|
|
|
return node
|