TradingAgents/frontend/src/pages/History.tsx

1574 lines
78 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import { Calendar, TrendingUp, TrendingDown, Minus, ChevronRight, ChevronDown, BarChart3, Target, HelpCircle, Activity, Calculator, LineChart, PieChart, Shield, Filter, Clock, Zap, Award, ArrowUpRight, ArrowDownRight, Play, Loader2, FileText, MessageSquare, Search, XCircle, AlertTriangle } from 'lucide-react';
import type { ReturnBreakdown } from '../types';
import { NIFTY_50_STOCKS } from '../types';
import { DecisionBadge, HoldDaysBadge, RankBadge } from '../components/StockCard';
import Sparkline from '../components/Sparkline';
import AccuracyExplainModal from '../components/AccuracyExplainModal';
import ReturnExplainModal from '../components/ReturnExplainModal';
import OverallReturnModal, { type OverallReturnBreakdown } from '../components/OverallReturnModal';
import AccuracyTrendChart, { type AccuracyTrendPoint } from '../components/AccuracyTrendChart';
import ReturnDistributionChart from '../components/ReturnDistributionChart';
import RiskMetricsCard from '../components/RiskMetricsCard';
import PortfolioSimulator, { type InvestmentMode } from '../components/PortfolioSimulator';
import IndexComparisonChart from '../components/IndexComparisonChart';
import InfoModal from '../components/InfoModal';
import { api } from '../services/api';
import { useSettings } from '../contexts/SettingsContext';
import type { StockAnalysis, DailyRecommendation, RiskMetrics, ReturnBucket, CumulativeReturnPoint } from '../types';
// Type for batch backtest data (per date, per symbol)
type BacktestByDate = 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;
}>>;
// Helper for consistent positive/negative color classes
function getValueColorClass(value: number): string {
return value >= 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-500 dark:text-red-400';
}
// Format percentage without negative zero (e.g. "-0.0" becomes "0.0")
function fmtPct(val: number, decimals = 1): string {
const s = val.toFixed(decimals);
if (s === '-0.0' || s === '-0.00') return s.replace('-', '');
return s;
}
// Investment Mode Toggle Component
function InvestmentModeToggle({
mode,
onChange,
size = 'sm'
}: {
mode: InvestmentMode;
onChange: (mode: InvestmentMode) => void;
size?: 'sm' | 'md';
}) {
const sizeClasses = size === 'sm'
? 'px-2.5 py-1 text-[10px]'
: 'px-3 py-1.5 text-xs';
return (
<div className="flex items-center gap-0.5 bg-gray-100 dark:bg-slate-700/80 rounded-lg p-0.5">
<button
onClick={() => onChange('all50')}
className={`${sizeClasses} font-semibold rounded-md transition-all duration-200 ${
mode === 'all50'
? 'bg-white dark:bg-slate-600 text-nifty-600 dark:text-nifty-400 shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
All 50
</button>
<button
onClick={() => onChange('topPicks')}
className={`${sizeClasses} font-semibold rounded-md transition-all duration-200 ${
mode === 'topPicks'
? 'bg-white dark:bg-slate-600 text-nifty-600 dark:text-nifty-400 shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
Top Picks
</button>
</div>
);
}
// 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}`} />;
}
// Section header with icon and optional right content
function SectionHeader({ icon, title, right, subtitle }: { icon: React.ReactNode; title: string; right?: React.ReactNode; subtitle?: string }) {
return (
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-nifty-50 dark:bg-nifty-900/30">
{icon}
</div>
<div>
<h2 className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{title}</h2>
{subtitle && <p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{subtitle}</p>}
</div>
</div>
{right}
</div>
);
}
export default function History() {
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [showAccuracyModal, setShowAccuracyModal] = useState(false);
const [showReturnModal, setShowReturnModal] = useState(false);
const [returnModalDate, setReturnModalDate] = useState<string | null>(null);
const [showOverallModal, setShowOverallModal] = useState(false);
// Investment modes for various sections
const [dateFilterMode, setDateFilterMode] = useState<InvestmentMode>('all50');
const [summaryMode, setSummaryMode] = useState<InvestmentMode>('all50');
const [indexChartMode, setIndexChartMode] = useState<InvestmentMode>('all50');
const [distributionMode, setDistributionMode] = useState<InvestmentMode>('all50');
// Performance Summary modal state
type SummaryModalType = 'daysTracked' | 'avgReturn' | 'buySignals' | 'sellSignals' | null;
const [activeSummaryModal, setActiveSummaryModal] = useState<SummaryModalType>(null);
// Backtest feature state
const [backtestDateInput, setBacktestDateInput] = useState('');
const [isRunningBacktest, setIsRunningBacktest] = useState(false);
const [detailedBacktest, setDetailedBacktest] = useState<{
date: string;
total_stocks: number;
stocks: Array<{
symbol: string; company_name: string; rank?: number;
decision: string; confidence: string; risk: string;
hold_days: number; hold_days_elapsed: number; hold_period_active: boolean;
price_at_prediction: number | null; price_current: number | null; price_at_hold_end: number | null;
return_current: number | null; return_at_hold: number | null;
prediction_correct: boolean | null; formula: string; raw_analysis: string;
agent_summary: Record<string, string>; debate_summary: Record<string, string>;
}>;
} | null>(null);
const [expandedStock, setExpandedStock] = useState<string | null>(null);
const [activeAgentTab, setActiveAgentTab] = useState<string>('market');
const [isLoadingDetailed, setIsLoadingDetailed] = useState(false);
const [backtestMessage, setBacktestMessage] = useState<{ type: 'error' | 'info' | 'progress'; text: string } | null>(null);
const backtestPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const { settings } = useSettings();
// ==========================================================
// SINGLE-FETCH: All data loaded in one API call
// ==========================================================
const [recommendations, setRecommendations] = useState<DailyRecommendation[]>([]);
const [batchBacktestByDate, setBatchBacktestByDate] = useState<BacktestByDate>({});
const [isLoading, setIsLoading] = useState(true);
const [nifty50Prices, setNifty50Prices] = useState<Record<string, number>>({});
const [apiAccuracyMetrics, setApiAccuracyMetrics] = useState<{
overall_accuracy: number;
total_predictions: number;
correct_predictions: number;
by_decision: Record<string, { accuracy: number; total: number; correct: number }>;
} | null>(null);
const [loadTimeMs, setLoadTimeMs] = useState<number | null>(null);
// Single useEffect: fetch the bundle
useEffect(() => {
const fetchBundle = async () => {
setIsLoading(true);
const t0 = performance.now();
try {
const bundle = await api.getHistoryBundle();
if (bundle.recommendations && bundle.recommendations.length > 0) {
setRecommendations(bundle.recommendations);
}
if (bundle.backtest_by_date) {
setBatchBacktestByDate(bundle.backtest_by_date);
}
if (bundle.accuracy && bundle.accuracy.total_predictions > 0) {
setApiAccuracyMetrics(bundle.accuracy);
}
if (bundle.nifty50_prices && Object.keys(bundle.nifty50_prices).length > 0) {
setNifty50Prices(bundle.nifty50_prices);
}
setLoadTimeMs(Math.round(performance.now() - t0));
} catch (error) {
console.error('Failed to fetch history bundle:', error);
setLoadTimeMs(Math.round(performance.now() - t0));
} finally {
setIsLoading(false);
}
};
fetchBundle();
}, []);
// If Nifty50 wasn't in the bundle (cache cold), retry once after 3s
const niftyRetried = useRef(false);
useEffect(() => {
if (!isLoading && Object.keys(nifty50Prices).length === 0 && !niftyRetried.current) {
niftyRetried.current = true;
const timer = setTimeout(async () => {
try {
const data = await api.getNifty50History();
if (data.prices && Object.keys(data.prices).length > 0) {
setNifty50Prices(data.prices);
}
} catch { /* ignore */ }
}, 3000);
return () => clearTimeout(timer);
}
}, [isLoading, nifty50Prices]);
const dates = recommendations.map(r => r.date);
const hasBacktestData = Object.keys(batchBacktestByDate).length > 0;
// ==========================================================
// COMPUTED: All chart data derived synchronously from bundle
// ==========================================================
// Accuracy metrics
const accuracyMetrics = useMemo(() => {
if (apiAccuracyMetrics && apiAccuracyMetrics.total_predictions > 0) {
return {
total_predictions: apiAccuracyMetrics.total_predictions,
correct_predictions: apiAccuracyMetrics.correct_predictions,
success_rate: apiAccuracyMetrics.overall_accuracy / 100,
buy_accuracy: (apiAccuracyMetrics.by_decision?.BUY?.accuracy || 0) / 100,
sell_accuracy: (apiAccuracyMetrics.by_decision?.SELL?.accuracy || 0) / 100,
hold_accuracy: (apiAccuracyMetrics.by_decision?.HOLD?.accuracy || 0) / 100,
};
}
return { total_predictions: 0, correct_predictions: 0, success_rate: 0, buy_accuracy: 0, sell_accuracy: 0, hold_accuracy: 0 };
}, [apiAccuracyMetrics]);
// Accuracy trend data
const accuracyTrendData = useMemo<AccuracyTrendPoint[]>(() => {
if (!hasBacktestData) return [];
const sortedDates = [...recommendations]
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
.map(r => r.date);
const trendData: AccuracyTrendPoint[] = [];
for (const date of sortedDates) {
const rec = recommendations.find(r => r.date === date);
const dateBacktest = batchBacktestByDate[date];
if (!rec || !dateBacktest) continue;
let totalBuy = 0, correctBuy = 0, totalSell = 0, correctSell = 0, totalHold = 0, correctHold = 0;
for (const symbol of Object.keys(rec.analysis)) {
const stockAnalysis = rec.analysis[symbol];
const bt = dateBacktest[symbol];
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')
? primaryRet > 0
: primaryRet < 0;
if (stockAnalysis.decision === 'BUY') { totalBuy++; if (predictionCorrect) correctBuy++; }
else if (stockAnalysis.decision === 'SELL') { totalSell++; if (predictionCorrect) correctSell++; }
else { totalHold++; if (predictionCorrect) correctHold++; }
}
const totalPredictions = totalBuy + totalSell + totalHold;
const totalCorrect = correctBuy + correctSell + correctHold;
if (totalPredictions < 3) continue;
trendData.push({
date,
overall: totalPredictions > 0 ? Math.round((totalCorrect / totalPredictions) * 100) : 0,
buy: totalBuy > 0 ? Math.round((correctBuy / totalBuy) * 100) : 0,
sell: totalSell > 0 ? Math.round((correctSell / totalSell) * 100) : 0,
hold: totalHold > 0 ? Math.round((correctHold / totalHold) * 100) : 0,
});
}
return trendData;
}, [batchBacktestByDate, hasBacktestData, recommendations]);
// All chart data computed from batch backtest
const chartData = useMemo(() => {
if (recommendations.length === 0 || !hasBacktestData) {
return {
riskMetrics: undefined as RiskMetrics | undefined,
returnDistribution: undefined as ReturnBucket[] | undefined,
cumulativeReturns: undefined as CumulativeReturnPoint[] | undefined,
overallBreakdown: undefined as OverallReturnBreakdown | undefined,
topPicksCumulativeReturns: undefined as CumulativeReturnPoint[] | undefined,
topPicksReturnDistribution: undefined as ReturnBucket[] | undefined,
dateReturns: {} as Record<string, number>,
allBacktestData: {} as Record<string, Record<string, number>>,
dailyReturnsArray: [] as number[],
topPicksDailyReturns: [] as number[],
};
}
const sortedDates = [...recommendations]
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
.map(r => r.date);
// Risk metrics accumulators
const dailyReturns: number[] = [];
let wins = 0, losses = 0, totalWinReturn = 0, totalLossReturn = 0;
let totalCorrect = 0, totalPredictions = 0;
// Return distribution buckets
const returnBuckets: ReturnBucket[] = [
{ range: '< -3%', min: -Infinity, max: -3, count: 0, stocks: [] },
{ range: '-3% to -2%', min: -3, max: -2, count: 0, stocks: [] },
{ range: '-2% to -1%', min: -2, max: -1, count: 0, stocks: [] },
{ range: '-1% to 0%', min: -1, max: 0, count: 0, stocks: [] },
{ range: '0% to 1%', min: 0, max: 1, count: 0, stocks: [] },
{ range: '1% to 2%', min: 1, max: 2, count: 0, stocks: [] },
{ range: '2% to 3%', min: 2, max: 3, count: 0, stocks: [] },
{ range: '> 3%', min: 3, max: Infinity, count: 0, stocks: [] },
];
// Cumulative returns
const cumulativeData: CumulativeReturnPoint[] = [];
let aiMultiplier = 1;
// Nifty50 price ratio approach: direct comparison to start price
// This avoids losing Nifty returns on days without backtest data
const sortedNiftyDates = Object.keys(nifty50Prices).sort();
const hasNiftyData = sortedNiftyDates.length > 0;
const niftyStartPrice = hasNiftyData ? nifty50Prices[sortedNiftyDates[0]] : null;
const getNiftyReturnForDate = (date: string): number => {
if (!hasNiftyData || !niftyStartPrice) return 0;
const closestDate = sortedNiftyDates.find(d => d >= date) || sortedNiftyDates[sortedNiftyDates.length - 1];
if (!closestDate || !nifty50Prices[closestDate]) return 0;
return ((nifty50Prices[closestDate] / niftyStartPrice) - 1) * 100;
};
const dateReturnsMap: Record<string, number> = {};
const allBacktest: Record<string, Record<string, number>> = {};
let latestDateWithData: string | null = null;
for (const date of sortedDates) {
const rec = recommendations.find(r => r.date === date);
const dateBacktest = batchBacktestByDate[date];
if (!rec || !dateBacktest) continue;
let dateCorrectCount = 0, dateTotalCount = 0, dateCorrectReturn = 0, dateIncorrectReturn = 0;
for (const symbol of Object.keys(rec.analysis)) {
const stockAnalysis = rec.analysis[symbol];
const bt = dateBacktest[symbol];
const primaryRet = bt?.return_at_hold ?? bt?.return_1d;
if (!stockAnalysis?.decision || primaryRet === undefined || primaryRet === null) continue;
if (!allBacktest[date]) allBacktest[date] = {};
allBacktest[date][symbol] = primaryRet;
const predictionCorrect = (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD')
? primaryRet > 0
: primaryRet < 0;
totalPredictions++;
if (predictionCorrect) {
totalCorrect++;
dateCorrectCount++;
dateCorrectReturn += (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD') ? primaryRet : Math.abs(primaryRet);
} else {
dateIncorrectReturn += (stockAnalysis.decision === 'BUY' || stockAnalysis.decision === 'HOLD') ? primaryRet : -Math.abs(primaryRet);
}
dateTotalCount++;
}
if (dateTotalCount > 0) latestDateWithData = date;
if (dateTotalCount > 0) {
const correctAvg = dateCorrectCount > 0 ? dateCorrectReturn / dateCorrectCount : 0;
const incorrectAvg = (dateTotalCount - dateCorrectCount) > 0
? dateIncorrectReturn / (dateTotalCount - dateCorrectCount) : 0;
const weightedReturn = (correctAvg * (dateCorrectCount / dateTotalCount))
+ (incorrectAvg * ((dateTotalCount - dateCorrectCount) / dateTotalCount));
dailyReturns.push(weightedReturn);
dateReturnsMap[date] = Math.round(weightedReturn * 10) / 10;
if (weightedReturn > 0) { wins++; totalWinReturn += weightedReturn; }
else if (weightedReturn < 0) { losses++; totalLossReturn += Math.abs(weightedReturn); }
aiMultiplier *= (1 + weightedReturn / 100);
const niftyCumulativeReturn = getNiftyReturnForDate(date);
cumulativeData.push({
date,
value: Math.round(aiMultiplier * 10000) / 100,
aiReturn: Math.round((aiMultiplier - 1) * 1000) / 10,
indexReturn: Math.round(niftyCumulativeReturn * 10) / 10,
});
}
}
// Return distribution from latest date
if (latestDateWithData) {
const rec = recommendations.find(r => r.date === latestDateWithData);
const dateBacktest = batchBacktestByDate[latestDateWithData];
if (rec && dateBacktest) {
for (const symbol of Object.keys(rec.analysis)) {
const bt = dateBacktest[symbol];
const retVal = bt?.return_at_hold ?? bt?.return_1d;
if (retVal === undefined || retVal === null) continue;
for (const bucket of returnBuckets) {
if (retVal >= bucket.min && retVal < bucket.max) {
bucket.count++;
bucket.stocks.push(symbol);
break;
}
}
}
}
}
// Risk metrics
let riskMetrics: RiskMetrics | undefined;
if (dailyReturns.length > 0) {
const mean = dailyReturns.reduce((a, b) => a + b, 0) / dailyReturns.length;
const variance = dailyReturns.reduce((sum, r) => sum + Math.pow(r - mean, 2), 0) / dailyReturns.length;
const volatility = Math.sqrt(variance);
const riskFreeRate = 0.02;
const sharpeRatio = volatility > 0 ? (mean - riskFreeRate) / volatility : 0;
let peak = 100, maxDrawdown = 0, maxDrawdownTrough = 100, maxDrawdownPeak = 100, currentValue = 100;
for (const ret of dailyReturns) {
currentValue = currentValue * (1 + ret / 100);
if (currentValue > peak) peak = currentValue;
const drawdown = ((peak - currentValue) / peak) * 100;
if (drawdown > maxDrawdown) { maxDrawdown = drawdown; maxDrawdownPeak = peak; maxDrawdownTrough = currentValue; }
}
const avgWin = wins > 0 ? totalWinReturn / wins : 0;
const avgLoss = losses > 0 ? totalLossReturn / losses : 0;
riskMetrics = {
sharpeRatio: Math.round(sharpeRatio * 100) / 100,
maxDrawdown: Math.round(maxDrawdown * 10) / 10,
winLossRatio: Math.round((avgLoss > 0 ? avgWin / avgLoss : avgWin) * 100) / 100,
winRate: Math.round(totalPredictions > 0 ? (totalCorrect / totalPredictions) * 100 : 0),
volatility: Math.round(volatility * 100) / 100,
totalTrades: totalPredictions,
meanReturn: Math.round(mean * 100) / 100,
riskFreeRate,
winningTrades: wins,
losingTrades: losses,
avgWinReturn: Math.round(avgWin * 100) / 100,
avgLossReturn: Math.round(avgLoss * 100) / 100,
peakValue: Math.round(maxDrawdownPeak * 100) / 100,
troughValue: Math.round(maxDrawdownTrough * 100) / 100,
};
}
// Overall breakdown
let overallBreakdown: OverallReturnBreakdown | undefined;
if (cumulativeData.length > 0) {
const breakdownDailyReturns: { date: string; return: number; multiplier: number; cumulative: number }[] = [];
let cumulativeMultiplier = 1;
for (let i = 0; i < cumulativeData.length; i++) {
const point = cumulativeData[i];
const dailyReturn = i === 0
? point.aiReturn
: Math.round((((1 + point.aiReturn / 100) / (1 + cumulativeData[i - 1].aiReturn / 100)) - 1) * 1000) / 10;
const dailyMultiplier = 1 + dailyReturn / 100;
cumulativeMultiplier *= dailyMultiplier;
breakdownDailyReturns.push({
date: point.date, return: dailyReturn,
multiplier: Math.round(dailyMultiplier * 10000) / 10000,
cumulative: Math.round((cumulativeMultiplier - 1) * 1000) / 10,
});
}
const finalMultiplier = 1 + cumulativeData[cumulativeData.length - 1].aiReturn / 100;
overallBreakdown = {
dailyReturns: breakdownDailyReturns,
finalMultiplier: Math.round(finalMultiplier * 10000) / 10000,
finalReturn: Math.round((finalMultiplier - 1) * 1000) / 10,
formula: '',
};
}
// Top Picks data
const topPicksCumulative: CumulativeReturnPoint[] = [];
const topPicksDistribution: ReturnBucket[] = [
{ range: '< -3%', min: -Infinity, max: -3, count: 0, stocks: [] },
{ range: '-3% to -2%', min: -3, max: -2, count: 0, stocks: [] },
{ range: '-2% to -1%', min: -2, max: -1, count: 0, stocks: [] },
{ range: '-1% to 0%', min: -1, max: 0, count: 0, stocks: [] },
{ range: '0% to 1%', min: 0, max: 1, count: 0, stocks: [] },
{ range: '1% to 2%', min: 1, max: 2, count: 0, stocks: [] },
{ range: '2% to 3%', min: 2, max: 3, count: 0, stocks: [] },
{ range: '> 3%', min: 3, max: Infinity, count: 0, stocks: [] },
];
let topPicksMultiplier = 1;
let latestTopPicksDateWithData: string | null = null;
const topPicksDailyReturnsArr: number[] = [];
for (const date of sortedDates) {
const rec = recommendations.find(r => r.date === date);
const dateBacktest = batchBacktestByDate[date];
if (!rec || !rec.top_picks || !dateBacktest) continue;
let dateReturn = 0, dateCount = 0;
for (const pick of rec.top_picks) {
const bt = dateBacktest[pick.symbol];
const retVal = bt?.return_at_hold ?? bt?.return_1d;
if (retVal !== undefined && retVal !== null) { dateReturn += retVal; dateCount++; }
}
if (dateCount > 0) latestTopPicksDateWithData = date;
if (dateCount > 0) {
const avgReturn = dateReturn / dateCount;
topPicksDailyReturnsArr.push(avgReturn);
topPicksMultiplier *= (1 + avgReturn / 100);
const topPicksNiftyReturn = getNiftyReturnForDate(date);
topPicksCumulative.push({
date,
value: Math.round(topPicksMultiplier * 10000) / 100,
aiReturn: Math.round((topPicksMultiplier - 1) * 1000) / 10,
indexReturn: Math.round(topPicksNiftyReturn * 10) / 10,
});
}
}
if (latestTopPicksDateWithData) {
const rec = recommendations.find(r => r.date === latestTopPicksDateWithData);
const dateBacktest = batchBacktestByDate[latestTopPicksDateWithData];
if (rec && dateBacktest) {
for (const pick of rec.top_picks) {
const bt = dateBacktest[pick.symbol];
const retVal = bt?.return_at_hold ?? bt?.return_1d;
if (retVal !== undefined && retVal !== null) {
for (const bucket of topPicksDistribution) {
if (retVal >= bucket.min && retVal < bucket.max) { bucket.count++; bucket.stocks.push(pick.symbol); break; }
}
}
}
}
}
return {
riskMetrics,
returnDistribution: returnBuckets,
cumulativeReturns: cumulativeData,
overallBreakdown,
topPicksCumulativeReturns: topPicksCumulative,
topPicksReturnDistribution: topPicksDistribution,
dateReturns: dateReturnsMap,
allBacktestData: allBacktest,
dailyReturnsArray: dailyReturns,
topPicksDailyReturns: topPicksDailyReturnsArr,
};
}, [batchBacktestByDate, hasBacktestData, recommendations, nifty50Prices]);
// Overall stats
const overallStats = useMemo(() => {
if (recommendations.length > 0 && chartData.dailyReturnsArray && chartData.dailyReturnsArray.length > 0) {
const mean = chartData.dailyReturnsArray.reduce((a, b) => a + b, 0) / chartData.dailyReturnsArray.length;
return {
totalDays: recommendations.length,
totalPredictions: accuracyMetrics.total_predictions,
avgDailyReturn: Math.round(mean * 10) / 10,
avgMonthlyReturn: 0,
overallAccuracy: Math.round(accuracyMetrics.success_rate * 100),
bestDay: null,
worstDay: null,
};
}
return { totalDays: recommendations.length, totalPredictions: 0, avgDailyReturn: 0, avgMonthlyReturn: 0, overallAccuracy: 0, bestDay: null, worstDay: null };
}, [recommendations, chartData.dailyReturnsArray, accuracyMetrics]);
// Filtered stats for Performance Summary
const filteredStats = useMemo(() => {
if (summaryMode === 'all50') {
const signalTotals = recommendations.reduce(
(acc, r) => ({ buy: acc.buy + r.summary.buy, sell: acc.sell + r.summary.sell, hold: acc.hold + r.summary.hold }),
{ buy: 0, sell: 0, hold: 0 }
);
return { totalDays: dates.length, avgDailyReturn: overallStats.avgDailyReturn, buySignals: signalTotals.buy, sellSignals: signalTotals.sell, holdSignals: signalTotals.hold };
}
const topPicksMean = chartData.topPicksDailyReturns.length > 0
? chartData.topPicksDailyReturns.reduce((a, b) => a + b, 0) / chartData.topPicksDailyReturns.length
: 0;
return {
totalDays: dates.length,
avgDailyReturn: Math.round(topPicksMean * 10) / 10,
buySignals: recommendations.reduce((acc, r) => acc + r.top_picks.length, 0),
sellSignals: 0,
holdSignals: 0,
};
}, [summaryMode, dates.length, overallStats.avgDailyReturn, recommendations, chartData.topPicksDailyReturns]);
// Date stats
const dateStatsMap = useMemo(() => {
return Object.fromEntries(dates.map(date => {
const rec = recommendations.find(r => r.date === date);
if (rec) {
const stocks = Object.values(rec.analysis);
return [date, {
date,
avgReturn1d: chartData.dateReturns[date] ?? 0,
avgReturn1m: 0,
totalStocks: stocks.length,
correctPredictions: 0,
accuracy: 0,
buyCount: rec.summary.buy,
sellCount: rec.summary.sell,
holdCount: rec.summary.hold,
}];
}
return [date, { date, avgReturn1d: 0, avgReturn1m: 0, totalStocks: 0, correctPredictions: 0, accuracy: 0, buyCount: 0, sellCount: 0, holdCount: 0 }];
}));
}, [dates, recommendations, chartData.dateReturns]);
const getRecommendation = (date: string) => recommendations.find(r => r.date === date);
const getFilteredStocks = (date: string) => {
const rec = getRecommendation(date);
if (!rec) return [];
let stocks: StockAnalysis[];
if (dateFilterMode === 'topPicks') {
stocks = rec.top_picks.map(pick => rec.analysis[pick.symbol]).filter(Boolean);
} else {
stocks = Object.values(rec.analysis);
}
return [...stocks].sort((a, b) => (a.rank ?? Infinity) - (b.rank ?? Infinity));
};
// Build ReturnBreakdown for the modal (from already-loaded batch data)
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, 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);
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)}% x ${correctStocks.length}/${totalCount}) + (${incorrectAvg.toFixed(2)}% x ${incorrectStocks.length}/${totalCount}) = ${weightedReturn.toFixed(2)}%`,
};
}, [recommendations, batchBacktestByDate]);
// Load detailed backtest data for a specific date
const loadDetailedBacktest = useCallback(async (date: string) => {
setIsLoadingDetailed(true);
setDetailedBacktest(null);
setExpandedStock(null);
try {
const data = await api.getDetailedBacktest(date);
setDetailedBacktest(data);
} catch (error) {
console.error('Failed to load detailed backtest:', error);
} finally {
setIsLoadingDetailed(false);
}
}, []);
// Cleanup poll on unmount
useEffect(() => {
return () => {
if (backtestPollRef.current) clearInterval(backtestPollRef.current);
};
}, []);
const handleRunBacktest = useCallback(async () => {
if (!backtestDateInput) return;
setBacktestMessage(null);
if (backtestPollRef.current) { clearInterval(backtestPollRef.current); backtestPollRef.current = null; }
// If date already has data in the loaded bundle, select it and load detailed view
if (dates.includes(backtestDateInput)) {
setSelectedDate(backtestDateInput);
loadDetailedBacktest(backtestDateInput);
return;
}
// Try fetching existing data first
setIsRunningBacktest(true);
setBacktestMessage({ type: 'progress', text: 'Checking for existing data...' });
try {
const data = await api.getDetailedBacktest(backtestDateInput);
if (data && data.stocks && data.stocks.length > 0) {
setSelectedDate(backtestDateInput);
setDetailedBacktest(data);
setIsRunningBacktest(false);
setBacktestMessage(null);
return;
}
} catch {
// No existing data, proceed to run analysis
}
// No data — auto-trigger bulk analysis for this date
setBacktestMessage({ type: 'progress', text: `Starting analysis for ${backtestDateInput}... This runs all 50 stocks through the AI pipeline.` });
try {
await api.runBulkAnalysis(backtestDateInput, {
deep_think_model: settings.deepThinkModel,
quick_think_model: settings.quickThinkModel,
provider: settings.provider,
api_key: settings.anthropicApiKey || undefined,
max_debate_rounds: settings.maxDebateRounds,
parallel_workers: settings.parallelWorkers,
});
// Poll for progress
backtestPollRef.current = setInterval(async () => {
try {
const status = await api.getBulkAnalysisStatus();
const pct = status.total > 0 ? Math.round((status.completed / status.total) * 100) : 0;
const currentStocks = status.current_symbols?.join(', ') || status.current_symbol || '';
if (status.status === 'completed' || status.status === 'idle') {
if (backtestPollRef.current) { clearInterval(backtestPollRef.current); backtestPollRef.current = null; }
setBacktestMessage({ type: 'progress', text: 'Analysis complete! Loading detailed backtest...' });
// Load the results
try {
const data = await api.getDetailedBacktest(backtestDateInput);
setSelectedDate(backtestDateInput);
setDetailedBacktest(data);
setBacktestMessage(null);
} catch {
setBacktestMessage({ type: 'error', text: 'Analysis completed but failed to load results. Try clicking a date card.' });
}
setIsRunningBacktest(false);
} else if (status.status === 'failed' || status.cancelled) {
if (backtestPollRef.current) { clearInterval(backtestPollRef.current); backtestPollRef.current = null; }
setIsRunningBacktest(false);
setBacktestMessage({ type: 'error', text: `Analysis ${status.cancelled ? 'cancelled' : 'failed'}. ${status.completed}/${status.total} stocks completed.` });
} else {
setBacktestMessage({
type: 'progress',
text: `Analyzing stocks... ${status.completed}/${status.total} done (${pct}%)${currentStocks ? ` — Currently: ${currentStocks}` : ''}`
});
}
} catch {
// Poll error, keep trying
}
}, 3000);
} catch (error) {
console.error('Failed to start analysis:', error);
setIsRunningBacktest(false);
setBacktestMessage({ type: 'error', text: 'Failed to start analysis. Check that the backend is running.' });
}
}, [backtestDateInput, dates, loadDetailedBacktest, settings]);
const handleCancelBacktest = useCallback(async () => {
try {
await api.cancelBulkAnalysis();
} catch {
// ignore
}
if (backtestPollRef.current) { clearInterval(backtestPollRef.current); backtestPollRef.current = null; }
setIsRunningBacktest(false);
setBacktestMessage({ type: 'error', text: 'Analysis cancelled.' });
}, []);
// ==========================================================
// RENDER
// ==========================================================
if (isLoading) {
return (
<div className="min-h-[50vh] flex items-center justify-center">
<div className="text-center">
<div className="relative w-16 h-16 mx-auto mb-4">
<div className="absolute inset-0 rounded-full border-4 border-nifty-100 dark:border-nifty-900/40" />
<div className="absolute inset-0 rounded-full border-4 border-t-nifty-500 animate-spin" />
</div>
<h2 className="text-lg font-semibold text-gray-700 dark:text-gray-300">Loading historical data...</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Fetching recommendations & backtest results</p>
</div>
</div>
);
}
return (
<div className="space-y-5">
{/* Page Header */}
<section className="card p-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-display font-bold text-gray-900 dark:text-gray-100">
Historical <span className="gradient-text">Performance</span>
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
AI recommendations with real backtest results and market validation
</p>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-nifty-50 dark:bg-nifty-900/30">
<BarChart3 className="w-4 h-4 text-nifty-600 dark:text-nifty-400" />
<span className="font-bold text-nifty-700 dark:text-nifty-300">{dates.length}</span>
<span className="text-gray-500 dark:text-gray-400">days</span>
</div>
{loadTimeMs !== null && (
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-900/20">
<Zap className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" />
<span className="font-bold text-emerald-700 dark:text-emerald-300">{loadTimeMs}ms</span>
</div>
)}
</div>
</div>
</section>
{/* Prediction Accuracy - Hero Card */}
<section className="card p-5">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-xl bg-nifty-50 dark:bg-nifty-900/30">
<Target className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />
</div>
<div>
<h2 className="font-bold text-gray-900 dark:text-gray-100">Prediction Accuracy</h2>
<p className="text-[10px] text-gray-500 dark:text-gray-400">{accuracyMetrics.total_predictions} predictions tracked</p>
</div>
</div>
<button
onClick={() => setShowAccuracyModal(true)}
className="flex items-center gap-1 px-2.5 py-1.5 text-xs text-gray-500 dark:text-gray-400 hover:text-nifty-600 dark:hover:text-nifty-400 hover:bg-gray-50 dark:hover:bg-slate-700/60 rounded-lg transition-colors"
>
<HelpCircle className="w-3.5 h-3.5" />
<span className="hidden sm:inline">How it works</span>
</button>
</div>
<div className="flex flex-col sm:flex-row items-center gap-6">
{/* Circular gauge for Overall accuracy */}
<div className="flex-shrink-0 text-center">
<div className="relative w-28 h-28 sm:w-32 sm:h-32">
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 128 128">
<circle cx="64" cy="64" r="54" stroke="currentColor" strokeWidth="10" fill="transparent" className="text-gray-100 dark:text-slate-700" />
<circle cx="64" cy="64" r="54" strokeWidth="10" fill="transparent"
strokeDasharray={`${accuracyMetrics.success_rate * 339.29} 339.29`}
className="text-nifty-500 dark:text-nifty-400"
stroke="currentColor"
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 tabular-nums">
{(accuracyMetrics.success_rate * 100).toFixed(0)}%
</span>
<span className="text-[10px] font-medium text-gray-400 dark:text-gray-500">Overall</span>
</div>
</div>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-1.5">
{accuracyMetrics.correct_predictions}/{accuracyMetrics.total_predictions} correct
</p>
</div>
{/* Progress bars for Buy / Sell / Hold */}
<div className="flex-1 space-y-3.5 w-full">
{/* Buy */}
<div>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<TrendingUp className="w-3.5 h-3.5 text-emerald-500 dark:text-emerald-400" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Buy Accuracy</span>
</div>
<span className="text-sm font-bold text-emerald-600 dark:text-emerald-400 tabular-nums">
{(accuracyMetrics.buy_accuracy * 100).toFixed(0)}%
</span>
</div>
<div className="h-2 bg-gray-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div className="h-full bg-emerald-500 dark:bg-emerald-400 rounded-full transition-all duration-700"
style={{ width: `${Math.max(accuracyMetrics.buy_accuracy * 100, 2)}%` }}
/>
</div>
</div>
{/* Sell */}
<div>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<TrendingDown className="w-3.5 h-3.5 text-red-500 dark:text-red-400" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Sell Accuracy</span>
</div>
<span className="text-sm font-bold text-red-600 dark:text-red-400 tabular-nums">
{(accuracyMetrics.sell_accuracy * 100).toFixed(0)}%
</span>
</div>
<div className="h-2 bg-gray-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div className="h-full bg-red-500 dark:bg-red-400 rounded-full transition-all duration-700"
style={{ width: `${Math.max(accuracyMetrics.sell_accuracy * 100, 2)}%` }}
/>
</div>
</div>
{/* Hold */}
<div>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<Minus className="w-3.5 h-3.5 text-amber-500 dark:text-amber-400" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Hold Accuracy</span>
</div>
<span className="text-sm font-bold text-amber-600 dark:text-amber-400 tabular-nums">
{(accuracyMetrics.hold_accuracy * 100).toFixed(0)}%
</span>
</div>
<div className="h-2 bg-gray-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div className="h-full bg-amber-500 dark:bg-amber-400 rounded-full transition-all duration-700"
style={{ width: `${Math.max(accuracyMetrics.hold_accuracy * 100, 2)}%` }}
/>
</div>
</div>
</div>
</div>
</section>
{/* Accuracy Trend Chart */}
<section className="card p-5">
<SectionHeader
icon={<LineChart className="w-4 h-4 text-nifty-600 dark:text-nifty-400" />}
title="Accuracy Trend"
subtitle={accuracyTrendData.length > 0 ? `${accuracyTrendData.length} trading days tracked` : undefined}
/>
<AccuracyTrendChart
height={200}
data={accuracyTrendData}
/>
</section>
{/* Risk Metrics */}
<section className="card p-5">
<SectionHeader
icon={<Shield className="w-4 h-4 text-nifty-600 dark:text-nifty-400" />}
title="Risk Metrics"
subtitle={chartData.riskMetrics ? `${chartData.riskMetrics.totalTrades} trades analyzed` : undefined}
/>
<RiskMetricsCard metrics={chartData.riskMetrics} />
</section>
{/* Portfolio Simulator */}
<PortfolioSimulator
recommendations={recommendations}
nifty50Prices={nifty50Prices}
allBacktestData={chartData.allBacktestData}
/>
{/* Date Selector */}
<div className="card p-5">
<SectionHeader
icon={<Calendar className="w-4 h-4 text-nifty-600 dark:text-nifty-400" />}
title="Select Date"
right={
<div className="flex items-center gap-2">
<Filter className="w-3.5 h-3.5 text-gray-400" />
<InvestmentModeToggle mode={dateFilterMode} onChange={setDateFilterMode} />
</div>
}
/>
{/* Backtest Date Input */}
<div className="flex items-center gap-2 mb-4 p-3 rounded-xl bg-gray-50 dark:bg-slate-700/40 border border-gray-100 dark:border-slate-700">
<Search className="w-4 h-4 text-gray-400 flex-shrink-0" />
<input
type="date"
value={backtestDateInput}
onChange={(e) => setBacktestDateInput(e.target.value)}
className="flex-1 px-3 py-1.5 text-sm bg-white dark:bg-slate-600 border border-gray-200 dark:border-slate-500 rounded-lg focus:ring-2 focus:ring-nifty-500 focus:border-transparent outline-none text-gray-900 dark:text-gray-100"
max={new Date().toISOString().split('T')[0]}
/>
<button
onClick={handleRunBacktest}
disabled={!backtestDateInput || isRunningBacktest}
className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium text-white bg-nifty-600 hover:bg-nifty-700 disabled:bg-gray-300 dark:disabled:bg-slate-600 disabled:cursor-not-allowed rounded-lg transition-colors"
>
{isRunningBacktest ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Running...
</>
) : (
<>
<Play className="w-3.5 h-3.5" />
Run Backtest
</>
)}
</button>
</div>
{/* Backtest feedback message */}
{backtestMessage && (
<div className={`mb-4 rounded-xl text-sm border ${
backtestMessage.type === 'error'
? 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800'
: backtestMessage.type === 'progress'
? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800'
: 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800'
}`}>
<div className="flex items-center gap-2 px-4 py-3">
{backtestMessage.type === 'progress' && <Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />}
<span className="flex-1">{backtestMessage.text}</span>
{backtestMessage.type === 'progress' ? (
<button
onClick={handleCancelBacktest}
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors"
>
<XCircle className="w-3.5 h-3.5" />
Cancel
</button>
) : (
<button onClick={() => setBacktestMessage(null)} className="text-current opacity-50 hover:opacity-100 font-bold">×</button>
)}
</div>
{backtestMessage.type === 'progress' && (
<div className="px-4 pb-3 pt-0 flex items-start gap-1.5 text-xs text-amber-600/70 dark:text-amber-400/60">
<AlertTriangle className="w-3 h-3 mt-0.5 flex-shrink-0" />
<span>Note: Price data & technical indicators use historical data for the selected date. News & analyst ratings reflect current data (yfinance limitation).</span>
</div>
)}
</div>
)}
<div className="flex flex-wrap gap-2">
{dates.map((date) => {
const rec = getRecommendation(date);
const stats = dateStatsMap[date];
const avgReturn = stats?.avgReturn1d ?? 0;
const hasData = chartData.dateReturns[date] !== undefined;
const isPositive = avgReturn >= 0;
const filteredSummary = dateFilterMode === 'topPicks'
? { buy: rec?.top_picks.length || 0, sell: 0, hold: 0 }
: rec?.summary || { buy: 0, sell: 0, hold: 0 };
return (
<div key={date} className="relative group">
<button
onClick={() => setSelectedDate(selectedDate === date ? null : date)}
className={`px-3 py-2.5 rounded-xl text-xs font-medium transition-all min-w-[90px] border ${
selectedDate === date
? 'bg-nifty-600 text-white ring-2 ring-nifty-400/50 border-nifty-500 shadow-lg shadow-nifty-500/20'
: 'bg-white dark:bg-slate-700/80 text-gray-700 dark:text-gray-200 border-gray-200 dark:border-slate-600 hover:border-nifty-300 dark:hover:border-nifty-700 hover:shadow-md'
}`}
>
<div className="font-bold">{new Date(date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' })}</div>
{!hasData ? (
<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 ? '+' : ''}{fmtPct(avgReturn)}%
</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>
</button>
<button
onClick={(e) => { e.stopPropagation(); setReturnModalDate(date); setShowReturnModal(true); }}
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-white dark:bg-slate-600 shadow-sm border border-gray-200 dark:border-slate-500 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
title="How is this calculated?"
>
<Calculator className="w-2.5 h-2.5 text-gray-500 dark:text-gray-300" />
</button>
</div>
);
})}
{/* Overall Summary Card */}
<div className="relative group">
<button
onClick={() => setShowOverallModal(true)}
className="px-3 py-2.5 rounded-xl text-xs font-medium min-w-[100px] bg-gradient-to-br from-nifty-500 to-nifty-700 text-white hover:from-nifty-600 hover:to-nifty-800 transition-all text-left shadow-lg shadow-nifty-500/20 border border-nifty-400/30"
>
<div className="font-bold flex items-center gap-1">
<Award className="w-3 h-3" />
Overall
</div>
<div className="text-sm font-bold mt-0.5">
{overallStats.avgDailyReturn >= 0 ? '+' : ''}{fmtPct(overallStats.avgDailyReturn)}%
</div>
<div className="text-[10px] mt-0.5 text-white/80">
{overallStats.overallAccuracy}% accurate
</div>
</button>
</div>
</div>
</div>
{/* Selected Date Details */}
{selectedDate && (
<div className="card overflow-hidden">
<div className="p-4 border-b border-gray-100 dark:border-slate-700 bg-gradient-to-r from-gray-50 to-white dark:from-slate-700/50 dark:to-slate-800">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-lg bg-nifty-100 dark:bg-nifty-900/40">
<Calendar className="w-4 h-4 text-nifty-600 dark:text-nifty-400" />
</div>
<h2 className="font-bold text-gray-900 dark:text-gray-100">
{new Date(selectedDate).toLocaleDateString('en-IN', {
weekday: 'short', month: 'short', day: 'numeric', year: 'numeric',
})}
</h2>
<InvestmentModeToggle mode={dateFilterMode} onChange={setDateFilterMode} />
</div>
<div className="flex items-center gap-3 text-xs">
{dateFilterMode === 'all50' ? (
<>
<span className="flex items-center gap-1 px-2 py-1 rounded-md bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 font-medium">
<ArrowUpRight className="w-3 h-3" />
{getRecommendation(selectedDate)?.summary.buy} Buy
</span>
<span className="flex items-center gap-1 px-2 py-1 rounded-md bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 font-medium">
<ArrowDownRight className="w-3 h-3" />
{getRecommendation(selectedDate)?.summary.sell} Sell
</span>
<span className="flex items-center gap-1 px-2 py-1 rounded-md bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400 font-medium">
<Minus className="w-3 h-3" />
{getRecommendation(selectedDate)?.summary.hold} Hold
</span>
</>
) : (
<span className="flex items-center gap-1 px-2 py-1 rounded-md bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 font-medium">
<TrendingUp className="w-3 h-3" />
{getRecommendation(selectedDate)?.top_picks.length} Top Picks (BUY)
</span>
)}
</div>
</div>
</div>
{/* Detailed Backtest Action Bar */}
<div className="px-4 py-2 border-b border-gray-100 dark:border-slate-700 flex items-center justify-between">
<span className="text-xs text-gray-500 dark:text-gray-400">
{detailedBacktest?.date === selectedDate
? `Detailed backtest: ${detailedBacktest.total_stocks} stocks with live P&L`
: 'View live P&L with full explainability'}
</span>
<button
onClick={() => {
if (detailedBacktest?.date === selectedDate) {
setDetailedBacktest(null);
} else {
loadDetailedBacktest(selectedDate);
}
}}
disabled={isLoadingDetailed}
className="flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-nifty-600 dark:text-nifty-400 hover:bg-nifty-50 dark:hover:bg-nifty-900/20 rounded-lg transition-colors disabled:opacity-50"
>
{isLoadingDetailed ? (
<><Loader2 className="w-3 h-3 animate-spin" /> Loading...</>
) : detailedBacktest?.date === selectedDate ? (
<><ChevronRight className="w-3 h-3" /> Simple View</>
) : (
<><Search className="w-3 h-3" /> View Detailed</>
)}
</button>
</div>
{detailedBacktest && detailedBacktest.date === selectedDate ? (
/* Detailed expandable view */
<div className="divide-y divide-gray-50 dark:divide-slate-700/50 max-h-[70vh] overflow-y-auto">
{detailedBacktest.stocks.map((stock) => {
const isExpanded = expandedStock === stock.symbol;
const returnVal = stock.return_at_hold ?? stock.return_current;
const isPositiveReturn = returnVal !== null && returnVal >= 0;
return (
<div key={stock.symbol}>
{/* Stock Summary Row */}
<button
onClick={() => setExpandedStock(isExpanded ? null : stock.symbol)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-gray-50/80 dark:hover:bg-slate-700/30 transition-colors text-left"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<RankBadge rank={stock.rank} size="small" />
<span className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{stock.symbol}</span>
<DecisionBadge decision={stock.decision} size="small" />
<HoldDaysBadge holdDays={stock.hold_days} decision={stock.decision} />
</div>
<div className="flex items-center gap-3">
{stock.price_at_prediction !== null && (
<span className="text-xs text-gray-500 dark:text-gray-400 tabular-nums hidden sm:inline">
{stock.price_at_prediction.toFixed(0)}
<span className="mx-1"></span>
{(stock.hold_period_active ? stock.price_current : stock.price_at_hold_end)?.toFixed(0) ?? '—'}
</span>
)}
{returnVal !== null && (
<span className={`inline-flex items-center gap-1 text-xs font-bold tabular-nums px-2 py-0.5 rounded-full ${
isPositiveReturn
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400'
: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400'
}`}>
{isPositiveReturn ? '+' : ''}{returnVal.toFixed(2)}%
</span>
)}
{stock.hold_period_active && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 font-medium">
LIVE
</span>
)}
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
</div>
</button>
{/* Expanded Detail */}
{isExpanded && (
<div className="px-4 pb-4 space-y-4 bg-gray-50/50 dark:bg-slate-800/50 border-t border-gray-100 dark:border-slate-700">
{/* P&L Formula */}
<div className="mt-3 p-3 rounded-lg bg-white dark:bg-slate-700/60 border border-gray-100 dark:border-slate-600">
<div className="flex items-center gap-2 mb-2">
<Calculator className="w-3.5 h-3.5 text-nifty-600 dark:text-nifty-400" />
<span className="text-xs font-semibold text-gray-700 dark:text-gray-300">P&L Formula</span>
</div>
<pre className="text-xs text-gray-600 dark:text-gray-300 font-mono whitespace-pre-wrap leading-relaxed">
{stock.formula || 'No formula available'}
</pre>
</div>
{/* Hold Period Progress */}
<div className="p-3 rounded-lg bg-white dark:bg-slate-700/60 border border-gray-100 dark:border-slate-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Clock className="w-3.5 h-3.5 text-nifty-600 dark:text-nifty-400" />
<span className="text-xs font-semibold text-gray-700 dark:text-gray-300">Hold Period</span>
</div>
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium ${
stock.hold_period_active
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300'
: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
}`}>
{stock.hold_period_active ? 'Active' : 'Completed'}
</span>
</div>
<div className="h-2.5 bg-gray-200 dark:bg-slate-600 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
stock.hold_period_active
? 'bg-amber-500 dark:bg-amber-400'
: 'bg-emerald-500 dark:bg-emerald-400'
}`}
style={{ width: `${Math.min((stock.hold_days_elapsed / Math.max(stock.hold_days, 1)) * 100, 100)}%` }}
/>
</div>
<div className="flex justify-between mt-1 text-[10px] text-gray-500 dark:text-gray-400">
<span>{stock.hold_days_elapsed} / {stock.hold_days} days elapsed</span>
{stock.prediction_correct !== null && (
<span className={stock.prediction_correct ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500 dark:text-red-400'}>
Prediction {stock.prediction_correct ? 'Correct' : 'Incorrect'}
</span>
)}
</div>
</div>
{/* Decision Reasoning */}
<div className="p-3 rounded-lg bg-white dark:bg-slate-700/60 border border-gray-100 dark:border-slate-600">
<div className="flex items-center gap-2 mb-2">
<FileText className="w-3.5 h-3.5 text-nifty-600 dark:text-nifty-400" />
<span className="text-xs font-semibold text-gray-700 dark:text-gray-300">Decision Reasoning</span>
</div>
<div className="flex items-center gap-2 mb-2">
<DecisionBadge decision={stock.decision} size="small" />
<span className="text-xs text-gray-500 dark:text-gray-400">
{stock.confidence} confidence, {stock.risk} risk
</span>
</div>
<p className="text-xs text-gray-600 dark:text-gray-300 leading-relaxed max-h-32 overflow-y-auto">
{stock.raw_analysis || 'No analysis text available'}
</p>
</div>
{/* Agent Reports */}
{Object.keys(stock.agent_summary).length > 0 && (
<div className="p-3 rounded-lg bg-white dark:bg-slate-700/60 border border-gray-100 dark:border-slate-600">
<div className="flex items-center gap-2 mb-3">
<MessageSquare className="w-3.5 h-3.5 text-nifty-600 dark:text-nifty-400" />
<span className="text-xs font-semibold text-gray-700 dark:text-gray-300">Agent Reports</span>
</div>
<div className="flex gap-1 mb-3">
{Object.keys(stock.agent_summary).map((key) => (
<button
key={key}
onClick={(e) => { e.stopPropagation(); setActiveAgentTab(key); }}
className={`px-2.5 py-1 text-[10px] font-medium rounded-md transition-colors capitalize ${
activeAgentTab === key
? 'bg-nifty-100 dark:bg-nifty-900/40 text-nifty-700 dark:text-nifty-300'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-600'
}`}
>
{key.replace('_', ' ')}
</button>
))}
</div>
<p className="text-xs text-gray-600 dark:text-gray-300 leading-relaxed max-h-24 overflow-y-auto">
{stock.agent_summary[activeAgentTab] || 'No report available for this agent'}
</p>
</div>
)}
{/* Debate Summary */}
{Object.keys(stock.debate_summary).length > 0 && (
<div className="p-3 rounded-lg bg-white dark:bg-slate-700/60 border border-gray-100 dark:border-slate-600">
<div className="flex items-center gap-2 mb-2">
<Activity className="w-3.5 h-3.5 text-nifty-600 dark:text-nifty-400" />
<span className="text-xs font-semibold text-gray-700 dark:text-gray-300">Debate Summary</span>
</div>
<div className="space-y-2">
{Object.entries(stock.debate_summary).map(([type, summary]) => (
<div key={type} className="text-xs">
<span className="font-medium text-gray-700 dark:text-gray-200 capitalize">{type}:</span>{' '}
<span className="text-gray-600 dark:text-gray-300">{summary}</span>
</div>
))}
</div>
</div>
)}
{/* Link to full stock detail */}
<Link
to={`/stock/${stock.symbol}`}
className="flex items-center gap-1.5 text-xs font-medium text-nifty-600 dark:text-nifty-400 hover:underline"
>
View full stock detail
<ChevronRight className="w-3 h-3" />
</Link>
</div>
)}
</div>
);
})}
</div>
) : (
/* Simple stock list */
<div className="divide-y divide-gray-50 dark:divide-slate-700/50 max-h-[60vh] sm:max-h-[400px] overflow-y-auto">
{getFilteredStocks(selectedDate).map((stock: StockAnalysis) => {
const bt = batchBacktestByDate[selectedDate]?.[stock.symbol];
let nextDayReturn: number | null = null;
let predictionCorrect: boolean | null = null;
if (bt) {
nextDayReturn = bt.return_at_hold ?? bt.return_1d ?? null;
if (nextDayReturn !== null) {
predictionCorrect = (stock.decision === 'BUY' || stock.decision === 'HOLD')
? nextDayReturn > 0
: nextDayReturn < 0;
}
}
return (
<Link
key={stock.symbol}
to={`/stock/${stock.symbol}`}
className="flex items-center justify-between px-4 py-2.5 hover:bg-gray-50/80 dark:hover:bg-slate-700/30 transition-colors group"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<RankBadge rank={stock.rank} size="small" />
<span className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{stock.symbol}</span>
<span className="text-xs text-gray-500 dark:text-gray-400 hidden sm:inline truncate">{(stock.company_name && stock.company_name !== stock.symbol) ? stock.company_name : (NIFTY_50_STOCKS.find(n => n.symbol === stock.symbol)?.company_name || stock.company_name)}</span>
</div>
<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={`inline-flex items-center gap-1 text-xs font-semibold tabular-nums px-1.5 py-0.5 rounded-full ${
predictionCorrect === true
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400'
: predictionCorrect === false
? 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400'
: getValueColorClass(nextDayReturn)
}`} title={bt?.hold_days ? `${bt.hold_days}d return` : '1d return'}>
{nextDayReturn >= 0 ? '+' : ''}{fmtPct(nextDayReturn)}%
{bt?.hold_days && <span className="text-[9px] opacity-60">/{bt.hold_days}d</span>}
</span>
)}
<ChevronRight className="w-4 h-4 text-gray-300 dark:text-gray-600 group-hover:text-nifty-600 dark:group-hover:text-nifty-400 transition-colors" />
</div>
</Link>
);
})}
</div>
)}
</div>
)}
{/* Performance Summary */}
<div className="card p-5">
<SectionHeader
icon={<Activity className="w-4 h-4 text-nifty-600 dark:text-nifty-400" />}
title="Performance Summary"
right={<InvestmentModeToggle mode={summaryMode} onChange={setSummaryMode} />}
/>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: 'Days Tracked', value: filteredStats.totalDays.toString(), icon: <Clock className="w-4 h-4" />, color: 'nifty', modal: 'daysTracked' as SummaryModalType },
{ label: 'Avg Return', value: `${filteredStats.avgDailyReturn >= 0 ? '+' : ''}${fmtPct(filteredStats.avgDailyReturn)}%`, icon: <TrendingUp className="w-4 h-4" />, color: filteredStats.avgDailyReturn >= 0 ? 'emerald' : 'red', modal: 'avgReturn' as SummaryModalType },
{ label: summaryMode === 'topPicks' ? 'Top Picks' : 'Buy Signals', value: filteredStats.buySignals.toString(), icon: <ArrowUpRight className="w-4 h-4" />, color: 'emerald', modal: 'buySignals' as SummaryModalType },
{ label: 'Sell Signals', value: filteredStats.sellSignals.toString(), icon: <ArrowDownRight className="w-4 h-4" />, color: 'red', modal: 'sellSignals' as SummaryModalType },
].map(({ label, value, icon, color, modal }) => (
<div
key={label}
className="p-4 rounded-xl bg-gray-50/80 dark:bg-slate-700/40 border border-gray-100 dark:border-slate-700 cursor-pointer hover:border-nifty-200 dark:hover:border-nifty-800 hover:shadow-sm transition-all group"
onClick={() => setActiveSummaryModal(modal)}
>
<div className={`text-2xl font-bold tabular-nums text-${color}-600 dark:text-${color}-400`}>{value}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1.5 mt-1">
<span className="opacity-50">{icon}</span>
{label}
<HelpCircle className="w-3 h-3 opacity-0 group-hover:opacity-50 transition-opacity" />
</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)'
: 'Returns measured over hold period (or 1-day when no hold period specified)'}
</p>
</div>
{/* AI vs Nifty50 Index Comparison */}
<section className="card p-5">
<SectionHeader
icon={<BarChart3 className="w-4 h-4 text-nifty-600 dark:text-nifty-400" />}
title="AI Strategy vs Nifty50 Index"
right={<InvestmentModeToggle mode={indexChartMode} onChange={setIndexChartMode} />}
/>
<IndexComparisonChart
height={220}
data={(indexChartMode === 'topPicks' ? chartData.topPicksCumulativeReturns : chartData.cumulativeReturns) ?? []}
/>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center">
{(indexChartMode === 'topPicks' ? chartData.topPicksCumulativeReturns : chartData.cumulativeReturns)?.length ? (
<>Cumulative returns for {indexChartMode === 'topPicks' ? 'Top Picks' : 'All 50 stocks'} over {(indexChartMode === 'topPicks' ? chartData.topPicksCumulativeReturns : chartData.cumulativeReturns)?.length} trading days</>
) : (
<>AI strategy vs Nifty50 index cumulative returns</>
)}
</p>
</section>
{/* Return Distribution */}
<section className="card p-5">
<SectionHeader
icon={<PieChart className="w-4 h-4 text-nifty-600 dark:text-nifty-400" />}
title="Return Distribution"
right={<InvestmentModeToggle mode={distributionMode} onChange={setDistributionMode} />}
/>
<ReturnDistributionChart
height={200}
data={(distributionMode === 'topPicks' ? chartData.topPicksReturnDistribution : chartData.returnDistribution) ?? []}
/>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 text-center">
{(distributionMode === 'topPicks' ? chartData.topPicksReturnDistribution : chartData.returnDistribution)?.some(b => b.count > 0) ? (
<>Distribution of {distributionMode === 'topPicks' ? 'Top Picks' : 'all 50 stocks'} hold-period returns. Click bars to see stocks.</>
) : (
<>Distribution of hold-period returns across all predictions</>
)}
</p>
</section>
{/* Modals */}
<AccuracyExplainModal isOpen={showAccuracyModal} onClose={() => setShowAccuracyModal(false)} metrics={accuracyMetrics} />
<ReturnExplainModal
isOpen={showReturnModal}
onClose={() => setShowReturnModal(false)}
breakdown={returnModalDate ? buildReturnBreakdown(returnModalDate) : null}
date={returnModalDate || ''}
/>
<OverallReturnModal
isOpen={showOverallModal}
onClose={() => setShowOverallModal(false)}
breakdown={chartData.overallBreakdown}
cumulativeData={chartData.cumulativeReturns}
/>
{/* Performance Summary Modals */}
<InfoModal isOpen={activeSummaryModal === 'daysTracked'} onClose={() => setActiveSummaryModal(null)} title="Days Tracked" icon={<Calendar className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />}>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<p><strong>Days Tracked</strong> shows the total number of trading days where AI recommendations have been recorded and analyzed.</p>
<div className="p-3 bg-nifty-50 dark:bg-nifty-900/20 rounded-lg">
<div className="font-semibold text-nifty-800 dark:text-nifty-200 mb-1">Current Count:</div>
<div className="text-2xl font-bold text-nifty-600 dark:text-nifty-400">{filteredStats.totalDays} days</div>
</div>
<p className="text-xs text-gray-500">Each day includes analysis for {summaryMode === 'topPicks' ? '3 top picks' : 'all 50 Nifty stocks'}.</p>
</div>
</InfoModal>
<InfoModal isOpen={activeSummaryModal === 'avgReturn'} onClose={() => setActiveSummaryModal(null)} 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 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 after the recommended hold period</li>
<li>Calculate: (Exit - Entry) / Entry x 100</li>
<li>Average all returns across stocks</li>
</ol>
</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>
<div className={`text-xl font-bold ${filteredStats.avgDailyReturn >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{filteredStats.avgDailyReturn >= 0 ? '+' : ''}{filteredStats.avgDailyReturn.toFixed(2)}%
</div>
</div>
</div>
</InfoModal>
<InfoModal isOpen={activeSummaryModal === 'buySignals'} onClose={() => setActiveSummaryModal(null)} title={summaryMode === 'topPicks' ? 'Top Pick Signals' : 'Buy Signals'} 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>{summaryMode === 'topPicks' ? 'Top Pick Signals' : 'Buy Signals'}</strong> counts all {summaryMode === 'topPicks' ? 'Top Picks' : 'BUY recommendations'} across all tracked days.</p>
<div className="p-2 bg-gray-100 dark:bg-slate-700 rounded-lg flex justify-between items-center">
<span>Total:</span>
<strong className="text-green-600 text-lg">{filteredStats.buySignals}</strong>
</div>
</div>
</InfoModal>
<InfoModal isOpen={activeSummaryModal === 'sellSignals'} onClose={() => setActiveSummaryModal(null)} title="Sell Signals" icon={<TrendingDown className="w-5 h-5 text-red-600 dark:text-red-400" />}>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<p><strong>Sell Signals</strong> counts every SELL recommendation issued across all tracked days.</p>
{summaryMode === 'topPicks' ? (
<div className="p-2 bg-amber-50 dark:bg-amber-900/20 rounded-lg text-xs">
<strong>Note:</strong> Top Picks mode only shows BUY recommendations, so sell signals are 0.
</div>
) : (
<div className="p-2 bg-gray-100 dark:bg-slate-700 rounded-lg flex justify-between items-center">
<span>Total:</span>
<strong className="text-red-600 text-lg">{filteredStats.sellSignals}</strong>
</div>
)}
</div>
</InfoModal>
</div>
);
}