TradingAgents/tradingagents/agents/structured/tier3.py

419 lines
16 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 _low_confidence_warnings(state: Dict[str, Any]) -> str:
"""Check if any Tier 2 agents have confidence < 0.2 and return warnings."""
_TIER2_FIELDS = {
"business_quality": "Business Quality",
"institutional_flow": "Institutional Flow",
"valuation": "Valuation",
"entry_timing": "Entry Timing",
"earnings_revisions": "Earnings Revisions",
"sector_rotation": "Sector Rotation",
"backlog": "Backlog / Order Momentum",
"crowding": "Narrative Crowding",
}
warnings = []
for field, display_name in _TIER2_FIELDS.items():
agent_data = state.get(field) or {}
conf = agent_data.get("confidence_0_to_1")
if conf is not None and conf < 0.2:
warnings.append(
f" WARNING: {display_name} has low confidence ({conf:.2f}) — "
f"its score may be unreliable (fallback defaults or poor data)"
)
if warnings:
return "\nDATA QUALITY WARNINGS:\n" + "\n".join(warnings) + "\n"
return ""
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 {}
# Check for low-confidence Tier 2 agents
confidence_warnings = _low_confidence_warnings(state)
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" Risk Appetite: {macro.get('risk_appetite', '?')} | Liquidity Regime: {macro.get('liquidity_regime', '?')}",
f" Regime Score Adjustment: {macro.get('regime_score_adjustment', 0):+.1f}",
f" Moat: {bq.get('competitive_moat', '?')} | Valuation: {val.get('valuation_verdict', '?')}",
f" Smart Money: {inst.get('smart_money_signal', '?')} | Accumulation: {inst.get('accumulation_signal', '?')}",
f" Short Trend: {inst.get('short_interest_trend', '?')} | Insider Signal: {inst.get('insider_transaction_signal', '?')}",
f" Timing: {et.get('timing_verdict', '?')}",
]
if confidence_warnings:
lines.append("")
lines.append(confidence_warnings)
lines.append("Factor these warnings into your analysis — low-confidence scores may not reflect reality.")
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 {}
theme = state.get("theme_substitution") or {}
replacement = state.get("position_replacement") 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"
# Theme/replacement context
theme_lines = ""
if theme.get("theme_name"):
theme_lines = (
f"\nTHEME CONTEXT:"
f"\n Theme: {theme.get('theme_name', '?')}"
f"\n Best expression: {'Yes' if theme.get('best_expression_of_theme') else 'No'}"
f"\n Stronger alternatives: {', '.join(theme.get('stronger_alternatives', [])) or 'None'}"
f"\n Score gap vs best: {theme.get('relative_score_gap', 0):.1f}"
)
if replacement.get("should_replace"):
theme_lines += (
f"\n REPLACEMENT FLAG: Consider {replacement.get('replace_with', '?')} instead"
f"\n Reason: {replacement.get('replacement_reason', '')}"
)
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', '?')}%
{theme_lines}
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).
5. If theme analysis found stronger alternatives, mention them and whether
this stock is still the best expression of the theme.
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"