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_1m=result['return_1m'],
prediction_correct=result['prediction_correct'],
hold_days=result.get('hold_days')
hold_days=result.get('hold_days'),
return_at_hold=result.get('return_at_hold'),
)
return result
@ -231,6 +232,8 @@ def get_backtest_data_for_frontend(date: str, symbol: str) -> dict:
'actual_return_1d': result['return_1d'],
'actual_return_1w': result['return_1w'],
'actual_return_1m': result['return_1m'],
'return_at_hold': result.get('return_at_hold'),
'hold_days': result.get('hold_days'),
'price_at_prediction': result['price_at_prediction'],
'current_price': result.get('price_1m_later') or result.get('price_1w_later'),
'price_history': price_history

View File

@ -172,6 +172,13 @@ def init_db():
cursor.execute("ALTER TABLE backtest_results ADD COLUMN hold_days INTEGER")
except sqlite3.OperationalError:
pass # Column already exists
try:
cursor.execute("ALTER TABLE backtest_results ADD COLUMN return_at_hold REAL")
# New column added — delete stale backtest data so it gets recalculated with return_at_hold
cursor.execute("DELETE FROM backtest_results")
print("Migration: Added return_at_hold column, cleared stale backtest data for recalculation")
except sqlite3.OperationalError:
pass # Column already exists
# Create indexes for new tables
cursor.execute("""
@ -193,6 +200,80 @@ def init_db():
conn.commit()
conn.close()
# Re-extract hold_days from raw_analysis for rows that have the default value (5)
# This fixes data where the signal processor LLM failed to extract the actual hold period
_fix_default_hold_days()
def _fix_default_hold_days():
"""Re-extract hold_days from raw_analysis for rows where hold_days is NULL or 5 (defaults).
The signal processor sometimes defaults to 5 or leaves hold_days NULL when the
LLM fails to extract the actual hold period. This function uses regex on the
raw_analysis text to find the correct value.
"""
import re
patterns = [
r'(\d+)[\s-]*(?:day|trading[\s-]*day)[\s-]*(?:hold|horizon|period|timeframe)',
r'(?:hold|holding)[\s\w]*?(?:for|of|period\s+of)[\s]*(\d+)[\s]*(?:trading\s+)?days?',
r'setting\s+(\d+)\s+(?:trading\s+)?days',
r'(?:over|within|next)\s+(\d+)\s+(?:trading\s+)?days',
r'(\d+)\s+trading\s+days?\s*\(',
]
def extract_days(text):
if not text:
return None
# Search the conclusion/rationale section first (last 500 chars)
conclusion = text[-500:]
for pattern in patterns:
for match in re.finditer(pattern, conclusion, re.IGNORECASE):
days = int(match.group(1))
if 1 <= days <= 90:
return days
# Fall back to full text
for pattern in patterns:
for match in re.finditer(pattern, text, re.IGNORECASE):
days = int(match.group(1))
if 1 <= days <= 90:
return days
return None
conn = get_connection()
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
try:
# Fix rows where hold_days is NULL or the default 5
cursor.execute(
"SELECT id, symbol, date, raw_analysis, hold_days, decision FROM stock_analysis "
"WHERE (hold_days IS NULL OR hold_days = 5) "
"AND decision != 'SELL' "
"AND raw_analysis IS NOT NULL AND raw_analysis != ''"
)
rows = cursor.fetchall()
fixed = 0
for row in rows:
extracted = extract_days(row['raw_analysis'])
old_val = row['hold_days']
if extracted is not None and extracted != old_val:
cursor.execute(
"UPDATE stock_analysis SET hold_days = ? WHERE id = ?",
(extracted, row['id'])
)
fixed += 1
print(f" Fixed hold_days for {row['symbol']} ({row['date']}): {old_val} -> {extracted}")
if fixed > 0:
conn.commit()
# Also clear backtest results so they recalculate with correct hold_days
cursor.execute("DELETE FROM backtest_results")
conn.commit()
print(f"Fixed {fixed} stock(s) with missing/default hold_days. Cleared backtest cache.")
finally:
conn.close()
def save_recommendation(date: str, analysis_data: dict, summary: dict,
top_picks: list, stocks_to_avoid: list):
@ -884,7 +965,7 @@ def save_backtest_result(date: str, symbol: str, decision: str,
price_1w_later: float = None, price_1m_later: float = None,
return_1d: float = None, return_1w: float = None,
return_1m: float = None, prediction_correct: bool = None,
hold_days: int = None):
hold_days: int = None, return_at_hold: float = None):
"""Save a backtest result for a stock recommendation."""
conn = get_connection()
cursor = conn.cursor()
@ -894,14 +975,14 @@ def save_backtest_result(date: str, symbol: str, decision: str,
INSERT OR REPLACE INTO backtest_results
(date, symbol, decision, price_at_prediction,
price_1d_later, price_1w_later, price_1m_later,
return_1d, return_1w, return_1m, prediction_correct, hold_days)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
return_1d, return_1w, return_1m, prediction_correct, hold_days, return_at_hold)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
date, symbol, decision, price_at_prediction,
price_1d_later, price_1w_later, price_1m_later,
return_1d, return_1w, return_1m,
1 if prediction_correct else 0 if prediction_correct is not None else None,
hold_days
hold_days, return_at_hold
))
conn.commit()
finally:
@ -933,6 +1014,7 @@ def get_backtest_result(date: str, symbol: str) -> Optional[dict]:
'return_1m': row['return_1m'],
'prediction_correct': bool(row['prediction_correct']) if row['prediction_correct'] is not None else None,
'hold_days': row['hold_days'] if 'hold_days' in row.keys() else None,
'return_at_hold': row['return_at_hold'] if 'return_at_hold' in row.keys() else None,
'calculated_at': row['calculated_at']
}
return None
@ -962,7 +1044,8 @@ def get_backtest_results_by_date(date: str) -> list:
'return_1w': row['return_1w'],
'return_1m': row['return_1m'],
'prediction_correct': bool(row['prediction_correct']) if row['prediction_correct'] is not None else None,
'hold_days': row['hold_days'] if 'hold_days' in row.keys() else None
'hold_days': row['hold_days'] if 'hold_days' in row.keys() else None,
'return_at_hold': row['return_at_hold'] if 'return_at_hold' in row.keys() else None,
}
for row in cursor.fetchall()
]
@ -995,7 +1078,9 @@ def get_all_backtest_results() -> list:
'return_1d': row['return_1d'],
'return_1w': row['return_1w'],
'return_1m': row['return_1m'],
'prediction_correct': bool(row['prediction_correct'])
'prediction_correct': bool(row['prediction_correct']),
'hold_days': row['hold_days'] if 'hold_days' in row.keys() else None,
'return_at_hold': row['return_at_hold'] if 'return_at_hold' in row.keys() else None,
}
for row in cursor.fetchall()
]
@ -1013,7 +1098,7 @@ def calculate_accuracy_metrics() -> dict:
'total_predictions': 0,
'correct_predictions': 0,
'by_decision': {'BUY': {'accuracy': 0, 'total': 0}, 'SELL': {'accuracy': 0, 'total': 0}, 'HOLD': {'accuracy': 0, 'total': 0}},
'by_confidence': {'High': {'accuracy': 0, 'total': 0}, 'Medium': {'accuracy': 0, 'total': 0}, 'Low': {'accuracy': 0, 'total': 0}}
'by_confidence': {'HIGH': {'accuracy': 0, 'total': 0}, 'MEDIUM': {'accuracy': 0, 'total': 0}, 'LOW': {'accuracy': 0, 'total': 0}}
}
total = len(results)
@ -1035,7 +1120,7 @@ def calculate_accuracy_metrics() -> dict:
# By confidence level
by_confidence = {}
for conf in ['High', 'Medium', 'Low']:
for conf in ['HIGH', 'MEDIUM', 'LOW']:
conf_results = [r for r in results if r.get('confidence') == conf]
if conf_results:
conf_correct = sum(1 for r in conf_results if r['prediction_correct'])

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

View File

@ -1,10 +1,11 @@
import {
TrendingUp, TrendingDown, Users, Newspaper, FileText,
Scale, Target, Zap, Shield, ShieldCheck,
Clock, Loader2, CheckCircle, AlertCircle, ChevronDown, ChevronUp
Clock, Loader2, CheckCircle, AlertCircle, ChevronDown, ChevronUp, GitBranch
} from 'lucide-react';
import { useState } from 'react';
import type { FlowchartNodeData, PipelineStepStatus } from '../../types/pipeline';
import { STEP_INPUT_SOURCES } from '../../types/pipeline';
interface FlowchartNodeProps {
node: FlowchartNodeData;
@ -100,6 +101,7 @@ export function FlowchartNode({ node, isSelected, onClick }: FlowchartNodeProps)
const hasPreview = !!(node.output_summary || node.agentReport?.report_content || node.debateContent);
const previewText = node.output_summary || node.agentReport?.report_content || node.debateContent || '';
const inputCount = (STEP_INPUT_SOURCES[node.id] || []).length;
return (
<div className="w-full">
@ -145,6 +147,12 @@ export function FlowchartNode({ node, isSelected, onClick }: FlowchartNodeProps)
{/* Status + Duration */}
<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' && (
<span className="text-[10px] sm:text-xs font-mono font-semibold text-gray-500 dark:text-gray-400">
{formatDuration(node.duration_ms)}

View File

@ -1,6 +1,7 @@
import { useState } from 'react';
import { X, Clock, Timer, CheckCircle, AlertCircle, FileText, MessageSquare, ChevronDown, ChevronRight, Terminal, Bot, User, Wrench } from 'lucide-react';
import { useState, useMemo } from 'react';
import { X, Clock, Timer, CheckCircle, AlertCircle, FileText, MessageSquare, ChevronDown, ChevronRight, Terminal, Bot, User, GitBranch, ArrowRight, Info, Wrench } from 'lucide-react';
import type { FlowchartNodeData, FullPipelineData, StepDetails } from '../../types/pipeline';
import { STEP_INPUT_SOURCES, FLOWCHART_STEPS, mapPipelineToFlowchart } from '../../types/pipeline';
interface NodeDetailDrawerProps {
node: FlowchartNodeData;
@ -26,24 +27,33 @@ function formatDuration(ms: number): string {
return `${mins}m ${remSecs}s`;
}
function getFallbackContent(node: FlowchartNodeData, data: FullPipelineData | null): string {
/**
* Extract the ACTUAL meaningful output for a step.
* Prefers agent_reports and debate content over raw step_details.response,
* since the latter may contain "TOOL_CALL:" noise or be empty for debate steps.
*/
function getStepOutput(node: FlowchartNodeData, data: FullPipelineData | null): string {
// 1. Agent report content (for analyst steps)
if (node.agentReport?.report_content) return node.agentReport.report_content;
// 2. Debate content (for debate participants)
if (node.debateContent) return node.debateContent;
// 3. Output summary from pipeline step
if (node.output_summary) return node.output_summary;
if (!data) return '';
if (node.debateType === 'investment' && data.debates?.investment) {
const d = data.debates.investment;
if (node.debateRole === 'bull') return d.bull_arguments || '';
if (node.debateRole === 'bear') return d.bear_arguments || '';
if (node.debateRole === 'judge') return d.judge_decision || '';
}
if (node.debateType === 'risk' && data.debates?.risk) {
const d = data.debates.risk;
if (node.debateRole === 'risky') return d.risky_arguments || '';
if (node.debateRole === 'safe') return d.safe_arguments || '';
if (node.debateRole === 'neutral') return d.neutral_arguments || '';
if (node.debateRole === 'judge') return d.judge_decision || '';
// 4. Try debate tables directly
if (data) {
if (node.debateType === 'investment' && data.debates?.investment) {
const d = data.debates.investment;
if (node.debateRole === 'bull') return d.bull_arguments || '';
if (node.debateRole === 'bear') return d.bear_arguments || '';
if (node.debateRole === 'judge') return d.judge_decision || '';
}
if (node.debateType === 'risk' && data.debates?.risk) {
const d = data.debates.risk;
if (node.debateRole === 'risky') return d.risky_arguments || '';
if (node.debateRole === 'safe') return d.safe_arguments || '';
if (node.debateRole === 'neutral') return d.neutral_arguments || '';
if (node.debateRole === 'judge') return d.judge_decision || '';
}
}
return '';
}
@ -94,12 +104,243 @@ function CodeBlock({ content, maxHeight = 'max-h-64' }: { content: string; maxHe
);
}
/** Tool call with optional result */
interface ToolCallEntry {
name: string;
args: string;
result_preview?: string;
}
/** Get tool calls: prefer stored step_details.tool_calls, fall back to text parsing */
function getToolCalls(details: StepDetails | undefined, rawResponse: string): ToolCallEntry[] {
// Prefer stored tool_calls with actual results from the backend
if (details?.tool_calls && details.tool_calls.length > 0) {
return details.tool_calls.map(tc => ({
name: tc.name,
args: tc.args || '',
result_preview: tc.result_preview,
}));
}
// Fallback: parse TOOL_CALL: patterns from response text (legacy data)
const regex = /TOOL_CALL:\s*(\w+)\(([^)]*)\)/g;
const calls: ToolCallEntry[] = [];
let match;
while ((match = regex.exec(rawResponse)) !== null) {
calls.push({ name: match[1], args: match[2] });
}
return calls;
}
/** Strip TOOL_CALL lines from response to get only the prose */
function stripToolCalls(text: string): string {
return text.replace(/TOOL_CALL:\s*\w+\([^)]*\)\s*/g, '').trim();
}
/** Collapsible result preview for a single tool call */
function ToolResultPreview({ result }: { result: string }) {
const [expanded, setExpanded] = useState(false);
const isLong = result.length > 200;
const display = expanded ? result : result.slice(0, 200);
return (
<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) {
const details: StepDetails | undefined = node.step_details;
const fallbackContent = getFallbackContent(node, pipelineData);
const inputSources = STEP_INPUT_SOURCES[node.id] || [];
const hasForwardedContext = inputSources.length > 0;
// Determine if we have structured data or just fallback
const hasStructuredData = details && (details.system_prompt || details.user_prompt || details.response);
// Step Output: the ACTUAL meaningful output (agent report / debate content first, then raw response)
// Always strip TOOL_CALL lines from step output since they're noise in the output view
const rawStepOutput = getStepOutput(node, pipelineData) || details?.response || '';
const stepOutput = stripToolCalls(rawStepOutput);
// Raw LLM Response: the raw text from step_details (may contain TOOL_CALL noise)
const rawResponse = details?.response || '';
// Get tool calls: prefers stored results from backend, falls back to text parsing
const parsedToolCalls = useMemo(() => getToolCalls(details, rawResponse), [details, rawResponse]);
// Clean response = raw response minus TOOL_CALL lines (for display without noise)
const cleanRawResponse = useMemo(() => parsedToolCalls.length > 0 ? stripToolCalls(rawResponse) : rawResponse, [rawResponse, parsedToolCalls]);
// Only show raw response section if it differs from step output (avoid duplication)
const showRawResponse = rawResponse && rawResponse !== stepOutput;
const hasAnyContent = stepOutput || details?.user_prompt || details?.system_prompt;
return (
<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>
{/* Content sections */}
{/* Content sections — unified flow, no branching */}
<div className="p-3 space-y-2">
{hasStructuredData ? (
{/* 1. Context Received — always first */}
<ContextReceivedPanel node={node} pipelineData={pipelineData} />
{hasAnyContent ? (
<>
{/* System Prompt */}
{details.system_prompt && (
{/* 2. System Instructions — role definition (default OPEN) */}
{details?.system_prompt && (
<Section
title="System Prompt"
title="System Instructions"
icon={Bot}
iconColor="text-violet-500"
defaultOpen={true}
badge={`${details.system_prompt.length} chars`}
>
<CodeBlock content={details.system_prompt} maxHeight="max-h-48" />
</Section>
)}
{/* User Prompt / Input */}
{details.user_prompt && (
{/* 3. Input Prompt — forwarded context or initial prompt */}
{details?.user_prompt && (
<Section
title="User Prompt / Input"
title="Input Prompt"
icon={User}
iconColor="text-blue-500"
badge={`${details.user_prompt.length} chars`}
@ -181,80 +426,44 @@ export function NodeDetailDrawer({ node, pipelineData, onClose }: NodeDetailDraw
</Section>
)}
{/* Tool Calls */}
{details.tool_calls && details.tool_calls.length > 0 && (
{/* 4. Tool Calls — parsed from raw response (analyst steps) */}
{parsedToolCalls.length > 0 && (
<Section
title="Tool Calls"
icon={Wrench}
iconColor="text-amber-500"
badge={`${details.tool_calls.length} calls`}
badge={`${parsedToolCalls.length} calls`}
>
<div className="p-3 space-y-3">
{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>
<ToolCallsPanel calls={parsedToolCalls} />
</Section>
)}
{/* LLM Response */}
{details.response && (
{/* 5. Raw LLM Response — only when different from Step Output */}
{showRawResponse && (
<Section
title="LLM Response"
title="Raw LLM Response"
icon={MessageSquare}
iconColor="text-green-500"
defaultOpen={true}
badge={`${details.response.length} chars`}
iconColor="text-gray-500"
badge={`${rawResponse.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>
)}
</>
) : 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">
<FileText className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No output data available</p>
</div>
<EmptyState status={node.status} />
)}
</div>
</div>

View File

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

View File

@ -2,6 +2,7 @@ import { useState, useMemo, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { Calendar, TrendingUp, TrendingDown, Minus, ChevronRight, BarChart3, Target, HelpCircle, Activity, Calculator, LineChart, PieChart, Shield, Filter, Loader2, AlertCircle } from 'lucide-react';
import { sampleRecommendations, getBacktestResult as getStaticBacktestResult, calculateAccuracyMetrics as calculateStaticAccuracyMetrics, getDateStats as getStaticDateStats, getOverallStats as getStaticOverallStats, getReturnBreakdown as getStaticReturnBreakdown } from '../data/recommendations';
import type { ReturnBreakdown } from '../data/recommendations';
import { DecisionBadge, HoldDaysBadge } from '../components/StockCard';
import Sparkline from '../components/Sparkline';
import AccuracyBadge from '../components/AccuracyBadge';
@ -23,6 +24,9 @@ interface RealBacktestData {
decision: string;
return1d: number | null;
return1w: number | null;
returnAtHold: number | null;
holdDays: number | null;
primaryReturn: number | null; // return_at_hold ?? return_1d
predictionCorrect: boolean | null;
priceHistory?: Array<{ date: string; price: number }>;
}
@ -74,6 +78,21 @@ function InvestmentModeToggle({
);
}
// Pulsing skeleton bar for loading states
function SkeletonBar({ className = '' }: { className?: string }) {
return <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() {
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [showAccuracyModal, setShowAccuracyModal] = useState(false);
@ -163,7 +182,7 @@ export default function History() {
// Batch-fetch all backtest results per date (used by both accuracy trend and chart data)
const [batchBacktestByDate, setBatchBacktestByDate] = useState<
Record<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);
@ -203,6 +222,8 @@ export default function History() {
return_1d: r.return_1d,
return_1w: r.return_1w,
return_1m: r.return_1m,
return_at_hold: r.return_at_hold,
hold_days: r.hold_days,
prediction_correct: r.prediction_correct,
decision: r.decision,
};
@ -245,11 +266,12 @@ export default function History() {
for (const symbol of Object.keys(rec.analysis)) {
const stockAnalysis = rec.analysis[symbol];
const bt = dateBacktest[symbol];
if (!stockAnalysis?.decision || !bt || bt.return_1d === undefined || bt.return_1d === null) continue;
const primaryRet = bt?.return_at_hold ?? bt?.return_1d;
if (!stockAnalysis?.decision || primaryRet === undefined || primaryRet === null) continue;
const predictionCorrect = (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD')
? bt.return_1d > 0
: bt.return_1d < 0;
? primaryRet > 0
: primaryRet < 0;
if (stockAnalysis.decision === 'BUY') { totalBuy++; if (predictionCorrect) correctBuy++; }
else if (stockAnalysis.decision === 'SELL') { totalSell++; if (predictionCorrect) correctSell++; }
@ -361,32 +383,31 @@ export default function History() {
for (const symbol of Object.keys(rec.analysis)) {
const stockAnalysis = rec.analysis[symbol];
const bt = dateBacktest[symbol];
if (!stockAnalysis?.decision || !bt || bt.return_1d === undefined || bt.return_1d === null) continue;
const return1d = bt.return_1d;
const primaryRet = bt?.return_at_hold ?? bt?.return_1d;
if (!stockAnalysis?.decision || primaryRet === undefined || primaryRet === null) continue;
// Store for PortfolioSimulator
if (!allBacktest[date]) allBacktest[date] = {};
allBacktest[date][symbol] = return1d;
allBacktest[date][symbol] = primaryRet;
const predictionCorrect = (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD')
? return1d > 0
: return1d < 0;
? primaryRet > 0
: primaryRet < 0;
totalPredictions++;
if (predictionCorrect) {
totalCorrect++;
dateCorrectCount++;
if (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD') {
dateCorrectReturn += return1d;
dateCorrectReturn += primaryRet;
} else {
dateCorrectReturn += Math.abs(return1d);
dateCorrectReturn += Math.abs(primaryRet);
}
} else {
if (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD') {
dateIncorrectReturn += return1d;
dateIncorrectReturn += primaryRet;
} else {
dateIncorrectReturn += -Math.abs(return1d);
dateIncorrectReturn += -Math.abs(primaryRet);
}
}
dateTotalCount++;
@ -428,9 +449,10 @@ export default function History() {
if (rec && dateBacktest) {
for (const symbol of Object.keys(rec.analysis)) {
const bt = dateBacktest[symbol];
if (!bt || bt.return_1d === undefined || bt.return_1d === null) continue;
const retVal = bt?.return_at_hold ?? bt?.return_1d;
if (retVal === undefined || retVal === null) continue;
for (const bucket of returnBuckets) {
if (bt.return_1d >= bucket.min && bt.return_1d < bucket.max) {
if (retVal >= bucket.min && retVal < bucket.max) {
bucket.count++;
bucket.stocks.push(symbol);
break;
@ -535,8 +557,9 @@ export default function History() {
for (const pick of rec.top_picks) {
const bt = dateBacktest[pick.symbol];
if (bt && bt.return_1d !== undefined && bt.return_1d !== null) {
dateReturn += bt.return_1d;
const retVal = bt?.return_at_hold ?? bt?.return_1d;
if (retVal !== undefined && retVal !== null) {
dateReturn += retVal;
dateCount++;
}
}
@ -564,9 +587,10 @@ export default function History() {
if (rec && dateBacktest) {
for (const pick of rec.top_picks) {
const bt = dateBacktest[pick.symbol];
if (bt && bt.return_1d !== undefined && bt.return_1d !== null) {
const retVal = bt?.return_at_hold ?? bt?.return_1d;
if (retVal !== undefined && retVal !== null) {
for (const bucket of topPicksDistribution) {
if (bt.return_1d >= bucket.min && bt.return_1d < bucket.max) {
if (retVal >= bucket.min && retVal < bucket.max) {
bucket.count++;
bucket.stocks.push(pick.symbol);
break;
@ -690,14 +714,14 @@ export default function History() {
const backtest = await api.getBacktestResult(date, stock.symbol);
if (backtest.available) {
// Calculate prediction correctness based on 1-day return
// BUY/HOLD correct if return > 0, SELL correct if return < 0
// Use hold-period return when available, fall back to 1-day
const primaryReturn = backtest.return_at_hold ?? backtest.actual_return_1d ?? null;
let predictionCorrect: boolean | null = null;
if (backtest.actual_return_1d !== undefined && backtest.actual_return_1d !== null) {
if (primaryReturn !== null) {
if (stock.decision === 'BUY' || stock.decision === 'HOLD') {
predictionCorrect = backtest.actual_return_1d > 0;
predictionCorrect = primaryReturn > 0;
} else if (stock.decision === 'SELL') {
predictionCorrect = backtest.actual_return_1d < 0;
predictionCorrect = primaryReturn < 0;
}
}
@ -706,6 +730,9 @@ export default function History() {
decision: stock.decision,
return1d: backtest.actual_return_1d ?? null,
return1w: backtest.actual_return_1w ?? null,
returnAtHold: backtest.return_at_hold ?? null,
holdDays: backtest.hold_days ?? null,
primaryReturn,
predictionCorrect,
priceHistory: backtest.price_history,
};
@ -752,8 +779,9 @@ export default function History() {
rec.top_picks.map(pick => {
// Try real backtest data first
const realData = realBacktestData[pick.symbol];
if (realData?.return1d !== null && realData?.return1d !== undefined) {
return realData.return1d;
const primaryRet = realData?.primaryReturn ?? realData?.return1d;
if (primaryRet !== null && primaryRet !== undefined) {
return primaryRet;
}
// Only fall back to mock when actually using mock data
return isUsingMockData ? getStaticBacktestResult(pick.symbol)?.actual_return_1d : undefined;
@ -809,6 +837,71 @@ export default function History() {
return Object.values(rec.analysis);
};
// Build ReturnBreakdown from real batch backtest data for the modal
const buildReturnBreakdown = useCallback((date: string): ReturnBreakdown | null => {
const rec = recommendations.find(r => r.date === date);
const dateBacktest = batchBacktestByDate[date];
if (!rec || !dateBacktest) return null;
const correctStocks: { symbol: string; decision: string; return1d: number }[] = [];
const incorrectStocks: { symbol: string; decision: string; return1d: number }[] = [];
let correctTotal = 0;
let incorrectTotal = 0;
for (const symbol of Object.keys(rec.analysis)) {
const stockAnalysis = rec.analysis[symbol];
const bt = dateBacktest[symbol];
const retVal = bt?.return_at_hold ?? bt?.return_1d;
if (!stockAnalysis?.decision || retVal === undefined || retVal === null) continue;
const isCorrect = (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD')
? retVal > 0
: retVal < 0;
const entry = { symbol, decision: stockAnalysis.decision, return1d: retVal };
if (isCorrect) {
correctStocks.push(entry);
correctTotal += (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD') ? retVal : Math.abs(retVal);
} else {
incorrectStocks.push(entry);
incorrectTotal += (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD') ? retVal : -Math.abs(retVal);
}
}
const totalCount = correctStocks.length + incorrectStocks.length;
if (totalCount === 0) return null;
const correctAvg = correctStocks.length > 0 ? correctTotal / correctStocks.length : 0;
const incorrectAvg = incorrectStocks.length > 0 ? incorrectTotal / incorrectStocks.length : 0;
const correctWeight = correctStocks.length / totalCount;
const incorrectWeight = incorrectStocks.length / totalCount;
const weightedReturn = (correctAvg * correctWeight) + (incorrectAvg * incorrectWeight);
// Sort stocks by return magnitude
correctStocks.sort((a, b) => Math.abs(b.return1d) - Math.abs(a.return1d));
incorrectStocks.sort((a, b) => Math.abs(b.return1d) - Math.abs(a.return1d));
return {
correctPredictions: {
count: correctStocks.length,
totalReturn: correctTotal,
avgReturn: correctAvg,
stocks: correctStocks.slice(0, 5),
},
incorrectPredictions: {
count: incorrectStocks.length,
totalReturn: incorrectTotal,
avgReturn: incorrectAvg,
stocks: incorrectStocks.slice(0, 5),
},
weightedReturn: Math.round(weightedReturn * 10) / 10,
formula: `(${correctAvg.toFixed(2)}% × ${correctStocks.length}/${totalCount}) + (${incorrectAvg.toFixed(2)}% × ${incorrectStocks.length}/${totalCount}) = ${weightedReturn.toFixed(2)}%`,
};
}, [recommendations, batchBacktestByDate]);
// Whether any backtest data is still loading (for skeleton states)
const isBacktestDataLoading = isBatchLoading || (!isUsingMockData && !isLoadingRecommendations && Object.keys(batchBacktestByDate).length === 0);
// Show loading state
if (isLoadingRecommendations) {
return (
@ -859,6 +952,9 @@ export default function History() {
<div className="flex items-center gap-2">
<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>
{isBacktestDataLoading && (
<Loader2 className="w-3.5 h-3.5 animate-spin text-nifty-500" />
)}
</div>
<button
onClick={() => setShowAccuracyModal(true)}
@ -869,34 +965,48 @@ export default function History() {
<span className="hidden sm:inline">How it's calculated</span>
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<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>
{isBacktestDataLoading ? (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{['nifty', 'green', 'red', 'amber'].map(color => (
<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`}>
<SkeletonBar className="h-7 w-16 mx-auto mb-1" />
<SkeletonBar className="h-3 w-20 mx-auto" />
</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">
{(accuracyMetrics.buy_accuracy * 100).toFixed(0)}%
) : (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<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 className="text-xs text-gray-500 dark:text-gray-400">Buy Accuracy</div>
</div>
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-center">
<div className="text-2xl font-bold text-red-600 dark:text-red-400">
{(accuracyMetrics.sell_accuracy * 100).toFixed(0)}%
<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">
{(accuracyMetrics.buy_accuracy * 100).toFixed(0)}%
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Buy Accuracy</div>
</div>
<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 className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-center">
<div className="text-2xl font-bold text-red-600 dark:text-red-400">
{(accuracyMetrics.sell_accuracy * 100).toFixed(0)}%
</div>
<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 className="text-xs text-gray-500 dark:text-gray-400">Hold Accuracy</div>
</div>
</div>
)}
<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>
</section>
@ -914,23 +1024,28 @@ export default function History() {
</div>
)}
</div>
{/* Pass real data if available, use mock fallback only when in mock mode */}
<AccuracyTrendChart
height={200}
data={isUsingMockData
? (accuracyTrendData.length > 0 ? accuracyTrendData : undefined)
: accuracyTrendData
}
/>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center">
{accuracyTrendData.length > 0 ? (
<>Prediction accuracy from real backtest data over {accuracyTrendData.length} trading days</>
) : isUsingMockData ? (
<>Demo data - Start backend for real accuracy tracking</>
) : (
<>Prediction accuracy over the past {dates.length} trading days</>
)}
</p>
{isBacktestDataLoading && !isUsingMockData ? (
<SectionLoader message="Computing accuracy trend from backtest data..." />
) : (
<>
<AccuracyTrendChart
height={200}
data={isUsingMockData
? (accuracyTrendData.length > 0 ? accuracyTrendData : undefined)
: accuracyTrendData
}
/>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center">
{accuracyTrendData.length > 0 ? (
<>Prediction accuracy from real backtest data over {accuracyTrendData.length} trading days</>
) : isUsingMockData ? (
<>Demo data - Start backend for real accuracy tracking</>
) : (
<>Prediction accuracy over the past {dates.length} trading days</>
)}
</p>
</>
)}
</section>
{/* Risk Metrics */}
@ -947,19 +1062,25 @@ export default function History() {
</div>
)}
</div>
<RiskMetricsCard metrics={!isUsingMockData && !realRiskMetrics ? {
sharpeRatio: 0, maxDrawdown: 0, winLossRatio: 0, winRate: 0,
volatility: 0, totalTrades: 0,
} : realRiskMetrics} />
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center">
{realRiskMetrics ? (
<>Risk-adjusted performance from real backtest data ({realRiskMetrics.totalTrades} trades)</>
) : isUsingMockData ? (
<>Demo data - Start backend for real risk metrics</>
) : (
<>Risk-adjusted performance metrics for the AI trading strategy</>
)}
</p>
{isBacktestDataLoading && !isUsingMockData ? (
<SectionLoader message="Computing risk metrics from backtest data..." />
) : (
<>
<RiskMetricsCard metrics={!isUsingMockData && !realRiskMetrics ? {
sharpeRatio: 0, maxDrawdown: 0, winLossRatio: 0, winRate: 0,
volatility: 0, totalTrades: 0,
} : realRiskMetrics} />
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center">
{realRiskMetrics ? (
<>Risk-adjusted performance from real backtest data ({realRiskMetrics.totalTrades} trades)</>
) : isUsingMockData ? (
<>Demo data - Start backend for real risk metrics</>
) : (
<>Risk-adjusted performance metrics for the AI trading strategy</>
)}
</p>
</>
)}
</section>
{/* Portfolio Simulator */}
@ -987,6 +1108,7 @@ export default function History() {
const rec = getRecommendation(date);
const stats = dateStatsMap[date];
const avgReturn = stats?.avgReturn1d ?? 0;
const hasBacktestData = !isUsingMockData ? (realDateReturns[date] !== undefined) : true;
const isPositive = avgReturn >= 0;
// Calculate filtered summary for this date
@ -1005,11 +1127,21 @@ export default function History() {
}`}
>
<div className="font-semibold">{new Date(date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' })}</div>
<div className={`text-sm font-bold mt-0.5 ${
selectedDate === date ? 'text-white' : getValueColorClass(avgReturn)
}`}>
{isPositive ? '+' : ''}{avgReturn.toFixed(1)}%
</div>
{!hasBacktestData && isBacktestDataLoading ? (
<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" />
</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'}`}>
{filteredSummary.buy}B/{filteredSummary.sell}S/{filteredSummary.hold}H
</div>
@ -1118,8 +1250,8 @@ export default function History() {
let predictionCorrect: boolean | null = null;
if (!isUsingMockData) {
// Real data mode: only use real backtest, no mock fallback
nextDayReturn = realData?.return1d ?? null;
// Real data mode: use hold-period return when available
nextDayReturn = realData?.primaryReturn ?? realData?.return1d ?? null;
priceHistory = realData?.priceHistory;
if (realData?.predictionCorrect !== undefined) {
predictionCorrect = realData.predictionCorrect;
@ -1127,7 +1259,7 @@ export default function History() {
} else {
// Mock data mode: use real if available, fall back to mock
const mockBacktest = getStaticBacktestResult(stock.symbol);
nextDayReturn = realData?.return1d ?? mockBacktest?.actual_return_1d ?? 0;
nextDayReturn = realData?.primaryReturn ?? realData?.return1d ?? mockBacktest?.actual_return_1d ?? 0;
priceHistory = realData?.priceHistory ?? mockBacktest?.price_history;
if (realData?.predictionCorrect !== undefined) {
predictionCorrect = realData.predictionCorrect;
@ -1159,6 +1291,12 @@ export default function History() {
<div className="flex items-center gap-3">
<DecisionBadge decision={stock.decision} size="small" />
<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 && (
<AccuracyBadge
correct={predictionCorrect}
@ -1193,54 +1331,67 @@ export default function History() {
</div>
<InvestmentModeToggle mode={summaryMode} onChange={setSummaryMode} />
</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" />
{isBacktestDataLoading && !isUsingMockData ? (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[1, 2, 3, 4].map(i => (
<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" />
<SkeletonBar className="h-3 w-20 mx-auto" />
</div>
))}
</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
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">
{summaryMode === 'topPicks'
? 'Performance based on Top Picks recommendations only (3 stocks per day)'
: 'Next-day return = Price change on the trading day after recommendation'
{isBacktestDataLoading && !isUsingMockData
? 'Loading performance data from market...'
: summaryMode === 'topPicks'
? 'Performance based on Top Picks recommendations only (3 stocks per day)'
: 'Returns measured over hold period (or 1-day when no hold period specified)'
}
</p>
</div>
@ -1262,25 +1413,31 @@ export default function History() {
<InvestmentModeToggle mode={indexChartMode} onChange={setIndexChartMode} />
</div>
</div>
<IndexComparisonChart
height={220}
data={isUsingMockData
? undefined
: (indexChartMode === 'topPicks' ? topPicksCumulativeReturns : realCumulativeReturns) ?? []
}
/>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center">
{(indexChartMode === 'topPicks' ? topPicksCumulativeReturns : realCumulativeReturns)?.length ? (
<>
Cumulative returns for {indexChartMode === 'topPicks' ? 'Top Picks' : 'All 50 stocks'} over{' '}
{(indexChartMode === 'topPicks' ? topPicksCumulativeReturns : realCumulativeReturns)?.length} trading days
</>
) : isUsingMockData ? (
<>Demo data - Start backend for real performance comparison</>
) : (
<>Comparison of cumulative returns between AI strategy and Nifty50 index</>
)}
</p>
{isBacktestDataLoading && !isUsingMockData ? (
<SectionLoader message="Computing cumulative returns vs Nifty50 index..." />
) : (
<>
<IndexComparisonChart
height={220}
data={isUsingMockData
? undefined
: (indexChartMode === 'topPicks' ? topPicksCumulativeReturns : realCumulativeReturns) ?? []
}
/>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center">
{(indexChartMode === 'topPicks' ? topPicksCumulativeReturns : realCumulativeReturns)?.length ? (
<>
Cumulative returns for {indexChartMode === 'topPicks' ? 'Top Picks' : 'All 50 stocks'} over{' '}
{(indexChartMode === 'topPicks' ? topPicksCumulativeReturns : realCumulativeReturns)?.length} trading days
</>
) : isUsingMockData ? (
<>Demo data - Start backend for real performance comparison</>
) : (
<>Comparison of cumulative returns between AI strategy and Nifty50 index</>
)}
</p>
</>
)}
</section>
{/* Return Distribution */}
@ -1300,22 +1457,28 @@ export default function History() {
<InvestmentModeToggle mode={distributionMode} onChange={setDistributionMode} />
</div>
</div>
<ReturnDistributionChart
height={200}
data={isUsingMockData
? undefined
: (distributionMode === 'topPicks' ? topPicksReturnDistribution : realReturnDistribution) ?? []
}
/>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center">
{(distributionMode === 'topPicks' ? topPicksReturnDistribution : realReturnDistribution) ? (
<>Distribution of {distributionMode === 'topPicks' ? 'Top Picks' : 'all 50 stocks'} next-day returns. Click bars to see stocks.</>
) : isUsingMockData ? (
<>Demo data - Start backend for real return distribution</>
) : (
<>Distribution of next-day returns across all predictions. Click bars to see stocks.</>
)}
</p>
{isBacktestDataLoading && !isUsingMockData ? (
<SectionLoader message="Computing return distribution from backtest data..." />
) : (
<>
<ReturnDistributionChart
height={200}
data={isUsingMockData
? undefined
: (distributionMode === 'topPicks' ? topPicksReturnDistribution : realReturnDistribution) ?? []
}
/>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center">
{(distributionMode === 'topPicks' ? topPicksReturnDistribution : realReturnDistribution) ? (
<>Distribution of {distributionMode === 'topPicks' ? 'Top Picks' : 'all 50 stocks'} hold-period returns. Click bars to see stocks.</>
) : isUsingMockData ? (
<>Demo data - Start backend for real return distribution</>
) : (
<>Distribution of hold-period returns across all predictions. Click bars to see stocks.</>
)}
</p>
</>
)}
</section>
{/* Accuracy Explanation Modal */}
@ -1329,7 +1492,7 @@ export default function History() {
<ReturnExplainModal
isOpen={showReturnModal}
onClose={() => setShowReturnModal(false)}
breakdown={returnModalDate ? (isUsingMockData ? getStaticReturnBreakdown(returnModalDate) : null) : null}
breakdown={returnModalDate ? (isUsingMockData ? getStaticReturnBreakdown(returnModalDate) : buildReturnBreakdown(returnModalDate)) : null}
date={returnModalDate || ''}
/>
@ -1361,19 +1524,20 @@ export default function History() {
<InfoModal
isOpen={activeSummaryModal === 'avgReturn'}
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" />}
>
<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="font-semibold mb-1">How it's calculated:</div>
<ol className="text-xs space-y-1 list-decimal list-inside">
<li>Record stock price at recommendation time</li>
<li>Record price at next trading day close</li>
<li>Calculate: (Next Day Price - Rec Price) / Rec Price × 100</li>
<li>Average all these returns</li>
<li>Record price after the recommended hold period (e.g. 15 days)</li>
<li>Calculate: (Exit Price - Entry Price) / Entry Price × 100</li>
<li>Average all these returns across stocks</li>
</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 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>

View File

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

View File

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

View File

@ -211,6 +211,25 @@ export const DEBATE_ROLES = {
}
} as const;
/**
* Maps each pipeline step to the IDs of steps whose output is forwarded as context.
* Steps 1-4 are independent (no forwarded context).
*/
export const STEP_INPUT_SOURCES: Record<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
// ============================================================
@ -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
import time
import json
from tradingagents.agents.utils.agent_utils import get_fundamentals, get_balance_sheet, get_cashflow, get_income_statement, get_insider_sentiment, get_insider_transactions
from tradingagents.agents.utils.agent_utils import get_fundamentals, get_balance_sheet, get_cashflow, get_income_statement, get_insider_sentiment, get_insider_transactions, execute_text_tool_calls, needs_followup_call, execute_default_tools, generate_analysis_from_data
from tradingagents.dataflows.config import get_config
from tradingagents.log_utils import add_log, step_timer, symbol_progress
@ -70,6 +70,23 @@ def create_fundamentals_analyst(llm):
if len(result.tool_calls) == 0:
report = result.content
add_log("llm", "fundamentals", f"LLM responded in {elapsed:.1f}s ({len(report)} chars)")
tool_results = execute_text_tool_calls(report, tools)
if tool_results:
add_log("data", "fundamentals", f"Executed {len(tool_results)} tool calls: {', '.join(t['name'] for t in tool_results)}")
else:
add_log("agent", "fundamentals", f"🔄 No tool calls found, proactively fetching data for {ticker}...")
tool_results = execute_default_tools(tools, ticker, current_date)
add_log("data", "fundamentals", f"Proactively fetched {len(tool_results)} data sources")
if tool_results and needs_followup_call(report):
add_log("agent", "fundamentals", f"🔄 Generating analysis from {len(tool_results)} tool results...")
t1 = time.time()
followup = generate_analysis_from_data(llm, tool_results, system_message, ticker, current_date)
elapsed2 = time.time() - t1
if followup and len(followup) > 100:
report = followup
add_log("llm", "fundamentals", f"Follow-up analysis generated in {elapsed2:.1f}s ({len(report)} chars)")
add_log("agent", "fundamentals", f"✅ Fundamentals report ready: {report[:300]}...")
step_timer.end_step("fundamentals_analyst", "completed", report[:200])
symbol_progress.step_done(ticker, "fundamentals_analyst")
@ -77,6 +94,7 @@ def create_fundamentals_analyst(llm):
"system_prompt": system_message[:2000],
"user_prompt": f"Analyze fundamentals for {ticker} on {current_date}",
"response": report[:3000],
"tool_calls": tool_results if tool_results else [],
})
else:
tool_call_info = [{"name": tc["name"], "args": str(tc.get("args", {}))[:200]} for tc in result.tool_calls]

View File

@ -1,7 +1,7 @@
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
import time
import json
from tradingagents.agents.utils.agent_utils import get_stock_data, get_indicators
from tradingagents.agents.utils.agent_utils import get_stock_data, get_indicators, execute_text_tool_calls, needs_followup_call, execute_default_tools, generate_analysis_from_data
from tradingagents.dataflows.config import get_config
from tradingagents.log_utils import add_log, step_timer, symbol_progress
@ -93,14 +93,34 @@ Volume-Based Indicators:
if len(result.tool_calls) == 0:
report = result.content
add_log("llm", "market_analyst", f"LLM responded in {elapsed:.1f}s ({len(report)} chars)")
# Execute any text-based tool calls and capture results
tool_results = execute_text_tool_calls(report, tools)
if tool_results:
add_log("data", "market_analyst", f"Executed {len(tool_results)} tool calls: {', '.join(t['name'] for t in tool_results)}")
else:
# LLM didn't produce TOOL_CALL patterns — proactively fetch data
add_log("agent", "market_analyst", f"🔄 No tool calls found, proactively fetching data for {ticker}...")
tool_results = execute_default_tools(tools, ticker, current_date)
add_log("data", "market_analyst", f"Proactively fetched {len(tool_results)} data sources")
# If report is mostly tool calls / thin prose, make follow-up LLM call with actual data
if tool_results and needs_followup_call(report):
add_log("agent", "market_analyst", f"🔄 Generating analysis from {len(tool_results)} tool results...")
t1 = time.time()
followup = generate_analysis_from_data(llm, tool_results, system_message, ticker, current_date)
elapsed2 = time.time() - t1
if followup and len(followup) > 100:
report = followup
add_log("llm", "market_analyst", f"Follow-up analysis generated in {elapsed2:.1f}s ({len(report)} chars)")
add_log("agent", "market_analyst", f"✅ Market report ready: {report[:300]}...")
step_timer.end_step("market_analyst", "completed", report[:200])
symbol_progress.step_done(ticker, "market_analyst")
# Use update_details to preserve tool_calls from previous invocation
step_timer.update_details("market_analyst", {
"system_prompt": system_message[:2000],
"user_prompt": f"Analyze {ticker} on {current_date} using technical indicators",
"response": report[:3000],
"tool_calls": tool_results if tool_results else [],
})
else:
tool_call_info = [{"name": tc["name"], "args": str(tc.get("args", {}))[:200]} for tc in result.tool_calls]

View File

@ -1,7 +1,7 @@
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
import time
import json
from tradingagents.agents.utils.agent_utils import get_news, get_global_news
from tradingagents.agents.utils.agent_utils import get_news, get_global_news, execute_text_tool_calls, needs_followup_call, execute_default_tools, generate_analysis_from_data
from tradingagents.dataflows.config import get_config
from tradingagents.log_utils import add_log, step_timer, symbol_progress
@ -66,6 +66,23 @@ def create_news_analyst(llm):
if len(result.tool_calls) == 0:
report = result.content
add_log("llm", "news_analyst", f"LLM responded in {elapsed:.1f}s ({len(report)} chars)")
tool_results = execute_text_tool_calls(report, tools)
if tool_results:
add_log("data", "news_analyst", f"Executed {len(tool_results)} tool calls: {', '.join(t['name'] for t in tool_results)}")
else:
add_log("agent", "news_analyst", f"🔄 No tool calls found, proactively fetching data for {ticker}...")
tool_results = execute_default_tools(tools, ticker, current_date)
add_log("data", "news_analyst", f"Proactively fetched {len(tool_results)} data sources")
if tool_results and needs_followup_call(report):
add_log("agent", "news_analyst", f"🔄 Generating analysis from {len(tool_results)} tool results...")
t1 = time.time()
followup = generate_analysis_from_data(llm, tool_results, system_message, ticker, current_date)
elapsed2 = time.time() - t1
if followup and len(followup) > 100:
report = followup
add_log("llm", "news_analyst", f"Follow-up analysis generated in {elapsed2:.1f}s ({len(report)} chars)")
add_log("agent", "news_analyst", f"✅ News report ready: {report[:300]}...")
step_timer.end_step("news_analyst", "completed", report[:200])
symbol_progress.step_done(ticker, "news_analyst")
@ -73,6 +90,7 @@ def create_news_analyst(llm):
"system_prompt": system_message[:2000],
"user_prompt": f"Analyze news and macro trends for {ticker} on {current_date}",
"response": report[:3000],
"tool_calls": tool_results if tool_results else [],
})
else:
tool_call_info = [{"name": tc["name"], "args": str(tc.get("args", {}))[:200]} for tc in result.tool_calls]

View File

@ -1,7 +1,7 @@
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
import time
import json
from tradingagents.agents.utils.agent_utils import get_news
from tradingagents.agents.utils.agent_utils import get_news, execute_text_tool_calls, needs_followup_call, execute_default_tools, generate_analysis_from_data
from tradingagents.dataflows.config import get_config
from tradingagents.log_utils import add_log, step_timer, symbol_progress
@ -66,6 +66,23 @@ def create_social_media_analyst(llm):
if len(result.tool_calls) == 0:
report = result.content
add_log("llm", "social_analyst", f"LLM responded in {elapsed:.1f}s ({len(report)} chars)")
tool_results = execute_text_tool_calls(report, tools)
if tool_results:
add_log("data", "social_analyst", f"Executed {len(tool_results)} tool calls: {', '.join(t['name'] for t in tool_results)}")
else:
add_log("agent", "social_analyst", f"🔄 No tool calls found, proactively fetching data for {ticker}...")
tool_results = execute_default_tools(tools, ticker, current_date)
add_log("data", "social_analyst", f"Proactively fetched {len(tool_results)} data sources")
if tool_results and needs_followup_call(report):
add_log("agent", "social_analyst", f"🔄 Generating analysis from {len(tool_results)} tool results...")
t1 = time.time()
followup = generate_analysis_from_data(llm, tool_results, system_message, ticker, current_date)
elapsed2 = time.time() - t1
if followup and len(followup) > 100:
report = followup
add_log("llm", "social_analyst", f"Follow-up analysis generated in {elapsed2:.1f}s ({len(report)} chars)")
add_log("agent", "social_analyst", f"✅ Sentiment report ready: {report[:300]}...")
step_timer.end_step("social_media_analyst", "completed", report[:200])
symbol_progress.step_done(ticker, "social_media_analyst")
@ -73,6 +90,7 @@ def create_social_media_analyst(llm):
"system_prompt": system_message[:2000],
"user_prompt": f"Analyze social media sentiment for {ticker} on {current_date}",
"response": report[:3000],
"tool_calls": tool_results if tool_results else [],
})
else:
tool_call_info = [{"name": tc["name"], "args": str(tc.get("args", {}))[:200]} for tc in result.tool_calls]

View File

@ -14,7 +14,7 @@ def create_risk_manager(llm, memory):
risk_debate_state = state["risk_debate_state"]
market_research_report = state["market_report"]
news_report = state["news_report"]
fundamentals_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
sentiment_report = state["sentiment_report"]
trader_plan = state["investment_plan"]
@ -39,8 +39,10 @@ Your task: Evaluate the risk debate between Aggressive, Neutral, and Conservativ
Your response must include:
1. FINAL DECISION: BUY, SELL, or HOLD
2. HOLD_DAYS: Number of trading days to hold the position before exiting (for BUY/HOLD only, write N/A for SELL)
3. RISK ASSESSMENT: Summary of key risks identified
4. RATIONALE: Why this decision balances risk and reward appropriately
3. CONFIDENCE: HIGH, MEDIUM, or LOW (how confident you are in this decision)
4. RISK_LEVEL: HIGH, MEDIUM, or LOW (overall risk level of this investment)
5. RISK ASSESSMENT: Summary of key risks identified
6. RATIONALE: Why this decision balances risk and reward appropriately
RESPONSE FORMAT:
- Maximum 1500 characters. Lead with your decision, then key rationale.

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.
Be precise, analytical, and data-driven. Base all conclusions on quantitative evidence from the data provided. Avoid speculative language present findings with specific numbers and clear logical reasoning. When uncertain, state the uncertainty rather than guessing.
Respond ONLY with your analysis. Do not include any meta-commentary about being an AI, ethical concerns, or disclaimers. Simply provide the requested financial analysis based on the data given."""
def get_simulation_prompt(role_prompt: str) -> list:
@ -44,6 +46,171 @@ from tradingagents.agents.utils.news_data_tools import (
get_global_news
)
def strip_tool_call_lines(text):
"""Remove TOOL_CALL: lines from text, returning only the prose."""
import re
return re.sub(r'TOOL_CALL:\s*\w+\([^)]*\)\s*\n?', '', text).strip()
def needs_followup_call(report):
"""Check if the report is mostly tool calls and needs a follow-up LLM call."""
clean = strip_tool_call_lines(report)
return len(clean) < 300
def execute_default_tools(tools, ticker, current_date):
"""
Proactively call all available tools with sensible default arguments.
Used when the LLM fails to produce TOOL_CALL patterns.
"""
from datetime import datetime, timedelta
end_dt = datetime.strptime(current_date, "%Y-%m-%d")
week_ago = (end_dt - timedelta(days=7)).strftime("%Y-%m-%d")
three_months_ago = (end_dt - timedelta(days=90)).strftime("%Y-%m-%d")
tool_map = {t.name: t for t in tools}
default_configs = {
"get_stock_data": {"symbol": ticker, "start_date": three_months_ago, "end_date": current_date},
"get_indicators": [
{"symbol": ticker, "indicator": "rsi", "curr_date": current_date, "look_back_days": 90},
{"symbol": ticker, "indicator": "macd", "curr_date": current_date, "look_back_days": 90},
{"symbol": ticker, "indicator": "close_50_sma", "curr_date": current_date, "look_back_days": 90},
{"symbol": ticker, "indicator": "boll_ub", "curr_date": current_date, "look_back_days": 90},
{"symbol": ticker, "indicator": "atr", "curr_date": current_date, "look_back_days": 90},
],
"get_news": {"ticker": ticker, "start_date": week_ago, "end_date": current_date},
"get_global_news": {"curr_date": current_date, "look_back_days": 7, "limit": 5},
"get_fundamentals": {"ticker": ticker, "curr_date": current_date},
"get_balance_sheet": {"ticker": ticker, "curr_date": current_date},
"get_cashflow": {"ticker": ticker, "curr_date": current_date},
"get_income_statement": {"ticker": ticker, "curr_date": current_date},
}
results = []
for tool in tools:
config = default_configs.get(tool.name)
if config is None:
continue
# Handle tools that need multiple calls (e.g., get_indicators with different indicators)
calls = config if isinstance(config, list) else [config]
for args in calls:
try:
result = tool.invoke(args)
results.append({
"name": tool.name,
"args": str(args),
"result_preview": str(result)[:1500],
})
except Exception as e:
results.append({
"name": tool.name,
"args": str(args),
"result_preview": f"[Tool error: {str(e)[:200]}]",
})
return results
def generate_analysis_from_data(llm, tool_results, system_message, ticker, current_date):
"""
Make a follow-up LLM call with actual tool data to generate the analysis.
Called when the first LLM response was mostly tool call requests without analysis.
"""
data_sections = []
for r in tool_results:
preview = r.get('result_preview', '')
if not preview:
data_sections.append(f"### {r['name']}({r['args']})\n(No data returned — tool executed but returned empty result)")
elif preview.startswith('[Tool error') or preview.startswith('[Could not') or preview.startswith('[Unknown'):
data_sections.append(f"### {r['name']}({r['args']})\nError: {preview}")
else:
data_sections.append(f"### {r['name']}({r['args']})\n```\n{preview}\n```")
if not data_sections:
return ""
data_text = "\n\n".join(data_sections)
message = f"""Here are the results from the data retrieval tools for {ticker} as of {current_date}:
{data_text}
Based on this data, write your comprehensive analysis.
{system_message}
IMPORTANT:
- Write your analysis directly based on the data above
- Do NOT request any more tool calls or use TOOL_CALL syntax
- Provide detailed, actionable insights with specific numbers from the data
- Include a Markdown table summarizing key findings"""
result = llm.invoke([HumanMessage(content=message)])
return result.content
def execute_text_tool_calls(response_text, tools):
"""
Parse TOOL_CALL: patterns from LLM response text, execute the actual
tool functions, and return structured results.
Args:
response_text: Raw LLM response that may contain TOOL_CALL: patterns
tools: List of @tool-decorated LangChain tool objects available for this agent
Returns:
List of dicts with {name, args, result_preview} for each executed tool call
"""
import re
import ast
tool_map = {t.name: t for t in tools}
regex = re.compile(r'TOOL_CALL:\s*(\w+)\(([^)]*)\)')
results = []
for match in regex.finditer(response_text):
fn_name = match.group(1)
raw_args = match.group(2).strip()
tool_fn = tool_map.get(fn_name)
if not tool_fn:
results.append({
"name": fn_name,
"args": raw_args,
"result_preview": f"[Unknown tool: {fn_name}]",
})
continue
# Parse positional args and map to parameter names
try:
parsed = ast.literal_eval(f"({raw_args},)") # tuple of values
param_names = list(tool_fn.args_schema.model_fields.keys())
invoke_args = {}
for i, val in enumerate(parsed):
if i < len(param_names):
invoke_args[param_names[i]] = val
except Exception:
invoke_args = None
# Execute the tool
result_text = ""
try:
if invoke_args:
result_text = tool_fn.invoke(invoke_args)
else:
result_text = f"[Could not parse args: {raw_args}]"
except Exception as e:
result_text = f"[Tool error: {str(e)[:200]}]"
results.append({
"name": fn_name,
"args": raw_args,
"result_preview": str(result_text)[:1500],
})
return results
def create_msg_delete():
def delete_messages(state):
"""Clear messages and add placeholder for Anthropic compatibility"""

View File

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

View File

@ -1,7 +1,7 @@
from typing import Annotated, Union
from datetime import datetime
from dateutil.relativedelta import relativedelta
from .googlenews_utils import getNewsData
from .googlenews_utils import getNewsData, getGlobalNewsData
from .markets import is_nifty_50_stock, get_nifty_50_company_name
@ -65,4 +65,22 @@ def get_google_news(
# Use original query (symbol) in the header for clarity
display_query = original_query if is_nifty_50_stock(original_query) else query.replace("+", " ")
return f"## {display_query} Google News, from {before} to {curr_date}:\n\n{news_str}"
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
from bs4 import BeautifulSoup
from datetime import datetime
import time
import random
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
retry_if_result,
)
def is_rate_limited(response):
"""Check if the response indicates rate limiting (status code 429)"""
return response.status_code == 429
@retry(
retry=(retry_if_result(is_rate_limited)),
wait=wait_exponential(multiplier=1, min=4, max=60),
stop=stop_after_attempt(5),
)
def make_request(url, headers):
"""Make a request with retry logic for rate limiting"""
# Random delay before each request to avoid detection
time.sleep(random.uniform(2, 6))
response = requests.get(url, headers=headers)
return response
import urllib.parse
def getNewsData(query, start_date, end_date):
"""
Scrape Google News search results for a given query and date range.
query: str - search query
start_date: str - start date in the format yyyy-mm-dd or mm/dd/yyyy
end_date: str - end date in the format yyyy-mm-dd or mm/dd/yyyy
"""
if "-" in start_date:
start_date = datetime.strptime(start_date, "%Y-%m-%d")
start_date = start_date.strftime("%m/%d/%Y")
if "-" in end_date:
end_date = datetime.strptime(end_date, "%Y-%m-%d")
end_date = end_date.strftime("%m/%d/%Y")
Fetch Google News via RSS feed for a given query and date range.
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/101.0.4951.54 Safari/537.36"
)
}
Uses Google News RSS which is reliable (no JS rendering or CSS selectors needed).
Results are filtered to only include articles within the date range.
query: str - search query (spaces or '+' separated)
start_date: str - start date in yyyy-mm-dd or mm/dd/yyyy format
end_date: str - end date in yyyy-mm-dd or mm/dd/yyyy format
"""
# Normalize dates to datetime objects for filtering
if "/" in str(start_date):
start_dt = datetime.strptime(start_date, "%m/%d/%Y")
else:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
if "/" in str(end_date):
end_dt = datetime.strptime(end_date, "%m/%d/%Y")
else:
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
# Clean up query (replace + with spaces for URL encoding)
clean_query = query.replace("+", " ")
encoded_query = urllib.parse.quote(clean_query)
# Use Google News RSS feed — reliable, no scraping issues
url = f"https://news.google.com/rss/search?q={encoded_query}+after:{start_dt.strftime('%Y-%m-%d')}+before:{end_dt.strftime('%Y-%m-%d')}&hl=en-IN&gl=IN&ceid=IN:en"
news_results = []
page = 0
while True:
offset = page * 10
url = (
f"https://www.google.com/search?q={query}"
f"&tbs=cdr:1,cd_min:{start_date},cd_max:{end_date}"
f"&tbm=nws&start={offset}"
)
try:
resp = requests.get(url, timeout=15)
if resp.status_code != 200:
return news_results
try:
response = make_request(url, headers)
soup = BeautifulSoup(response.content, "html.parser")
results_on_page = soup.select("div.SoaBEf")
soup = BeautifulSoup(resp.content, "xml")
items = soup.find_all("item")
if not results_on_page:
break # No more results found
for item in items[:20]: # Limit to 20 articles
try:
title = item.find("title").text if item.find("title") else ""
pub_date_str = item.find("pubDate").text if item.find("pubDate") else ""
source = item.find("source").text if item.find("source") else ""
link = item.find("link").text if item.find("link") else ""
# Description often contains HTML snippet
desc_tag = item.find("description")
snippet = ""
if desc_tag:
desc_soup = BeautifulSoup(desc_tag.text, "html.parser")
snippet = desc_soup.get_text()[:300]
for el in results_on_page:
try:
link = el.find("a")["href"]
title = el.select_one("div.MBeuO").get_text()
snippet = el.select_one(".GI74Re").get_text()
date = el.select_one(".LfVVr").get_text()
source = el.select_one(".NUnG9d span").get_text()
news_results.append(
{
"link": link,
"title": title,
"snippet": snippet,
"date": date,
"source": source,
}
)
except Exception as e:
print(f"Error processing result: {e}")
# If one of the fields is not found, skip this result
continue
# Parse and filter by date
if pub_date_str:
try:
pub_dt = datetime.strptime(pub_date_str, "%a, %d %b %Y %H:%M:%S %Z")
if pub_dt.date() < start_dt.date() or pub_dt.date() > end_dt.date():
continue
date_display = pub_dt.strftime("%Y-%m-%d")
except ValueError:
date_display = pub_date_str
else:
date_display = ""
# Update the progress bar with the current count of results scraped
news_results.append({
"link": link,
"title": title,
"snippet": snippet if snippet else title,
"date": date_display,
"source": source,
})
except Exception:
continue
# Check for the "Next" link (pagination)
next_link = soup.find("a", id="pnnext")
if not next_link:
break
page += 1
except Exception as e:
print(f"Failed after multiple retries: {e}")
break
except Exception as e:
print(f"Google News RSS fetch failed: {e}")
return news_results
def getGlobalNewsData(curr_date, look_back_days=7, limit=10):
"""
Fetch global/macro news via Google News RSS feed.
Uses broad financial/market queries to get macroeconomic news.
"""
if isinstance(curr_date, str):
end_dt = datetime.strptime(curr_date, "%Y-%m-%d")
else:
end_dt = curr_date
from dateutil.relativedelta import relativedelta
start_dt = end_dt - relativedelta(days=look_back_days)
queries = [
"stock market India NSE Nifty",
"global economy markets finance",
]
all_results = []
seen_titles = set()
for query in queries:
encoded = urllib.parse.quote(query)
url = f"https://news.google.com/rss/search?q={encoded}+after:{start_dt.strftime('%Y-%m-%d')}+before:{end_dt.strftime('%Y-%m-%d')}&hl=en-IN&gl=IN&ceid=IN:en"
try:
resp = requests.get(url, timeout=15)
if resp.status_code != 200:
continue
soup = BeautifulSoup(resp.content, "xml")
items = soup.find_all("item")
for item in items:
try:
title = item.find("title").text if item.find("title") else ""
if title in seen_titles:
continue
seen_titles.add(title)
pub_date_str = item.find("pubDate").text if item.find("pubDate") else ""
source = item.find("source").text if item.find("source") else ""
desc_tag = item.find("description")
snippet = ""
if desc_tag:
desc_soup = BeautifulSoup(desc_tag.text, "html.parser")
snippet = desc_soup.get_text()[:300]
date_display = ""
if pub_date_str:
try:
pub_dt = datetime.strptime(pub_date_str, "%a, %d %b %Y %H:%M:%S %Z")
date_display = pub_dt.strftime("%Y-%m-%d")
except ValueError:
date_display = pub_date_str
all_results.append({
"title": title,
"snippet": snippet if snippet else title,
"date": date_display,
"source": source,
})
except Exception:
continue
except Exception:
continue
# Sort by date descending and limit
all_results.sort(key=lambda x: x.get("date", ""), reverse=True)
return all_results[:limit]

View File

@ -42,8 +42,8 @@ def clear_request_cache():
# Import from vendor-specific modules
from .local import get_YFin_data, get_finnhub_news, get_finnhub_company_insider_sentiment, get_finnhub_company_insider_transactions, get_simfin_balance_sheet, get_simfin_cashflow, get_simfin_income_statements, get_reddit_global_news, get_reddit_company_news
from .y_finance import get_YFin_data_online, get_stock_stats_indicators_window, get_balance_sheet as get_yfinance_balance_sheet, get_cashflow as get_yfinance_cashflow, get_income_statement as get_yfinance_income_statement, get_insider_transactions as get_yfinance_insider_transactions
from .google import get_google_news
from .y_finance import get_YFin_data_online, get_stock_stats_indicators_window, get_balance_sheet as get_yfinance_balance_sheet, get_cashflow as get_yfinance_cashflow, get_income_statement as get_yfinance_income_statement, get_insider_transactions as get_yfinance_insider_transactions, get_fundamentals as get_yfinance_fundamentals
from .google import get_google_news, get_google_global_news
from .openai import get_stock_news_openai, get_global_news_openai, get_fundamentals_openai
from .alpha_vantage import (
get_stock as get_alpha_vantage_stock,
@ -122,6 +122,7 @@ VENDOR_METHODS = {
},
# fundamental_data
"get_fundamentals": {
"yfinance": get_yfinance_fundamentals,
"alpha_vantage": get_alpha_vantage_fundamentals,
"openai": get_fundamentals_openai,
},
@ -148,8 +149,9 @@ VENDOR_METHODS = {
"local": [get_finnhub_news, get_reddit_company_news, get_google_news],
},
"get_global_news": {
"google": get_google_global_news,
"openai": get_global_news_openai,
"local": get_reddit_global_news
"local": get_reddit_global_news,
},
"get_insider_sentiment": {
"local": get_finnhub_company_insider_sentiment

View File

@ -461,6 +461,63 @@ def get_income_statement(
return f"Error retrieving income statement for {normalized_ticker}: {str(e)}"
def get_fundamentals(
ticker: Annotated[str, "ticker symbol of the company"],
curr_date: Annotated[str, "current date for reference"] = None,
) -> str:
"""Get comprehensive company fundamentals from yfinance (.info)."""
try:
normalized_ticker = normalize_symbol(ticker, target="yfinance")
ticker_obj = yf.Ticker(normalized_ticker)
info = ticker_obj.info
if not info or len(info) < 5:
return f"No fundamentals data found for symbol '{normalized_ticker}'"
# Select the most useful keys for analysis
key_groups = {
"Valuation": ["marketCap", "enterpriseValue", "trailingPE", "forwardPE",
"priceToBook", "priceToSalesTrailing12Months", "enterpriseToRevenue",
"enterpriseToEbitda"],
"Profitability": ["profitMargins", "operatingMargins", "grossMargins",
"returnOnAssets", "returnOnEquity", "revenueGrowth",
"earningsGrowth", "earningsQuarterlyGrowth"],
"Dividends": ["dividendRate", "dividendYield", "payoutRatio",
"fiveYearAvgDividendYield", "trailingAnnualDividendRate"],
"Financial Health": ["totalCash", "totalDebt", "debtToEquity",
"currentRatio", "quickRatio", "freeCashflow",
"operatingCashflow", "totalRevenue", "ebitda"],
"Trading": ["currentPrice", "targetHighPrice", "targetLowPrice",
"targetMeanPrice", "recommendationKey", "numberOfAnalystOpinions",
"fiftyTwoWeekHigh", "fiftyTwoWeekLow", "fiftyDayAverage",
"twoHundredDayAverage", "beta", "volume", "averageVolume"],
"Company Info": ["sector", "industry", "fullTimeEmployees", "country", "city"],
}
sections = []
sections.append(f"# Fundamentals for {normalized_ticker}")
if curr_date:
sections.append(f"# As of: {curr_date}")
sections.append(f"# Company: {info.get('longName', info.get('shortName', ticker))}")
sections.append("")
for group_name, keys in key_groups.items():
group_lines = []
for key in keys:
val = info.get(key)
if val is not None:
group_lines.append(f" {key}: {val}")
if group_lines:
sections.append(f"## {group_name}")
sections.extend(group_lines)
sections.append("")
return "\n".join(sections)
except Exception as e:
return f"Error retrieving fundamentals for {ticker}: {str(e)}"
def get_insider_transactions(
ticker: Annotated[str, "ticker symbol of the company"]
):

View File

@ -13,6 +13,7 @@ DEFAULT_CONFIG = {
"deep_think_llm": "o4-mini",
"quick_think_llm": "gpt-4o-mini",
"backend_url": "https://api.openai.com/v1",
"llm_temperature": 0.2, # Low temperature for deterministic financial analysis
# Anthropic-specific config for Claude models (using aliases for Claude Max subscription)
"anthropic_config": {
"deep_think_llm": "opus", # Claude Opus 4.5 for deep analysis

View File

@ -1,5 +1,6 @@
# TradingAgents/graph/signal_processing.py
import re
from langchain_openai import ChatOpenAI
@ -12,37 +13,97 @@ class SignalProcessor:
def process_signal(self, full_signal: str) -> dict:
"""
Process a full trading signal to extract the core decision and hold_days.
Process a full trading signal to extract the core decision, hold_days,
confidence, and risk level.
Args:
full_signal: Complete trading signal text
Returns:
Dict with 'decision' (BUY/SELL/HOLD) and 'hold_days' (int or None)
Dict with 'decision', 'hold_days', 'confidence', 'risk'
"""
messages = [
(
"system",
"You are an efficient assistant designed to analyze paragraphs or financial reports "
"provided by a group of analysts. Extract two pieces of information:\n"
"provided by a group of analysts. Extract the following information:\n"
"1. The investment decision: SELL, BUY, or HOLD\n"
"2. The recommended holding period in trading days (only for BUY or HOLD decisions)\n\n"
"2. The recommended holding period in trading days (only for BUY or HOLD decisions)\n"
"3. The confidence level of the decision: HIGH, MEDIUM, or LOW\n"
"4. The risk level of the investment: HIGH, MEDIUM, or LOW\n\n"
"Respond in exactly this format (nothing else):\n"
"DECISION: <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 BUY or HOLD decisions, extract the number of days if mentioned, otherwise default to 5.",
"For BUY or HOLD decisions, extract the EXACT number of days mentioned in the report. "
"Look for phrases like 'N-day hold', 'N trading days', 'hold for N days', "
"'N-day horizon', 'over N days'. If no specific number is mentioned, use 5.\n"
"For CONFIDENCE and RISK_LEVEL, infer from the tone and content of the report. Default to MEDIUM if unclear.",
),
("human", full_signal),
]
response = self.quick_thinking_llm.invoke(messages).content
return self._parse_signal_response(response)
result = self._parse_signal_response(response)
# If LLM returned default hold_days (5) or failed to extract, try regex on original text
if result["decision"] != "SELL" and result["hold_days"] == 5:
regex_days = self._extract_hold_days_regex(full_signal)
if regex_days is not None:
result["hold_days"] = regex_days
return result
@staticmethod
def _extract_hold_days_regex(text: str) -> int | None:
"""Extract hold period from text using regex patterns.
Looks for common patterns like '15-day hold', 'hold for 45 days',
'30 trading days', 'N-day horizon', etc.
"""
patterns = [
# "15-day hold", "45-day horizon", "30-day period"
r'(\d+)[\s-]*(?:day|trading[\s-]*day)[\s-]*(?:hold|horizon|period|timeframe)',
# "hold for 15 days", "holding period of 45 days"
r'(?:hold|holding)[\s\w]*?(?:for|of|period\s+of)[\s]*(\d+)[\s]*(?:trading\s+)?days?',
# "setting 45 trading days"
r'setting\s+(\d+)\s+(?:trading\s+)?days',
# "over 15 days", "within 30 days"
r'(?:over|within|next)\s+(\d+)\s+(?:trading\s+)?days',
# "N trading days (~2 months)" pattern
r'(\d+)\s+trading\s+days?\s*\(',
]
candidates = []
for pattern in patterns:
for match in re.finditer(pattern, text, re.IGNORECASE):
days = int(match.group(1))
if 1 <= days <= 90:
candidates.append(days)
if not candidates:
return None
# If multiple matches, prefer the one that appears in the conclusion
# (last ~500 chars of text, which is typically the RATIONALE section)
conclusion = text[-500:]
for pattern in patterns:
for match in re.finditer(pattern, conclusion, re.IGNORECASE):
days = int(match.group(1))
if 1 <= days <= 90:
return days
# Fall back to most common candidate
return max(set(candidates), key=candidates.count)
def _parse_signal_response(self, response: str) -> dict:
"""Parse the structured LLM response into decision and hold_days."""
"""Parse the structured LLM response into decision, hold_days, confidence, risk."""
decision = "HOLD"
hold_days = None
confidence = "MEDIUM"
risk = "MEDIUM"
for line in response.strip().split("\n"):
line = line.strip()
@ -63,6 +124,16 @@ class SignalProcessor:
hold_days = max(1, min(90, hold_days))
except (ValueError, TypeError):
hold_days = None
elif upper.startswith("CONFIDENCE:"):
raw = upper.split(":", 1)[1].strip()
raw = raw.replace("*", "").strip()
if raw in ("HIGH", "MEDIUM", "LOW"):
confidence = raw
elif upper.startswith("RISK_LEVEL:") or upper.startswith("RISK:"):
raw = upper.split(":", 1)[1].strip()
raw = raw.replace("*", "").strip()
if raw in ("HIGH", "MEDIUM", "LOW"):
risk = raw
# Enforce: SELL never has hold_days; BUY/HOLD default to 5 if missing
if decision == "SELL":
@ -70,4 +141,4 @@ class SignalProcessor:
elif hold_days is None:
hold_days = 5 # Default hold period
return {"decision": decision, "hold_days": hold_days}
return {"decision": decision, "hold_days": hold_days, "confidence": confidence, "risk": risk}

View File

@ -81,17 +81,18 @@ class TradingAgentsGraph:
exist_ok=True,
)
# Initialize LLMs
# Initialize LLMs with low temperature for deterministic financial analysis
llm_temp = self.config.get("llm_temperature", 0.2)
if self.config["llm_provider"].lower() == "openai" or self.config["llm_provider"] == "ollama" or self.config["llm_provider"] == "openrouter":
self.deep_thinking_llm = ChatOpenAI(model=self.config["deep_think_llm"], base_url=self.config["backend_url"])
self.quick_thinking_llm = ChatOpenAI(model=self.config["quick_think_llm"], base_url=self.config["backend_url"])
self.deep_thinking_llm = ChatOpenAI(model=self.config["deep_think_llm"], base_url=self.config["backend_url"], temperature=llm_temp)
self.quick_thinking_llm = ChatOpenAI(model=self.config["quick_think_llm"], base_url=self.config["backend_url"], temperature=llm_temp)
elif self.config["llm_provider"].lower() == "anthropic":
# Use ClaudeMaxLLM to leverage Claude Max subscription via CLI
self.deep_thinking_llm = ClaudeMaxLLM(model=self.config["deep_think_llm"])
self.quick_thinking_llm = ClaudeMaxLLM(model=self.config["quick_think_llm"])
self.deep_thinking_llm = ClaudeMaxLLM(model=self.config["deep_think_llm"], temperature=llm_temp)
self.quick_thinking_llm = ClaudeMaxLLM(model=self.config["quick_think_llm"], temperature=llm_temp)
elif self.config["llm_provider"].lower() == "google":
self.deep_thinking_llm = ChatGoogleGenerativeAI(model=self.config["deep_think_llm"])
self.quick_thinking_llm = ChatGoogleGenerativeAI(model=self.config["quick_think_llm"])
self.deep_thinking_llm = ChatGoogleGenerativeAI(model=self.config["deep_think_llm"], temperature=llm_temp)
self.quick_thinking_llm = ChatGoogleGenerativeAI(model=self.config["quick_think_llm"], temperature=llm_temp)
else:
raise ValueError(f"Unsupported LLM provider: {self.config['llm_provider']}")
@ -116,7 +117,10 @@ class TradingAgentsGraph:
self.tool_nodes = self._create_tool_nodes()
# Initialize components
self.conditional_logic = ConditionalLogic()
self.conditional_logic = ConditionalLogic(
max_debate_rounds=self.config.get("max_debate_rounds", 1),
max_risk_discuss_rounds=self.config.get("max_risk_discuss_rounds", 1),
)
self.graph_setup = GraphSetup(
self.quick_thinking_llm,
self.deep_thinking_llm,
@ -250,16 +254,18 @@ class TradingAgentsGraph:
self._save_to_frontend_db(trade_date, final_state)
add_log("info", "system", f"Database save completed in {_time.time() - t0:.1f}s")
# Extract and log the final decision + hold_days
# Extract and log the final decision + hold_days + confidence + risk
signal_result = self.process_signal(final_state["final_trade_decision"])
final_decision = signal_result["decision"]
hold_days = signal_result.get("hold_days")
confidence = signal_result.get("confidence", "MEDIUM")
risk = signal_result.get("risk", "MEDIUM")
total_elapsed = _time.time() - pipeline_start
hold_info = f", hold {hold_days}d" if hold_days else ""
add_log("success", "system", f"✅ Analysis complete for {company_name}: {final_decision}{hold_info} (total: {total_elapsed:.0f}s)")
add_log("success", "system", f"✅ Analysis complete for {company_name}: {final_decision}{hold_info}, confidence={confidence}, risk={risk} (total: {total_elapsed:.0f}s)")
# Return decision, hold_days, and processed signal
return final_state, final_decision, hold_days
# Return decision, hold_days, confidence, risk
return final_state, final_decision, hold_days, confidence, risk
def _log_state(self, trade_date, final_state):
"""Log the final state to a JSON file."""