|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}>;
|
||||
}> {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||