This commit is contained in:
hemangjoshi37a 2026-02-08 22:24:13 +11:00
parent a556099d97
commit 473478a32d
41 changed files with 1400 additions and 419 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
dashboard-hold-days.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@ -136,7 +136,8 @@ def calculate_and_save_backtest(date: str, symbol: str, decision: str,
return_1w=result['return_1w'], return_1w=result['return_1w'],
return_1m=result['return_1m'], return_1m=result['return_1m'],
prediction_correct=result['prediction_correct'], 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 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_1d': result['return_1d'],
'actual_return_1w': result['return_1w'], 'actual_return_1w': result['return_1w'],
'actual_return_1m': result['return_1m'], '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'], 'price_at_prediction': result['price_at_prediction'],
'current_price': result.get('price_1m_later') or result.get('price_1w_later'), 'current_price': result.get('price_1m_later') or result.get('price_1w_later'),
'price_history': price_history 'price_history': price_history

View File

@ -172,6 +172,13 @@ def init_db():
cursor.execute("ALTER TABLE backtest_results ADD COLUMN hold_days INTEGER") cursor.execute("ALTER TABLE backtest_results ADD COLUMN hold_days INTEGER")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass # Column already exists 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 # Create indexes for new tables
cursor.execute(""" cursor.execute("""
@ -193,6 +200,80 @@ def init_db():
conn.commit() conn.commit()
conn.close() 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, def save_recommendation(date: str, analysis_data: dict, summary: dict,
top_picks: list, stocks_to_avoid: list): 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, price_1w_later: float = None, price_1m_later: float = None,
return_1d: float = None, return_1w: float = None, return_1d: float = None, return_1w: float = None,
return_1m: float = None, prediction_correct: bool = 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.""" """Save a backtest result for a stock recommendation."""
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
@ -894,14 +975,14 @@ def save_backtest_result(date: str, symbol: str, decision: str,
INSERT OR REPLACE INTO backtest_results INSERT OR REPLACE INTO backtest_results
(date, symbol, decision, price_at_prediction, (date, symbol, decision, price_at_prediction,
price_1d_later, price_1w_later, price_1m_later, price_1d_later, price_1w_later, price_1m_later,
return_1d, return_1w, return_1m, prediction_correct, hold_days) return_1d, return_1w, return_1m, prediction_correct, hold_days, return_at_hold)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", ( """, (
date, symbol, decision, price_at_prediction, date, symbol, decision, price_at_prediction,
price_1d_later, price_1w_later, price_1m_later, price_1d_later, price_1w_later, price_1m_later,
return_1d, return_1w, return_1m, return_1d, return_1w, return_1m,
1 if prediction_correct else 0 if prediction_correct is not None else None, 1 if prediction_correct else 0 if prediction_correct is not None else None,
hold_days hold_days, return_at_hold
)) ))
conn.commit() conn.commit()
finally: finally:
@ -933,6 +1014,7 @@ def get_backtest_result(date: str, symbol: str) -> Optional[dict]:
'return_1m': row['return_1m'], 'return_1m': row['return_1m'],
'prediction_correct': bool(row['prediction_correct']) if row['prediction_correct'] is not None else None, '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,
'calculated_at': row['calculated_at'] 'calculated_at': row['calculated_at']
} }
return None return None
@ -962,7 +1044,8 @@ def get_backtest_results_by_date(date: str) -> list:
'return_1w': row['return_1w'], 'return_1w': row['return_1w'],
'return_1m': row['return_1m'], 'return_1m': row['return_1m'],
'prediction_correct': bool(row['prediction_correct']) if row['prediction_correct'] is not None else None, '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() for row in cursor.fetchall()
] ]
@ -995,7 +1078,9 @@ def get_all_backtest_results() -> list:
'return_1d': row['return_1d'], 'return_1d': row['return_1d'],
'return_1w': row['return_1w'], 'return_1w': row['return_1w'],
'return_1m': row['return_1m'], '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() for row in cursor.fetchall()
] ]
@ -1013,7 +1098,7 @@ def calculate_accuracy_metrics() -> dict:
'total_predictions': 0, 'total_predictions': 0,
'correct_predictions': 0, 'correct_predictions': 0,
'by_decision': {'BUY': {'accuracy': 0, 'total': 0}, 'SELL': {'accuracy': 0, 'total': 0}, 'HOLD': {'accuracy': 0, 'total': 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) total = len(results)
@ -1035,7 +1120,7 @@ def calculate_accuracy_metrics() -> dict:
# By confidence level # By confidence level
by_confidence = {} 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] conf_results = [r for r in results if r.get('confidence') == conf]
if conf_results: if conf_results:
conf_correct = sum(1 for r in conf_results if r['prediction_correct']) conf_correct = sum(1 for r in conf_results if r['prediction_correct'])

Binary file not shown.

View File

@ -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("agent", "system", f"Starting propagation for {symbol}...")
add_log("data", "data_fetch", f"Fetching market data 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) # Check cancellation after graph execution (skip saving results)
if _is_cancelled(symbol): if _is_cancelled(symbol):
@ -251,8 +251,8 @@ def run_analysis_task(symbol: str, date: str, analysis_config: dict = None):
analysis_data = { analysis_data = {
"company_name": symbol, "company_name": symbol,
"decision": decision.upper() if decision else "HOLD", "decision": decision.upper() if decision else "HOLD",
"confidence": "MEDIUM", "confidence": confidence or "MEDIUM",
"risk": "MEDIUM", "risk": risk or "MEDIUM",
"raw_analysis": raw_analysis, "raw_analysis": raw_analysis,
"hold_days": hold_days "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_1d': result['return_1d'],
'actual_return_1w': result['return_1w'], 'actual_return_1w': result['return_1w'],
'actual_return_1m': result['return_1m'], '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'], 'price_at_prediction': result['price_at_prediction'],
'current_price': result.get('price_1m_later') or result.get('price_1w_later'), 'current_price': result.get('price_1m_later') or result.get('price_1w_later'),
'hold_days': result.get('hold_days'),
} }

View File

