fix: 10 reliability and observability fixes for trading pipeline

invoke_structured() catches ValidationError with safe defaults, ticker validation
(empty/length), 60s per-LLM-call timeout, event buffer capped at 5000, recursion
limit 50→25, tier 2 low-confidence DataFlags, tier 3 upstream confidence checks,
heartbeat JSON every 15s, data source attribution in all prompts, structured logging
replaces print()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dtarkent2-sys 2026-03-10 21:08:01 +00:00
parent fe41a2dad9
commit 1d3f5e9c86
5 changed files with 263 additions and 108 deletions

82
app.py
View File

@ -1,5 +1,6 @@
"""FastAPI SSE backend for the structured equity ranking engine.""" """FastAPI SSE backend for the structured equity ranking engine."""
import logging
import os import os
import re import re
import time import time
@ -10,6 +11,12 @@ import traceback as _tb
from datetime import date from datetime import date
from fastapi import FastAPI, HTTPException, Request, Depends from fastapi import FastAPI, HTTPException, Request, Depends
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger(__name__)
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
from sse_starlette.sse import EventSourceResponse from sse_starlette.sse import EventSourceResponse
@ -51,9 +58,21 @@ async def verify_api_key(request: Request):
MAX_CONCURRENT = int(os.getenv("MAX_CONCURRENT_ANALYSES", "3")) MAX_CONCURRENT = int(os.getenv("MAX_CONCURRENT_ANALYSES", "3"))
_semaphore = asyncio.Semaphore(MAX_CONCURRENT) _semaphore = asyncio.Semaphore(MAX_CONCURRENT)
# --- Event buffer cap ---
MAX_EVENTS_PER_ANALYSIS = 5000
analyses: dict[str, dict] = {} analyses: dict[str, dict] = {}
def _append_event(state: dict, evt: dict):
"""Append an event to the analysis state, enforcing the buffer cap."""
events = state["events"]
events.append(evt)
if len(events) > MAX_EVENTS_PER_ANALYSIS:
# Drop oldest events, keep the last MAX_EVENTS_PER_ANALYSIS
state["events"] = events[-MAX_EVENTS_PER_ANALYSIS:]
class AnalyzeRequest(BaseModel): class AnalyzeRequest(BaseModel):
ticker: str ticker: str
date: str | None = None date: str | None = None
@ -74,12 +93,10 @@ def build_config():
"fundamental_data": "yfinance", "fundamental_data": "yfinance",
"news_data": "yfinance", "news_data": "yfinance",
} }
print( logger.info(
f"[CONFIG] provider={config['llm_provider']}, " "config_built provider=%s deep=%s quick=%s url=%s",
f"deep={config['deep_think_llm']}, " config['llm_provider'], config['deep_think_llm'],
f"quick={config['quick_think_llm']}, " config['quick_think_llm'], config['backend_url'],
f"url={config['backend_url']}",
flush=True,
) )
return config return config
@ -129,13 +146,14 @@ async def _run_analysis_inner(analysis_id: str, ticker: str, trade_date: str):
try: try:
graph = TradingAgentsGraph(debug=False, config=config) graph = TradingAgentsGraph(debug=False, config=config)
print( logger.info(
f"[ANALYSIS] LLM types: deep={type(graph.deep_thinking_llm).__name__}, " "analysis_init_ok deep_llm=%s quick_llm=%s analysis_id=%s",
f"quick={type(graph.quick_thinking_llm).__name__}", type(graph.deep_thinking_llm).__name__,
flush=True, type(graph.quick_thinking_llm).__name__,
analysis_id,
) )
except Exception as e: except Exception as e:
print(f"[ANALYSIS] Init failed: {e}\n{_tb.format_exc()}", flush=True) logger.error("analysis_init_failed analysis_id=%s error=%s\n%s", analysis_id, e, _tb.format_exc())
await q.put({"type": "error", "message": f"Init failed: {e}"}) await q.put({"type": "error", "message": f"Init failed: {e}"})
await q.put(None) await q.put(None)
return return
@ -156,14 +174,14 @@ async def _run_analysis_inner(analysis_id: str, ticker: str, trade_date: str):
"status": "pending", "status": "pending",
"stats": _stats(start_time, emitted_fields), "stats": _stats(start_time, emitted_fields),
} }
state["events"].append(evt) _append_event(state, evt)
await q.put(evt) await q.put(evt)
try: try:
async for chunk in graph.graph.astream( async for chunk in graph.graph.astream(
init_state, init_state,
stream_mode="values", stream_mode="values",
config={"recursion_limit": 50}, config={"recursion_limit": 25},
): ):
final_state = chunk final_state = chunk
@ -188,7 +206,7 @@ async def _run_analysis_inner(analysis_id: str, ticker: str, trade_date: str):
"status": "completed", "status": "completed",
"stats": st, "stats": st,
} }
state["events"].append(evt) _append_event(state, evt)
await q.put(evt) await q.put(evt)
# Emit report data for key fields # Emit report data for key fields
@ -201,7 +219,7 @@ async def _run_analysis_inner(analysis_id: str, ticker: str, trade_date: str):
"report": _format_report(field, value), "report": _format_report(field, value),
"stats": st, "stats": st,
} }
state["events"].append(evt) _append_event(state, evt)
await q.put(evt) await q.put(evt)
elif field == "debate": elif field == "debate":
@ -216,7 +234,7 @@ async def _run_analysis_inner(analysis_id: str, ticker: str, trade_date: str):
"winner": (value or {}).get("winner", ""), "winner": (value or {}).get("winner", ""),
"stats": st, "stats": st,
} }
state["events"].append(evt) _append_event(state, evt)
await q.put(evt) await q.put(evt)
elif field == "master_score": elif field == "master_score":
@ -228,16 +246,16 @@ async def _run_analysis_inner(analysis_id: str, ticker: str, trade_date: str):
"position_role": chunk.get("position_role"), "position_role": chunk.get("position_role"),
"stats": st, "stats": st,
} }
state["events"].append(evt) _append_event(state, evt)
await q.put(evt) await q.put(evt)
# Mark in-progress agents for upcoming stages # Mark in-progress agents for upcoming stages
await _update_in_progress(chunk, emitted_fields, prev_agent_statuses, state, q, start_time) await _update_in_progress(chunk, emitted_fields, prev_agent_statuses, state, q, start_time)
except Exception as e: except Exception as e:
print(f"[ANALYSIS] Stream error: {e}\n{_tb.format_exc()}", flush=True) logger.error("analysis_stream_error analysis_id=%s error=%s\n%s", analysis_id, e, _tb.format_exc())
evt = {"type": "error", "message": str(e)} evt = {"type": "error", "message": str(e)}
state["events"].append(evt) _append_event(state, evt)
await q.put(evt) await q.put(evt)
state["done"] = True state["done"] = True
await q.put(None) await q.put(None)
@ -260,7 +278,7 @@ async def _run_analysis_inner(analysis_id: str, ticker: str, trade_date: str):
"status": "completed", "status": "completed",
"stats": st, "stats": st,
} }
state["events"].append(evt) _append_event(state, evt)
await q.put(evt) await q.put(evt)
evt = { evt = {
@ -274,7 +292,7 @@ async def _run_analysis_inner(analysis_id: str, ticker: str, trade_date: str):
"final_decision": decision, "final_decision": decision,
"stats": st, "stats": st,
} }
state["events"].append(evt) _append_event(state, evt)
await q.put(evt) await q.put(evt)
state["done"] = True state["done"] = True
@ -296,7 +314,7 @@ async def _update_in_progress(chunk, emitted, statuses, state, q, start_time):
"status": "in_progress", "status": "in_progress",
"stats": _stats(start_time, emitted), "stats": _stats(start_time, emitted),
} }
state["events"].append(evt) _append_event(state, evt)
await q.put(evt) await q.put(evt)
# If tier 1 done, mark tier 2 in_progress # If tier 1 done, mark tier 2 in_progress
@ -317,7 +335,7 @@ async def _update_in_progress(chunk, emitted, statuses, state, q, start_time):
"status": "in_progress", "status": "in_progress",
"stats": _stats(start_time, emitted), "stats": _stats(start_time, emitted),
} }
state["events"].append(evt) _append_event(state, evt)
await q.put(evt) await q.put(evt)
# If scoring done, mark portfolio analysis in_progress # If scoring done, mark portfolio analysis in_progress
@ -333,7 +351,7 @@ async def _update_in_progress(chunk, emitted, statuses, state, q, start_time):
"status": "in_progress", "status": "in_progress",
"stats": _stats(start_time, emitted), "stats": _stats(start_time, emitted),
} }
state["events"].append(evt) _append_event(state, evt)
await q.put(evt) await q.put(evt)
@ -367,9 +385,9 @@ async def run_analysis(analysis_id: str, ticker: str, trade_date: str):
timeout=3600, timeout=3600,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
print(f"[ANALYSIS] Timeout for {analysis_id}", flush=True) logger.warning("analysis_timeout analysis_id=%s", analysis_id)
evt = {"type": "error", "message": "Analysis timed out after 60 minutes"} evt = {"type": "error", "message": "Analysis timed out after 60 minutes"}
state["events"].append(evt) _append_event(state, evt)
await q.put(evt) await q.put(evt)
state["done"] = True state["done"] = True
await q.put(None) await q.put(None)
@ -384,7 +402,7 @@ async def _cleanup_loop():
for aid in expired: for aid in expired:
analyses.pop(aid, None) analyses.pop(aid, None)
if expired: if expired:
print(f"[CLEANUP] Removed {len(expired)} expired analyses", flush=True) logger.info("cleanup_expired count=%d", len(expired))
@app.on_event("startup") @app.on_event("startup")
@ -397,8 +415,12 @@ async def _start_cleanup():
@app.post("/analyze", dependencies=[Depends(verify_api_key)]) @app.post("/analyze", dependencies=[Depends(verify_api_key)])
async def start_analysis(req: AnalyzeRequest): async def start_analysis(req: AnalyzeRequest):
ticker = req.ticker.upper().strip() ticker = req.ticker.upper().strip()
if not ticker or not re.match(r'^[A-Z0-9.\-]{1,6}$', ticker): if not ticker:
raise HTTPException(400, "Invalid ticker") raise HTTPException(400, "Ticker must not be empty")
if len(ticker) > 10:
raise HTTPException(400, f"Ticker too long ({len(ticker)} chars, max 10)")
if not re.match(r'^[A-Z0-9.\-]{1,10}$', ticker):
raise HTTPException(400, "Invalid ticker — only letters, digits, dots, and hyphens allowed")
trade_date = req.date or str(date.today()) trade_date = req.date or str(date.today())
analysis_id = str(uuid.uuid4()) analysis_id = str(uuid.uuid4())
analyses[analysis_id] = { analyses[analysis_id] = {
@ -431,7 +453,7 @@ async def stream_analysis(analysis_id: str, last_event: int = 0):
try: try:
event = await asyncio.wait_for(q.get(), timeout=15) event = await asyncio.wait_for(q.get(), timeout=15)
except asyncio.TimeoutError: except asyncio.TimeoutError:
yield {"event": "heartbeat", "data": ""} yield {"event": "heartbeat", "data": json.dumps({"type": "heartbeat"})}
continue continue
if event is None: if event is None:
break break

View File

@ -170,16 +170,18 @@ def create_macro_node(llm):
Ticker: {ticker} | Sector: {sector} Ticker: {ticker} | Sector: {sector}
MACRO DATA: MACRO DATA (source: yfinance):
- VIX: {macro_data.get('vix_level', 'N/A')} - VIX: {macro_data.get('vix_level', 'N/A')} (source: yfinance)
- 10Y Yield: {macro_data.get('ten_year_yield', 'N/A')}% - 10Y Yield: {macro_data.get('ten_year_yield', 'N/A')}% (source: yfinance)
- Dollar 1M: {macro_data.get('dollar_1m_return', 'N/A')}% - Dollar 1M: {macro_data.get('dollar_1m_return', 'N/A')}% (source: yfinance)
- Credit Spreads: {macro_data.get('credit_spread_direction', 'N/A')} - Credit Spreads: {macro_data.get('credit_spread_direction', 'N/A')} (source: yfinance)
- SPY 1M: {spy_perf.get('return_1m', 'N/A')}% - SPY 1M: {spy_perf.get('return_1m', 'N/A')}% (source: yfinance)
SECTOR PERFORMANCE (1M): SECTOR PERFORMANCE (1M, source: yfinance):
{chr(10).join(sector_lines[:12]) or 'N/A'} {chr(10).join(sector_lines[:12]) or 'N/A'}
NOTE: If a metric shows 'N/A' or 'unknown', say 'data unavailable' rather than guessing.
INSTRUCTIONS: INSTRUCTIONS:
1. Classify risk_appetite: "risk-on" / "risk-off" / "transitional". 1. Classify risk_appetite: "risk-on" / "risk-off" / "transitional".
- risk-on: VIX low, spreads tight, SPY up, breadth strong. - risk-on: VIX low, spreads tight, SPY up, breadth strong.
@ -244,11 +246,13 @@ def create_liquidity_node(llm):
Ticker: {ticker} | Sector: {card.get('sector', 'Unknown')} Ticker: {ticker} | Sector: {card.get('sector', 'Unknown')}
AVAILABLE DATA: AVAILABLE DATA (source: yfinance macro API):
- VIX: {macro_data.get('vix_level', 'N/A')} - VIX: {macro_data.get('vix_level', 'N/A')} (source: yfinance)
- 10Y Yield: {macro_data.get('ten_year_yield', 'N/A')}% - 10Y Yield: {macro_data.get('ten_year_yield', 'N/A')}% (source: yfinance)
- Credit Spreads: {macro_data.get('credit_spread_direction', 'N/A')} - Credit Spreads: {macro_data.get('credit_spread_direction', 'N/A')} (source: yfinance)
- Dollar Strength: {macro_data.get('dollar_strength', 'N/A')} - Dollar Strength: {macro_data.get('dollar_strength', 'N/A')} (source: yfinance)
NOTE: If a metric shows 'N/A' or 'unknown', say 'data unavailable' rather than guessing.
INSTRUCTIONS: INSTRUCTIONS:
1. Assess Fed stance (dovish / neutral / hawkish) based on yield environment. 1. Assess Fed stance (dovish / neutral / hawkish) based on yield environment.

View File

@ -59,15 +59,17 @@ def create_business_quality_node(llm):
Ticker: {ticker} | Sector: {card.get('sector', 'Unknown')} | Industry: {card.get('industry', 'Unknown')} Ticker: {ticker} | Sector: {card.get('sector', 'Unknown')} | Industry: {card.get('industry', 'Unknown')}
Market Cap: {card.get('market_cap_formatted', 'N/A')} Market Cap: {card.get('market_cap_formatted', 'N/A')}
FINANCIALS: FINANCIALS (source: yfinance):
- Revenue Growth: {_pct(_safe(info, 'revenueGrowth'))} - Revenue Growth: {_pct(_safe(info, 'revenueGrowth'))} (source: yfinance)
- Profit Margins: {_pct(_safe(info, 'profitMargins'))} - Profit Margins: {_pct(_safe(info, 'profitMargins'))} (source: yfinance)
- Operating Margins: {_pct(_safe(info, 'operatingMargins'))} - Operating Margins: {_pct(_safe(info, 'operatingMargins'))} (source: yfinance)
- ROE: {_pct(_safe(info, 'returnOnEquity'))} - ROE: {_pct(_safe(info, 'returnOnEquity'))} (source: yfinance)
- ROA: {_pct(_safe(info, 'returnOnAssets'))} - ROA: {_pct(_safe(info, 'returnOnAssets'))} (source: yfinance)
- Debt/Equity: {_safe(info, 'debtToEquity', 'N/A')} - Debt/Equity: {_safe(info, 'debtToEquity', 'N/A')} (source: yfinance)
- Free Cash Flow: {_safe(info, 'freeCashflow', 'N/A')} - Free Cash Flow: {_safe(info, 'freeCashflow', 'N/A')} (source: yfinance)
- Current Ratio: {_safe(info, 'currentRatio', 'N/A')} - Current Ratio: {_safe(info, 'currentRatio', 'N/A')} (source: yfinance)
NOTE: If a metric shows 'N/A' or 'unknown', say 'data unavailable' rather than guessing.
INSTRUCTIONS: INSTRUCTIONS:
1. Score business quality 0-10 based on margins, growth, returns, balance sheet. 1. Score business quality 0-10 based on margins, growth, returns, balance sheet.
@ -82,6 +84,10 @@ INSTRUCTIONS:
result = BusinessQualityOutput( result = BusinessQualityOutput(
score_0_to_10=5.0, confidence_0_to_1=0.1, score_0_to_10=5.0, confidence_0_to_1=0.1,
summary_1_sentence="Business quality analysis unavailable", summary_1_sentence="Business quality analysis unavailable",
data_quality_flags=[
DataFlag(field="business_quality", severity="moderate",
message="Tier 2 analysis used fallback defaults due to LLM failure")
],
) )
# Override with actual data # Override with actual data
@ -129,27 +135,29 @@ Your job: track real smart-money movement — not just static ownership percenta
Ticker: {ticker} Ticker: {ticker}
OWNERSHIP & VOLUME: OWNERSHIP & VOLUME (source: yfinance):
- Institutional Ownership: {data.get('held_percent_institutions', 'N/A')}% - Institutional Ownership: {data.get('held_percent_institutions', 'N/A')}% (source: yfinance)
- Insider Ownership: {data.get('held_percent_insiders', 'N/A')}% - Insider Ownership: {data.get('held_percent_insiders', 'N/A')}% (source: yfinance)
- Volume Ratio (10d/avg): {data.get('volume_ratio', 'N/A')} - Volume Ratio (10d/avg): {data.get('volume_ratio', 'N/A')} (source: yfinance)
- Short % of Float: {data.get('short_pct_of_float', 'N/A')}% - Short % of Float: {data.get('short_pct_of_float', 'N/A')}% (source: yfinance)
- Short Ratio (days): {data.get('short_ratio', 'N/A')} - Short Ratio (days): {data.get('short_ratio', 'N/A')} (source: yfinance)
- Float Turnover 5d: {data.get('float_turnover_5d_pct', 'N/A')}% - Float Turnover 5d: {data.get('float_turnover_5d_pct', 'N/A')}% (source: yfinance)
SHORT INTEREST TREND: SHORT INTEREST TREND (source: yfinance):
- Short Interest Change (vs prior month): {data.get('short_interest_change_pct', 'N/A')}% - Short Interest Change (vs prior month): {data.get('short_interest_change_pct', 'N/A')}%
- Short Interest Trend: {data.get('short_interest_trend', 'N/A')} - Short Interest Trend: {data.get('short_interest_trend', 'N/A')}
TOP INSTITUTIONAL HOLDERS (13F): TOP INSTITUTIONAL HOLDERS (13F, source: yfinance):
{chr(10).join(holder_lines) or ' No data available'} {chr(10).join(holder_lines) or ' No data available'}
- Total top holders tracked: {data.get('top_holders_count', 'N/A')} - Total top holders tracked: {data.get('top_holders_count', 'N/A')}
INSIDER TRANSACTIONS (recent): INSIDER TRANSACTIONS (recent, source: yfinance):
- Insider Buys: {data.get('insider_buys_recent', 'N/A')} - Insider Buys: {data.get('insider_buys_recent', 'N/A')}
- Insider Sells: {data.get('insider_sells_recent', 'N/A')} - Insider Sells: {data.get('insider_sells_recent', 'N/A')}
- Insider Signal: {data.get('insider_transaction_signal', 'N/A')} - Insider Signal: {data.get('insider_transaction_signal', 'N/A')}
NOTE: If a metric shows 'N/A' or 'unknown', say 'data unavailable' rather than guessing.
INSTRUCTIONS: INSTRUCTIONS:
1. Score institutional flow signal 0-10 (this has 15% weight make it count). 1. Score institutional flow signal 0-10 (this has 15% weight make it count).
High ownership + rising volume + low short interest + insider buying = bullish. High ownership + rising volume + low short interest + insider buying = bullish.
@ -171,6 +179,10 @@ INSTRUCTIONS:
result = InstitutionalFlowOutput( result = InstitutionalFlowOutput(
score_0_to_10=5.0, confidence_0_to_1=0.1, score_0_to_10=5.0, confidence_0_to_1=0.1,
summary_1_sentence="Institutional flow analysis unavailable", summary_1_sentence="Institutional flow analysis unavailable",
data_quality_flags=[
DataFlag(field="institutional_flow", severity="moderate",
message="Tier 2 analysis used fallback defaults due to LLM failure")
],
) )
# Override with actual fetched data # Override with actual fetched data
@ -212,16 +224,18 @@ def create_valuation_node(llm):
Ticker: {ticker} Ticker: {ticker}
VALUATION METRICS: VALUATION METRICS (source: yfinance):
- Trailing P/E: {data.get('trailing_pe', 'N/A')} - Trailing P/E: {data.get('trailing_pe', 'N/A')} (source: yfinance)
- Forward P/E: {data.get('forward_pe', 'N/A')} - Forward P/E: {data.get('forward_pe', 'N/A')} (source: yfinance)
- PEG Ratio: {data.get('peg_ratio', 'N/A')} - PEG Ratio: {data.get('peg_ratio', 'N/A')} (source: yfinance)
- P/B: {data.get('price_to_book', 'N/A')} - P/B: {data.get('price_to_book', 'N/A')} (source: yfinance)
- EV/EBITDA: {data.get('ev_to_ebitda', 'N/A')} - EV/EBITDA: {data.get('ev_to_ebitda', 'N/A')} (source: yfinance)
- P/S: {data.get('price_to_sales', 'N/A')} - P/S: {data.get('price_to_sales', 'N/A')} (source: yfinance)
- 52W Range Position: {data.get('vs_52w_range_pct', 'N/A')}% - 52W Range Position: {data.get('vs_52w_range_pct', 'N/A')}% (source: yfinance)
- Revenue Growth: {data.get('revenue_growth', 'N/A')} - Revenue Growth: {data.get('revenue_growth', 'N/A')} (source: yfinance)
- Earnings Growth: {data.get('earnings_growth', 'N/A')} - Earnings Growth: {data.get('earnings_growth', 'N/A')} (source: yfinance)
NOTE: If a metric shows 'N/A' or 'unknown', say 'data unavailable' rather than guessing.
INSTRUCTIONS: INSTRUCTIONS:
1. Score valuation attractiveness 0-10. 1. Score valuation attractiveness 0-10.
@ -236,6 +250,10 @@ INSTRUCTIONS:
result = ValuationOutput( result = ValuationOutput(
score_0_to_10=5.0, confidence_0_to_1=0.1, score_0_to_10=5.0, confidence_0_to_1=0.1,
summary_1_sentence="Valuation analysis unavailable", summary_1_sentence="Valuation analysis unavailable",
data_quality_flags=[
DataFlag(field="valuation", severity="moderate",
message="Tier 2 analysis used fallback defaults due to LLM failure")
],
) )
result.trailing_pe = data.get("trailing_pe") result.trailing_pe = data.get("trailing_pe")
@ -299,19 +317,23 @@ def create_entry_timing_node(llm):
if ma50 and ma200: if ma50 and ma200:
ma_rel = "above" if ma50 > ma200 else "below" ma_rel = "above" if ma50 > ma200 else "below"
_timing_source = "Alpaca" if price is not None and ma50 is not None else "yfinance"
prompt = f"""You are an Entry Timing Analyst in a structured equity ranking pipeline. prompt = f"""You are an Entry Timing Analyst in a structured equity ranking pipeline.
Ticker: {ticker} Ticker: {ticker}
TECHNICALS: TECHNICALS (source: {_timing_source}):
- Price: ${price or 'N/A'} - Price: ${price or 'N/A'} (source: {_timing_source})
- 50-day MA: ${ma50 or 'N/A'} - 50-day MA: ${ma50 or 'N/A'} (source: {_timing_source})
- 200-day MA: ${ma200 or 'N/A'} - 200-day MA: ${ma200 or 'N/A'} (source: {_timing_source})
- 50d vs 200d: {ma_rel} - 50d vs 200d: {ma_rel}
- 52W High: ${hi52 or 'N/A'} - 52W High: ${hi52 or 'N/A'} (source: {_timing_source})
- 52W Low: ${lo52 or 'N/A'} - 52W Low: ${lo52 or 'N/A'} (source: {_timing_source})
- Position in 52W Range: {range_pct or 'N/A'}% - Position in 52W Range: {range_pct or 'N/A'}%
NOTE: If a metric shows 'N/A' or 'unknown', say 'data unavailable' rather than guessing.
INSTRUCTIONS: INSTRUCTIONS:
1. Score entry timing 0-10. 1. Score entry timing 0-10.
Pullback to support in uptrend = high score. Overextended at highs = low score. Pullback to support in uptrend = high score. Overextended at highs = low score.
@ -325,6 +347,10 @@ INSTRUCTIONS:
result = EntryTimingOutput( result = EntryTimingOutput(
score_0_to_10=5.0, confidence_0_to_1=0.1, score_0_to_10=5.0, confidence_0_to_1=0.1,
summary_1_sentence="Entry timing analysis unavailable", summary_1_sentence="Entry timing analysis unavailable",
data_quality_flags=[
DataFlag(field="entry_timing", severity="moderate",
message="Tier 2 analysis used fallback defaults due to LLM failure")
],
) )
result.current_price = price result.current_price = price
@ -363,12 +389,14 @@ def create_earnings_revisions_node(llm):
Ticker: {ticker} Ticker: {ticker}
EARNINGS DATA: EARNINGS DATA (source: yfinance):
- Trailing EPS: {data.get('trailing_eps', 'N/A')} - Trailing EPS: {data.get('trailing_eps', 'N/A')} (source: yfinance)
- Forward EPS: {data.get('forward_eps', 'N/A')} - Forward EPS: {data.get('forward_eps', 'N/A')} (source: yfinance)
- Price Target Upside: {upside or 'N/A'}% - Price Target Upside: {upside or 'N/A'}% (source: yfinance)
- Price Targets: {json.dumps(targets)[:300] if targets else 'N/A'} - Price Targets: {json.dumps(targets)[:300] if targets else 'N/A'} (source: yfinance)
- Recent Recommendations: {len(recs)} entries - Recent Recommendations: {len(recs)} entries (source: yfinance)
NOTE: If a metric shows 'N/A' or 'unknown', say 'data unavailable' rather than guessing.
INSTRUCTIONS: INSTRUCTIONS:
1. Score earnings revision momentum 0-10. 1. Score earnings revision momentum 0-10.
@ -385,6 +413,10 @@ INSTRUCTIONS:
result = EarningsRevisionOutput( result = EarningsRevisionOutput(
score_0_to_10=5.0, confidence_0_to_1=0.1, score_0_to_10=5.0, confidence_0_to_1=0.1,
summary_1_sentence="Earnings revision analysis unavailable", summary_1_sentence="Earnings revision analysis unavailable",
data_quality_flags=[
DataFlag(field="earnings_revisions", severity="moderate",
message="Tier 2 analysis used fallback defaults due to LLM failure")
],
) )
result.trailing_eps = data.get("trailing_eps") result.trailing_eps = data.get("trailing_eps")
@ -417,10 +449,12 @@ def create_sector_rotation_node(llm):
Ticker: {ticker} | Sector: {data.get('sector', 'Unknown')} | Sector ETF: {data.get('sector_etf', 'N/A')} Ticker: {ticker} | Sector: {data.get('sector', 'Unknown')} | Sector ETF: {data.get('sector_etf', 'N/A')}
SECTOR DATA: SECTOR DATA (source: yfinance):
- Sector vs SPY 1M: {data.get('stock_sector_vs_spy_1m', 'N/A')}% - Sector vs SPY 1M: {data.get('stock_sector_vs_spy_1m', 'N/A')}% (source: yfinance)
- Sector vs SPY 3M: {data.get('stock_sector_vs_spy_3m', 'N/A')}% - Sector vs SPY 3M: {data.get('stock_sector_vs_spy_3m', 'N/A')}% (source: yfinance)
- Sector Rank: {data.get('stock_sector_rank', 'N/A')} / {data.get('total_sectors', 11)} - Sector Rank: {data.get('stock_sector_rank', 'N/A')} / {data.get('total_sectors', 11)} (source: yfinance)
NOTE: If a metric shows 'N/A' or 'unknown', say 'data unavailable' rather than guessing.
INSTRUCTIONS: INSTRUCTIONS:
1. Score sector rotation favorability 0-10. 1. Score sector rotation favorability 0-10.
@ -435,6 +469,10 @@ INSTRUCTIONS:
result = SectorRotationOutput( result = SectorRotationOutput(
score_0_to_10=5.0, confidence_0_to_1=0.1, score_0_to_10=5.0, confidence_0_to_1=0.1,
summary_1_sentence="Sector rotation analysis unavailable", summary_1_sentence="Sector rotation analysis unavailable",
data_quality_flags=[
DataFlag(field="sector_rotation", severity="moderate",
message="Tier 2 analysis used fallback defaults due to LLM failure")
],
) )
result.sector = data.get("sector", "Unknown") result.sector = data.get("sector", "Unknown")
@ -472,10 +510,12 @@ def create_backlog_node(llm):
Ticker: {ticker} | Sector: {sector} | Industry: {industry} Ticker: {ticker} | Sector: {sector} | Industry: {industry}
AVAILABLE DATA: AVAILABLE DATA (source: yfinance):
- Revenue Growth: {_pct(_safe(info, 'revenueGrowth'))} - Revenue Growth: {_pct(_safe(info, 'revenueGrowth'))} (source: yfinance)
- Earnings Growth: {_pct(_safe(info, 'earningsGrowth'))} - Earnings Growth: {_pct(_safe(info, 'earningsGrowth'))} (source: yfinance)
- Revenue: {_safe(info, 'totalRevenue', 'N/A')} - Revenue: {_safe(info, 'totalRevenue', 'N/A')} (source: yfinance)
NOTE: If a metric shows 'N/A' or 'unknown', say 'data unavailable' rather than guessing.
INSTRUCTIONS: INSTRUCTIONS:
1. Assess if this company type typically has meaningful backlog data 1. Assess if this company type typically has meaningful backlog data
@ -489,8 +529,12 @@ INSTRUCTIONS:
except Exception as e: except Exception as e:
logger.warning("Backlog LLM failed: %s", e) logger.warning("Backlog LLM failed: %s", e)
result = BacklogOrderMomentumOutput( result = BacklogOrderMomentumOutput(
score_0_to_10=5.0, confidence_0_to_1=0.3, score_0_to_10=5.0, confidence_0_to_1=0.1,
summary_1_sentence="Backlog analysis limited", summary_1_sentence="Backlog analysis limited",
data_quality_flags=[
DataFlag(field="backlog", severity="moderate",
message="Tier 2 analysis used fallback defaults due to LLM failure")
],
) )
flags = [f.model_dump() for f in result.data_quality_flags] flags = [f.model_dump() for f in result.data_quality_flags]
@ -526,11 +570,13 @@ def create_crowding_node(llm):
Ticker: {ticker} | Company: {card.get('company_name', 'Unknown')} Ticker: {ticker} | Company: {card.get('company_name', 'Unknown')}
Market Cap Category: {card.get('market_cap_category', 'unknown')} Market Cap Category: {card.get('market_cap_category', 'unknown')}
DATA: DATA (source: yfinance):
- Short % of Float: {short_pct or 'N/A'}% - Short % of Float: {short_pct or 'N/A'}% (source: yfinance)
- Short Ratio (days): {_safe(info, 'shortRatio', 'N/A')} - Short Ratio (days): {_safe(info, 'shortRatio', 'N/A')} (source: yfinance)
- Analyst Coverage: implied from market cap ({card.get('market_cap_category', 'unknown')}) - Analyst Coverage: implied from market cap ({card.get('market_cap_category', 'unknown')})
NOTE: If a metric shows 'N/A' or 'unknown', say 'data unavailable' rather than guessing.
INSTRUCTIONS: INSTRUCTIONS:
1. Score narrative crowding 0-10. 1. Score narrative crowding 0-10.
HIGH score = low crowding (contrarian, under-followed). HIGH score = low crowding (contrarian, under-followed).
@ -545,8 +591,12 @@ INSTRUCTIONS:
except Exception as e: except Exception as e:
logger.warning("Crowding LLM failed: %s", e) logger.warning("Crowding LLM failed: %s", e)
result = NarrativeCrowdingOutput( result = NarrativeCrowdingOutput(
score_0_to_10=5.0, confidence_0_to_1=0.3, score_0_to_10=5.0, confidence_0_to_1=0.1,
summary_1_sentence="Crowding analysis limited", summary_1_sentence="Crowding analysis limited",
data_quality_flags=[
DataFlag(field="crowding", severity="moderate",
message="Tier 2 analysis used fallback defaults due to LLM failure")
],
) )
flags = [f.model_dump() for f in result.data_quality_flags] flags = [f.model_dump() for f in result.data_quality_flags]

View File

@ -23,6 +23,32 @@ from tradingagents.models import (
logger = logging.getLogger(__name__) 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: def _summarize_tier2(state: Dict[str, Any]) -> str:
"""Build a compact summary of all Tier 1+2 findings for Tier 3 prompts.""" """Build a compact summary of all Tier 1+2 findings for Tier 3 prompts."""
card = state.get("company_card") or {} card = state.get("company_card") or {}
@ -38,6 +64,9 @@ def _summarize_tier2(state: Dict[str, Any]) -> str:
cr = state.get("crowding") or {} cr = state.get("crowding") or {}
arch = state.get("archetype") or {} arch = state.get("archetype") or {}
# Check for low-confidence Tier 2 agents
confidence_warnings = _low_confidence_warnings(state)
lines = [ lines = [
f"Company: {card.get('company_name', '?')} ({card.get('ticker', '?')})", f"Company: {card.get('company_name', '?')} ({card.get('ticker', '?')})",
f"Sector: {card.get('sector', '?')} | Industry: {card.get('industry', '?')}", f"Sector: {card.get('sector', '?')} | Industry: {card.get('industry', '?')}",
@ -67,6 +96,12 @@ def _summarize_tier2(state: Dict[str, Any]) -> str:
f" Short Trend: {inst.get('short_interest_trend', '?')} | Insider Signal: {inst.get('insider_transaction_signal', '?')}", f" Short Trend: {inst.get('short_interest_trend', '?')} | Insider Signal: {inst.get('insider_transaction_signal', '?')}",
f" Timing: {et.get('timing_verdict', '?')}", 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) return "\n".join(lines)

View File

@ -11,7 +11,7 @@ import json
import logging import logging
from typing import List, Literal, Optional, Tuple from typing import List, Literal, Optional, Tuple
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, ValidationError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -449,21 +449,65 @@ def should_hard_veto(
# LLM structured output helper # LLM structured output helper
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def invoke_structured(llm, model_cls, prompt: str): def invoke_structured(llm, model_cls, prompt: str, timeout: int = 60):
"""Call LLM with structured output, with JSON fallback.""" """Call LLM with structured output, with JSON fallback.
try:
Each LLM call is wrapped in a per-call timeout (default 60s) to avoid
hanging on a single call while the global 60-minute analysis timeout
covers the entire pipeline.
"""
import concurrent.futures
def _call_structured():
structured = llm.with_structured_output(model_cls) structured = llm.with_structured_output(model_cls)
return structured.invoke(prompt) return structured.invoke(prompt)
except Exception as e:
logger.warning("Structured output failed for %s: %s — using JSON fallback", model_cls.__name__, e) def _call_json_fallback():
schema_str = json.dumps(model_cls.model_json_schema(), indent=2) schema_str = json.dumps(model_cls.model_json_schema(), indent=2)
json_prompt = ( json_prompt = (
f"{prompt}\n\nReturn ONLY valid JSON matching this schema:\n{schema_str}" f"{prompt}\n\nReturn ONLY valid JSON matching this schema:\n{schema_str}"
) )
response = llm.invoke(json_prompt) return llm.invoke(json_prompt)
# Try structured output with per-call timeout
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(_call_structured)
return future.result(timeout=timeout)
except concurrent.futures.TimeoutError:
logger.warning("Structured output timed out after %ds for %s", timeout, model_cls.__name__)
raise TimeoutError(f"LLM call timed out after {timeout}s for {model_cls.__name__}")
except Exception as e:
logger.warning("Structured output failed for %s: %s — using JSON fallback", model_cls.__name__, e)
# JSON fallback with per-call timeout
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(_call_json_fallback)
response = future.result(timeout=timeout)
except concurrent.futures.TimeoutError:
logger.warning("JSON fallback timed out after %ds for %s", timeout, model_cls.__name__)
raise TimeoutError(f"LLM JSON fallback timed out after {timeout}s for {model_cls.__name__}")
content = response.content.strip() content = response.content.strip()
if "```json" in content: if "```json" in content:
content = content.split("```json")[1].split("```")[0].strip() content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content: elif "```" in content:
content = content.split("```")[1].split("```")[0].strip() content = content.split("```")[1].split("```")[0].strip()
try:
return model_cls.model_validate_json(content) return model_cls.model_validate_json(content)
except ValidationError as ve:
logger.error(
"JSON fallback validation failed for %s: %s — raw text: %.500s",
model_cls.__name__, ve, content,
)
# Return minimal defaults so the pipeline keeps running
defaults = {}
if hasattr(model_cls, "model_fields"):
if "score_0_to_10" in model_cls.model_fields:
defaults["score_0_to_10"] = 5.0
if "confidence_0_to_1" in model_cls.model_fields:
defaults["confidence_0_to_1"] = 0.1
if "summary_1_sentence" in model_cls.model_fields:
defaults["summary_1_sentence"] = f"{model_cls.__name__} parsing failed — using fallback defaults"
return model_cls(**defaults)