TradingAgents/tradingagents/agents/structured/tier3.py

359 lines
13 KiB
Python

"""Tier 3 agents: Bull/Bear debate, Risk assessment, Final decision.
Only runs on stocks that pass Tier 1 + Tier 2. Uses the deep-thinking LLM
for reasoning-heavy tasks (debate, risk, final synthesis).
"""
from __future__ import annotations
import json
import logging
from typing import Any, Dict
from tradingagents.models import (
BearCaseOutput,
BullCaseOutput,
DataFlag,
DebateRefereeOutput,
FinalDecisionOutput,
RiskInvalidationOutput,
invoke_structured,
)
logger = logging.getLogger(__name__)
def _summarize_tier2(state: Dict[str, Any]) -> str:
"""Build a compact summary of all Tier 1+2 findings for Tier 3 prompts."""
card = state.get("company_card") or {}
macro = state.get("macro") or {}
liq = state.get("liquidity") or {}
bq = state.get("business_quality") or {}
inst = state.get("institutional_flow") or {}
val = state.get("valuation") or {}
et = state.get("entry_timing") or {}
er = state.get("earnings_revisions") or {}
sr = state.get("sector_rotation") or {}
bl = state.get("backlog") or {}
cr = state.get("crowding") or {}
arch = state.get("archetype") or {}
lines = [
f"Company: {card.get('company_name', '?')} ({card.get('ticker', '?')})",
f"Sector: {card.get('sector', '?')} | Industry: {card.get('industry', '?')}",
f"Market Cap: {card.get('market_cap_formatted', 'N/A')}",
f"Price: ${card.get('current_price', 'N/A')}",
f"Archetype: {arch.get('archetype', 'N/A')}",
"",
f"Master Score: {state.get('master_score', 'N/A')} | Role: {state.get('position_role', 'N/A')}",
"",
"AGENT SCORES (0-10):",
f" Business Quality: {bq.get('score_0_to_10', 'N/A')}{bq.get('summary_1_sentence', '')}",
f" Macro Alignment: {macro.get('macro_alignment_0_to_10', 'N/A')}{macro.get('summary_1_sentence', '')}",
f" Institutional Flow: {inst.get('score_0_to_10', 'N/A')}{inst.get('summary_1_sentence', '')}",
f" Valuation: {val.get('score_0_to_10', 'N/A')}{val.get('summary_1_sentence', '')}",
f" Entry Timing: {et.get('score_0_to_10', 'N/A')}{et.get('summary_1_sentence', '')}",
f" Earnings Revisions: {er.get('score_0_to_10', 'N/A')}{er.get('summary_1_sentence', '')}",
f" Sector Rotation: {sr.get('score_0_to_10', 'N/A')}{sr.get('summary_1_sentence', '')}",
f" Backlog: {bl.get('score_0_to_10', 'N/A')}{bl.get('summary_1_sentence', '')}",
f" Crowding: {cr.get('score_0_to_10', 'N/A')}{cr.get('summary_1_sentence', '')}",
f" Liquidity: {liq.get('score_0_to_10', 'N/A')}{liq.get('summary_1_sentence', '')}",
"",
f" Macro Regime: {macro.get('regime_label', '?')} | VIX: {macro.get('vix_level', '?')}",
f" Moat: {bq.get('competitive_moat', '?')} | Valuation: {val.get('valuation_verdict', '?')}",
f" Accumulation: {inst.get('accumulation_signal', '?')} | Timing: {et.get('timing_verdict', '?')}",
]
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Bull Case
# ---------------------------------------------------------------------------
def create_bull_case_node(llm):
def node(state: Dict[str, Any]) -> Dict[str, Any]:
ticker = state["ticker"]
summary = _summarize_tier2(state)
prompt = f"""You are a Bull Case Researcher. Build the strongest possible bullish thesis for {ticker}.
{summary}
INSTRUCTIONS:
1. Write a concise thesis (2-3 sentences) for why this stock should be bought.
2. List 3-5 specific catalysts that could drive the stock higher.
3. Estimate upside_target (price) and upside_pct from current price.
4. List key assumptions your thesis depends on.
5. List thesis_invalidation_triggers — what would kill the bull case.
6. Set confidence 0-1 for how strong the bull case is.
Attack the investment aggressively. Find every reason to be bullish.
But be honest — don't fabricate catalysts. Use the data above."""
try:
result = invoke_structured(llm, BullCaseOutput, prompt)
except Exception as e:
logger.warning("BullCase LLM failed: %s", e)
result = BullCaseOutput(
thesis="Bull case analysis unavailable",
confidence_0_to_1=0.1,
)
return {"bull_case": result.model_dump()}
return node
# ---------------------------------------------------------------------------
# Bear Case
# ---------------------------------------------------------------------------
def create_bear_case_node(llm):
def node(state: Dict[str, Any]) -> Dict[str, Any]:
ticker = state["ticker"]
summary = _summarize_tier2(state)
prompt = f"""You are a Bear Case Researcher. Build the strongest possible bearish thesis for {ticker}.
{summary}
INSTRUCTIONS:
1. Write a concise thesis (2-3 sentences) for why this stock should be avoided or sold.
2. List 3-5 specific risks that could drive the stock lower.
3. Estimate downside_target (price) and downside_pct from current price.
4. List key assumptions your bear thesis depends on.
5. List thesis_invalidation_triggers — what would kill the bear case.
6. Set confidence 0-1 for how strong the bear case is.
Be ruthless. Find every vulnerability, every overvaluation, every risk.
But be honest — don't fabricate risks. Use the data above."""
try:
result = invoke_structured(llm, BearCaseOutput, prompt)
except Exception as e:
logger.warning("BearCase LLM failed: %s", e)
result = BearCaseOutput(
thesis="Bear case analysis unavailable",
confidence_0_to_1=0.1,
)
return {"bear_case": result.model_dump()}
return node
# ---------------------------------------------------------------------------
# Debate Referee
# ---------------------------------------------------------------------------
def create_debate_node(llm):
"""Referee that evaluates bull vs bear case."""
def node(state: Dict[str, Any]) -> Dict[str, Any]:
ticker = state["ticker"]
bull = state.get("bull_case") or {}
bear = state.get("bear_case") or {}
prompt = f"""You are the Debate Referee. Evaluate the bull vs bear case for {ticker}.
BULL CASE (confidence: {bull.get('confidence_0_to_1', 'N/A')}):
Thesis: {bull.get('thesis', 'N/A')}
Catalysts: {', '.join(bull.get('catalysts', []))}
Upside: {bull.get('upside_pct', 'N/A')}%
Invalidation: {', '.join(bull.get('thesis_invalidation_triggers', []))}
BEAR CASE (confidence: {bear.get('confidence_0_to_1', 'N/A')}):
Thesis: {bear.get('thesis', 'N/A')}
Risks: {', '.join(bear.get('risks', []))}
Downside: {bear.get('downside_pct', 'N/A')}%
Invalidation: {', '.join(bear.get('thesis_invalidation_triggers', []))}
MASTER SCORE: {state.get('master_score', 'N/A')} | ROLE: {state.get('position_role', 'N/A')}
INSTRUCTIONS:
1. Declare winner: "bull" or "bear".
2. Score each side 0-10 on argument strength.
3. List key unresolved questions.
4. Set net_conviction_adjustment (-2 to +2) to modify the master score.
Positive = debate strengthened the bull case. Negative = weakened it.
5. Provide reasoning for your decision."""
try:
result = invoke_structured(llm, DebateRefereeOutput, prompt)
except Exception as e:
logger.warning("Debate LLM failed: %s", e)
result = DebateRefereeOutput()
return {"debate": result.model_dump()}
return node
# ---------------------------------------------------------------------------
# Risk / Invalidation
# ---------------------------------------------------------------------------
def create_risk_node(llm):
def node(state: Dict[str, Any]) -> Dict[str, Any]:
ticker = state["ticker"]
summary = _summarize_tier2(state)
bull = state.get("bull_case") or {}
bear = state.get("bear_case") or {}
debate = state.get("debate") or {}
prompt = f"""You are the Risk / Invalidation Analyst. Final risk gate for {ticker}.
{summary}
DEBATE OUTCOME: {debate.get('winner', '?')} won
Bull strength: {debate.get('bull_strength_0_to_10', '?')}/10
Bear strength: {debate.get('bear_strength_0_to_10', '?')}/10
Conviction adjustment: {debate.get('net_conviction_adjustment', 0)}
Bear risks: {', '.join(bear.get('risks', []))}
Bull invalidation triggers: {', '.join(bull.get('thesis_invalidation_triggers', []))}
INSTRUCTIONS:
1. Classify overall_risk_level: low / medium / high.
2. Set max_position_size_pct (0-100). Low risk = up to 10%. High risk = max 2%.
3. Suggest stop_loss_pct (distance from entry to stop).
4. List invalidation_triggers — concrete events that should trigger exit.
5. Score overall risk-reward 0-10 (10 = great risk/reward).
6. Set veto=true ONLY if you find impossible/fraudulent data, or risk is so extreme
that no position should be taken. This is a hard kill switch.
7. Be concise."""
try:
result = invoke_structured(llm, RiskInvalidationOutput, prompt)
except Exception as e:
logger.warning("Risk LLM failed: %s", e)
result = RiskInvalidationOutput(
score_0_to_10=5.0, confidence_0_to_1=0.3,
summary_1_sentence="Risk analysis unavailable",
)
flags = [f.model_dump() for f in result.data_quality_flags]
update: Dict[str, Any] = {"risk": result.model_dump(), "global_flags": flags}
if result.veto:
update["hard_veto"] = True
update["hard_veto_reason"] = result.veto_reason
return update
return node
# ---------------------------------------------------------------------------
# Final Decision (prose generated AFTER all scoring)
# ---------------------------------------------------------------------------
def create_final_decision_node(llm):
def node(state: Dict[str, Any]) -> Dict[str, Any]:
ticker = state["ticker"]
card = state.get("company_card") or {}
summary = _summarize_tier2(state)
bull = state.get("bull_case") or {}
bear = state.get("bear_case") or {}
debate = state.get("debate") or {}
risk = state.get("risk") or {}
master_score = state.get("master_score", 0)
adjusted_score = state.get("adjusted_score", 0)
position_role = state.get("position_role", "Avoid")
conviction_adj = debate.get("net_conviction_adjustment", 0)
# Apply debate conviction adjustment
final_score = round(adjusted_score + conviction_adj, 2)
final_role = _role_from_score(final_score)
# Determine action
if state.get("hard_veto"):
action = "AVOID"
final_role = "Avoid"
final_score = 0.0
elif final_score >= 70:
action = "BUY"
elif final_score >= 50:
action = "HOLD"
else:
action = "AVOID"
prompt = f"""You are the Final Decision Synthesizer for {ticker}.
{summary}
DEBATE: {debate.get('winner', '?')} won | Conviction adjustment: {conviction_adj:+.1f}
RISK: {risk.get('overall_risk_level', '?')} | Max position: {risk.get('max_position_size_pct', '?')}%
FINAL SCORES:
Master Score: {master_score}
Adjusted Score: {adjusted_score} (after data quality penalties)
Post-Debate Score: {final_score} (after conviction adjustment)
Position Role: {final_role}
Action: {action}
INSTRUCTIONS:
Write a concise narrative (3-5 sentences) that:
1. Summarizes the investment thesis.
2. Highlights the top 2-3 catalysts and top 2-3 risks.
3. States the action ({action}) and position role ({final_role}).
4. Notes what would change the thesis (invalidation triggers).
Also provide:
- thesis_summary (one sentence)
- key_catalysts (top 3 from bull case)
- key_risks (top 3 from bear case)
- invalidation_triggers (from risk agent)
- position_sizing_pct (from risk agent)
- confidence (average of all agent confidences)"""
try:
result = invoke_structured(llm, FinalDecisionOutput, prompt)
except Exception as e:
logger.warning("FinalDecision LLM failed: %s", e)
result = FinalDecisionOutput()
# Override with computed values (deterministic, not LLM-driven)
result.ticker = ticker
result.company_name = card.get("company_name", "")
result.master_score = master_score
result.adjusted_score = final_score
result.position_role = final_role
result.action = action
result.risk_level = risk.get("overall_risk_level", "medium")
result.position_sizing_pct = risk.get("max_position_size_pct", 0)
# Compute aggregate confidence
agents_with_confidence = [
state.get(k, {}).get("confidence_0_to_1")
for k in (
"macro", "liquidity", "business_quality", "institutional_flow",
"valuation", "entry_timing", "earnings_revisions",
"sector_rotation", "backlog", "crowding",
)
]
valid_confs = [c for c in agents_with_confidence if c is not None]
result.confidence = round(sum(valid_confs) / len(valid_confs), 2) if valid_confs else 0.5
return {"final_decision": result.model_dump()}
return node
def _role_from_score(score: float) -> str:
if score > 80:
return "Core Position"
if score > 70:
return "Strong Position"
if score > 60:
return "Tactical / Satellite"
if score > 50:
return "Watchlist"
return "Avoid"