@ -1,10 +1,11 @@
import { import {
TrendingUp, TrendingDown, Users, Newspaper, FileText, TrendingUp, TrendingDown, Users, Newspaper, FileText,
Scale, Target, Zap, Shield, ShieldCheck, Scale, Target, Zap, Shield, ShieldCheck,
Clock, Loader2, CheckCircle, AlertCircle, ChevronDown, ChevronUp Clock, Loader2, CheckCircle, AlertCircle, ChevronDown, ChevronUp, GitBranch
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import type { FlowchartNodeData, PipelineStepStatus } from '../../types/pipeline'; import type { FlowchartNodeData, PipelineStepStatus } from '../../types/pipeline';
import { STEP_INPUT_SOURCES } from '../../types/pipeline';
interface FlowchartNodeProps { interface FlowchartNodeProps {
node: FlowchartNodeData; 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 hasPreview = !!(node.output_summary || node.agentReport?.report_content || node.debateContent);
const previewText = 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 ( return (
<div className="w-full"> <div className="w-full">
@ -145,6 +147,12 @@ export function FlowchartNode({ node, isSelected, onClick }: FlowchartNodeProps)
{/* Status + Duration */} {/* Status + Duration */}
<div className="flex items-center gap-1.5 flex-shrink-0"> <div className="flex items-center gap-1.5 flex-shrink-0">
{inputCount > 0 && node.status === 'completed' && (
<span className="hidden sm:inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 dark:text-blue-400 border border-blue-200 dark:border-blue-800/50">
<GitBranch className="w-2.5 h-2.5" />
{inputCount}
</span>
)}
{node.duration_ms != null && node.status === 'completed' && ( {node.duration_ms != null && node.status === 'completed' && (
<span className="text-[10px] sm:text-xs font-mono font-semibold text-gray-500 dark:text-gray-400"> <span className="text-[10px] sm:text-xs font-mono font-semibold text-gray-500 dark:text-gray-400">
{formatDuration(node.duration_ms)} {formatDuration(node.duration_ms)}

View File

@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { X, Clock, Timer, CheckCircle, AlertCircle, FileText, MessageSquare, ChevronDown, ChevronRight, Terminal, Bot, User, Wrench } from 'lucide-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 type { FlowchartNodeData, FullPipelineData, StepDetails } from '../../types/pipeline';
import { STEP_INPUT_SOURCES, FLOWCHART_STEPS, mapPipelineToFlowchart } from '../../types/pipeline';
interface NodeDetailDrawerProps { interface NodeDetailDrawerProps {
node: FlowchartNodeData; node: FlowchartNodeData;
@ -26,24 +27,33 @@ function formatDuration(ms: number): string {
return `${mins}m ${remSecs}s`; 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; if (node.agentReport?.report_content) return node.agentReport.report_content;
// 2. Debate content (for debate participants)
if (node.debateContent) return node.debateContent; if (node.debateContent) return node.debateContent;
// 3. Output summary from pipeline step
if (node.output_summary) return node.output_summary; if (node.output_summary) return node.output_summary;
if (!data) return ''; // 4. Try debate tables directly
if (data) {
if (node.debateType === 'investment' && data.debates?.investment) { if (node.debateType === 'investment' && data.debates?.investment) {
const d = data.debates.investment; const d = data.debates.investment;
if (node.debateRole === 'bull') return d.bull_arguments || ''; if (node.debateRole === 'bull') return d.bull_arguments || '';
if (node.debateRole === 'bear') return d.bear_arguments || ''; if (node.debateRole === 'bear') return d.bear_arguments || '';
if (node.debateRole === 'judge') return d.judge_decision || ''; if (node.debateRole === 'judge') return d.judge_decision || '';
} }
if (node.debateType === 'risk' && data.debates?.risk) { if (node.debateType === 'risk' && data.debates?.risk) {
const d = data.debates.risk; const d = data.debates.risk;
if (node.debateRole === 'risky') return d.risky_arguments || ''; if (node.debateRole === 'risky') return d.risky_arguments || '';
if (node.debateRole === 'safe') return d.safe_arguments || ''; if (node.debateRole === 'safe') return d.safe_arguments || '';
if (node.debateRole === 'neutral') return d.neutral_arguments || ''; if (node.debateRole === 'neutral') return d.neutral_arguments || '';
if (node.debateRole === 'judge') return d.judge_decision || ''; if (node.debateRole === 'judge') return d.judge_decision || '';
}
} }
return ''; 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 (
<div className="mt-1.5">
<div className="text-[10px] font-semibold text-green-600 dark:text-green-400 mb-0.5 flex items-center gap-1">
<CheckCircle className="w-2.5 h-2.5" />
Result:
</div>
<div className="max-h-40 overflow-y-auto rounded bg-slate-900 dark:bg-black/40 px-2 py-1.5">
<pre className="text-[10px] text-green-300 dark:text-green-400 font-mono whitespace-pre-wrap leading-relaxed break-all">
{display}{isLong && !expanded ? '...' : ''}
</pre>
</div>
{isLong && (
<button
onClick={() => setExpanded(!expanded)}
className="text-[10px] text-blue-500 hover:text-blue-400 mt-0.5"
>
{expanded ? 'Show less' : `Show all (${result.length} chars)`}
</button>
)}
</div>
);
}
/** Tool calls panel showing invocations and their results */
function ToolCallsPanel({ calls }: { calls: ToolCallEntry[] }) {
if (calls.length === 0) return null;
return (
<div className="divide-y divide-slate-200 dark:divide-slate-700">
{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 (
<div key={i} className="px-3 py-2 flex items-start gap-2">
<div className={`flex-shrink-0 mt-0.5 w-5 h-5 rounded flex items-center justify-center ${
hasData
? 'bg-green-100 dark:bg-green-900/30'
: isError
? 'bg-red-100 dark:bg-red-900/30'
: resultStored
? 'bg-slate-100 dark:bg-slate-800'
: 'bg-amber-100 dark:bg-amber-900/30'
}`}>
<Wrench className={`w-3 h-3 ${
hasData ? 'text-green-600 dark:text-green-400'
: isError ? 'text-red-600 dark:text-red-400'
: resultStored ? 'text-slate-500 dark:text-slate-400'
: 'text-amber-600 dark:text-amber-400'
}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-gray-800 dark:text-gray-200 font-mono">{call.name}</span>
<span className="text-[10px] text-gray-400">(#{i + 1})</span>
{resultStored && (
<span className={`text-[9px] px-1 py-0.5 rounded font-medium ${
hasData
? 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400'
: isError
? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400'
: 'bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400'
}`}>
{hasData ? 'executed' : isError ? 'error' : 'empty result'}
</span>
)}
</div>
<div className="mt-0.5 text-[11px] font-mono text-gray-500 dark:text-gray-400 break-all">
({call.args})
</div>
{hasData && (
<ToolResultPreview result={call.result_preview!} />
)}
{isError && (
<div className="mt-1 text-[10px] text-red-600 dark:text-red-400">
{call.result_preview}
</div>
)}
{isEmpty && (
<div className="mt-1 text-[10px] text-slate-500 dark:text-slate-400 italic">
Tool executed but returned no data
</div>
)}
{!resultStored && (
<div className="mt-1 text-[10px] text-amber-600 dark:text-amber-400 italic">
No result data available (legacy run)
</div>
)}
</div>
</div>
);
})}
</div>
);
}
/** 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 (
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-slate-50 dark:bg-slate-900/40 border border-slate-200 dark:border-slate-700">
<Info className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
<span className="text-xs text-slate-500 dark:text-slate-400">
Independent step no forwarded context from previous steps
</span>
</div>
);
}
return (
<div className="rounded-lg border border-blue-200 dark:border-blue-800/60 bg-blue-50/50 dark:bg-blue-900/10 overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 border-b border-blue-200 dark:border-blue-800/60">
<GitBranch className="w-3.5 h-3.5 text-blue-500 flex-shrink-0" />
<span className="text-xs font-semibold text-blue-700 dark:text-blue-300">
Context Received from {inputSources.length} Previous Steps
</span>
</div>
<div className="px-3 py-2.5 flex flex-wrap gap-1.5">
{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 (
<span
key={sourceId}
className={`inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-md border ${
isCompleted
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 border-green-200 dark:border-green-800/60'
: 'bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border-slate-200 dark:border-slate-700'
}`}
>
{isCompleted ? (
<CheckCircle className="w-3 h-3 text-green-500 flex-shrink-0" />
) : (
<Clock className="w-3 h-3 text-slate-400 flex-shrink-0" />
)}
{label}
<ArrowRight className="w-2.5 h-2.5 opacity-40" />
</span>
);
})}
</div>
</div>
);
}
/** Empty state for steps with no data */
function EmptyState({ status }: { status: string }) {
if (status === 'pending') {
return (
<div className="text-center py-6 text-gray-400 dark:text-gray-500">
<Clock className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">This step hasn't run yet</p>
<p className="text-xs mt-1">Run an analysis to see results here</p>
</div>
);
}
if (status === 'running') {
return (
<div className="text-center py-6 text-blue-500 dark:text-blue-400">
<div className="w-8 h-8 mx-auto mb-2 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<p className="text-sm">Processing...</p>
</div>
);
}
return (
<div className="text-center py-6 text-gray-400 dark:text-gray-500">
<FileText className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No output data available</p>
</div>
);
}
export function NodeDetailDrawer({ node, pipelineData, onClose }: NodeDetailDrawerProps) { export function NodeDetailDrawer({ node, pipelineData, onClose }: NodeDetailDrawerProps) {
const details: StepDetails | undefined = node.step_details; 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 // Step Output: the ACTUAL meaningful output (agent report / debate content first, then raw response)
const hasStructuredData = details && (details.system_prompt || details.user_prompt || details.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 ( return (
<div className="mt-3 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-lg overflow-hidden animate-in slide-in-from-top-2"> <div className="mt-3 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-lg overflow-hidden animate-in slide-in-from-top-2">
@ -153,26 +394,30 @@ export function NodeDetailDrawer({ node, pipelineData, onClose }: NodeDetailDraw
)} )}
</div> </div>
{/* Content sections */} {/* Content sections — unified flow, no branching */}
<div className="p-3 space-y-2"> <div className="p-3 space-y-2">
{hasStructuredData ? ( {/* 1. Context Received — always first */}
<ContextReceivedPanel node={node} pipelineData={pipelineData} />
{hasAnyContent ? (
<> <>
{/* System Prompt */} {/* 2. System Instructions — role definition (default OPEN) */}
{details.system_prompt && ( {details?.system_prompt && (
<Section <Section
title="System Prompt" title="System Instructions"
icon={Bot} icon={Bot}
iconColor="text-violet-500" iconColor="text-violet-500"
defaultOpen={true}
badge={`${details.system_prompt.length} chars`} badge={`${details.system_prompt.length} chars`}
> >
<CodeBlock content={details.system_prompt} maxHeight="max-h-48" /> <CodeBlock content={details.system_prompt} maxHeight="max-h-48" />
</Section> </Section>
)} )}
{/* User Prompt / Input */} {/* 3. Input Prompt — forwarded context or initial prompt */}
{details.user_prompt && ( {details?.user_prompt && (
<Section <Section
title="User Prompt / Input" title="Input Prompt"
icon={User} icon={User}
iconColor="text-blue-500" iconColor="text-blue-500"
badge={`${details.user_prompt.length} chars`} badge={`${details.user_prompt.length} chars`}
@ -181,80 +426,44 @@ export function NodeDetailDrawer({ node, pipelineData, onClose }: NodeDetailDraw
</Section> </Section>
)} )}
{/* Tool Calls */} {/* 4. Tool Calls — parsed from raw response (analyst steps) */}
{details.tool_calls && details.tool_calls.length > 0 && ( {parsedToolCalls.length > 0 && (
<Section <Section
title="Tool Calls" title="Tool Calls"
icon={Wrench} icon={Wrench}
iconColor="text-amber-500" iconColor="text-amber-500"
badge={`${details.tool_calls.length} calls`} badge={`${parsedToolCalls.length} calls`}
> >
<div className="p-3 space-y-3"> <ToolCallsPanel calls={parsedToolCalls} />
{details.tool_calls.map((tc, i) => (
<div key={i} className="space-y-1">
<div className="flex items-start gap-2 text-xs">
<span className="font-mono font-semibold text-amber-600 dark:text-amber-400 whitespace-nowrap">
{tc.name}()
</span>
{tc.args && (
<span className="font-mono text-gray-500 dark:text-gray-400 truncate">
{tc.args}
</span>
)}
</div>
{tc.result_preview && (
<div className="ml-4 p-2 bg-slate-900 dark:bg-black/40 rounded text-[11px] font-mono text-green-300 dark:text-green-400 max-h-32 overflow-auto whitespace-pre-wrap">
{tc.result_preview}
</div>
)}
</div>
))}
</div>
</Section> </Section>
)} )}
{/* LLM Response */} {/* 5. Raw LLM Response — only when different from Step Output */}
{details.response && ( {showRawResponse && (
<Section <Section
title="LLM Response" title="Raw LLM Response"
icon={MessageSquare} icon={MessageSquare}
iconColor="text-green-500" iconColor="text-gray-500"
defaultOpen={true} badge={`${rawResponse.length} chars`}
badge={`${details.response.length} chars`}
> >
<CodeBlock content={details.response} maxHeight="max-h-80" /> <CodeBlock content={cleanRawResponse || rawResponse} maxHeight="max-h-48" />
</Section>
)}
{/* 6. Step Output — the actual meaningful output */}
{stepOutput && (
<Section
title="Step Output"
icon={CheckCircle}
iconColor="text-green-500"
badge={`${stepOutput.length} chars`}
>
<CodeBlock content={stepOutput} maxHeight="max-h-80" />
</Section> </Section>
)} )}
</> </>
) : fallbackContent ? (
/* Fallback: show the old-style content */
<>
<Section
title={node.agentType ? 'Agent Report' : node.debateRole === 'judge' ? 'Decision' : node.debateType ? 'Debate Argument' : 'Output'}
icon={node.agentType ? FileText : node.debateType ? MessageSquare : FileText}
iconColor="text-gray-500"
defaultOpen={true}
badge={`${fallbackContent.length} chars`}
>
<CodeBlock content={fallbackContent} maxHeight="max-h-80" />
</Section>
</>
) : node.status === 'pending' ? (
<div className="text-center py-6 text-gray-400 dark:text-gray-500">
<Clock className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">This step hasn't run yet</p>
<p className="text-xs mt-1">Run an analysis to see results here</p>
</div>
) : node.status === 'running' ? (
<div className="text-center py-6 text-blue-500 dark:text-blue-400">
<div className="w-8 h-8 mx-auto mb-2 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<p className="text-sm">Processing...</p>
</div>
) : ( ) : (
<div className="text-center py-6 text-gray-400 dark:text-gray-500"> <EmptyState status={node.status} />
<FileText className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No output data available</p>
</div>
)} )}
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom'; 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 { 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 TopPicks, { StocksToAvoid } from '../components/TopPicks';
import { DecisionBadge } from '../components/StockCard'; import { DecisionBadge, HoldDaysBadge } from '../components/StockCard';
import TerminalModal from '../components/TerminalModal'; import TerminalModal from '../components/TerminalModal';
import HowItWorks from '../components/HowItWorks'; import HowItWorks from '../components/HowItWorks';
import BackgroundSparkline from '../components/BackgroundSparkline'; import BackgroundSparkline from '../components/BackgroundSparkline';
@ -196,7 +196,7 @@ export default function Dashboard() {
try { try {
// Pass settings from context to the API // Pass settings from context to the API
await api.runBulkAnalysis(undefined, { const result = await api.runBulkAnalysis(undefined, {
deep_think_model: settings.deepThinkModel, deep_think_model: settings.deepThinkModel,
quick_think_model: settings.quickThinkModel, quick_think_model: settings.quickThinkModel,
provider: settings.provider, provider: settings.provider,
@ -204,10 +204,25 @@ export default function Dashboard() {
max_debate_rounds: settings.maxDebateRounds, max_debate_rounds: settings.maxDebateRounds,
parallel_workers: settings.parallelWorkers 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({ addNotification({
type: 'info', type: 'info',
title: 'Analysis Started', 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, duration: 3000,
}); });
} catch (e) { } catch (e) {
@ -647,6 +662,11 @@ export default function Dashboard() {
<DecisionBadge decision={item.analysis.decision} size="small" /> <DecisionBadge decision={item.analysis.decision} size="small" />
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{item.company_name}</p> <p className="text-xs text-gray-500 dark:text-gray-400 truncate">{item.company_name}</p>
{item.analysis.hold_days != null && item.analysis.hold_days > 0 && item.analysis.decision !== 'SELL' && (
<div className="mt-1">
<HoldDaysBadge holdDays={item.analysis.hold_days} decision={item.analysis.decision} />
</div>
)}
</div> </div>
</Link> </Link>
); );

View File

@ -2,6 +2,7 @@ import { useState, useMemo, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom'; 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 { 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 { 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 { DecisionBadge, HoldDaysBadge } from '../components/StockCard';
import Sparkline from '../components/Sparkline'; import Sparkline from '../components/Sparkline';
import AccuracyBadge from '../components/AccuracyBadge'; import AccuracyBadge from '../components/AccuracyBadge';
@ -23,6 +24,9 @@ interface RealBacktestData {
decision: string; decision: string;
return1d: number | null; return1d: number | null;
return1w: number | null; return1w: number | null;
returnAtHold: number | null;
holdDays: number | null;
primaryReturn: number | null; // return_at_hold ?? return_1d
predictionCorrect: boolean | null; predictionCorrect: boolean | null;
priceHistory?: Array<{ date: string; price: number }>; priceHistory?: Array<{ date: string; price: number }>;
} }
@ -74,6 +78,21 @@ function InvestmentModeToggle({
); );
} }
// Pulsing skeleton bar for loading states
function SkeletonBar({ className = '' }: { className?: string }) {
return <div className={`animate-pulse bg-gray-200 dark:bg-slate-700 rounded ${className}`} />;
}
// Loading overlay for chart sections
function SectionLoader({ message = 'Calculating backtest results...' }: { message?: string }) {
return (
<div className="flex flex-col items-center justify-center py-8 gap-2">
<Loader2 className="w-6 h-6 text-nifty-500 animate-spin" />
<span className="text-xs text-gray-500 dark:text-gray-400">{message}</span>
</div>
);
}
export default function History() { export default function History() {
const [selectedDate, setSelectedDate] = useState<string | null>(null); const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [showAccuracyModal, setShowAccuracyModal] = useState(false); 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) // Batch-fetch all backtest results per date (used by both accuracy trend and chart data)
const [batchBacktestByDate, setBatchBacktestByDate] = useState< const [batchBacktestByDate, setBatchBacktestByDate] = useState<
Record<string, Record<string, { return_1d?: number; return_1w?: number; return_1m?: number; prediction_correct?: boolean; decision: string }>> Record<string, Record<string, { return_1d?: number; return_1w?: number; return_1m?: number; return_at_hold?: number; hold_days?: number; prediction_correct?: boolean; decision: string }>>
>({}); >({});
const [isBatchLoading, setIsBatchLoading] = useState(false); const [isBatchLoading, setIsBatchLoading] = useState(false);
@ -203,6 +222,8 @@ export default function History() {
return_1d: r.return_1d, return_1d: r.return_1d,
return_1w: r.return_1w, return_1w: r.return_1w,
return_1m: r.return_1m, return_1m: r.return_1m,
return_at_hold: r.return_at_hold,
hold_days: r.hold_days,
prediction_correct: r.prediction_correct, prediction_correct: r.prediction_correct,
decision: r.decision, decision: r.decision,
}; };
@ -245,11 +266,12 @@ export default function History() {
for (const symbol of Object.keys(rec.analysis)) { for (const symbol of Object.keys(rec.analysis)) {
const stockAnalysis = rec.analysis[symbol]; const stockAnalysis = rec.analysis[symbol];
const bt = dateBacktest[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') const predictionCorrect = (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD')
? bt.return_1d > 0 ? primaryRet > 0
: bt.return_1d < 0; : primaryRet < 0;
if (stockAnalysis.decision === 'BUY') { totalBuy++; if (predictionCorrect) correctBuy++; } if (stockAnalysis.decision === 'BUY') { totalBuy++; if (predictionCorrect) correctBuy++; }
else if (stockAnalysis.decision === 'SELL') { totalSell++; if (predictionCorrect) correctSell++; } 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)) { for (const symbol of Object.keys(rec.analysis)) {
const stockAnalysis = rec.analysis[symbol]; const stockAnalysis = rec.analysis[symbol];
const bt = dateBacktest[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 return1d = bt.return_1d;
// Store for PortfolioSimulator // Store for PortfolioSimulator
if (!allBacktest[date]) allBacktest[date] = {}; if (!allBacktest[date]) allBacktest[date] = {};
allBacktest[date][symbol] = return1d; allBacktest[date][symbol] = primaryRet;
const predictionCorrect = (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD') const predictionCorrect = (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD')
? return1d > 0 ? primaryRet > 0
: return1d < 0; : primaryRet < 0;
totalPredictions++; totalPredictions++;
if (predictionCorrect) { if (predictionCorrect) {
totalCorrect++; totalCorrect++;
dateCorrectCount++; dateCorrectCount++;
if (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD') { if (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD') {
dateCorrectReturn += return1d; dateCorrectReturn += primaryRet;
} else { } else {
dateCorrectReturn += Math.abs(return1d); dateCorrectReturn += Math.abs(primaryRet);
} }
} else { } else {
if (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD') { if (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD') {
dateIncorrectReturn += return1d; dateIncorrectReturn += primaryRet;
} else { } else {
dateIncorrectReturn += -Math.abs(return1d); dateIncorrectReturn += -Math.abs(primaryRet);
} }
} }
dateTotalCount++; dateTotalCount++;
@ -428,9 +449,10 @@ export default function History() {
if (rec && dateBacktest) { if (rec && dateBacktest) {
for (const symbol of Object.keys(rec.analysis)) { for (const symbol of Object.keys(rec.analysis)) {
const bt = dateBacktest[symbol]; 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) { 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.count++;
bucket.stocks.push(symbol); bucket.stocks.push(symbol);
break; break;
@ -535,8 +557,9 @@ export default function History() {
for (const pick of rec.top_picks) { for (const pick of rec.top_picks) {
const bt = dateBacktest[pick.symbol]; const bt = dateBacktest[pick.symbol];
if (bt && bt.return_1d !== undefined && bt.return_1d !== null) { const retVal = bt?.return_at_hold ?? bt?.return_1d;
dateReturn += bt.return_1d; if (retVal !== undefined && retVal !== null) {
dateReturn += retVal;
dateCount++; dateCount++;
} }
} }
@ -564,9 +587,10 @@ export default function History() {
if (rec && dateBacktest) { if (rec && dateBacktest) {
for (const pick of rec.top_picks) { for (const pick of rec.top_picks) {
const bt = dateBacktest[pick.symbol]; 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) { 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.count++;
bucket.stocks.push(pick.symbol); bucket.stocks.push(pick.symbol);
break; break;
@ -690,14 +714,14 @@ export default function History() {
const backtest = await api.getBacktestResult(date, stock.symbol); const backtest = await api.getBacktestResult(date, stock.symbol);
if (backtest.available) { if (backtest.available) {
// Calculate prediction correctness based on 1-day return // Use hold-period return when available, fall back to 1-day
// BUY/HOLD correct if return > 0, SELL correct if return < 0 const primaryReturn = backtest.return_at_hold ?? backtest.actual_return_1d ?? null;
let predictionCorrect: boolean | null = 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') { if (stock.decision === 'BUY' || stock.decision === 'HOLD') {
predictionCorrect = backtest.actual_return_1d > 0; predictionCorrect = primaryReturn > 0;
} else if (stock.decision === 'SELL') { } 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, decision: stock.decision,
return1d: backtest.actual_return_1d ?? null, return1d: backtest.actual_return_1d ?? null,
return1w: backtest.actual_return_1w ?? null, return1w: backtest.actual_return_1w ?? null,
returnAtHold: backtest.return_at_hold ?? null,
holdDays: backtest.hold_days ?? null,
primaryReturn,
predictionCorrect, predictionCorrect,
priceHistory: backtest.price_history, priceHistory: backtest.price_history,
}; };
@ -752,8 +779,9 @@ export default function History() {
rec.top_picks.map(pick => { rec.top_picks.map(pick => {
// Try real backtest data first // Try real backtest data first
const realData = realBacktestData[pick.symbol]; const realData = realBacktestData[pick.symbol];
if (realData?.return1d !== null && realData?.return1d !== undefined) { const primaryRet = realData?.primaryReturn ?? realData?.return1d;
return realData.return1d; if (primaryRet !== null && primaryRet !== undefined) {
return primaryRet;
} }
// Only fall back to mock when actually using mock data // Only fall back to mock when actually using mock data
return isUsingMockData ? getStaticBacktestResult(pick.symbol)?.actual_return_1d : undefined; return isUsingMockData ? getStaticBacktestResult(pick.symbol)?.actual_return_1d : undefined;
@ -809,6 +837,71 @@ export default function History() {
return Object.values(rec.analysis); 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 // Show loading state
if (isLoadingRecommendations) { if (isLoadingRecommendations) {
return ( return (
@ -859,6 +952,9 @@ export default function History() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Target className="w-5 h-5 text-nifty-600 dark:text-nifty-400" /> <Target className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />
<h2 className="font-semibold text-gray-900 dark:text-gray-100">Prediction Accuracy</h2> <h2 className="font-semibold text-gray-900 dark:text-gray-100">Prediction Accuracy</h2>
{isBacktestDataLoading && (
<Loader2 className="w-3.5 h-3.5 animate-spin text-nifty-500" />
)}
</div> </div>
<button <button
onClick={() => setShowAccuracyModal(true)} onClick={() => setShowAccuracyModal(true)}
@ -869,34 +965,48 @@ export default function History() {
<span className="hidden sm:inline">How it's calculated</span> <span className="hidden sm:inline">How it's calculated</span>
</button> </button>
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> {isBacktestDataLoading ? (
<div className="p-3 rounded-lg bg-nifty-50 dark:bg-nifty-900/20 text-center"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="text-2xl font-bold text-nifty-600 dark:text-nifty-400"> {['nifty', 'green', 'red', 'amber'].map(color => (
{(accuracyMetrics.success_rate * 100).toFixed(0)}% <div key={color} className={`p-3 rounded-lg bg-${color === 'nifty' ? 'nifty-50 dark:bg-nifty-900/20' : `${color}-50 dark:bg-${color}-900/20`} text-center`}>
</div> <SkeletonBar className="h-7 w-16 mx-auto mb-1" />
<div className="text-xs text-gray-500 dark:text-gray-400">Overall Accuracy</div> <SkeletonBar className="h-3 w-20 mx-auto" />
</div>
))}
</div> </div>
<div className="p-3 rounded-lg bg-green-50 dark:bg-green-900/20 text-center"> ) : (
<div className="text-2xl font-bold text-green-600 dark:text-green-400"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{(accuracyMetrics.buy_accuracy * 100).toFixed(0)}% <div className="p-3 rounded-lg bg-nifty-50 dark:bg-nifty-900/20 text-center">
<div className="text-2xl font-bold text-nifty-600 dark:text-nifty-400">
{(accuracyMetrics.success_rate * 100).toFixed(0)}%
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Overall Accuracy</div>
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400">Buy Accuracy</div> <div className="p-3 rounded-lg bg-green-50 dark:bg-green-900/20 text-center">
</div> <div className="text-2xl font-bold text-green-600 dark:text-green-400">
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-center"> {(accuracyMetrics.buy_accuracy * 100).toFixed(0)}%
<div className="text-2xl font-bold text-red-600 dark:text-red-400"> </div>
{(accuracyMetrics.sell_accuracy * 100).toFixed(0)}% <div className="text-xs text-gray-500 dark:text-gray-400">Buy Accuracy</div>
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400">Sell Accuracy</div> <div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-center">
</div> <div className="text-2xl font-bold text-red-600 dark:text-red-400">
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-center"> {(accuracyMetrics.sell_accuracy * 100).toFixed(0)}%
<div className="text-2xl font-bold text-amber-600 dark:text-amber-400"> </div>
{(accuracyMetrics.hold_accuracy * 100).toFixed(0)}% <div className="text-xs text-gray-500 dark:text-gray-400">Sell Accuracy</div>
</div>
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-center">
<div className="text-2xl font-bold text-amber-600 dark:text-amber-400">
{(accuracyMetrics.hold_accuracy * 100).toFixed(0)}%
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Hold Accuracy</div>
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400">Hold Accuracy</div>
</div> </div>
</div> )}
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center"> <p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center">
Based on {accuracyMetrics.total_predictions} predictions tracked over time {isBacktestDataLoading
? 'Fetching backtest data from market...'
: `Based on ${accuracyMetrics.total_predictions} predictions tracked over time`
}
</p> </p>
</section> </section>
@ -914,23 +1024,28 @@ export default function History() {
</div> </div>
)} )}
</div> </div>
{/* Pass real data if available, use mock fallback only when in mock mode */} {isBacktestDataLoading && !isUsingMockData ? (
<AccuracyTrendChart <SectionLoader message="Computing accuracy trend from backtest data..." />
height={200} ) : (
data={isUsingMockData <>
? (accuracyTrendData.length > 0 ? accuracyTrendData : undefined) <AccuracyTrendChart
: accuracyTrendData height={200}
} data={isUsingMockData
/> ? (accuracyTrendData.length > 0 ? accuracyTrendData : undefined)
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center"> : accuracyTrendData
{accuracyTrendData.length > 0 ? ( }
<>Prediction accuracy from real backtest data over {accuracyTrendData.length} trading days</> />
) : isUsingMockData ? ( <p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center">
<>Demo data - Start backend for real accuracy tracking</> {accuracyTrendData.length > 0 ? (
) : ( <>Prediction accuracy from real backtest data over {accuracyTrendData.length} trading days</>
<>Prediction accuracy over the past {dates.length} trading days</> ) : isUsingMockData ? (
)} <>Demo data - Start backend for real accuracy tracking</>
</p> ) : (
<>Prediction accuracy over the past {dates.length} trading days</>
)}
</p>
</>
)}
</section> </section>
{/* Risk Metrics */} {/* Risk Metrics */}
@ -947,19 +1062,25 @@ export default function History() {
</div> </div>
)} )}
</div> </div>
<RiskMetricsCard metrics={!isUsingMockData && !realRiskMetrics ? { {isBacktestDataLoading && !isUsingMockData ? (
sharpeRatio: 0, maxDrawdown: 0, winLossRatio: 0, winRate: 0, <SectionLoader message="Computing risk metrics from backtest data..." />
volatility: 0, totalTrades: 0, ) : (
} : realRiskMetrics} /> <>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center"> <RiskMetricsCard metrics={!isUsingMockData && !realRiskMetrics ? {
{realRiskMetrics ? ( sharpeRatio: 0, maxDrawdown: 0, winLossRatio: 0, winRate: 0,
<>Risk-adjusted performance from real backtest data ({realRiskMetrics.totalTrades} trades)</> volatility: 0, totalTrades: 0,
) : isUsingMockData ? ( } : realRiskMetrics} />
<>Demo data - Start backend for real risk metrics</> <p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center">
) : ( {realRiskMetrics ? (
<>Risk-adjusted performance metrics for the AI trading strategy</> <>Risk-adjusted performance from real backtest data ({realRiskMetrics.totalTrades} trades)</>
)} ) : isUsingMockData ? (
</p> <>Demo data - Start backend for real risk metrics</>
) : (
<>Risk-adjusted performance metrics for the AI trading strategy</>
)}
</p>
</>
)}
</section> </section>
{/* Portfolio Simulator */} {/* Portfolio Simulator */}
@ -987,6 +1108,7 @@ export default function History() {
const rec = getRecommendation(date); const rec = getRecommendation(date);
const stats = dateStatsMap[date]; const stats = dateStatsMap[date];
const avgReturn = stats?.avgReturn1d ?? 0; const avgReturn = stats?.avgReturn1d ?? 0;
const hasBacktestData = !isUsingMockData ? (realDateReturns[date] !== undefined) : true;
const isPositive = avgReturn >= 0; const isPositive = avgReturn >= 0;
// Calculate filtered summary for this date // Calculate filtered summary for this date
@ -1005,11 +1127,21 @@ export default function History() {
}`} }`}
> >
<div className="font-semibold">{new Date(date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' })}</div> <div className="font-semibold">{new Date(date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' })}</div>
<div className={`text-sm font-bold mt-0.5 ${ {!hasBacktestData && isBacktestDataLoading ? (
selectedDate === date ? 'text-white' : getValueColorClass(avgReturn) <div className={`text-sm font-bold mt-0.5 ${selectedDate === date ? 'text-white/60' : 'text-gray-400 dark:text-gray-500'}`}>
}`}> <span className="inline-block w-8 h-4 animate-pulse bg-gray-300 dark:bg-slate-600 rounded" />
{isPositive ? '+' : ''}{avgReturn.toFixed(1)}% </div>
</div> ) : !hasBacktestData ? (
<div className={`text-sm mt-0.5 ${selectedDate === date ? 'text-white/60' : 'text-gray-400 dark:text-gray-500'}`}>
Pending
</div>
) : (
<div className={`text-sm font-bold mt-0.5 ${
selectedDate === date ? 'text-white' : getValueColorClass(avgReturn)
}`}>
{isPositive ? '+' : ''}{avgReturn.toFixed(1)}%
</div>
)}
<div className={`text-[10px] mt-0.5 ${selectedDate === date ? 'text-white/80' : 'opacity-60'}`}> <div className={`text-[10px] mt-0.5 ${selectedDate === date ? 'text-white/80' : 'opacity-60'}`}>
{filteredSummary.buy}B/{filteredSummary.sell}S/{filteredSummary.hold}H {filteredSummary.buy}B/{filteredSummary.sell}S/{filteredSummary.hold}H
</div> </div>
@ -1118,8 +1250,8 @@ export default function History() {
let predictionCorrect: boolean | null = null; let predictionCorrect: boolean | null = null;
if (!isUsingMockData) { if (!isUsingMockData) {
// Real data mode: only use real backtest, no mock fallback // Real data mode: use hold-period return when available
nextDayReturn = realData?.return1d ?? null; nextDayReturn = realData?.primaryReturn ?? realData?.return1d ?? null;
priceHistory = realData?.priceHistory; priceHistory = realData?.priceHistory;
if (realData?.predictionCorrect !== undefined) { if (realData?.predictionCorrect !== undefined) {
predictionCorrect = realData.predictionCorrect; predictionCorrect = realData.predictionCorrect;
@ -1127,7 +1259,7 @@ export default function History() {
} else { } else {
// Mock data mode: use real if available, fall back to mock // Mock data mode: use real if available, fall back to mock
const mockBacktest = getStaticBacktestResult(stock.symbol); 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; priceHistory = realData?.priceHistory ?? mockBacktest?.price_history;
if (realData?.predictionCorrect !== undefined) { if (realData?.predictionCorrect !== undefined) {
predictionCorrect = realData.predictionCorrect; predictionCorrect = realData.predictionCorrect;
@ -1159,6 +1291,12 @@ export default function History() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<DecisionBadge decision={stock.decision} size="small" /> <DecisionBadge decision={stock.decision} size="small" />
<HoldDaysBadge holdDays={stock.hold_days} decision={stock.decision} /> <HoldDaysBadge holdDays={stock.hold_days} decision={stock.decision} />
{nextDayReturn !== null && (
<span className={`text-xs font-medium tabular-nums ${getValueColorClass(nextDayReturn)}`} title={realData?.holdDays ? `${realData.holdDays}d return` : '1d return'}>
{nextDayReturn >= 0 ? '+' : ''}{nextDayReturn.toFixed(1)}%
{realData?.holdDays && <span className="text-[9px] opacity-60 ml-0.5">/{realData.holdDays}d</span>}
</span>
)}
{predictionCorrect !== null && ( {predictionCorrect !== null && (
<AccuracyBadge <AccuracyBadge
correct={predictionCorrect} correct={predictionCorrect}
@ -1193,54 +1331,67 @@ export default function History() {
</div> </div>
<InvestmentModeToggle mode={summaryMode} onChange={setSummaryMode} /> <InvestmentModeToggle mode={summaryMode} onChange={setSummaryMode} />
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> {isBacktestDataLoading && !isUsingMockData ? (
<div <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 text-center cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors" {[1, 2, 3, 4].map(i => (
onClick={() => setActiveSummaryModal('daysTracked')} <div key={i} className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 text-center">
> <SkeletonBar className="h-6 w-12 mx-auto mb-1" />
<div className="text-xl font-bold text-nifty-600 dark:text-nifty-400">{filteredStats.totalDays}</div> <SkeletonBar className="h-3 w-20 mx-auto" />
<div className="text-xs text-gray-500 dark:text-gray-400 flex items-center justify-center gap-1"> </div>
Days Tracked <HelpCircle className="w-3 h-3" /> ))}
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div
className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 text-center cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
onClick={() => setActiveSummaryModal('daysTracked')}
>
<div className="text-xl font-bold text-nifty-600 dark:text-nifty-400">{filteredStats.totalDays}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 flex items-center justify-center gap-1">
Days Tracked <HelpCircle className="w-3 h-3" />
</div>
</div>
<div
className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 text-center cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
onClick={() => setActiveSummaryModal('avgReturn')}
>
<div className={`text-xl font-bold ${getValueColorClass(filteredStats.avgDailyReturn)}`}>
{filteredStats.avgDailyReturn >= 0 ? '+' : ''}{filteredStats.avgDailyReturn.toFixed(1)}%
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 flex items-center justify-center gap-1">
Avg Return <HelpCircle className="w-3 h-3" />
</div>
</div>
<div
className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 text-center cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
onClick={() => setActiveSummaryModal('buySignals')}
>
<div className="text-xl font-bold text-green-600 dark:text-green-400">
{filteredStats.buySignals}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 flex items-center justify-center gap-1">
{summaryMode === 'topPicks' ? 'Top Pick Signals' : 'Buy Signals'} <HelpCircle className="w-3 h-3" />
</div>
</div>
<div
className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 text-center cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
onClick={() => setActiveSummaryModal('sellSignals')}
>
<div className="text-xl font-bold text-red-600 dark:text-red-400">
{filteredStats.sellSignals}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 flex items-center justify-center gap-1">
Sell Signals <HelpCircle className="w-3 h-3" />
</div>
</div> </div>
</div> </div>
<div )}
className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 text-center cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
onClick={() => setActiveSummaryModal('avgReturn')}
>
<div className={`text-xl font-bold ${getValueColorClass(filteredStats.avgDailyReturn)}`}>
{filteredStats.avgDailyReturn >= 0 ? '+' : ''}{filteredStats.avgDailyReturn.toFixed(1)}%
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 flex items-center justify-center gap-1">
Avg Next-Day Return <HelpCircle className="w-3 h-3" />
</div>
</div>
<div
className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 text-center cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
onClick={() => setActiveSummaryModal('buySignals')}
>
<div className="text-xl font-bold text-green-600 dark:text-green-400">
{filteredStats.buySignals}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 flex items-center justify-center gap-1">
{summaryMode === 'topPicks' ? 'Top Pick Signals' : 'Buy Signals'} <HelpCircle className="w-3 h-3" />
</div>
</div>
<div
className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 text-center cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
onClick={() => setActiveSummaryModal('sellSignals')}
>
<div className="text-xl font-bold text-red-600 dark:text-red-400">
{filteredStats.sellSignals}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 flex items-center justify-center gap-1">
Sell Signals <HelpCircle className="w-3 h-3" />
</div>
</div>
</div>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-3 text-center"> <p className="text-[10px] text-gray-400 dark:text-gray-500 mt-3 text-center">
{summaryMode === 'topPicks' {isBacktestDataLoading && !isUsingMockData
? 'Performance based on Top Picks recommendations only (3 stocks per day)' ? 'Loading performance data from market...'
: 'Next-day return = Price change on the trading day after recommendation' : 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)'
} }
</p> </p>
</div> </div>
@ -1262,25 +1413,31 @@ export default function History() {
<InvestmentModeToggle mode={indexChartMode} onChange={setIndexChartMode} /> <InvestmentModeToggle mode={indexChartMode} onChange={setIndexChartMode} />
</div> </div>
</div> </div>
<IndexComparisonChart {isBacktestDataLoading && !isUsingMockData ? (
height={220} <SectionLoader message="Computing cumulative returns vs Nifty50 index..." />
data={isUsingMockData ) : (
? undefined <>
: (indexChartMode === 'topPicks' ? topPicksCumulativeReturns : realCumulativeReturns) ?? [] <IndexComparisonChart
} height={220}
/> data={isUsingMockData
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center"> ? undefined
{(indexChartMode === 'topPicks' ? topPicksCumulativeReturns : realCumulativeReturns)?.length ? ( : (indexChartMode === 'topPicks' ? topPicksCumulativeReturns : realCumulativeReturns) ?? []
<> }
Cumulative returns for {indexChartMode === 'topPicks' ? 'Top Picks' : 'All 50 stocks'} over{' '} />
{(indexChartMode === 'topPicks' ? topPicksCumulativeReturns : realCumulativeReturns)?.length} trading days <p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center">
</> {(indexChartMode === 'topPicks' ? topPicksCumulativeReturns : realCumulativeReturns)?.length ? (
) : isUsingMockData ? ( <>
<>Demo data - Start backend for real performance comparison</> Cumulative returns for {indexChartMode === 'topPicks' ? 'Top Picks' : 'All 50 stocks'} over{' '}
) : ( {(indexChartMode === 'topPicks' ? topPicksCumulativeReturns : realCumulativeReturns)?.length} trading days
<>Comparison of cumulative returns between AI strategy and Nifty50 index</> </>
)} ) : isUsingMockData ? (
</p> <>Demo data - Start backend for real performance comparison</>
) : (
<>Comparison of cumulative returns between AI strategy and Nifty50 index</>
)}
</p>
</>
)}
</section> </section>
{/* Return Distribution */} {/* Return Distribution */}
@ -1300,22 +1457,28 @@ export default function History() {
<InvestmentModeToggle mode={distributionMode} onChange={setDistributionMode} /> <InvestmentModeToggle mode={distributionMode} onChange={setDistributionMode} />
</div> </div>
</div> </div>
<ReturnDistributionChart {isBacktestDataLoading && !isUsingMockData ? (
height={200} <SectionLoader message="Computing return distribution from backtest data..." />
data={isUsingMockData ) : (
? undefined <>
: (distributionMode === 'topPicks' ? topPicksReturnDistribution : realReturnDistribution) ?? [] <ReturnDistributionChart
} height={200}
/> data={isUsingMockData
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center"> ? undefined
{(distributionMode === 'topPicks' ? topPicksReturnDistribution : realReturnDistribution) ? ( : (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</> <p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center">
) : ( {(distributionMode === 'topPicks' ? topPicksReturnDistribution : realReturnDistribution) ? (
<>Distribution of next-day returns across all predictions. Click bars to see stocks.</> <>Distribution of {distributionMode === 'topPicks' ? 'Top Picks' : 'all 50 stocks'} hold-period returns. Click bars to see stocks.</>
)} ) : isUsingMockData ? (
</p> <>Demo data - Start backend for real return distribution</>
) : (
<>Distribution of hold-period returns across all predictions. Click bars to see stocks.</>
)}
</p>
</>
)}
</section> </section>
{/* Accuracy Explanation Modal */} {/* Accuracy Explanation Modal */}
@ -1329,7 +1492,7 @@ export default function History() {
<ReturnExplainModal <ReturnExplainModal
isOpen={showReturnModal} isOpen={showReturnModal}
onClose={() => setShowReturnModal(false)} onClose={() => setShowReturnModal(false)}
breakdown={returnModalDate ? (isUsingMockData ? getStaticReturnBreakdown(returnModalDate) : null) : null} breakdown={returnModalDate ? (isUsingMockData ? getStaticReturnBreakdown(returnModalDate) : buildReturnBreakdown(returnModalDate)) : null}
date={returnModalDate || ''} date={returnModalDate || ''}
/> />
@ -1361,19 +1524,20 @@ export default function History() {
<InfoModal <InfoModal
isOpen={activeSummaryModal === 'avgReturn'} isOpen={activeSummaryModal === 'avgReturn'}
onClose={() => setActiveSummaryModal(null)} onClose={() => setActiveSummaryModal(null)}
title="Average Next-Day Return" title="Average Return"
icon={<TrendingUp className="w-5 h-5 text-green-600 dark:text-green-400" />} icon={<TrendingUp className="w-5 h-5 text-green-600 dark:text-green-400" />}
> >
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300"> <div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<p><strong>Average Next-Day Return</strong> measures the mean percentage price change one trading day after each recommendation.</p> <p><strong>Average Return</strong> measures the mean percentage price change over each stock's recommended hold period.</p>
<div className="p-3 bg-gray-100 dark:bg-slate-700 rounded-lg"> <div className="p-3 bg-gray-100 dark:bg-slate-700 rounded-lg">
<div className="font-semibold mb-1">How it's calculated:</div> <div className="font-semibold mb-1">How it's calculated:</div>
<ol className="text-xs space-y-1 list-decimal list-inside"> <ol className="text-xs space-y-1 list-decimal list-inside">
<li>Record stock price at recommendation time</li> <li>Record stock price at recommendation time</li>
<li>Record price at next trading day close</li> <li>Record price after the recommended hold period (e.g. 15 days)</li>
<li>Calculate: (Next Day Price - Rec Price) / Rec Price × 100</li> <li>Calculate: (Exit Price - Entry Price) / Entry Price × 100</li>
<li>Average all these returns</li> <li>Average all these returns across stocks</li>
</ol> </ol>
<p className="text-xs text-gray-500 mt-2">If no hold period is specified, falls back to 1-day return.</p>
</div> </div>
<div className={`p-3 ${filteredStats.avgDailyReturn >= 0 ? 'bg-green-50 dark:bg-green-900/20' : 'bg-red-50 dark:bg-red-900/20'} rounded-lg`}> <div className={`p-3 ${filteredStats.avgDailyReturn >= 0 ? 'bg-green-50 dark:bg-green-900/20' : 'bg-red-50 dark:bg-red-900/20'} rounded-lg`}>
<div className="text-xs text-gray-500 mb-1">Current Average:</div> <div className="text-xs text-gray-500 mb-1">Current Average:</div>

View File

@ -27,6 +27,9 @@ interface BacktestResult {
decision: string; decision: string;
return1d: number | null; return1d: number | null;
return1w: number | null; return1w: number | null;
returnAtHold: number | null;
holdDays: number | null;
primaryReturn: number | null; // return_at_hold ?? return_1d
predictionCorrect: boolean | null; predictionCorrect: boolean | null;
isLoading?: boolean; isLoading?: boolean;
} }
@ -124,16 +127,14 @@ export default function StockDetail() {
const backtest = await api.getBacktestResult(entry.date, symbol); const backtest = await api.getBacktestResult(entry.date, symbol);
if (backtest.available) { if (backtest.available) {
// Calculate prediction correctness based on 1-day return // Use hold-period return when available (BUY/HOLD with hold_days), else 1-day return
// BUY is correct if return > 0, HOLD is correct if return > 0, SELL is correct if return < 0 const primaryReturn = backtest.return_at_hold ?? backtest.actual_return_1d ?? null;
let predictionCorrect: boolean | null = 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') { if (entry.decision === 'BUY' || entry.decision === 'HOLD') {
// BUY and HOLD are correct if stock price went up predictionCorrect = primaryReturn > 0;
predictionCorrect = backtest.actual_return_1d > 0;
} else if (entry.decision === 'SELL') { } else if (entry.decision === 'SELL') {
// SELL is correct if stock price went down predictionCorrect = primaryReturn < 0;
predictionCorrect = backtest.actual_return_1d < 0;
} }
} }
@ -142,6 +143,9 @@ export default function StockDetail() {
decision: entry.decision, decision: entry.decision,
return1d: backtest.actual_return_1d ?? null, return1d: backtest.actual_return_1d ?? null,
return1w: backtest.actual_return_1w ?? null, return1w: backtest.actual_return_1w ?? null,
returnAtHold: backtest.return_at_hold ?? null,
holdDays: backtest.hold_days ?? null,
primaryReturn,
predictionCorrect, predictionCorrect,
}); });
} else { } else {
@ -151,6 +155,9 @@ export default function StockDetail() {
decision: entry.decision, decision: entry.decision,
return1d: null, return1d: null,
return1w: null, return1w: null,
returnAtHold: null,
holdDays: null,
primaryReturn: null,
predictionCorrect: null, predictionCorrect: null,
}); });
} }
@ -161,6 +168,9 @@ export default function StockDetail() {
decision: entry.decision, decision: entry.decision,
return1d: null, return1d: null,
return1w: null, return1w: null,
returnAtHold: null,
holdDays: null,
primaryReturn: null,
predictionCorrect: null, predictionCorrect: null,
}); });
} }
@ -179,7 +189,7 @@ export default function StockDetail() {
const predictionStats = useMemo((): PredictionStats | null => { const predictionStats = useMemo((): PredictionStats | null => {
if (backtestResults.length === 0) return 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; if (resultsWithData.length === 0) return null;
let correct = 0; let correct = 0;
@ -189,8 +199,8 @@ export default function StockDetail() {
let holdTotal = 0, holdCorrect = 0; let holdTotal = 0, holdCorrect = 0;
for (const result of resultsWithData) { for (const result of resultsWithData) {
if (result.return1d !== null) { if (result.primaryReturn !== null) {
totalReturn += result.return1d; totalReturn += result.primaryReturn;
} }
if (result.predictionCorrect !== null) { if (result.predictionCorrect !== null) {
if (result.predictionCorrect) correct++; if (result.predictionCorrect) correct++;
@ -916,7 +926,7 @@ export default function StockDetail() {
)} )}
</div> </div>
<span className="text-[10px] text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-slate-600 px-2 py-0.5 rounded-full"> <span className="text-[10px] text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-slate-600 px-2 py-0.5 rounded-full">
Real 1-Day Returns Actual Returns (Hold Period)
</span> </span>
</div> </div>
</div> </div>
@ -951,18 +961,20 @@ export default function StockDetail() {
)} )}
</div> </div>
{/* Outcome - 1 Day Return */} {/* Outcome - Hold Period Return */}
{entry.return1d !== null ? ( {entry.primaryReturn !== null ? (
<> <>
<div className="flex-1 flex items-center gap-2"> <div className="flex-1 flex items-center gap-2">
<div className={`text-sm font-semibold ${ <div className={`text-sm font-semibold ${
entry.return1d >= 0 entry.primaryReturn >= 0
? 'text-green-600 dark:text-green-400' ? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400' : 'text-red-600 dark:text-red-400'
}`}> }`}>
{entry.return1d >= 0 ? '+' : ''}{entry.return1d.toFixed(1)}% {entry.primaryReturn >= 0 ? '+' : ''}{entry.primaryReturn.toFixed(1)}%
</div>
<div className="text-[10px] text-gray-400 dark:text-gray-500">
{entry.holdDays && entry.holdDays > 0 ? `${entry.holdDays}d` : '1d'}
</div> </div>
<div className="text-[10px] text-gray-400 dark:text-gray-500">next day</div>
</div> </div>
{/* Prediction Result Icon */} {/* Prediction Result Icon */}

View File

@ -408,6 +408,8 @@ class ApiService {
return_1d?: number; return_1d?: number;
return_1w?: number; return_1w?: number;
return_1m?: number; return_1m?: number;
return_at_hold?: number;
hold_days?: number;
prediction_correct?: boolean; prediction_correct?: boolean;
}>; }>;
}> { }> {

View File

@ -211,6 +211,25 @@ export const DEBATE_ROLES = {
} }
} as const; } 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<string, string[]> = {
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 // 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 };
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
pipeline-drawer-bottom.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
pipeline-light-theme.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
pipeline-tool-calls.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
tool-calls-indicators.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
tool-calls-with-results.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@ -1,7 +1,7 @@
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
import time import time
import json 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.dataflows.config import get_config
from tradingagents.log_utils import add_log, step_timer, symbol_progress 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: if len(result.tool_calls) == 0:
report = result.content report = result.content
add_log("llm", "fundamentals", f"LLM responded in {elapsed:.1f}s ({len(report)} chars)") 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]}...") add_log("agent", "fundamentals", f"✅ Fundamentals report ready: {report[:300]}...")
step_timer.end_step("fundamentals_analyst", "completed", report[:200]) step_timer.end_step("fundamentals_analyst", "completed", report[:200])
symbol_progress.step_done(ticker, "fundamentals_analyst") symbol_progress.step_done(ticker, "fundamentals_analyst")
@ -77,6 +94,7 @@ def create_fundamentals_analyst(llm):
"system_prompt": system_message[:2000], "system_prompt": system_message[:2000],
"user_prompt": f"Analyze fundamentals for {ticker} on {current_date}", "user_prompt": f"Analyze fundamentals for {ticker} on {current_date}",
"response": report[:3000], "response": report[:3000],
"tool_calls": tool_results if tool_results else [],
}) })
else: else:
tool_call_info = [{"name": tc["name"], "args": str(tc.get("args", {}))[:200]} for tc in result.tool_calls] tool_call_info = [{"name": tc["name"], "args": str(tc.get("args", {}))[:200]} for tc in result.tool_calls]

View File

@ -1,7 +1,7 @@
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
import time import time
import json 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.dataflows.config import get_config
from tradingagents.log_utils import add_log, step_timer, symbol_progress from tradingagents.log_utils import add_log, step_timer, symbol_progress
@ -93,14 +93,34 @@ Volume-Based Indicators:
if len(result.tool_calls) == 0: if len(result.tool_calls) == 0:
report = result.content report = result.content
add_log("llm", "market_analyst", f"LLM responded in {elapsed:.1f}s ({len(report)} chars)") 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]}...") add_log("agent", "market_analyst", f"✅ Market report ready: {report[:300]}...")
step_timer.end_step("market_analyst", "completed", report[:200]) step_timer.end_step("market_analyst", "completed", report[:200])
symbol_progress.step_done(ticker, "market_analyst") symbol_progress.step_done(ticker, "market_analyst")
# Use update_details to preserve tool_calls from previous invocation
step_timer.update_details("market_analyst", { step_timer.update_details("market_analyst", {
"system_prompt": system_message[:2000], "system_prompt": system_message[:2000],
"user_prompt": f"Analyze {ticker} on {current_date} using technical indicators", "user_prompt": f"Analyze {ticker} on {current_date} using technical indicators",
"response": report[:3000], "response": report[:3000],
"tool_calls": tool_results if tool_results else [],
}) })
else: else:
tool_call_info = [{"name": tc["name"], "args": str(tc.get("args", {}))[:200]} for tc in result.tool_calls] tool_call_info = [{"name": tc["name"], "args": str(tc.get("args", {}))[:200]} for tc in result.tool_calls]

View File

@ -1,7 +1,7 @@
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
import time import time
import json 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.dataflows.config import get_config
from tradingagents.log_utils import add_log, step_timer, symbol_progress 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: if len(result.tool_calls) == 0:
report = result.content report = result.content
add_log("llm", "news_analyst", f"LLM responded in {elapsed:.1f}s ({len(report)} chars)") 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]}...") add_log("agent", "news_analyst", f"✅ News report ready: {report[:300]}...")
step_timer.end_step("news_analyst", "completed", report[:200]) step_timer.end_step("news_analyst", "completed", report[:200])
symbol_progress.step_done(ticker, "news_analyst") symbol_progress.step_done(ticker, "news_analyst")
@ -73,6 +90,7 @@ def create_news_analyst(llm):
"system_prompt": system_message[:2000], "system_prompt": system_message[:2000],
"user_prompt": f"Analyze news and macro trends for {ticker} on {current_date}", "user_prompt": f"Analyze news and macro trends for {ticker} on {current_date}",
"response": report[:3000], "response": report[:3000],
"tool_calls": tool_results if tool_results else [],
}) })
else: else:
tool_call_info = [{"name": tc["name"], "args": str(tc.get("args", {}))[:200]} for tc in result.tool_calls] tool_call_info = [{"name": tc["name"], "args": str(tc.get("args", {}))[:200]} for tc in result.tool_calls]

View File

@ -1,7 +1,7 @@
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
import time import time
import json 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.dataflows.config import get_config
from tradingagents.log_utils import add_log, step_timer, symbol_progress 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: if len(result.tool_calls) == 0:
report = result.content report = result.content
add_log("llm", "social_analyst", f"LLM responded in {elapsed:.1f}s ({len(report)} chars)") 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]}...") add_log("agent", "social_analyst", f"✅ Sentiment report ready: {report[:300]}...")
step_timer.end_step("social_media_analyst", "completed", report[:200]) step_timer.end_step("social_media_analyst", "completed", report[:200])
symbol_progress.step_done(ticker, "social_media_analyst") symbol_progress.step_done(ticker, "social_media_analyst")
@ -73,6 +90,7 @@ def create_social_media_analyst(llm):
"system_prompt": system_message[:2000], "system_prompt": system_message[:2000],
"user_prompt": f"Analyze social media sentiment for {ticker} on {current_date}", "user_prompt": f"Analyze social media sentiment for {ticker} on {current_date}",
"response": report[:3000], "response": report[:3000],
"tool_calls": tool_results if tool_results else [],
}) })
else: else:
tool_call_info = [{"name": tc["name"], "args": str(tc.get("args", {}))[:200]} for tc in result.tool_calls] tool_call_info = [{"name": tc["name"], "args": str(tc.get("args", {}))[:200]} for tc in result.tool_calls]

View File

@ -14,7 +14,7 @@ def create_risk_manager(llm, memory):
risk_debate_state = state["risk_debate_state"] risk_debate_state = state["risk_debate_state"]
market_research_report = state["market_report"] market_research_report = state["market_report"]
news_report = state["news_report"] news_report = state["news_report"]
fundamentals_report = state["news_report"] fundamentals_report = state["fundamentals_report"]
sentiment_report = state["sentiment_report"] sentiment_report = state["sentiment_report"]
trader_plan = state["investment_plan"] trader_plan = state["investment_plan"]
@ -39,8 +39,10 @@ Your task: Evaluate the risk debate between Aggressive, Neutral, and Conservativ
Your response must include: Your response must include:
1. FINAL DECISION: BUY, SELL, or HOLD 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) 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 3. CONFIDENCE: HIGH, MEDIUM, or LOW (how confident you are in this decision)
4. RATIONALE: Why this decision balances risk and reward appropriately 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: RESPONSE FORMAT:
- Maximum 1500 characters. Lead with your decision, then key rationale. - Maximum 1500 characters. Lead with your decision, then key rationale.

View File

@ -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. 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.""" 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: def get_simulation_prompt(role_prompt: str) -> list:
@ -44,6 +46,171 @@ from tradingagents.agents.utils.news_data_tools import (
get_global_news 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 create_msg_delete():
def delete_messages(state): def delete_messages(state):
"""Clear messages and add placeholder for Anthropic compatibility""" """Clear messages and add placeholder for Anthropic compatibility"""

View File

@ -36,7 +36,7 @@ class ClaudeMaxLLM(BaseChatModel):
model: str = "sonnet" # Use alias for Claude Max subscription model: str = "sonnet" # Use alias for Claude Max subscription
max_tokens: int = 4096 max_tokens: int = 4096
temperature: float = 0.7 temperature: float = 0.2
claude_cli_path: str = "claude" claude_cli_path: str = "claude"
tools: List[Any] = [] # Bound tools tools: List[Any] = [] # Bound tools

View File

@ -1,7 +1,7 @@
from typing import Annotated, Union from typing import Annotated, Union
from datetime import datetime from datetime import datetime
from dateutil.relativedelta import relativedelta 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 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 # Use original query (symbol) in the header for clarity
display_query = original_query if is_nifty_50_stock(original_query) else query.replace("+", " ") 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}" 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}"

View File

@ -1,108 +1,157 @@
import json
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from datetime import datetime from datetime import datetime
import time import urllib.parse
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
def getNewsData(query, start_date, end_date): def getNewsData(query, start_date, end_date):
""" """
Scrape Google News search results for a given query and date range. Fetch Google News via RSS feed 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")
headers = { Uses Google News RSS which is reliable (no JS rendering or CSS selectors needed).
"User-Agent": ( Results are filtered to only include articles within the date range.
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) " query: str - search query (spaces or '+' separated)
"Chrome/101.0.4951.54 Safari/537.36" 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 = [] news_results = []
page = 0 try:
while True: resp = requests.get(url, timeout=15)
offset = page * 10 if resp.status_code != 200:
url = ( return news_results
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: soup = BeautifulSoup(resp.content, "xml")
response = make_request(url, headers) items = soup.find_all("item")
soup = BeautifulSoup(response.content, "html.parser")
results_on_page = soup.select("div.SoaBEf")
if not results_on_page: for item in items[:20]: # Limit to 20 articles
break # No more results found 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: # Parse and filter by date
try: if pub_date_str:
link = el.find("a")["href"] try:
title = el.select_one("div.MBeuO").get_text() pub_dt = datetime.strptime(pub_date_str, "%a, %d %b %Y %H:%M:%S %Z")
snippet = el.select_one(".GI74Re").get_text() if pub_dt.date() < start_dt.date() or pub_dt.date() > end_dt.date():
date = el.select_one(".LfVVr").get_text() continue
source = el.select_one(".NUnG9d span").get_text() date_display = pub_dt.strftime("%Y-%m-%d")
news_results.append( except ValueError:
{ date_display = pub_date_str
"link": link, else:
"title": title, date_display = ""
"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
# 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) except Exception as e:
next_link = soup.find("a", id="pnnext") print(f"Google News RSS fetch failed: {e}")
if not next_link:
break
page += 1
except Exception as e:
print(f"Failed after multiple retries: {e}")
break
return news_results 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]

View File

@ -42,8 +42,8 @@ def clear_request_cache():
# Import from vendor-specific modules # 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 .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 .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 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 .openai import get_stock_news_openai, get_global_news_openai, get_fundamentals_openai
from .alpha_vantage import ( from .alpha_vantage import (
get_stock as get_alpha_vantage_stock, get_stock as get_alpha_vantage_stock,
@ -122,6 +122,7 @@ VENDOR_METHODS = {
}, },
# fundamental_data # fundamental_data
"get_fundamentals": { "get_fundamentals": {
"yfinance": get_yfinance_fundamentals,
"alpha_vantage": get_alpha_vantage_fundamentals, "alpha_vantage": get_alpha_vantage_fundamentals,
"openai": get_fundamentals_openai, "openai": get_fundamentals_openai,
}, },
@ -148,8 +149,9 @@ VENDOR_METHODS = {
"local": [get_finnhub_news, get_reddit_company_news, get_google_news], "local": [get_finnhub_news, get_reddit_company_news, get_google_news],
}, },
"get_global_news": { "get_global_news": {
"google": get_google_global_news,
"openai": get_global_news_openai, "openai": get_global_news_openai,
"local": get_reddit_global_news "local": get_reddit_global_news,
}, },
"get_insider_sentiment": { "get_insider_sentiment": {
"local": get_finnhub_company_insider_sentiment "local": get_finnhub_company_insider_sentiment

View File

@ -461,6 +461,63 @@ def get_income_statement(
return f"Error retrieving income statement for {normalized_ticker}: {str(e)}" 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( def get_insider_transactions(
ticker: Annotated[str, "ticker symbol of the company"] ticker: Annotated[str, "ticker symbol of the company"]
): ):

View File

@ -13,6 +13,7 @@ DEFAULT_CONFIG = {
"deep_think_llm": "o4-mini", "deep_think_llm": "o4-mini",
"quick_think_llm": "gpt-4o-mini", "quick_think_llm": "gpt-4o-mini",
"backend_url": "https://api.openai.com/v1", "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-specific config for Claude models (using aliases for Claude Max subscription)
"anthropic_config": { "anthropic_config": {
"deep_think_llm": "opus", # Claude Opus 4.5 for deep analysis "deep_think_llm": "opus", # Claude Opus 4.5 for deep analysis

View File

@ -1,5 +1,6 @@
# TradingAgents/graph/signal_processing.py # TradingAgents/graph/signal_processing.py
import re
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
@ -12,37 +13,97 @@ class SignalProcessor:
def process_signal(self, full_signal: str) -> dict: 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: Args:
full_signal: Complete trading signal text full_signal: Complete trading signal text
Returns: Returns:
Dict with 'decision' (BUY/SELL/HOLD) and 'hold_days' (int or None) Dict with 'decision', 'hold_days', 'confidence', 'risk'
""" """
messages = [ messages = [
( (
"system", "system",
"You are an efficient assistant designed to analyze paragraphs or financial reports " "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" "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" "Respond in exactly this format (nothing else):\n"
"DECISION: <BUY|SELL|HOLD>\n" "DECISION: <BUY|SELL|HOLD>\n"
"HOLD_DAYS: <number|N/A>\n\n" "HOLD_DAYS: <number|N/A>\n"
"CONFIDENCE: <HIGH|MEDIUM|LOW>\n"
"RISK_LEVEL: <HIGH|MEDIUM|LOW>\n\n"
"For SELL decisions, always use HOLD_DAYS: N/A\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), ("human", full_signal),
] ]
response = self.quick_thinking_llm.invoke(messages).content 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: 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" decision = "HOLD"
hold_days = None hold_days = None
confidence = "MEDIUM"
risk = "MEDIUM"
for line in response.strip().split("\n"): for line in response.strip().split("\n"):
line = line.strip() line = line.strip()
@ -63,6 +124,16 @@ class SignalProcessor:
hold_days = max(1, min(90, hold_days)) hold_days = max(1, min(90, hold_days))
except (ValueError, TypeError): except (ValueError, TypeError):
hold_days = None 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 # Enforce: SELL never has hold_days; BUY/HOLD default to 5 if missing
if decision == "SELL": if decision == "SELL":
@ -70,4 +141,4 @@ class SignalProcessor:
elif hold_days is None: elif hold_days is None:
hold_days = 5 # Default hold period hold_days = 5 # Default hold period
return {"decision": decision, "hold_days": hold_days} return {"decision": decision, "hold_days": hold_days, "confidence": confidence, "risk": risk}

View File

@ -81,17 +81,18 @@ class TradingAgentsGraph:
exist_ok=True, 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": 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.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"]) 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": elif self.config["llm_provider"].lower() == "anthropic":
# Use ClaudeMaxLLM to leverage Claude Max subscription via CLI # Use ClaudeMaxLLM to leverage Claude Max subscription via CLI
self.deep_thinking_llm = ClaudeMaxLLM(model=self.config["deep_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"]) self.quick_thinking_llm = ClaudeMaxLLM(model=self.config["quick_think_llm"], temperature=llm_temp)
elif self.config["llm_provider"].lower() == "google": elif self.config["llm_provider"].lower() == "google":
self.deep_thinking_llm = ChatGoogleGenerativeAI(model=self.config["deep_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"]) self.quick_thinking_llm = ChatGoogleGenerativeAI(model=self.config["quick_think_llm"], temperature=llm_temp)
else: else:
raise ValueError(f"Unsupported LLM provider: {self.config['llm_provider']}") raise ValueError(f"Unsupported LLM provider: {self.config['llm_provider']}")
@ -116,7 +117,10 @@ class TradingAgentsGraph:
self.tool_nodes = self._create_tool_nodes() self.tool_nodes = self._create_tool_nodes()
# Initialize components # 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.graph_setup = GraphSetup(
self.quick_thinking_llm, self.quick_thinking_llm,
self.deep_thinking_llm, self.deep_thinking_llm,
@ -250,16 +254,18 @@ class TradingAgentsGraph:
self._save_to_frontend_db(trade_date, final_state) self._save_to_frontend_db(trade_date, final_state)
add_log("info", "system", f"Database save completed in {_time.time() - t0:.1f}s") 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"]) signal_result = self.process_signal(final_state["final_trade_decision"])
final_decision = signal_result["decision"] final_decision = signal_result["decision"]
hold_days = signal_result.get("hold_days") 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 total_elapsed = _time.time() - pipeline_start
hold_info = f", hold {hold_days}d" if hold_days else "" 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 decision, hold_days, confidence, risk
return final_state, final_decision, hold_days return final_state, final_decision, hold_days, confidence, risk
def _log_state(self, trade_date, final_state): def _log_state(self, trade_date, final_state):
"""Log the final state to a JSON file.""" """Log the final state to a JSON file."""