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 && (
+
setExpanded(!expanded)}
+ className="text-[10px] text-blue-500 hover:text-blue-400 mt-0.5"
+ >
+ {expanded ? 'Show less' : `Show all (${result.length} chars)`}
+
+ )}
+
+ );
+}
+
+/** 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 (
+
+ );
+ }
+ 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' ? (
-
) : (
-
-
-
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 && (
+
+ )}
setShowAccuracyModal(true)}
@@ -869,34 +965,48 @@ export default function History() {
How it's calculated
-
-
-
- {(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:
Record stock price at recommendation time
- Record price at next trading day close
- Calculate: (Next Day Price - Rec Price) / Rec Price × 100
- Average all these returns
+ Record price after the recommended hold period (e.g. 15 days)
+ Calculate: (Exit Price - Entry Price) / Entry Price × 100
+ 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."""