diff --git a/dashboard-hold-days-bottom.png b/dashboard-hold-days-bottom.png new file mode 100644 index 00000000..592db7e9 Binary files /dev/null and b/dashboard-hold-days-bottom.png differ diff --git a/dashboard-hold-days-chips.png b/dashboard-hold-days-chips.png new file mode 100644 index 00000000..4c61a1f0 Binary files /dev/null and b/dashboard-hold-days-chips.png differ diff --git a/dashboard-hold-days.png b/dashboard-hold-days.png new file mode 100644 index 00000000..4c61a1f0 Binary files /dev/null and b/dashboard-hold-days.png differ diff --git a/frontend/backend/backtest_service.py b/frontend/backend/backtest_service.py index 4df17615..b3c373a8 100644 --- a/frontend/backend/backtest_service.py +++ b/frontend/backend/backtest_service.py @@ -136,7 +136,8 @@ def calculate_and_save_backtest(date: str, symbol: str, decision: str, return_1w=result['return_1w'], return_1m=result['return_1m'], prediction_correct=result['prediction_correct'], - hold_days=result.get('hold_days') + hold_days=result.get('hold_days'), + return_at_hold=result.get('return_at_hold'), ) return result @@ -231,6 +232,8 @@ def get_backtest_data_for_frontend(date: str, symbol: str) -> dict: 'actual_return_1d': result['return_1d'], 'actual_return_1w': result['return_1w'], 'actual_return_1m': result['return_1m'], + 'return_at_hold': result.get('return_at_hold'), + 'hold_days': result.get('hold_days'), 'price_at_prediction': result['price_at_prediction'], 'current_price': result.get('price_1m_later') or result.get('price_1w_later'), 'price_history': price_history diff --git a/frontend/backend/database.py b/frontend/backend/database.py index fbdf5154..7d611437 100644 --- a/frontend/backend/database.py +++ b/frontend/backend/database.py @@ -172,6 +172,13 @@ def init_db(): cursor.execute("ALTER TABLE backtest_results ADD COLUMN hold_days INTEGER") except sqlite3.OperationalError: pass # Column already exists + try: + cursor.execute("ALTER TABLE backtest_results ADD COLUMN return_at_hold REAL") + # New column added — delete stale backtest data so it gets recalculated with return_at_hold + cursor.execute("DELETE FROM backtest_results") + print("Migration: Added return_at_hold column, cleared stale backtest data for recalculation") + except sqlite3.OperationalError: + pass # Column already exists # Create indexes for new tables cursor.execute(""" @@ -193,6 +200,80 @@ def init_db(): conn.commit() conn.close() + # Re-extract hold_days from raw_analysis for rows that have the default value (5) + # This fixes data where the signal processor LLM failed to extract the actual hold period + _fix_default_hold_days() + + +def _fix_default_hold_days(): + """Re-extract hold_days from raw_analysis for rows where hold_days is NULL or 5 (defaults). + + The signal processor sometimes defaults to 5 or leaves hold_days NULL when the + LLM fails to extract the actual hold period. This function uses regex on the + raw_analysis text to find the correct value. + """ + import re + + patterns = [ + r'(\d+)[\s-]*(?:day|trading[\s-]*day)[\s-]*(?:hold|horizon|period|timeframe)', + r'(?:hold|holding)[\s\w]*?(?:for|of|period\s+of)[\s]*(\d+)[\s]*(?:trading\s+)?days?', + r'setting\s+(\d+)\s+(?:trading\s+)?days', + r'(?:over|within|next)\s+(\d+)\s+(?:trading\s+)?days', + r'(\d+)\s+trading\s+days?\s*\(', + ] + + def extract_days(text): + if not text: + return None + # Search the conclusion/rationale section first (last 500 chars) + conclusion = text[-500:] + for pattern in patterns: + for match in re.finditer(pattern, conclusion, re.IGNORECASE): + days = int(match.group(1)) + if 1 <= days <= 90: + return days + # Fall back to full text + for pattern in patterns: + for match in re.finditer(pattern, text, re.IGNORECASE): + days = int(match.group(1)) + if 1 <= days <= 90: + return days + return None + + conn = get_connection() + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + try: + # Fix rows where hold_days is NULL or the default 5 + cursor.execute( + "SELECT id, symbol, date, raw_analysis, hold_days, decision FROM stock_analysis " + "WHERE (hold_days IS NULL OR hold_days = 5) " + "AND decision != 'SELL' " + "AND raw_analysis IS NOT NULL AND raw_analysis != ''" + ) + rows = cursor.fetchall() + fixed = 0 + for row in rows: + extracted = extract_days(row['raw_analysis']) + old_val = row['hold_days'] + if extracted is not None and extracted != old_val: + cursor.execute( + "UPDATE stock_analysis SET hold_days = ? WHERE id = ?", + (extracted, row['id']) + ) + fixed += 1 + print(f" Fixed hold_days for {row['symbol']} ({row['date']}): {old_val} -> {extracted}") + + if fixed > 0: + conn.commit() + # Also clear backtest results so they recalculate with correct hold_days + cursor.execute("DELETE FROM backtest_results") + conn.commit() + print(f"Fixed {fixed} stock(s) with missing/default hold_days. Cleared backtest cache.") + finally: + conn.close() + def save_recommendation(date: str, analysis_data: dict, summary: dict, top_picks: list, stocks_to_avoid: list): @@ -884,7 +965,7 @@ def save_backtest_result(date: str, symbol: str, decision: str, price_1w_later: float = None, price_1m_later: float = None, return_1d: float = None, return_1w: float = None, return_1m: float = None, prediction_correct: bool = None, - hold_days: int = None): + hold_days: int = None, return_at_hold: float = None): """Save a backtest result for a stock recommendation.""" conn = get_connection() cursor = conn.cursor() @@ -894,14 +975,14 @@ def save_backtest_result(date: str, symbol: str, decision: str, INSERT OR REPLACE INTO backtest_results (date, symbol, decision, price_at_prediction, price_1d_later, price_1w_later, price_1m_later, - return_1d, return_1w, return_1m, prediction_correct, hold_days) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + return_1d, return_1w, return_1m, prediction_correct, hold_days, return_at_hold) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( date, symbol, decision, price_at_prediction, price_1d_later, price_1w_later, price_1m_later, return_1d, return_1w, return_1m, 1 if prediction_correct else 0 if prediction_correct is not None else None, - hold_days + hold_days, return_at_hold )) conn.commit() finally: @@ -933,6 +1014,7 @@ def get_backtest_result(date: str, symbol: str) -> Optional[dict]: 'return_1m': row['return_1m'], 'prediction_correct': bool(row['prediction_correct']) if row['prediction_correct'] is not None else None, 'hold_days': row['hold_days'] if 'hold_days' in row.keys() else None, + 'return_at_hold': row['return_at_hold'] if 'return_at_hold' in row.keys() else None, 'calculated_at': row['calculated_at'] } return None @@ -962,7 +1044,8 @@ def get_backtest_results_by_date(date: str) -> list: 'return_1w': row['return_1w'], 'return_1m': row['return_1m'], 'prediction_correct': bool(row['prediction_correct']) if row['prediction_correct'] is not None else None, - 'hold_days': row['hold_days'] if 'hold_days' in row.keys() else None + 'hold_days': row['hold_days'] if 'hold_days' in row.keys() else None, + 'return_at_hold': row['return_at_hold'] if 'return_at_hold' in row.keys() else None, } for row in cursor.fetchall() ] @@ -995,7 +1078,9 @@ def get_all_backtest_results() -> list: 'return_1d': row['return_1d'], 'return_1w': row['return_1w'], 'return_1m': row['return_1m'], - 'prediction_correct': bool(row['prediction_correct']) + 'prediction_correct': bool(row['prediction_correct']), + 'hold_days': row['hold_days'] if 'hold_days' in row.keys() else None, + 'return_at_hold': row['return_at_hold'] if 'return_at_hold' in row.keys() else None, } for row in cursor.fetchall() ] @@ -1013,7 +1098,7 @@ def calculate_accuracy_metrics() -> dict: 'total_predictions': 0, 'correct_predictions': 0, 'by_decision': {'BUY': {'accuracy': 0, 'total': 0}, 'SELL': {'accuracy': 0, 'total': 0}, 'HOLD': {'accuracy': 0, 'total': 0}}, - 'by_confidence': {'High': {'accuracy': 0, 'total': 0}, 'Medium': {'accuracy': 0, 'total': 0}, 'Low': {'accuracy': 0, 'total': 0}} + 'by_confidence': {'HIGH': {'accuracy': 0, 'total': 0}, 'MEDIUM': {'accuracy': 0, 'total': 0}, 'LOW': {'accuracy': 0, 'total': 0}} } total = len(results) @@ -1035,7 +1120,7 @@ def calculate_accuracy_metrics() -> dict: # By confidence level by_confidence = {} - for conf in ['High', 'Medium', 'Low']: + for conf in ['HIGH', 'MEDIUM', 'LOW']: conf_results = [r for r in results if r.get('confidence') == conf] if conf_results: conf_correct = sum(1 for r in conf_results if r['prediction_correct']) diff --git a/frontend/backend/recommendations.db b/frontend/backend/recommendations.db index e6ccf03f..afaa6600 100644 Binary files a/frontend/backend/recommendations.db and b/frontend/backend/recommendations.db differ diff --git a/frontend/backend/server.py b/frontend/backend/server.py index 38b537b4..31efca37 100644 --- a/frontend/backend/server.py +++ b/frontend/backend/server.py @@ -228,7 +228,7 @@ def run_analysis_task(symbol: str, date: str, analysis_config: dict = None): add_log("agent", "system", f"Starting propagation for {symbol}...") add_log("data", "data_fetch", f"Fetching market data for {symbol}...") - final_state, decision, hold_days = ta.propagate(symbol, date) + final_state, decision, hold_days, confidence, risk = ta.propagate(symbol, date) # Check cancellation after graph execution (skip saving results) if _is_cancelled(symbol): @@ -251,8 +251,8 @@ def run_analysis_task(symbol: str, date: str, analysis_config: dict = None): analysis_data = { "company_name": symbol, "decision": decision.upper() if decision else "HOLD", - "confidence": "MEDIUM", - "risk": "MEDIUM", + "confidence": confidence or "MEDIUM", + "risk": risk or "MEDIUM", "raw_analysis": raw_analysis, "hold_days": hold_days } @@ -947,9 +947,10 @@ async def get_backtest_result(date: str, symbol: str): 'actual_return_1d': result['return_1d'], 'actual_return_1w': result['return_1w'], 'actual_return_1m': result['return_1m'], + 'return_at_hold': result.get('return_at_hold'), + 'hold_days': result.get('hold_days'), 'price_at_prediction': result['price_at_prediction'], 'current_price': result.get('price_1m_later') or result.get('price_1w_later'), - 'hold_days': result.get('hold_days'), } diff --git a/frontend/src/components/pipeline/FlowchartNode.tsx b/frontend/src/components/pipeline/FlowchartNode.tsx index 17e64e6c..2b9e6d63 100644 --- a/frontend/src/components/pipeline/FlowchartNode.tsx +++ b/frontend/src/components/pipeline/FlowchartNode.tsx @@ -1,10 +1,11 @@ import { TrendingUp, TrendingDown, Users, Newspaper, FileText, Scale, Target, Zap, Shield, ShieldCheck, - Clock, Loader2, CheckCircle, AlertCircle, ChevronDown, ChevronUp + Clock, Loader2, CheckCircle, AlertCircle, ChevronDown, ChevronUp, GitBranch } from 'lucide-react'; import { useState } from 'react'; import type { FlowchartNodeData, PipelineStepStatus } from '../../types/pipeline'; +import { STEP_INPUT_SOURCES } from '../../types/pipeline'; interface FlowchartNodeProps { node: FlowchartNodeData; @@ -100,6 +101,7 @@ export function FlowchartNode({ node, isSelected, onClick }: FlowchartNodeProps) const hasPreview = !!(node.output_summary || node.agentReport?.report_content || node.debateContent); const previewText = node.output_summary || node.agentReport?.report_content || node.debateContent || ''; + const inputCount = (STEP_INPUT_SOURCES[node.id] || []).length; return (
@@ -145,6 +147,12 @@ export function FlowchartNode({ node, isSelected, onClick }: FlowchartNodeProps) {/* Status + Duration */}
+ {inputCount > 0 && node.status === 'completed' && ( + + + {inputCount} + + )} {node.duration_ms != null && node.status === 'completed' && ( {formatDuration(node.duration_ms)} diff --git a/frontend/src/components/pipeline/NodeDetailDrawer.tsx b/frontend/src/components/pipeline/NodeDetailDrawer.tsx index 61c9d211..b46bccef 100644 --- a/frontend/src/components/pipeline/NodeDetailDrawer.tsx +++ b/frontend/src/components/pipeline/NodeDetailDrawer.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react'; -import { X, Clock, Timer, CheckCircle, AlertCircle, FileText, MessageSquare, ChevronDown, ChevronRight, Terminal, Bot, User, Wrench } from 'lucide-react'; +import { useState, useMemo } from 'react'; +import { X, Clock, Timer, CheckCircle, AlertCircle, FileText, MessageSquare, ChevronDown, ChevronRight, Terminal, Bot, User, GitBranch, ArrowRight, Info, Wrench } from 'lucide-react'; import type { FlowchartNodeData, FullPipelineData, StepDetails } from '../../types/pipeline'; +import { STEP_INPUT_SOURCES, FLOWCHART_STEPS, mapPipelineToFlowchart } from '../../types/pipeline'; interface NodeDetailDrawerProps { node: FlowchartNodeData; @@ -26,24 +27,33 @@ function formatDuration(ms: number): string { return `${mins}m ${remSecs}s`; } -function getFallbackContent(node: FlowchartNodeData, data: FullPipelineData | null): string { +/** + * Extract the ACTUAL meaningful output for a step. + * Prefers agent_reports and debate content over raw step_details.response, + * since the latter may contain "TOOL_CALL:" noise or be empty for debate steps. + */ +function getStepOutput(node: FlowchartNodeData, data: FullPipelineData | null): string { + // 1. Agent report content (for analyst steps) if (node.agentReport?.report_content) return node.agentReport.report_content; + // 2. Debate content (for debate participants) if (node.debateContent) return node.debateContent; + // 3. Output summary from pipeline step if (node.output_summary) return node.output_summary; - if (!data) return ''; - - if (node.debateType === 'investment' && data.debates?.investment) { - const d = data.debates.investment; - if (node.debateRole === 'bull') return d.bull_arguments || ''; - if (node.debateRole === 'bear') return d.bear_arguments || ''; - if (node.debateRole === 'judge') return d.judge_decision || ''; - } - if (node.debateType === 'risk' && data.debates?.risk) { - const d = data.debates.risk; - if (node.debateRole === 'risky') return d.risky_arguments || ''; - if (node.debateRole === 'safe') return d.safe_arguments || ''; - if (node.debateRole === 'neutral') return d.neutral_arguments || ''; - if (node.debateRole === 'judge') return d.judge_decision || ''; + // 4. Try debate tables directly + if (data) { + if (node.debateType === 'investment' && data.debates?.investment) { + const d = data.debates.investment; + if (node.debateRole === 'bull') return d.bull_arguments || ''; + if (node.debateRole === 'bear') return d.bear_arguments || ''; + if (node.debateRole === 'judge') return d.judge_decision || ''; + } + if (node.debateType === 'risk' && data.debates?.risk) { + const d = data.debates.risk; + if (node.debateRole === 'risky') return d.risky_arguments || ''; + if (node.debateRole === 'safe') return d.safe_arguments || ''; + if (node.debateRole === 'neutral') return d.neutral_arguments || ''; + if (node.debateRole === 'judge') return d.judge_decision || ''; + } } return ''; } @@ -94,12 +104,243 @@ function CodeBlock({ content, maxHeight = 'max-h-64' }: { content: string; maxHe ); } +/** Tool call with optional result */ +interface ToolCallEntry { + name: string; + args: string; + result_preview?: string; +} + +/** Get tool calls: prefer stored step_details.tool_calls, fall back to text parsing */ +function getToolCalls(details: StepDetails | undefined, rawResponse: string): ToolCallEntry[] { + // Prefer stored tool_calls with actual results from the backend + if (details?.tool_calls && details.tool_calls.length > 0) { + return details.tool_calls.map(tc => ({ + name: tc.name, + args: tc.args || '', + result_preview: tc.result_preview, + })); + } + // Fallback: parse TOOL_CALL: patterns from response text (legacy data) + const regex = /TOOL_CALL:\s*(\w+)\(([^)]*)\)/g; + const calls: ToolCallEntry[] = []; + let match; + while ((match = regex.exec(rawResponse)) !== null) { + calls.push({ name: match[1], args: match[2] }); + } + return calls; +} + +/** Strip TOOL_CALL lines from response to get only the prose */ +function stripToolCalls(text: string): string { + return text.replace(/TOOL_CALL:\s*\w+\([^)]*\)\s*/g, '').trim(); +} + +/** Collapsible result preview for a single tool call */ +function ToolResultPreview({ result }: { result: string }) { + const [expanded, setExpanded] = useState(false); + const isLong = result.length > 200; + const display = expanded ? result : result.slice(0, 200); + + return ( +
+
+ + Result: +
+
+
+          {display}{isLong && !expanded ? '...' : ''}
+        
+
+ {isLong && ( + + )} +
+ ); +} + +/** Tool calls panel showing invocations and their results */ +function ToolCallsPanel({ calls }: { calls: ToolCallEntry[] }) { + if (calls.length === 0) return null; + + return ( +
+ {calls.map((call, i) => { + // Distinguish: undefined/null = no data stored, empty string = tool returned nothing, error = starts with [ + const resultStored = call.result_preview !== undefined && call.result_preview !== null; + const isError = resultStored && call.result_preview!.startsWith('['); + const hasData = resultStored && call.result_preview!.length > 0 && !isError; + const isEmpty = resultStored && call.result_preview!.length === 0; + return ( +
+
+ +
+
+
+ {call.name} + (#{i + 1}) + {resultStored && ( + + {hasData ? 'executed' : isError ? 'error' : 'empty result'} + + )} +
+
+ ({call.args}) +
+ {hasData && ( + + )} + {isError && ( +
+ {call.result_preview} +
+ )} + {isEmpty && ( +
+ Tool executed but returned no data +
+ )} + {!resultStored && ( +
+ No result data available (legacy run) +
+ )} +
+
+ ); + })} +
+ ); +} + +/** Context received panel showing data lineage */ +function ContextReceivedPanel({ node, pipelineData }: { node: FlowchartNodeData; pipelineData: FullPipelineData | null }) { + const inputSources = STEP_INPUT_SOURCES[node.id] || []; + const allNodes = useMemo(() => mapPipelineToFlowchart(pipelineData), [pipelineData]); + + if (inputSources.length === 0) { + return ( +
+ + + Independent step — no forwarded context from previous steps + +
+ ); + } + + return ( +
+
+ + + Context Received from {inputSources.length} Previous Steps + +
+
+ {inputSources.map(sourceId => { + const stepDef = FLOWCHART_STEPS.find(s => s.id === sourceId); + const sourceNode = allNodes.find(n => n.id === sourceId); + const isCompleted = sourceNode?.status === 'completed'; + const label = stepDef?.label || sourceId; + + return ( + + {isCompleted ? ( + + ) : ( + + )} + {label} + + + ); + })} +
+
+ ); +} + +/** Empty state for steps with no data */ +function EmptyState({ status }: { status: string }) { + if (status === 'pending') { + return ( +
+ +

This step hasn't run yet

+

Run an analysis to see results here

+
+ ); + } + if (status === 'running') { + return ( +
+
+

Processing...

+
+ ); + } + return ( +
+ +

No output data available

+
+ ); +} + export function NodeDetailDrawer({ node, pipelineData, onClose }: NodeDetailDrawerProps) { const details: StepDetails | undefined = node.step_details; - const fallbackContent = getFallbackContent(node, pipelineData); + const inputSources = STEP_INPUT_SOURCES[node.id] || []; + const hasForwardedContext = inputSources.length > 0; - // Determine if we have structured data or just fallback - const hasStructuredData = details && (details.system_prompt || details.user_prompt || details.response); + // Step Output: the ACTUAL meaningful output (agent report / debate content first, then raw response) + // Always strip TOOL_CALL lines from step output since they're noise in the output view + const rawStepOutput = getStepOutput(node, pipelineData) || details?.response || ''; + const stepOutput = stripToolCalls(rawStepOutput); + // Raw LLM Response: the raw text from step_details (may contain TOOL_CALL noise) + const rawResponse = details?.response || ''; + // Get tool calls: prefers stored results from backend, falls back to text parsing + const parsedToolCalls = useMemo(() => getToolCalls(details, rawResponse), [details, rawResponse]); + // Clean response = raw response minus TOOL_CALL lines (for display without noise) + const cleanRawResponse = useMemo(() => parsedToolCalls.length > 0 ? stripToolCalls(rawResponse) : rawResponse, [rawResponse, parsedToolCalls]); + // Only show raw response section if it differs from step output (avoid duplication) + const showRawResponse = rawResponse && rawResponse !== stepOutput; + + const hasAnyContent = stepOutput || details?.user_prompt || details?.system_prompt; return (
@@ -153,26 +394,30 @@ export function NodeDetailDrawer({ node, pipelineData, onClose }: NodeDetailDraw )}
- {/* Content sections */} + {/* Content sections — unified flow, no branching */}
- {hasStructuredData ? ( + {/* 1. Context Received — always first */} + + + {hasAnyContent ? ( <> - {/* System Prompt */} - {details.system_prompt && ( + {/* 2. System Instructions — role definition (default OPEN) */} + {details?.system_prompt && (
)} - {/* User Prompt / Input */} - {details.user_prompt && ( + {/* 3. Input Prompt — forwarded context or initial prompt */} + {details?.user_prompt && (
)} - {/* Tool Calls */} - {details.tool_calls && details.tool_calls.length > 0 && ( + {/* 4. Tool Calls — parsed from raw response (analyst steps) */} + {parsedToolCalls.length > 0 && (
-
- {details.tool_calls.map((tc, i) => ( -
-
- - {tc.name}() - - {tc.args && ( - - {tc.args} - - )} -
- {tc.result_preview && ( -
- {tc.result_preview} -
- )} -
- ))} -
+
)} - {/* LLM Response */} - {details.response && ( + {/* 5. Raw LLM Response — only when different from Step Output */} + {showRawResponse && (
- + +
+ )} + + {/* 6. Step Output — the actual meaningful output */} + {stepOutput && ( +
+
)} - ) : fallbackContent ? ( - /* Fallback: show the old-style content */ - <> -
- -
- - ) : node.status === 'pending' ? ( -
- -

This step hasn't run yet

-

Run an analysis to see results here

-
- ) : node.status === 'running' ? ( -
-
-

Processing...

-
) : ( -
- -

No output data available

-
+ )}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 9ac3da6a..f9415207 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import { Link } from 'react-router-dom'; import { Calendar, RefreshCw, Filter, ChevronRight, TrendingUp, TrendingDown, Minus, History, Search, X, Play, Loader2, Square, AlertCircle, Terminal } from 'lucide-react'; import TopPicks, { StocksToAvoid } from '../components/TopPicks'; -import { DecisionBadge } from '../components/StockCard'; +import { DecisionBadge, HoldDaysBadge } from '../components/StockCard'; import TerminalModal from '../components/TerminalModal'; import HowItWorks from '../components/HowItWorks'; import BackgroundSparkline from '../components/BackgroundSparkline'; @@ -196,7 +196,7 @@ export default function Dashboard() { try { // Pass settings from context to the API - await api.runBulkAnalysis(undefined, { + const result = await api.runBulkAnalysis(undefined, { deep_think_model: settings.deepThinkModel, quick_think_model: settings.quickThinkModel, provider: settings.provider, @@ -204,10 +204,25 @@ export default function Dashboard() { max_debate_rounds: settings.maxDebateRounds, parallel_workers: settings.parallelWorkers }); + + // If all stocks already analyzed, exit analyzing mode + if (result.status === 'completed' || result.total_stocks === 0) { + updateAnalysisState(false, null); + addNotification({ + type: 'info', + title: 'Already Analyzed', + message: result.skipped + ? `All ${result.skipped} stocks already analyzed for today.` + : 'All stocks already analyzed for today.', + duration: 5000, + }); + return; + } + addNotification({ type: 'info', title: 'Analysis Started', - message: 'Running AI analysis for all 50 Nifty stocks...', + message: `Running AI analysis for ${result.total_stocks} stocks${result.skipped ? ` (${result.skipped} already done)` : ''}...`, duration: 3000, }); } catch (e) { @@ -647,6 +662,11 @@ export default function Dashboard() {

{item.company_name}

+ {item.analysis.hold_days != null && item.analysis.hold_days > 0 && item.analysis.decision !== 'SELL' && ( +
+ +
+ )}
); diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx index 70a2e346..10019d5b 100644 --- a/frontend/src/pages/History.tsx +++ b/frontend/src/pages/History.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, useEffect, useCallback } from 'react'; import { Link } from 'react-router-dom'; import { Calendar, TrendingUp, TrendingDown, Minus, ChevronRight, BarChart3, Target, HelpCircle, Activity, Calculator, LineChart, PieChart, Shield, Filter, Loader2, AlertCircle } from 'lucide-react'; import { sampleRecommendations, getBacktestResult as getStaticBacktestResult, calculateAccuracyMetrics as calculateStaticAccuracyMetrics, getDateStats as getStaticDateStats, getOverallStats as getStaticOverallStats, getReturnBreakdown as getStaticReturnBreakdown } from '../data/recommendations'; +import type { ReturnBreakdown } from '../data/recommendations'; import { DecisionBadge, HoldDaysBadge } from '../components/StockCard'; import Sparkline from '../components/Sparkline'; import AccuracyBadge from '../components/AccuracyBadge'; @@ -23,6 +24,9 @@ interface RealBacktestData { decision: string; return1d: number | null; return1w: number | null; + returnAtHold: number | null; + holdDays: number | null; + primaryReturn: number | null; // return_at_hold ?? return_1d predictionCorrect: boolean | null; priceHistory?: Array<{ date: string; price: number }>; } @@ -74,6 +78,21 @@ function InvestmentModeToggle({ ); } +// Pulsing skeleton bar for loading states +function SkeletonBar({ className = '' }: { className?: string }) { + return
; +} + +// Loading overlay for chart sections +function SectionLoader({ message = 'Calculating backtest results...' }: { message?: string }) { + return ( +
+ + {message} +
+ ); +} + export default function History() { const [selectedDate, setSelectedDate] = useState(null); const [showAccuracyModal, setShowAccuracyModal] = useState(false); @@ -163,7 +182,7 @@ export default function History() { // Batch-fetch all backtest results per date (used by both accuracy trend and chart data) const [batchBacktestByDate, setBatchBacktestByDate] = useState< - Record> + Record> >({}); const [isBatchLoading, setIsBatchLoading] = useState(false); @@ -203,6 +222,8 @@ export default function History() { return_1d: r.return_1d, return_1w: r.return_1w, return_1m: r.return_1m, + return_at_hold: r.return_at_hold, + hold_days: r.hold_days, prediction_correct: r.prediction_correct, decision: r.decision, }; @@ -245,11 +266,12 @@ export default function History() { for (const symbol of Object.keys(rec.analysis)) { const stockAnalysis = rec.analysis[symbol]; const bt = dateBacktest[symbol]; - if (!stockAnalysis?.decision || !bt || bt.return_1d === undefined || bt.return_1d === null) continue; + const primaryRet = bt?.return_at_hold ?? bt?.return_1d; + if (!stockAnalysis?.decision || primaryRet === undefined || primaryRet === null) continue; const predictionCorrect = (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD') - ? bt.return_1d > 0 - : bt.return_1d < 0; + ? primaryRet > 0 + : primaryRet < 0; if (stockAnalysis.decision === 'BUY') { totalBuy++; if (predictionCorrect) correctBuy++; } else if (stockAnalysis.decision === 'SELL') { totalSell++; if (predictionCorrect) correctSell++; } @@ -361,32 +383,31 @@ export default function History() { for (const symbol of Object.keys(rec.analysis)) { const stockAnalysis = rec.analysis[symbol]; const bt = dateBacktest[symbol]; - if (!stockAnalysis?.decision || !bt || bt.return_1d === undefined || bt.return_1d === null) continue; - - const return1d = bt.return_1d; + const primaryRet = bt?.return_at_hold ?? bt?.return_1d; + if (!stockAnalysis?.decision || primaryRet === undefined || primaryRet === null) continue; // Store for PortfolioSimulator if (!allBacktest[date]) allBacktest[date] = {}; - allBacktest[date][symbol] = return1d; + allBacktest[date][symbol] = primaryRet; const predictionCorrect = (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD') - ? return1d > 0 - : return1d < 0; + ? primaryRet > 0 + : primaryRet < 0; totalPredictions++; if (predictionCorrect) { totalCorrect++; dateCorrectCount++; if (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD') { - dateCorrectReturn += return1d; + dateCorrectReturn += primaryRet; } else { - dateCorrectReturn += Math.abs(return1d); + dateCorrectReturn += Math.abs(primaryRet); } } else { if (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD') { - dateIncorrectReturn += return1d; + dateIncorrectReturn += primaryRet; } else { - dateIncorrectReturn += -Math.abs(return1d); + dateIncorrectReturn += -Math.abs(primaryRet); } } dateTotalCount++; @@ -428,9 +449,10 @@ export default function History() { if (rec && dateBacktest) { for (const symbol of Object.keys(rec.analysis)) { const bt = dateBacktest[symbol]; - if (!bt || bt.return_1d === undefined || bt.return_1d === null) continue; + const retVal = bt?.return_at_hold ?? bt?.return_1d; + if (retVal === undefined || retVal === null) continue; for (const bucket of returnBuckets) { - if (bt.return_1d >= bucket.min && bt.return_1d < bucket.max) { + if (retVal >= bucket.min && retVal < bucket.max) { bucket.count++; bucket.stocks.push(symbol); break; @@ -535,8 +557,9 @@ export default function History() { for (const pick of rec.top_picks) { const bt = dateBacktest[pick.symbol]; - if (bt && bt.return_1d !== undefined && bt.return_1d !== null) { - dateReturn += bt.return_1d; + const retVal = bt?.return_at_hold ?? bt?.return_1d; + if (retVal !== undefined && retVal !== null) { + dateReturn += retVal; dateCount++; } } @@ -564,9 +587,10 @@ export default function History() { if (rec && dateBacktest) { for (const pick of rec.top_picks) { const bt = dateBacktest[pick.symbol]; - if (bt && bt.return_1d !== undefined && bt.return_1d !== null) { + const retVal = bt?.return_at_hold ?? bt?.return_1d; + if (retVal !== undefined && retVal !== null) { for (const bucket of topPicksDistribution) { - if (bt.return_1d >= bucket.min && bt.return_1d < bucket.max) { + if (retVal >= bucket.min && retVal < bucket.max) { bucket.count++; bucket.stocks.push(pick.symbol); break; @@ -690,14 +714,14 @@ export default function History() { const backtest = await api.getBacktestResult(date, stock.symbol); if (backtest.available) { - // Calculate prediction correctness based on 1-day return - // BUY/HOLD correct if return > 0, SELL correct if return < 0 + // Use hold-period return when available, fall back to 1-day + const primaryReturn = backtest.return_at_hold ?? backtest.actual_return_1d ?? null; let predictionCorrect: boolean | null = null; - if (backtest.actual_return_1d !== undefined && backtest.actual_return_1d !== null) { + if (primaryReturn !== null) { if (stock.decision === 'BUY' || stock.decision === 'HOLD') { - predictionCorrect = backtest.actual_return_1d > 0; + predictionCorrect = primaryReturn > 0; } else if (stock.decision === 'SELL') { - predictionCorrect = backtest.actual_return_1d < 0; + predictionCorrect = primaryReturn < 0; } } @@ -706,6 +730,9 @@ export default function History() { decision: stock.decision, return1d: backtest.actual_return_1d ?? null, return1w: backtest.actual_return_1w ?? null, + returnAtHold: backtest.return_at_hold ?? null, + holdDays: backtest.hold_days ?? null, + primaryReturn, predictionCorrect, priceHistory: backtest.price_history, }; @@ -752,8 +779,9 @@ export default function History() { rec.top_picks.map(pick => { // Try real backtest data first const realData = realBacktestData[pick.symbol]; - if (realData?.return1d !== null && realData?.return1d !== undefined) { - return realData.return1d; + const primaryRet = realData?.primaryReturn ?? realData?.return1d; + if (primaryRet !== null && primaryRet !== undefined) { + return primaryRet; } // Only fall back to mock when actually using mock data return isUsingMockData ? getStaticBacktestResult(pick.symbol)?.actual_return_1d : undefined; @@ -809,6 +837,71 @@ export default function History() { return Object.values(rec.analysis); }; + // Build ReturnBreakdown from real batch backtest data for the modal + const buildReturnBreakdown = useCallback((date: string): ReturnBreakdown | null => { + const rec = recommendations.find(r => r.date === date); + const dateBacktest = batchBacktestByDate[date]; + if (!rec || !dateBacktest) return null; + + const correctStocks: { symbol: string; decision: string; return1d: number }[] = []; + const incorrectStocks: { symbol: string; decision: string; return1d: number }[] = []; + let correctTotal = 0; + let incorrectTotal = 0; + + for (const symbol of Object.keys(rec.analysis)) { + const stockAnalysis = rec.analysis[symbol]; + const bt = dateBacktest[symbol]; + const retVal = bt?.return_at_hold ?? bt?.return_1d; + if (!stockAnalysis?.decision || retVal === undefined || retVal === null) continue; + + const isCorrect = (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD') + ? retVal > 0 + : retVal < 0; + + const entry = { symbol, decision: stockAnalysis.decision, return1d: retVal }; + if (isCorrect) { + correctStocks.push(entry); + correctTotal += (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD') ? retVal : Math.abs(retVal); + } else { + incorrectStocks.push(entry); + incorrectTotal += (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD') ? retVal : -Math.abs(retVal); + } + } + + const totalCount = correctStocks.length + incorrectStocks.length; + if (totalCount === 0) return null; + + const correctAvg = correctStocks.length > 0 ? correctTotal / correctStocks.length : 0; + const incorrectAvg = incorrectStocks.length > 0 ? incorrectTotal / incorrectStocks.length : 0; + const correctWeight = correctStocks.length / totalCount; + const incorrectWeight = incorrectStocks.length / totalCount; + const weightedReturn = (correctAvg * correctWeight) + (incorrectAvg * incorrectWeight); + + // Sort stocks by return magnitude + correctStocks.sort((a, b) => Math.abs(b.return1d) - Math.abs(a.return1d)); + incorrectStocks.sort((a, b) => Math.abs(b.return1d) - Math.abs(a.return1d)); + + return { + correctPredictions: { + count: correctStocks.length, + totalReturn: correctTotal, + avgReturn: correctAvg, + stocks: correctStocks.slice(0, 5), + }, + incorrectPredictions: { + count: incorrectStocks.length, + totalReturn: incorrectTotal, + avgReturn: incorrectAvg, + stocks: incorrectStocks.slice(0, 5), + }, + weightedReturn: Math.round(weightedReturn * 10) / 10, + formula: `(${correctAvg.toFixed(2)}% × ${correctStocks.length}/${totalCount}) + (${incorrectAvg.toFixed(2)}% × ${incorrectStocks.length}/${totalCount}) = ${weightedReturn.toFixed(2)}%`, + }; + }, [recommendations, batchBacktestByDate]); + + // Whether any backtest data is still loading (for skeleton states) + const isBacktestDataLoading = isBatchLoading || (!isUsingMockData && !isLoadingRecommendations && Object.keys(batchBacktestByDate).length === 0); + // Show loading state if (isLoadingRecommendations) { return ( @@ -859,6 +952,9 @@ export default function History() {

Prediction Accuracy

+ {isBacktestDataLoading && ( + + )}
-
-
-
- {(accuracyMetrics.success_rate * 100).toFixed(0)}% -
-
Overall Accuracy
+ {isBacktestDataLoading ? ( +
+ {['nifty', 'green', 'red', 'amber'].map(color => ( +
+ + +
+ ))}
-
-
- {(accuracyMetrics.buy_accuracy * 100).toFixed(0)}% + ) : ( +
+
+
+ {(accuracyMetrics.success_rate * 100).toFixed(0)}% +
+
Overall Accuracy
-
Buy Accuracy
-
-
-
- {(accuracyMetrics.sell_accuracy * 100).toFixed(0)}% +
+
+ {(accuracyMetrics.buy_accuracy * 100).toFixed(0)}% +
+
Buy Accuracy
-
Sell Accuracy
-
-
-
- {(accuracyMetrics.hold_accuracy * 100).toFixed(0)}% +
+
+ {(accuracyMetrics.sell_accuracy * 100).toFixed(0)}% +
+
Sell Accuracy
+
+
+
+ {(accuracyMetrics.hold_accuracy * 100).toFixed(0)}% +
+
Hold Accuracy
-
Hold Accuracy
-
+ )}

- Based on {accuracyMetrics.total_predictions} predictions tracked over time + {isBacktestDataLoading + ? 'Fetching backtest data from market...' + : `Based on ${accuracyMetrics.total_predictions} predictions tracked over time` + }

@@ -914,23 +1024,28 @@ export default function History() {
)}
- {/* Pass real data if available, use mock fallback only when in mock mode */} - 0 ? accuracyTrendData : undefined) - : accuracyTrendData - } - /> -

- {accuracyTrendData.length > 0 ? ( - <>Prediction accuracy from real backtest data over {accuracyTrendData.length} trading days - ) : isUsingMockData ? ( - <>Demo data - Start backend for real accuracy tracking - ) : ( - <>Prediction accuracy over the past {dates.length} trading days - )} -

+ {isBacktestDataLoading && !isUsingMockData ? ( + + ) : ( + <> + 0 ? accuracyTrendData : undefined) + : accuracyTrendData + } + /> +

+ {accuracyTrendData.length > 0 ? ( + <>Prediction accuracy from real backtest data over {accuracyTrendData.length} trading days + ) : isUsingMockData ? ( + <>Demo data - Start backend for real accuracy tracking + ) : ( + <>Prediction accuracy over the past {dates.length} trading days + )} +

+ + )} {/* Risk Metrics */} @@ -947,19 +1062,25 @@ export default function History() {
)}
- -

- {realRiskMetrics ? ( - <>Risk-adjusted performance from real backtest data ({realRiskMetrics.totalTrades} trades) - ) : isUsingMockData ? ( - <>Demo data - Start backend for real risk metrics - ) : ( - <>Risk-adjusted performance metrics for the AI trading strategy - )} -

+ {isBacktestDataLoading && !isUsingMockData ? ( + + ) : ( + <> + +

+ {realRiskMetrics ? ( + <>Risk-adjusted performance from real backtest data ({realRiskMetrics.totalTrades} trades) + ) : isUsingMockData ? ( + <>Demo data - Start backend for real risk metrics + ) : ( + <>Risk-adjusted performance metrics for the AI trading strategy + )} +

+ + )} {/* Portfolio Simulator */} @@ -987,6 +1108,7 @@ export default function History() { const rec = getRecommendation(date); const stats = dateStatsMap[date]; const avgReturn = stats?.avgReturn1d ?? 0; + const hasBacktestData = !isUsingMockData ? (realDateReturns[date] !== undefined) : true; const isPositive = avgReturn >= 0; // Calculate filtered summary for this date @@ -1005,11 +1127,21 @@ export default function History() { }`} >
{new Date(date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' })}
-
- {isPositive ? '+' : ''}{avgReturn.toFixed(1)}% -
+ {!hasBacktestData && isBacktestDataLoading ? ( +
+ +
+ ) : !hasBacktestData ? ( +
+ Pending +
+ ) : ( +
+ {isPositive ? '+' : ''}{avgReturn.toFixed(1)}% +
+ )}
{filteredSummary.buy}B/{filteredSummary.sell}S/{filteredSummary.hold}H
@@ -1118,8 +1250,8 @@ export default function History() { let predictionCorrect: boolean | null = null; if (!isUsingMockData) { - // Real data mode: only use real backtest, no mock fallback - nextDayReturn = realData?.return1d ?? null; + // Real data mode: use hold-period return when available + nextDayReturn = realData?.primaryReturn ?? realData?.return1d ?? null; priceHistory = realData?.priceHistory; if (realData?.predictionCorrect !== undefined) { predictionCorrect = realData.predictionCorrect; @@ -1127,7 +1259,7 @@ export default function History() { } else { // Mock data mode: use real if available, fall back to mock const mockBacktest = getStaticBacktestResult(stock.symbol); - nextDayReturn = realData?.return1d ?? mockBacktest?.actual_return_1d ?? 0; + nextDayReturn = realData?.primaryReturn ?? realData?.return1d ?? mockBacktest?.actual_return_1d ?? 0; priceHistory = realData?.priceHistory ?? mockBacktest?.price_history; if (realData?.predictionCorrect !== undefined) { predictionCorrect = realData.predictionCorrect; @@ -1159,6 +1291,12 @@ export default function History() {
+ {nextDayReturn !== null && ( + + {nextDayReturn >= 0 ? '+' : ''}{nextDayReturn.toFixed(1)}% + {realData?.holdDays && /{realData.holdDays}d} + + )} {predictionCorrect !== null && (
-
-
setActiveSummaryModal('daysTracked')} - > -
{filteredStats.totalDays}
-
- Days Tracked + {isBacktestDataLoading && !isUsingMockData ? ( +
+ {[1, 2, 3, 4].map(i => ( +
+ + +
+ ))} +
+ ) : ( +
+
setActiveSummaryModal('daysTracked')} + > +
{filteredStats.totalDays}
+
+ Days Tracked +
+
+
setActiveSummaryModal('avgReturn')} + > +
+ {filteredStats.avgDailyReturn >= 0 ? '+' : ''}{filteredStats.avgDailyReturn.toFixed(1)}% +
+
+ Avg Return +
+
+
setActiveSummaryModal('buySignals')} + > +
+ {filteredStats.buySignals} +
+
+ {summaryMode === 'topPicks' ? 'Top Pick Signals' : 'Buy Signals'} +
+
+
setActiveSummaryModal('sellSignals')} + > +
+ {filteredStats.sellSignals} +
+
+ Sell Signals +
-
setActiveSummaryModal('avgReturn')} - > -
- {filteredStats.avgDailyReturn >= 0 ? '+' : ''}{filteredStats.avgDailyReturn.toFixed(1)}% -
-
- Avg Next-Day Return -
-
-
setActiveSummaryModal('buySignals')} - > -
- {filteredStats.buySignals} -
-
- {summaryMode === 'topPicks' ? 'Top Pick Signals' : 'Buy Signals'} -
-
-
setActiveSummaryModal('sellSignals')} - > -
- {filteredStats.sellSignals} -
-
- Sell Signals -
-
-
+ )}

- {summaryMode === 'topPicks' - ? 'Performance based on Top Picks recommendations only (3 stocks per day)' - : 'Next-day return = Price change on the trading day after recommendation' + {isBacktestDataLoading && !isUsingMockData + ? 'Loading performance data from market...' + : summaryMode === 'topPicks' + ? 'Performance based on Top Picks recommendations only (3 stocks per day)' + : 'Returns measured over hold period (or 1-day when no hold period specified)' }

@@ -1262,25 +1413,31 @@ export default function History() {
- -

- {(indexChartMode === 'topPicks' ? topPicksCumulativeReturns : realCumulativeReturns)?.length ? ( - <> - Cumulative returns for {indexChartMode === 'topPicks' ? 'Top Picks' : 'All 50 stocks'} over{' '} - {(indexChartMode === 'topPicks' ? topPicksCumulativeReturns : realCumulativeReturns)?.length} trading days - - ) : isUsingMockData ? ( - <>Demo data - Start backend for real performance comparison - ) : ( - <>Comparison of cumulative returns between AI strategy and Nifty50 index - )} -

+ {isBacktestDataLoading && !isUsingMockData ? ( + + ) : ( + <> + +

+ {(indexChartMode === 'topPicks' ? topPicksCumulativeReturns : realCumulativeReturns)?.length ? ( + <> + Cumulative returns for {indexChartMode === 'topPicks' ? 'Top Picks' : 'All 50 stocks'} over{' '} + {(indexChartMode === 'topPicks' ? topPicksCumulativeReturns : realCumulativeReturns)?.length} trading days + + ) : isUsingMockData ? ( + <>Demo data - Start backend for real performance comparison + ) : ( + <>Comparison of cumulative returns between AI strategy and Nifty50 index + )} +

+ + )} {/* Return Distribution */} @@ -1300,22 +1457,28 @@ export default function History() {
- -

- {(distributionMode === 'topPicks' ? topPicksReturnDistribution : realReturnDistribution) ? ( - <>Distribution of {distributionMode === 'topPicks' ? 'Top Picks' : 'all 50 stocks'} next-day returns. Click bars to see stocks. - ) : isUsingMockData ? ( - <>Demo data - Start backend for real return distribution - ) : ( - <>Distribution of next-day returns across all predictions. Click bars to see stocks. - )} -

+ {isBacktestDataLoading && !isUsingMockData ? ( + + ) : ( + <> + +

+ {(distributionMode === 'topPicks' ? topPicksReturnDistribution : realReturnDistribution) ? ( + <>Distribution of {distributionMode === 'topPicks' ? 'Top Picks' : 'all 50 stocks'} hold-period returns. Click bars to see stocks. + ) : isUsingMockData ? ( + <>Demo data - Start backend for real return distribution + ) : ( + <>Distribution of hold-period returns across all predictions. Click bars to see stocks. + )} +

+ + )} {/* Accuracy Explanation Modal */} @@ -1329,7 +1492,7 @@ export default function History() { setShowReturnModal(false)} - breakdown={returnModalDate ? (isUsingMockData ? getStaticReturnBreakdown(returnModalDate) : null) : null} + breakdown={returnModalDate ? (isUsingMockData ? getStaticReturnBreakdown(returnModalDate) : buildReturnBreakdown(returnModalDate)) : null} date={returnModalDate || ''} /> @@ -1361,19 +1524,20 @@ export default function History() { setActiveSummaryModal(null)} - title="Average Next-Day Return" + title="Average Return" icon={} >
-

Average Next-Day Return measures the mean percentage price change one trading day after each recommendation.

+

Average Return measures the mean percentage price change over each stock's recommended hold period.

How it's calculated:
  1. Record stock price at recommendation time
  2. -
  3. Record price at next trading day close
  4. -
  5. Calculate: (Next Day Price - Rec Price) / Rec Price × 100
  6. -
  7. Average all these returns
  8. +
  9. Record price after the recommended hold period (e.g. 15 days)
  10. +
  11. Calculate: (Exit Price - Entry Price) / Entry Price × 100
  12. +
  13. Average all these returns across stocks
+

If no hold period is specified, falls back to 1-day return.

= 0 ? 'bg-green-50 dark:bg-green-900/20' : 'bg-red-50 dark:bg-red-900/20'} rounded-lg`}>
Current Average:
diff --git a/frontend/src/pages/StockDetail.tsx b/frontend/src/pages/StockDetail.tsx index bae46c64..74179f84 100644 --- a/frontend/src/pages/StockDetail.tsx +++ b/frontend/src/pages/StockDetail.tsx @@ -27,6 +27,9 @@ interface BacktestResult { decision: string; return1d: number | null; return1w: number | null; + returnAtHold: number | null; + holdDays: number | null; + primaryReturn: number | null; // return_at_hold ?? return_1d predictionCorrect: boolean | null; isLoading?: boolean; } @@ -124,16 +127,14 @@ export default function StockDetail() { const backtest = await api.getBacktestResult(entry.date, symbol); if (backtest.available) { - // Calculate prediction correctness based on 1-day return - // BUY is correct if return > 0, HOLD is correct if return > 0, SELL is correct if return < 0 + // Use hold-period return when available (BUY/HOLD with hold_days), else 1-day return + const primaryReturn = backtest.return_at_hold ?? backtest.actual_return_1d ?? null; let predictionCorrect: boolean | null = null; - if (backtest.actual_return_1d !== undefined && backtest.actual_return_1d !== null) { + if (primaryReturn !== undefined && primaryReturn !== null) { if (entry.decision === 'BUY' || entry.decision === 'HOLD') { - // BUY and HOLD are correct if stock price went up - predictionCorrect = backtest.actual_return_1d > 0; + predictionCorrect = primaryReturn > 0; } else if (entry.decision === 'SELL') { - // SELL is correct if stock price went down - predictionCorrect = backtest.actual_return_1d < 0; + predictionCorrect = primaryReturn < 0; } } @@ -142,6 +143,9 @@ export default function StockDetail() { decision: entry.decision, return1d: backtest.actual_return_1d ?? null, return1w: backtest.actual_return_1w ?? null, + returnAtHold: backtest.return_at_hold ?? null, + holdDays: backtest.hold_days ?? null, + primaryReturn, predictionCorrect, }); } else { @@ -151,6 +155,9 @@ export default function StockDetail() { decision: entry.decision, return1d: null, return1w: null, + returnAtHold: null, + holdDays: null, + primaryReturn: null, predictionCorrect: null, }); } @@ -161,6 +168,9 @@ export default function StockDetail() { decision: entry.decision, return1d: null, return1w: null, + returnAtHold: null, + holdDays: null, + primaryReturn: null, predictionCorrect: null, }); } @@ -179,7 +189,7 @@ export default function StockDetail() { const predictionStats = useMemo((): PredictionStats | null => { if (backtestResults.length === 0) return null; - const resultsWithData = backtestResults.filter(r => r.return1d !== null); + const resultsWithData = backtestResults.filter(r => r.primaryReturn !== null); if (resultsWithData.length === 0) return null; let correct = 0; @@ -189,8 +199,8 @@ export default function StockDetail() { let holdTotal = 0, holdCorrect = 0; for (const result of resultsWithData) { - if (result.return1d !== null) { - totalReturn += result.return1d; + if (result.primaryReturn !== null) { + totalReturn += result.primaryReturn; } if (result.predictionCorrect !== null) { if (result.predictionCorrect) correct++; @@ -916,7 +926,7 @@ export default function StockDetail() { )}
- Real 1-Day Returns + Actual Returns (Hold Period)
@@ -951,18 +961,20 @@ export default function StockDetail() { )} - {/* Outcome - 1 Day Return */} - {entry.return1d !== null ? ( + {/* Outcome - Hold Period Return */} + {entry.primaryReturn !== null ? ( <>
= 0 + entry.primaryReturn >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' }`}> - {entry.return1d >= 0 ? '+' : ''}{entry.return1d.toFixed(1)}% + {entry.primaryReturn >= 0 ? '+' : ''}{entry.primaryReturn.toFixed(1)}% +
+
+ {entry.holdDays && entry.holdDays > 0 ? `${entry.holdDays}d` : '1d'}
-
next day
{/* Prediction Result Icon */} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e19f3422..d8d7ef0e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -408,6 +408,8 @@ class ApiService { return_1d?: number; return_1w?: number; return_1m?: number; + return_at_hold?: number; + hold_days?: number; prediction_correct?: boolean; }>; }> { diff --git a/frontend/src/types/pipeline.ts b/frontend/src/types/pipeline.ts index 126ac75b..78e41f7f 100644 --- a/frontend/src/types/pipeline.ts +++ b/frontend/src/types/pipeline.ts @@ -211,6 +211,25 @@ export const DEBATE_ROLES = { } } as const; +/** + * Maps each pipeline step to the IDs of steps whose output is forwarded as context. + * Steps 1-4 are independent (no forwarded context). + */ +export const STEP_INPUT_SOURCES: Record = { + market_analyst: [], + social_analyst: [], + news_analyst: [], + fundamentals_analyst: [], + bull_researcher: ['market_analyst', 'social_analyst', 'news_analyst', 'fundamentals_analyst'], + bear_researcher: ['market_analyst', 'social_analyst', 'news_analyst', 'fundamentals_analyst'], + research_manager: ['bull_researcher', 'bear_researcher', 'market_analyst', 'social_analyst', 'news_analyst', 'fundamentals_analyst'], + trader: ['research_manager', 'market_analyst', 'social_analyst', 'news_analyst', 'fundamentals_analyst'], + aggressive_analyst: ['trader', 'market_analyst', 'social_analyst', 'news_analyst', 'fundamentals_analyst'], + conservative_analyst: ['trader', 'market_analyst', 'social_analyst', 'news_analyst', 'fundamentals_analyst'], + neutral_analyst: ['trader', 'market_analyst', 'social_analyst', 'news_analyst', 'fundamentals_analyst'], + risk_manager: ['aggressive_analyst', 'conservative_analyst', 'neutral_analyst', 'trader', 'market_analyst', 'social_analyst', 'news_analyst', 'fundamentals_analyst'], +}; + // ============================================================ // Flowchart types for the 12-step visual pipeline debug view // ============================================================ @@ -337,3 +356,14 @@ export function mapPipelineToFlowchart(data: FullPipelineData | null): Flowchart }; }); } + +/** + * Get human-readable labels for a step's input sources. + */ +export function getInputSourceLabels(stepId: string): { id: string; label: string }[] { + const sources = STEP_INPUT_SOURCES[stepId] || []; + return sources.map(id => { + const step = FLOWCHART_STEPS.find(s => s.id === id); + return { id, label: step?.label || id }; + }); +} diff --git a/fundamentals-step-output.png b/fundamentals-step-output.png new file mode 100644 index 00000000..b60a0efa Binary files /dev/null and b/fundamentals-step-output.png differ diff --git a/fundamentals-tool-calls-mixed.png b/fundamentals-tool-calls-mixed.png new file mode 100644 index 00000000..fd47e73e Binary files /dev/null and b/fundamentals-tool-calls-mixed.png differ diff --git a/pipeline-context-forwarded-raw.png b/pipeline-context-forwarded-raw.png new file mode 100644 index 00000000..bf24d08b Binary files /dev/null and b/pipeline-context-forwarded-raw.png differ diff --git a/pipeline-context-light-theme.png b/pipeline-context-light-theme.png new file mode 100644 index 00000000..ffab4127 Binary files /dev/null and b/pipeline-context-light-theme.png differ diff --git a/pipeline-context-risk-manager.png b/pipeline-context-risk-manager.png new file mode 100644 index 00000000..cc2863a6 Binary files /dev/null and b/pipeline-context-risk-manager.png differ diff --git a/pipeline-drawer-bottom.png b/pipeline-drawer-bottom.png new file mode 100644 index 00000000..2fc6dbdb Binary files /dev/null and b/pipeline-drawer-bottom.png differ diff --git a/pipeline-forwarded-context-expanded.png b/pipeline-forwarded-context-expanded.png new file mode 100644 index 00000000..cdc65c25 Binary files /dev/null and b/pipeline-forwarded-context-expanded.png differ diff --git a/pipeline-light-theme.png b/pipeline-light-theme.png new file mode 100644 index 00000000..23840a21 Binary files /dev/null and b/pipeline-light-theme.png differ diff --git a/pipeline-redesigned-drawer.png b/pipeline-redesigned-drawer.png new file mode 100644 index 00000000..8c31a093 Binary files /dev/null and b/pipeline-redesigned-drawer.png differ diff --git a/pipeline-tool-calls.png b/pipeline-tool-calls.png new file mode 100644 index 00000000..b15b9325 Binary files /dev/null and b/pipeline-tool-calls.png differ diff --git a/step-output-actual-analysis.png b/step-output-actual-analysis.png new file mode 100644 index 00000000..94f9be66 Binary files /dev/null and b/step-output-actual-analysis.png differ diff --git a/tool-calls-indicators.png b/tool-calls-indicators.png new file mode 100644 index 00000000..04ab6cec Binary files /dev/null and b/tool-calls-indicators.png differ diff --git a/tool-calls-with-results.png b/tool-calls-with-results.png new file mode 100644 index 00000000..527fe65e Binary files /dev/null and b/tool-calls-with-results.png differ diff --git a/tradingagents/agents/analysts/fundamentals_analyst.py b/tradingagents/agents/analysts/fundamentals_analyst.py index 0fe41a40..01829068 100644 --- a/tradingagents/agents/analysts/fundamentals_analyst.py +++ b/tradingagents/agents/analysts/fundamentals_analyst.py @@ -1,7 +1,7 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder import time import json -from tradingagents.agents.utils.agent_utils import get_fundamentals, get_balance_sheet, get_cashflow, get_income_statement, get_insider_sentiment, get_insider_transactions +from tradingagents.agents.utils.agent_utils import get_fundamentals, get_balance_sheet, get_cashflow, get_income_statement, get_insider_sentiment, get_insider_transactions, execute_text_tool_calls, needs_followup_call, execute_default_tools, generate_analysis_from_data from tradingagents.dataflows.config import get_config from tradingagents.log_utils import add_log, step_timer, symbol_progress @@ -70,6 +70,23 @@ def create_fundamentals_analyst(llm): if len(result.tool_calls) == 0: report = result.content add_log("llm", "fundamentals", f"LLM responded in {elapsed:.1f}s ({len(report)} chars)") + tool_results = execute_text_tool_calls(report, tools) + if tool_results: + add_log("data", "fundamentals", f"Executed {len(tool_results)} tool calls: {', '.join(t['name'] for t in tool_results)}") + else: + add_log("agent", "fundamentals", f"🔄 No tool calls found, proactively fetching data for {ticker}...") + tool_results = execute_default_tools(tools, ticker, current_date) + add_log("data", "fundamentals", f"Proactively fetched {len(tool_results)} data sources") + + if tool_results and needs_followup_call(report): + add_log("agent", "fundamentals", f"🔄 Generating analysis from {len(tool_results)} tool results...") + t1 = time.time() + followup = generate_analysis_from_data(llm, tool_results, system_message, ticker, current_date) + elapsed2 = time.time() - t1 + if followup and len(followup) > 100: + report = followup + add_log("llm", "fundamentals", f"Follow-up analysis generated in {elapsed2:.1f}s ({len(report)} chars)") + add_log("agent", "fundamentals", f"✅ Fundamentals report ready: {report[:300]}...") step_timer.end_step("fundamentals_analyst", "completed", report[:200]) symbol_progress.step_done(ticker, "fundamentals_analyst") @@ -77,6 +94,7 @@ def create_fundamentals_analyst(llm): "system_prompt": system_message[:2000], "user_prompt": f"Analyze fundamentals for {ticker} on {current_date}", "response": report[:3000], + "tool_calls": tool_results if tool_results else [], }) else: tool_call_info = [{"name": tc["name"], "args": str(tc.get("args", {}))[:200]} for tc in result.tool_calls] diff --git a/tradingagents/agents/analysts/market_analyst.py b/tradingagents/agents/analysts/market_analyst.py index 318293cd..8d85e7d5 100644 --- a/tradingagents/agents/analysts/market_analyst.py +++ b/tradingagents/agents/analysts/market_analyst.py @@ -1,7 +1,7 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder import time import json -from tradingagents.agents.utils.agent_utils import get_stock_data, get_indicators +from tradingagents.agents.utils.agent_utils import get_stock_data, get_indicators, execute_text_tool_calls, needs_followup_call, execute_default_tools, generate_analysis_from_data from tradingagents.dataflows.config import get_config from tradingagents.log_utils import add_log, step_timer, symbol_progress @@ -93,14 +93,34 @@ Volume-Based Indicators: if len(result.tool_calls) == 0: report = result.content add_log("llm", "market_analyst", f"LLM responded in {elapsed:.1f}s ({len(report)} chars)") + # Execute any text-based tool calls and capture results + tool_results = execute_text_tool_calls(report, tools) + if tool_results: + add_log("data", "market_analyst", f"Executed {len(tool_results)} tool calls: {', '.join(t['name'] for t in tool_results)}") + else: + # LLM didn't produce TOOL_CALL patterns — proactively fetch data + add_log("agent", "market_analyst", f"🔄 No tool calls found, proactively fetching data for {ticker}...") + tool_results = execute_default_tools(tools, ticker, current_date) + add_log("data", "market_analyst", f"Proactively fetched {len(tool_results)} data sources") + + # If report is mostly tool calls / thin prose, make follow-up LLM call with actual data + if tool_results and needs_followup_call(report): + add_log("agent", "market_analyst", f"🔄 Generating analysis from {len(tool_results)} tool results...") + t1 = time.time() + followup = generate_analysis_from_data(llm, tool_results, system_message, ticker, current_date) + elapsed2 = time.time() - t1 + if followup and len(followup) > 100: + report = followup + add_log("llm", "market_analyst", f"Follow-up analysis generated in {elapsed2:.1f}s ({len(report)} chars)") + add_log("agent", "market_analyst", f"✅ Market report ready: {report[:300]}...") step_timer.end_step("market_analyst", "completed", report[:200]) symbol_progress.step_done(ticker, "market_analyst") - # Use update_details to preserve tool_calls from previous invocation step_timer.update_details("market_analyst", { "system_prompt": system_message[:2000], "user_prompt": f"Analyze {ticker} on {current_date} using technical indicators", "response": report[:3000], + "tool_calls": tool_results if tool_results else [], }) else: tool_call_info = [{"name": tc["name"], "args": str(tc.get("args", {}))[:200]} for tc in result.tool_calls] diff --git a/tradingagents/agents/analysts/news_analyst.py b/tradingagents/agents/analysts/news_analyst.py index ac8a4003..3735344e 100644 --- a/tradingagents/agents/analysts/news_analyst.py +++ b/tradingagents/agents/analysts/news_analyst.py @@ -1,7 +1,7 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder import time import json -from tradingagents.agents.utils.agent_utils import get_news, get_global_news +from tradingagents.agents.utils.agent_utils import get_news, get_global_news, execute_text_tool_calls, needs_followup_call, execute_default_tools, generate_analysis_from_data from tradingagents.dataflows.config import get_config from tradingagents.log_utils import add_log, step_timer, symbol_progress @@ -66,6 +66,23 @@ def create_news_analyst(llm): if len(result.tool_calls) == 0: report = result.content add_log("llm", "news_analyst", f"LLM responded in {elapsed:.1f}s ({len(report)} chars)") + tool_results = execute_text_tool_calls(report, tools) + if tool_results: + add_log("data", "news_analyst", f"Executed {len(tool_results)} tool calls: {', '.join(t['name'] for t in tool_results)}") + else: + add_log("agent", "news_analyst", f"🔄 No tool calls found, proactively fetching data for {ticker}...") + tool_results = execute_default_tools(tools, ticker, current_date) + add_log("data", "news_analyst", f"Proactively fetched {len(tool_results)} data sources") + + if tool_results and needs_followup_call(report): + add_log("agent", "news_analyst", f"🔄 Generating analysis from {len(tool_results)} tool results...") + t1 = time.time() + followup = generate_analysis_from_data(llm, tool_results, system_message, ticker, current_date) + elapsed2 = time.time() - t1 + if followup and len(followup) > 100: + report = followup + add_log("llm", "news_analyst", f"Follow-up analysis generated in {elapsed2:.1f}s ({len(report)} chars)") + add_log("agent", "news_analyst", f"✅ News report ready: {report[:300]}...") step_timer.end_step("news_analyst", "completed", report[:200]) symbol_progress.step_done(ticker, "news_analyst") @@ -73,6 +90,7 @@ def create_news_analyst(llm): "system_prompt": system_message[:2000], "user_prompt": f"Analyze news and macro trends for {ticker} on {current_date}", "response": report[:3000], + "tool_calls": tool_results if tool_results else [], }) else: tool_call_info = [{"name": tc["name"], "args": str(tc.get("args", {}))[:200]} for tc in result.tool_calls] diff --git a/tradingagents/agents/analysts/social_media_analyst.py b/tradingagents/agents/analysts/social_media_analyst.py index fadd2e0c..243bcca5 100644 --- a/tradingagents/agents/analysts/social_media_analyst.py +++ b/tradingagents/agents/analysts/social_media_analyst.py @@ -1,7 +1,7 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder import time import json -from tradingagents.agents.utils.agent_utils import get_news +from tradingagents.agents.utils.agent_utils import get_news, execute_text_tool_calls, needs_followup_call, execute_default_tools, generate_analysis_from_data from tradingagents.dataflows.config import get_config from tradingagents.log_utils import add_log, step_timer, symbol_progress @@ -66,6 +66,23 @@ def create_social_media_analyst(llm): if len(result.tool_calls) == 0: report = result.content add_log("llm", "social_analyst", f"LLM responded in {elapsed:.1f}s ({len(report)} chars)") + tool_results = execute_text_tool_calls(report, tools) + if tool_results: + add_log("data", "social_analyst", f"Executed {len(tool_results)} tool calls: {', '.join(t['name'] for t in tool_results)}") + else: + add_log("agent", "social_analyst", f"🔄 No tool calls found, proactively fetching data for {ticker}...") + tool_results = execute_default_tools(tools, ticker, current_date) + add_log("data", "social_analyst", f"Proactively fetched {len(tool_results)} data sources") + + if tool_results and needs_followup_call(report): + add_log("agent", "social_analyst", f"🔄 Generating analysis from {len(tool_results)} tool results...") + t1 = time.time() + followup = generate_analysis_from_data(llm, tool_results, system_message, ticker, current_date) + elapsed2 = time.time() - t1 + if followup and len(followup) > 100: + report = followup + add_log("llm", "social_analyst", f"Follow-up analysis generated in {elapsed2:.1f}s ({len(report)} chars)") + add_log("agent", "social_analyst", f"✅ Sentiment report ready: {report[:300]}...") step_timer.end_step("social_media_analyst", "completed", report[:200]) symbol_progress.step_done(ticker, "social_media_analyst") @@ -73,6 +90,7 @@ def create_social_media_analyst(llm): "system_prompt": system_message[:2000], "user_prompt": f"Analyze social media sentiment for {ticker} on {current_date}", "response": report[:3000], + "tool_calls": tool_results if tool_results else [], }) else: tool_call_info = [{"name": tc["name"], "args": str(tc.get("args", {}))[:200]} for tc in result.tool_calls] diff --git a/tradingagents/agents/managers/risk_manager.py b/tradingagents/agents/managers/risk_manager.py index 667530ec..52a1a924 100644 --- a/tradingagents/agents/managers/risk_manager.py +++ b/tradingagents/agents/managers/risk_manager.py @@ -14,7 +14,7 @@ def create_risk_manager(llm, memory): risk_debate_state = state["risk_debate_state"] market_research_report = state["market_report"] news_report = state["news_report"] - fundamentals_report = state["news_report"] + fundamentals_report = state["fundamentals_report"] sentiment_report = state["sentiment_report"] trader_plan = state["investment_plan"] @@ -39,8 +39,10 @@ Your task: Evaluate the risk debate between Aggressive, Neutral, and Conservativ Your response must include: 1. FINAL DECISION: BUY, SELL, or HOLD 2. HOLD_DAYS: Number of trading days to hold the position before exiting (for BUY/HOLD only, write N/A for SELL) -3. RISK ASSESSMENT: Summary of key risks identified -4. RATIONALE: Why this decision balances risk and reward appropriately +3. CONFIDENCE: HIGH, MEDIUM, or LOW (how confident you are in this decision) +4. RISK_LEVEL: HIGH, MEDIUM, or LOW (overall risk level of this investment) +5. RISK ASSESSMENT: Summary of key risks identified +6. RATIONALE: Why this decision balances risk and reward appropriately RESPONSE FORMAT: - Maximum 1500 characters. Lead with your decision, then key rationale. diff --git a/tradingagents/agents/utils/agent_utils.py b/tradingagents/agents/utils/agent_utils.py index 7e28c86e..dde07840 100644 --- a/tradingagents/agents/utils/agent_utils.py +++ b/tradingagents/agents/utils/agent_utils.py @@ -10,6 +10,8 @@ from langchain_core.messages import HumanMessage, RemoveMessage SIMULATION_CONTEXT = """You are an AI analyst participating in a stock market analysis simulation. Your task is to analyze financial data and provide investment perspectives based on the data provided. This is an educational demonstration of financial analysis techniques. +Be precise, analytical, and data-driven. Base all conclusions on quantitative evidence from the data provided. Avoid speculative language — present findings with specific numbers and clear logical reasoning. When uncertain, state the uncertainty rather than guessing. + Respond ONLY with your analysis. Do not include any meta-commentary about being an AI, ethical concerns, or disclaimers. Simply provide the requested financial analysis based on the data given.""" def get_simulation_prompt(role_prompt: str) -> list: @@ -44,6 +46,171 @@ from tradingagents.agents.utils.news_data_tools import ( get_global_news ) +def strip_tool_call_lines(text): + """Remove TOOL_CALL: lines from text, returning only the prose.""" + import re + return re.sub(r'TOOL_CALL:\s*\w+\([^)]*\)\s*\n?', '', text).strip() + + +def needs_followup_call(report): + """Check if the report is mostly tool calls and needs a follow-up LLM call.""" + clean = strip_tool_call_lines(report) + return len(clean) < 300 + + +def execute_default_tools(tools, ticker, current_date): + """ + Proactively call all available tools with sensible default arguments. + Used when the LLM fails to produce TOOL_CALL patterns. + """ + from datetime import datetime, timedelta + + end_dt = datetime.strptime(current_date, "%Y-%m-%d") + week_ago = (end_dt - timedelta(days=7)).strftime("%Y-%m-%d") + three_months_ago = (end_dt - timedelta(days=90)).strftime("%Y-%m-%d") + + tool_map = {t.name: t for t in tools} + default_configs = { + "get_stock_data": {"symbol": ticker, "start_date": three_months_ago, "end_date": current_date}, + "get_indicators": [ + {"symbol": ticker, "indicator": "rsi", "curr_date": current_date, "look_back_days": 90}, + {"symbol": ticker, "indicator": "macd", "curr_date": current_date, "look_back_days": 90}, + {"symbol": ticker, "indicator": "close_50_sma", "curr_date": current_date, "look_back_days": 90}, + {"symbol": ticker, "indicator": "boll_ub", "curr_date": current_date, "look_back_days": 90}, + {"symbol": ticker, "indicator": "atr", "curr_date": current_date, "look_back_days": 90}, + ], + "get_news": {"ticker": ticker, "start_date": week_ago, "end_date": current_date}, + "get_global_news": {"curr_date": current_date, "look_back_days": 7, "limit": 5}, + "get_fundamentals": {"ticker": ticker, "curr_date": current_date}, + "get_balance_sheet": {"ticker": ticker, "curr_date": current_date}, + "get_cashflow": {"ticker": ticker, "curr_date": current_date}, + "get_income_statement": {"ticker": ticker, "curr_date": current_date}, + } + + results = [] + for tool in tools: + config = default_configs.get(tool.name) + if config is None: + continue + # Handle tools that need multiple calls (e.g., get_indicators with different indicators) + calls = config if isinstance(config, list) else [config] + for args in calls: + try: + result = tool.invoke(args) + results.append({ + "name": tool.name, + "args": str(args), + "result_preview": str(result)[:1500], + }) + except Exception as e: + results.append({ + "name": tool.name, + "args": str(args), + "result_preview": f"[Tool error: {str(e)[:200]}]", + }) + return results + + +def generate_analysis_from_data(llm, tool_results, system_message, ticker, current_date): + """ + Make a follow-up LLM call with actual tool data to generate the analysis. + Called when the first LLM response was mostly tool call requests without analysis. + """ + data_sections = [] + for r in tool_results: + preview = r.get('result_preview', '') + if not preview: + data_sections.append(f"### {r['name']}({r['args']})\n(No data returned — tool executed but returned empty result)") + elif preview.startswith('[Tool error') or preview.startswith('[Could not') or preview.startswith('[Unknown'): + data_sections.append(f"### {r['name']}({r['args']})\nError: {preview}") + else: + data_sections.append(f"### {r['name']}({r['args']})\n```\n{preview}\n```") + + if not data_sections: + return "" + + data_text = "\n\n".join(data_sections) + + message = f"""Here are the results from the data retrieval tools for {ticker} as of {current_date}: + +{data_text} + +Based on this data, write your comprehensive analysis. + +{system_message} + +IMPORTANT: +- Write your analysis directly based on the data above +- Do NOT request any more tool calls or use TOOL_CALL syntax +- Provide detailed, actionable insights with specific numbers from the data +- Include a Markdown table summarizing key findings""" + + result = llm.invoke([HumanMessage(content=message)]) + return result.content + + +def execute_text_tool_calls(response_text, tools): + """ + Parse TOOL_CALL: patterns from LLM response text, execute the actual + tool functions, and return structured results. + + Args: + response_text: Raw LLM response that may contain TOOL_CALL: patterns + tools: List of @tool-decorated LangChain tool objects available for this agent + + Returns: + List of dicts with {name, args, result_preview} for each executed tool call + """ + import re + import ast + + tool_map = {t.name: t for t in tools} + regex = re.compile(r'TOOL_CALL:\s*(\w+)\(([^)]*)\)') + results = [] + + for match in regex.finditer(response_text): + fn_name = match.group(1) + raw_args = match.group(2).strip() + tool_fn = tool_map.get(fn_name) + + if not tool_fn: + results.append({ + "name": fn_name, + "args": raw_args, + "result_preview": f"[Unknown tool: {fn_name}]", + }) + continue + + # Parse positional args and map to parameter names + try: + parsed = ast.literal_eval(f"({raw_args},)") # tuple of values + param_names = list(tool_fn.args_schema.model_fields.keys()) + invoke_args = {} + for i, val in enumerate(parsed): + if i < len(param_names): + invoke_args[param_names[i]] = val + except Exception: + invoke_args = None + + # Execute the tool + result_text = "" + try: + if invoke_args: + result_text = tool_fn.invoke(invoke_args) + else: + result_text = f"[Could not parse args: {raw_args}]" + except Exception as e: + result_text = f"[Tool error: {str(e)[:200]}]" + + results.append({ + "name": fn_name, + "args": raw_args, + "result_preview": str(result_text)[:1500], + }) + + return results + + def create_msg_delete(): def delete_messages(state): """Clear messages and add placeholder for Anthropic compatibility""" diff --git a/tradingagents/claude_max_llm.py b/tradingagents/claude_max_llm.py index 332dad69..4cf6c329 100644 --- a/tradingagents/claude_max_llm.py +++ b/tradingagents/claude_max_llm.py @@ -36,7 +36,7 @@ class ClaudeMaxLLM(BaseChatModel): model: str = "sonnet" # Use alias for Claude Max subscription max_tokens: int = 4096 - temperature: float = 0.7 + temperature: float = 0.2 claude_cli_path: str = "claude" tools: List[Any] = [] # Bound tools diff --git a/tradingagents/dataflows/google.py b/tradingagents/dataflows/google.py index bf424e71..c14da7ee 100644 --- a/tradingagents/dataflows/google.py +++ b/tradingagents/dataflows/google.py @@ -1,7 +1,7 @@ from typing import Annotated, Union from datetime import datetime from dateutil.relativedelta import relativedelta -from .googlenews_utils import getNewsData +from .googlenews_utils import getNewsData, getGlobalNewsData from .markets import is_nifty_50_stock, get_nifty_50_company_name @@ -65,4 +65,22 @@ def get_google_news( # Use original query (symbol) in the header for clarity display_query = original_query if is_nifty_50_stock(original_query) else query.replace("+", " ") - return f"## {display_query} Google News, from {before} to {curr_date}:\n\n{news_str}" \ No newline at end of file + return f"## {display_query} Google News, from {before} to {curr_date}:\n\n{news_str}" + + +def get_google_global_news( + curr_date: Annotated[str, "Current date in yyyy-mm-dd format"], + look_back_days: Annotated[int, "How many days to look back"] = 7, + limit: Annotated[int, "Maximum number of news items to return"] = 10, +) -> str: + """Fetch global/macro financial news via Google News RSS feed.""" + news_results = getGlobalNewsData(curr_date, look_back_days=look_back_days, limit=limit) + + if not news_results: + return "" + + news_str = "" + for news in news_results: + news_str += f"### {news['title']} (source: {news['source']})\n\n{news['snippet']}\n\n" + + return f"## Global Market News (past {look_back_days} days as of {curr_date}):\n\n{news_str}" \ No newline at end of file diff --git a/tradingagents/dataflows/googlenews_utils.py b/tradingagents/dataflows/googlenews_utils.py index bdc6124d..06801007 100644 --- a/tradingagents/dataflows/googlenews_utils.py +++ b/tradingagents/dataflows/googlenews_utils.py @@ -1,108 +1,157 @@ -import json import requests from bs4 import BeautifulSoup from datetime import datetime -import time -import random -from tenacity import ( - retry, - stop_after_attempt, - wait_exponential, - retry_if_exception_type, - retry_if_result, -) - - -def is_rate_limited(response): - """Check if the response indicates rate limiting (status code 429)""" - return response.status_code == 429 - - -@retry( - retry=(retry_if_result(is_rate_limited)), - wait=wait_exponential(multiplier=1, min=4, max=60), - stop=stop_after_attempt(5), -) -def make_request(url, headers): - """Make a request with retry logic for rate limiting""" - # Random delay before each request to avoid detection - time.sleep(random.uniform(2, 6)) - response = requests.get(url, headers=headers) - return response +import urllib.parse def getNewsData(query, start_date, end_date): """ - Scrape Google News search results for a given query and date range. - query: str - search query - start_date: str - start date in the format yyyy-mm-dd or mm/dd/yyyy - end_date: str - end date in the format yyyy-mm-dd or mm/dd/yyyy - """ - if "-" in start_date: - start_date = datetime.strptime(start_date, "%Y-%m-%d") - start_date = start_date.strftime("%m/%d/%Y") - if "-" in end_date: - end_date = datetime.strptime(end_date, "%Y-%m-%d") - end_date = end_date.strftime("%m/%d/%Y") + Fetch Google News via RSS feed for a given query and date range. - headers = { - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/101.0.4951.54 Safari/537.36" - ) - } + Uses Google News RSS which is reliable (no JS rendering or CSS selectors needed). + Results are filtered to only include articles within the date range. + + query: str - search query (spaces or '+' separated) + start_date: str - start date in yyyy-mm-dd or mm/dd/yyyy format + end_date: str - end date in yyyy-mm-dd or mm/dd/yyyy format + """ + # Normalize dates to datetime objects for filtering + if "/" in str(start_date): + start_dt = datetime.strptime(start_date, "%m/%d/%Y") + else: + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + + if "/" in str(end_date): + end_dt = datetime.strptime(end_date, "%m/%d/%Y") + else: + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + + # Clean up query (replace + with spaces for URL encoding) + clean_query = query.replace("+", " ") + encoded_query = urllib.parse.quote(clean_query) + + # Use Google News RSS feed — reliable, no scraping issues + url = f"https://news.google.com/rss/search?q={encoded_query}+after:{start_dt.strftime('%Y-%m-%d')}+before:{end_dt.strftime('%Y-%m-%d')}&hl=en-IN&gl=IN&ceid=IN:en" news_results = [] - page = 0 - while True: - offset = page * 10 - url = ( - f"https://www.google.com/search?q={query}" - f"&tbs=cdr:1,cd_min:{start_date},cd_max:{end_date}" - f"&tbm=nws&start={offset}" - ) + try: + resp = requests.get(url, timeout=15) + if resp.status_code != 200: + return news_results - try: - response = make_request(url, headers) - soup = BeautifulSoup(response.content, "html.parser") - results_on_page = soup.select("div.SoaBEf") + soup = BeautifulSoup(resp.content, "xml") + items = soup.find_all("item") - if not results_on_page: - break # No more results found + for item in items[:20]: # Limit to 20 articles + try: + title = item.find("title").text if item.find("title") else "" + pub_date_str = item.find("pubDate").text if item.find("pubDate") else "" + source = item.find("source").text if item.find("source") else "" + link = item.find("link").text if item.find("link") else "" + # Description often contains HTML snippet + desc_tag = item.find("description") + snippet = "" + if desc_tag: + desc_soup = BeautifulSoup(desc_tag.text, "html.parser") + snippet = desc_soup.get_text()[:300] - for el in results_on_page: - try: - link = el.find("a")["href"] - title = el.select_one("div.MBeuO").get_text() - snippet = el.select_one(".GI74Re").get_text() - date = el.select_one(".LfVVr").get_text() - source = el.select_one(".NUnG9d span").get_text() - news_results.append( - { - "link": link, - "title": title, - "snippet": snippet, - "date": date, - "source": source, - } - ) - except Exception as e: - print(f"Error processing result: {e}") - # If one of the fields is not found, skip this result - continue + # Parse and filter by date + if pub_date_str: + try: + pub_dt = datetime.strptime(pub_date_str, "%a, %d %b %Y %H:%M:%S %Z") + if pub_dt.date() < start_dt.date() or pub_dt.date() > end_dt.date(): + continue + date_display = pub_dt.strftime("%Y-%m-%d") + except ValueError: + date_display = pub_date_str + else: + date_display = "" - # Update the progress bar with the current count of results scraped + news_results.append({ + "link": link, + "title": title, + "snippet": snippet if snippet else title, + "date": date_display, + "source": source, + }) + except Exception: + continue - # Check for the "Next" link (pagination) - next_link = soup.find("a", id="pnnext") - if not next_link: - break - - page += 1 - - except Exception as e: - print(f"Failed after multiple retries: {e}") - break + except Exception as e: + print(f"Google News RSS fetch failed: {e}") return news_results + + +def getGlobalNewsData(curr_date, look_back_days=7, limit=10): + """ + Fetch global/macro news via Google News RSS feed. + + Uses broad financial/market queries to get macroeconomic news. + """ + if isinstance(curr_date, str): + end_dt = datetime.strptime(curr_date, "%Y-%m-%d") + else: + end_dt = curr_date + + from dateutil.relativedelta import relativedelta + start_dt = end_dt - relativedelta(days=look_back_days) + + queries = [ + "stock market India NSE Nifty", + "global economy markets finance", + ] + + all_results = [] + seen_titles = set() + + for query in queries: + encoded = urllib.parse.quote(query) + url = f"https://news.google.com/rss/search?q={encoded}+after:{start_dt.strftime('%Y-%m-%d')}+before:{end_dt.strftime('%Y-%m-%d')}&hl=en-IN&gl=IN&ceid=IN:en" + + try: + resp = requests.get(url, timeout=15) + if resp.status_code != 200: + continue + + soup = BeautifulSoup(resp.content, "xml") + items = soup.find_all("item") + + for item in items: + try: + title = item.find("title").text if item.find("title") else "" + if title in seen_titles: + continue + seen_titles.add(title) + + pub_date_str = item.find("pubDate").text if item.find("pubDate") else "" + source = item.find("source").text if item.find("source") else "" + desc_tag = item.find("description") + snippet = "" + if desc_tag: + desc_soup = BeautifulSoup(desc_tag.text, "html.parser") + snippet = desc_soup.get_text()[:300] + + date_display = "" + if pub_date_str: + try: + pub_dt = datetime.strptime(pub_date_str, "%a, %d %b %Y %H:%M:%S %Z") + date_display = pub_dt.strftime("%Y-%m-%d") + except ValueError: + date_display = pub_date_str + + all_results.append({ + "title": title, + "snippet": snippet if snippet else title, + "date": date_display, + "source": source, + }) + except Exception: + continue + + except Exception: + continue + + # Sort by date descending and limit + all_results.sort(key=lambda x: x.get("date", ""), reverse=True) + return all_results[:limit] diff --git a/tradingagents/dataflows/interface.py b/tradingagents/dataflows/interface.py index 3bf5a98f..3c3a3341 100644 --- a/tradingagents/dataflows/interface.py +++ b/tradingagents/dataflows/interface.py @@ -42,8 +42,8 @@ def clear_request_cache(): # Import from vendor-specific modules from .local import get_YFin_data, get_finnhub_news, get_finnhub_company_insider_sentiment, get_finnhub_company_insider_transactions, get_simfin_balance_sheet, get_simfin_cashflow, get_simfin_income_statements, get_reddit_global_news, get_reddit_company_news -from .y_finance import get_YFin_data_online, get_stock_stats_indicators_window, get_balance_sheet as get_yfinance_balance_sheet, get_cashflow as get_yfinance_cashflow, get_income_statement as get_yfinance_income_statement, get_insider_transactions as get_yfinance_insider_transactions -from .google import get_google_news +from .y_finance import get_YFin_data_online, get_stock_stats_indicators_window, get_balance_sheet as get_yfinance_balance_sheet, get_cashflow as get_yfinance_cashflow, get_income_statement as get_yfinance_income_statement, get_insider_transactions as get_yfinance_insider_transactions, get_fundamentals as get_yfinance_fundamentals +from .google import get_google_news, get_google_global_news from .openai import get_stock_news_openai, get_global_news_openai, get_fundamentals_openai from .alpha_vantage import ( get_stock as get_alpha_vantage_stock, @@ -122,6 +122,7 @@ VENDOR_METHODS = { }, # fundamental_data "get_fundamentals": { + "yfinance": get_yfinance_fundamentals, "alpha_vantage": get_alpha_vantage_fundamentals, "openai": get_fundamentals_openai, }, @@ -148,8 +149,9 @@ VENDOR_METHODS = { "local": [get_finnhub_news, get_reddit_company_news, get_google_news], }, "get_global_news": { + "google": get_google_global_news, "openai": get_global_news_openai, - "local": get_reddit_global_news + "local": get_reddit_global_news, }, "get_insider_sentiment": { "local": get_finnhub_company_insider_sentiment diff --git a/tradingagents/dataflows/y_finance.py b/tradingagents/dataflows/y_finance.py index cbe6490a..a7e3a8ae 100644 --- a/tradingagents/dataflows/y_finance.py +++ b/tradingagents/dataflows/y_finance.py @@ -461,6 +461,63 @@ def get_income_statement( return f"Error retrieving income statement for {normalized_ticker}: {str(e)}" +def get_fundamentals( + ticker: Annotated[str, "ticker symbol of the company"], + curr_date: Annotated[str, "current date for reference"] = None, +) -> str: + """Get comprehensive company fundamentals from yfinance (.info).""" + try: + normalized_ticker = normalize_symbol(ticker, target="yfinance") + ticker_obj = yf.Ticker(normalized_ticker) + info = ticker_obj.info + + if not info or len(info) < 5: + return f"No fundamentals data found for symbol '{normalized_ticker}'" + + # Select the most useful keys for analysis + key_groups = { + "Valuation": ["marketCap", "enterpriseValue", "trailingPE", "forwardPE", + "priceToBook", "priceToSalesTrailing12Months", "enterpriseToRevenue", + "enterpriseToEbitda"], + "Profitability": ["profitMargins", "operatingMargins", "grossMargins", + "returnOnAssets", "returnOnEquity", "revenueGrowth", + "earningsGrowth", "earningsQuarterlyGrowth"], + "Dividends": ["dividendRate", "dividendYield", "payoutRatio", + "fiveYearAvgDividendYield", "trailingAnnualDividendRate"], + "Financial Health": ["totalCash", "totalDebt", "debtToEquity", + "currentRatio", "quickRatio", "freeCashflow", + "operatingCashflow", "totalRevenue", "ebitda"], + "Trading": ["currentPrice", "targetHighPrice", "targetLowPrice", + "targetMeanPrice", "recommendationKey", "numberOfAnalystOpinions", + "fiftyTwoWeekHigh", "fiftyTwoWeekLow", "fiftyDayAverage", + "twoHundredDayAverage", "beta", "volume", "averageVolume"], + "Company Info": ["sector", "industry", "fullTimeEmployees", "country", "city"], + } + + sections = [] + sections.append(f"# Fundamentals for {normalized_ticker}") + if curr_date: + sections.append(f"# As of: {curr_date}") + sections.append(f"# Company: {info.get('longName', info.get('shortName', ticker))}") + sections.append("") + + for group_name, keys in key_groups.items(): + group_lines = [] + for key in keys: + val = info.get(key) + if val is not None: + group_lines.append(f" {key}: {val}") + if group_lines: + sections.append(f"## {group_name}") + sections.extend(group_lines) + sections.append("") + + return "\n".join(sections) + + except Exception as e: + return f"Error retrieving fundamentals for {ticker}: {str(e)}" + + def get_insider_transactions( ticker: Annotated[str, "ticker symbol of the company"] ): diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index 57adff38..5ddf1ef4 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -13,6 +13,7 @@ DEFAULT_CONFIG = { "deep_think_llm": "o4-mini", "quick_think_llm": "gpt-4o-mini", "backend_url": "https://api.openai.com/v1", + "llm_temperature": 0.2, # Low temperature for deterministic financial analysis # Anthropic-specific config for Claude models (using aliases for Claude Max subscription) "anthropic_config": { "deep_think_llm": "opus", # Claude Opus 4.5 for deep analysis diff --git a/tradingagents/graph/signal_processing.py b/tradingagents/graph/signal_processing.py index 593c1b38..e81e4c13 100644 --- a/tradingagents/graph/signal_processing.py +++ b/tradingagents/graph/signal_processing.py @@ -1,5 +1,6 @@ # TradingAgents/graph/signal_processing.py +import re from langchain_openai import ChatOpenAI @@ -12,37 +13,97 @@ class SignalProcessor: def process_signal(self, full_signal: str) -> dict: """ - Process a full trading signal to extract the core decision and hold_days. + Process a full trading signal to extract the core decision, hold_days, + confidence, and risk level. Args: full_signal: Complete trading signal text Returns: - Dict with 'decision' (BUY/SELL/HOLD) and 'hold_days' (int or None) + Dict with 'decision', 'hold_days', 'confidence', 'risk' """ messages = [ ( "system", "You are an efficient assistant designed to analyze paragraphs or financial reports " - "provided by a group of analysts. Extract two pieces of information:\n" + "provided by a group of analysts. Extract the following information:\n" "1. The investment decision: SELL, BUY, or HOLD\n" - "2. The recommended holding period in trading days (only for BUY or HOLD decisions)\n\n" + "2. The recommended holding period in trading days (only for BUY or HOLD decisions)\n" + "3. The confidence level of the decision: HIGH, MEDIUM, or LOW\n" + "4. The risk level of the investment: HIGH, MEDIUM, or LOW\n\n" "Respond in exactly this format (nothing else):\n" "DECISION: \n" - "HOLD_DAYS: \n\n" + "HOLD_DAYS: \n" + "CONFIDENCE: \n" + "RISK_LEVEL: \n\n" "For SELL decisions, always use HOLD_DAYS: N/A\n" - "For BUY or HOLD decisions, extract the number of days if mentioned, otherwise default to 5.", + "For BUY or HOLD decisions, extract the EXACT number of days mentioned in the report. " + "Look for phrases like 'N-day hold', 'N trading days', 'hold for N days', " + "'N-day horizon', 'over N days'. If no specific number is mentioned, use 5.\n" + "For CONFIDENCE and RISK_LEVEL, infer from the tone and content of the report. Default to MEDIUM if unclear.", ), ("human", full_signal), ] response = self.quick_thinking_llm.invoke(messages).content - return self._parse_signal_response(response) + result = self._parse_signal_response(response) + + # If LLM returned default hold_days (5) or failed to extract, try regex on original text + if result["decision"] != "SELL" and result["hold_days"] == 5: + regex_days = self._extract_hold_days_regex(full_signal) + if regex_days is not None: + result["hold_days"] = regex_days + + return result + + @staticmethod + def _extract_hold_days_regex(text: str) -> int | None: + """Extract hold period from text using regex patterns. + + Looks for common patterns like '15-day hold', 'hold for 45 days', + '30 trading days', 'N-day horizon', etc. + """ + patterns = [ + # "15-day hold", "45-day horizon", "30-day period" + r'(\d+)[\s-]*(?:day|trading[\s-]*day)[\s-]*(?:hold|horizon|period|timeframe)', + # "hold for 15 days", "holding period of 45 days" + r'(?:hold|holding)[\s\w]*?(?:for|of|period\s+of)[\s]*(\d+)[\s]*(?:trading\s+)?days?', + # "setting 45 trading days" + r'setting\s+(\d+)\s+(?:trading\s+)?days', + # "over 15 days", "within 30 days" + r'(?:over|within|next)\s+(\d+)\s+(?:trading\s+)?days', + # "N trading days (~2 months)" pattern + r'(\d+)\s+trading\s+days?\s*\(', + ] + + candidates = [] + for pattern in patterns: + for match in re.finditer(pattern, text, re.IGNORECASE): + days = int(match.group(1)) + if 1 <= days <= 90: + candidates.append(days) + + if not candidates: + return None + + # If multiple matches, prefer the one that appears in the conclusion + # (last ~500 chars of text, which is typically the RATIONALE section) + conclusion = text[-500:] + for pattern in patterns: + for match in re.finditer(pattern, conclusion, re.IGNORECASE): + days = int(match.group(1)) + if 1 <= days <= 90: + return days + + # Fall back to most common candidate + return max(set(candidates), key=candidates.count) def _parse_signal_response(self, response: str) -> dict: - """Parse the structured LLM response into decision and hold_days.""" + """Parse the structured LLM response into decision, hold_days, confidence, risk.""" decision = "HOLD" hold_days = None + confidence = "MEDIUM" + risk = "MEDIUM" for line in response.strip().split("\n"): line = line.strip() @@ -63,6 +124,16 @@ class SignalProcessor: hold_days = max(1, min(90, hold_days)) except (ValueError, TypeError): hold_days = None + elif upper.startswith("CONFIDENCE:"): + raw = upper.split(":", 1)[1].strip() + raw = raw.replace("*", "").strip() + if raw in ("HIGH", "MEDIUM", "LOW"): + confidence = raw + elif upper.startswith("RISK_LEVEL:") or upper.startswith("RISK:"): + raw = upper.split(":", 1)[1].strip() + raw = raw.replace("*", "").strip() + if raw in ("HIGH", "MEDIUM", "LOW"): + risk = raw # Enforce: SELL never has hold_days; BUY/HOLD default to 5 if missing if decision == "SELL": @@ -70,4 +141,4 @@ class SignalProcessor: elif hold_days is None: hold_days = 5 # Default hold period - return {"decision": decision, "hold_days": hold_days} + return {"decision": decision, "hold_days": hold_days, "confidence": confidence, "risk": risk} diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 9892fa9f..750a22ff 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -81,17 +81,18 @@ class TradingAgentsGraph: exist_ok=True, ) - # Initialize LLMs + # Initialize LLMs with low temperature for deterministic financial analysis + llm_temp = self.config.get("llm_temperature", 0.2) if self.config["llm_provider"].lower() == "openai" or self.config["llm_provider"] == "ollama" or self.config["llm_provider"] == "openrouter": - self.deep_thinking_llm = ChatOpenAI(model=self.config["deep_think_llm"], base_url=self.config["backend_url"]) - self.quick_thinking_llm = ChatOpenAI(model=self.config["quick_think_llm"], base_url=self.config["backend_url"]) + self.deep_thinking_llm = ChatOpenAI(model=self.config["deep_think_llm"], base_url=self.config["backend_url"], temperature=llm_temp) + self.quick_thinking_llm = ChatOpenAI(model=self.config["quick_think_llm"], base_url=self.config["backend_url"], temperature=llm_temp) elif self.config["llm_provider"].lower() == "anthropic": # Use ClaudeMaxLLM to leverage Claude Max subscription via CLI - self.deep_thinking_llm = ClaudeMaxLLM(model=self.config["deep_think_llm"]) - self.quick_thinking_llm = ClaudeMaxLLM(model=self.config["quick_think_llm"]) + self.deep_thinking_llm = ClaudeMaxLLM(model=self.config["deep_think_llm"], temperature=llm_temp) + self.quick_thinking_llm = ClaudeMaxLLM(model=self.config["quick_think_llm"], temperature=llm_temp) elif self.config["llm_provider"].lower() == "google": - self.deep_thinking_llm = ChatGoogleGenerativeAI(model=self.config["deep_think_llm"]) - self.quick_thinking_llm = ChatGoogleGenerativeAI(model=self.config["quick_think_llm"]) + self.deep_thinking_llm = ChatGoogleGenerativeAI(model=self.config["deep_think_llm"], temperature=llm_temp) + self.quick_thinking_llm = ChatGoogleGenerativeAI(model=self.config["quick_think_llm"], temperature=llm_temp) else: raise ValueError(f"Unsupported LLM provider: {self.config['llm_provider']}") @@ -116,7 +117,10 @@ class TradingAgentsGraph: self.tool_nodes = self._create_tool_nodes() # Initialize components - self.conditional_logic = ConditionalLogic() + self.conditional_logic = ConditionalLogic( + max_debate_rounds=self.config.get("max_debate_rounds", 1), + max_risk_discuss_rounds=self.config.get("max_risk_discuss_rounds", 1), + ) self.graph_setup = GraphSetup( self.quick_thinking_llm, self.deep_thinking_llm, @@ -250,16 +254,18 @@ class TradingAgentsGraph: self._save_to_frontend_db(trade_date, final_state) add_log("info", "system", f"Database save completed in {_time.time() - t0:.1f}s") - # Extract and log the final decision + hold_days + # Extract and log the final decision + hold_days + confidence + risk signal_result = self.process_signal(final_state["final_trade_decision"]) final_decision = signal_result["decision"] hold_days = signal_result.get("hold_days") + confidence = signal_result.get("confidence", "MEDIUM") + risk = signal_result.get("risk", "MEDIUM") total_elapsed = _time.time() - pipeline_start hold_info = f", hold {hold_days}d" if hold_days else "" - add_log("success", "system", f"✅ Analysis complete for {company_name}: {final_decision}{hold_info} (total: {total_elapsed:.0f}s)") + add_log("success", "system", f"✅ Analysis complete for {company_name}: {final_decision}{hold_info}, confidence={confidence}, risk={risk} (total: {total_elapsed:.0f}s)") - # Return decision, hold_days, and processed signal - return final_state, final_decision, hold_days + # Return decision, hold_days, confidence, risk + return final_state, final_decision, hold_days, confidence, risk def _log_state(self, trade_date, final_state): """Log the final state to a JSON file."""