diff --git a/backend/src/db/analyticsDb.ts b/backend/src/db/analyticsDb.ts index d3ae569..140cbaf 100644 --- a/backend/src/db/analyticsDb.ts +++ b/backend/src/db/analyticsDb.ts @@ -9,6 +9,14 @@ export interface AnalyticsSnapshotRow { balances: unknown } +export interface AnalyticsSnapshot { + portfolioId: string + timestamp: string + totalValue: number + allocations: Record + balances: Record +} + export async function dbInsertAnalyticsSnapshot( portfolioId: string, totalValue: number, @@ -21,7 +29,7 @@ export async function dbInsertAnalyticsSnapshot( ) } -export async function dbGetAnalyticsSnapshots(portfolioId: string, days: number) { +export async function dbGetAnalyticsSnapshots(portfolioId: string, days: number): Promise { const result = await query( `SELECT * FROM analytics_snapshots WHERE portfolio_id = $1 AND timestamp > NOW() - INTERVAL '1 day' * $2 ORDER BY timestamp ASC`, [portfolioId, days] @@ -34,3 +42,55 @@ export async function dbGetAnalyticsSnapshots(portfolioId: string, days: number) balances: (r.balances as Record) ?? {} })) } + +export async function dbGetLatestSnapshot(portfolioId: string): Promise { + const result = await query( + `SELECT * FROM analytics_snapshots WHERE portfolio_id = $1 ORDER BY timestamp DESC LIMIT 1`, + [portfolioId] + ) + if (result.rows.length === 0) return null + const r = result.rows[0] + return { + portfolioId: r.portfolio_id, + timestamp: r.timestamp.toISOString(), + totalValue: Number(r.total_value), + allocations: (r.allocations as Record) ?? {}, + balances: (r.balances as Record) ?? {} + } +} + +export async function dbGetClosestSnapshot( + portfolioId: string, + targetDate: Date, + maxDiffMs: number = 2 * 60 * 60 * 1000 +): Promise { + const result = await query( + `SELECT *, ABS(EXTRACT(EPOCH FROM (timestamp - $2::timestamp))) AS diff_sec + FROM analytics_snapshots + WHERE portfolio_id = $1 + AND ABS(EXTRACT(EPOCH FROM (timestamp - $2::timestamp))) <= $3 + ORDER BY diff_sec ASC + LIMIT 1`, + [portfolioId, targetDate.toISOString(), maxDiffMs / 1000] + ) + if (result.rows.length === 0) return null + const r = result.rows[0] + return { + portfolioId: r.portfolio_id, + timestamp: r.timestamp.toISOString(), + totalValue: Number(r.total_value), + allocations: (r.allocations as Record) ?? {}, + balances: (r.balances as Record) ?? {} + } +} + +export async function dbGetSnapshotAt( + portfolioId: string, + targetDate: Date +): Promise { + return dbGetClosestSnapshot(portfolioId, targetDate) +} + +export const getLatestSnapshot = dbGetLatestSnapshot +export const getClosestSnapshot = dbGetClosestSnapshot +export const getSnapshotAt = dbGetSnapshotAt diff --git a/backend/src/services/analyticsService.ts b/backend/src/services/analyticsService.ts index 92b0b44..9984ebc 100644 --- a/backend/src/services/analyticsService.ts +++ b/backend/src/services/analyticsService.ts @@ -1,6 +1,12 @@ import { portfolioStorage } from './portfolioStorage.js' import { ReflectorService } from './reflector.js' import { logger } from '../utils/logger.js' +import { + dbInsertAnalyticsSnapshot, + dbGetLatestSnapshot, + dbGetClosestSnapshot, + dbGetAnalyticsSnapshots +} from '../db/analyticsDb.js' interface PortfolioSnapshot { portfolioId: string @@ -12,8 +18,8 @@ interface PortfolioSnapshot { interface PerformanceMetrics { totalReturn: number - dailyChange: number - weeklyChange: number + dailyChange: number | null + weeklyChange: number | null maxDrawdown: number bestDay: { date: string; change: number } worstDay: { date: string; change: number } @@ -26,16 +32,13 @@ class AnalyticsService { private lastSnapshotTimes: Map = new Map() private readonly MIN_SNAPSHOT_INTERVAL_MS = 5 * 60 * 1000 - // NOTE: No setInterval here. Periodic snapshots are driven by - // the BullMQ analytics-snapshot worker (src/queue/workers/analyticsSnapshotWorker.ts). - /** * Capture snapshots for every portfolio. * Called by the BullMQ analytics-snapshot worker. */ async captureAllPortfolios() { try { - const portfolios = portfolioStorage.getAllPortfolios() + const portfolios = await portfolioStorage.getAllPortfolios() const reflector = new ReflectorService() const prices = await reflector.getCurrentPrices() @@ -49,7 +52,7 @@ class AnalyticsService { async captureSnapshot(portfolioId: string, prices?: Record) { try { - const portfolio = portfolioStorage.getPortfolio(portfolioId) + const portfolio = await portfolioStorage.getPortfolio(portfolioId) if (!portfolio) return const now = Date.now() @@ -97,6 +100,17 @@ class AnalyticsService { snapshotsForPortfolio.shift() } + try { + await dbInsertAnalyticsSnapshot( + portfolioId, + totalValue, + allocations, + portfolio.balances + ) + } catch { + // Non-fatal if SQL DB is not active + } + logger.info('Portfolio snapshot captured', { portfolioId, totalValue }) } catch (error) { logger.error('Failed to capture snapshot', { portfolioId, error }) @@ -114,14 +128,108 @@ class AnalyticsService { }) } + getClosestSnapshotInList( + snapshots: PortfolioSnapshot[], + targetDate: Date, + maxDiffMs: number = 2 * 60 * 60 * 1000 + ): PortfolioSnapshot | null { + if (snapshots.length === 0) return null + + let closest: PortfolioSnapshot | null = null + let minDiff = Infinity + + for (const s of snapshots) { + const time = new Date(s.timestamp).getTime() + const diff = Math.abs(time - targetDate.getTime()) + if (diff <= maxDiffMs && diff < minDiff) { + minDiff = diff + closest = s + } + } + + return closest + } + + async calculateDayChange(portfolioId: string): Promise { + return this.calculatePeriodChange(portfolioId, 24 * 60 * 60 * 1000, 2 * 60 * 60 * 1000) + } + + async calculateWeekChange(portfolioId: string): Promise { + return this.calculatePeriodChange(portfolioId, 7 * 24 * 60 * 60 * 1000, 12 * 60 * 60 * 1000) + } + + async calculateMonthChange(portfolioId: string): Promise { + return this.calculatePeriodChange(portfolioId, 30 * 24 * 60 * 60 * 1000, 24 * 60 * 60 * 1000) + } + + async calculateAllTimeChange(portfolioId: string): Promise { + let firstSnap: PortfolioSnapshot | null = null + let latestSnap: PortfolioSnapshot | null = null + + try { + const dbSnaps = await dbGetAnalyticsSnapshots(portfolioId, 365) + if (dbSnaps.length > 0) { + firstSnap = dbSnaps[0] + latestSnap = dbSnaps[dbSnaps.length - 1] + } + } catch { + // DB unavailable + } + + const inMem = this.snapshots.get(portfolioId) || [] + if (!latestSnap && inMem.length > 0) { + latestSnap = inMem[inMem.length - 1] + } + if (!firstSnap && inMem.length > 0) { + firstSnap = inMem[0] + } + + if (!latestSnap || !firstSnap || firstSnap === latestSnap || firstSnap.totalValue === 0) { + return null + } + + return ((latestSnap.totalValue - firstSnap.totalValue) / firstSnap.totalValue) * 100 + } + + async calculatePeriodChange( + portfolioId: string, + periodMs: number, + maxDiffMs: number = 2 * 60 * 60 * 1000 + ): Promise { + const now = new Date() + const targetDate = new Date(now.getTime() - periodMs) + + let latest: PortfolioSnapshot | null = null + let previous: PortfolioSnapshot | null = null + + try { + latest = await dbGetLatestSnapshot(portfolioId) + previous = await dbGetClosestSnapshot(portfolioId, targetDate, maxDiffMs) + } catch { + // DB unavailable + } + + const inMemSnapshots = this.snapshots.get(portfolioId) || [] + if (!latest && inMemSnapshots.length > 0) { + latest = inMemSnapshots[inMemSnapshots.length - 1] + } + if (!previous && inMemSnapshots.length > 0) { + previous = this.getClosestSnapshotInList(inMemSnapshots, targetDate, maxDiffMs) + } + + if (!latest || !previous || previous.totalValue === 0) return null + + return ((latest.totalValue - previous.totalValue) / previous.totalValue) * 100 + } + calculatePerformanceMetrics(portfolioId: string): PerformanceMetrics { const snapshots = this.getAnalytics(portfolioId, 90) if (snapshots.length < 2) { return { totalReturn: 0, - dailyChange: 0, - weeklyChange: 0, + dailyChange: null, + weeklyChange: null, maxDrawdown: 0, bestDay: { date: '', change: 0 }, worstDay: { date: '', change: 0 }, @@ -149,11 +257,19 @@ class AnalyticsService { dailyChangeData.push({ date: sortedSnapshots[i].timestamp, change }) } - const dailyChange = dailyChanges.length > 0 ? dailyChanges[dailyChanges.length - 1] : 0 + const latestTimestamp = new Date(sortedSnapshots[sortedSnapshots.length - 1].timestamp).getTime() + const oneDayAgo = new Date(latestTimestamp - 24 * 60 * 60 * 1000) + const sevenDaysAgo = new Date(latestTimestamp - 7 * 24 * 60 * 60 * 1000) + + const closestDaySnap = this.getClosestSnapshotInList(sortedSnapshots, oneDayAgo, 2 * 60 * 60 * 1000) + const dailyChange = closestDaySnap && closestDaySnap.totalValue > 0 + ? ((finalValue - closestDaySnap.totalValue) / closestDaySnap.totalValue) * 100 + : null - const weekAgoIndex = Math.max(0, sortedSnapshots.length - 7) - const weekAgoValue = sortedSnapshots[weekAgoIndex].totalValue - const weeklyChange = weekAgoValue > 0 ? ((finalValue - weekAgoValue) / weekAgoValue) * 100 : 0 + const closestWeekSnap = this.getClosestSnapshotInList(sortedSnapshots, sevenDaysAgo, 12 * 60 * 60 * 1000) + const weeklyChange = closestWeekSnap && closestWeekSnap.totalValue > 0 + ? ((finalValue - closestWeekSnap.totalValue) / closestWeekSnap.totalValue) * 100 + : null let maxDrawdown = 0 let peak = initialValue diff --git a/backend/src/test/analyticsService.test.ts b/backend/src/test/analyticsService.test.ts new file mode 100644 index 0000000..78c17e6 --- /dev/null +++ b/backend/src/test/analyticsService.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { analyticsService } from '../services/analyticsService.js' + +describe('AnalyticsService', () => { + const portfolioId = 'test-portfolio-1' + + beforeEach(() => { + (analyticsService as any).snapshots.clear() + (analyticsService as any).lastSnapshotTimes.clear() + }) + + it('returns null for calculateDayChange when no snapshot exists in 2h window', async () => { + const change = await analyticsService.calculateDayChange(portfolioId) + expect(change).toBeNull() + }) + + it('calculates real day change when snapshot exists within 2h window', async () => { + const now = Date.now() + const oneDayAgo = now - 24 * 60 * 60 * 1000 + + const snapshots = [ + { + portfolioId, + timestamp: new Date(oneDayAgo).toISOString(), + totalValue: 1000, + allocations: { XLM: 100 }, + balances: { XLM: 1000 }, + }, + { + portfolioId, + timestamp: new Date(now).toISOString(), + totalValue: 1100, + allocations: { XLM: 100 }, + balances: { XLM: 1000 }, + } + ] + + ;(analyticsService as any).snapshots.set(portfolioId, snapshots) + + const change = await analyticsService.calculateDayChange(portfolioId) + expect(change).not.toBeNull() + expect(change).toBeCloseTo(10) + }) + + it('returns null if snapshot is outside 2h window', async () => { + const now = Date.now() + const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000 + + const snapshots = [ + { + portfolioId, + timestamp: new Date(threeDaysAgo).toISOString(), + totalValue: 1000, + allocations: { XLM: 100 }, + balances: { XLM: 1000 }, + }, + { + portfolioId, + timestamp: new Date(now).toISOString(), + totalValue: 1100, + allocations: { XLM: 100 }, + balances: { XLM: 1000 }, + } + ] + + ;(analyticsService as any).snapshots.set(portfolioId, snapshots) + + const dayChange = await analyticsService.calculateDayChange(portfolioId) + expect(dayChange).toBeNull() + }) +}) diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index dbb9e54..0489f48 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react' import { motion } from 'framer-motion' import { PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' -import { TrendingUp, AlertCircle, RefreshCw, ArrowLeft, ExternalLink } from 'lucide-react' +import { TrendingUp, TrendingDown, AlertCircle, RefreshCw, ArrowLeft, ExternalLink } from 'lucide-react' import ThemeToggle from './ThemeToggle' import { useTheme } from '../context/ThemeContext' import AssetCard from './AssetCard' @@ -436,8 +436,20 @@ const Dashboard: React.FC = ({ onNavigate, publicKey }) => { ${portfolioData?.totalValue?.toLocaleString() || '0'}
- - +{portfolioData?.dayChange || 0}% + {portfolioData?.dayChange !== null && portfolioData?.dayChange !== undefined ? ( + <> + {portfolioData.dayChange >= 0 ? ( + + ) : ( + + )} + = 0 ? 'text-green-500 font-medium' : 'text-red-500 font-medium'}> + {portfolioData.dayChange >= 0 ? '+' : ''}{portfolioData.dayChange.toFixed(2)}% + + + ) : ( + + )} Today
@@ -587,4 +599,4 @@ const Dashboard: React.FC = ({ onNavigate, publicKey }) => { ) } -export default Dashboard \ No newline at end of file +export default Dashboard diff --git a/frontend/src/components/PerformanceChart.tsx b/frontend/src/components/PerformanceChart.tsx index 7b9c281..f0876aa 100644 --- a/frontend/src/components/PerformanceChart.tsx +++ b/frontend/src/components/PerformanceChart.tsx @@ -19,8 +19,8 @@ interface AnalyticsData { interface PerformanceSummary { metrics: { totalReturn: number - dailyChange: number - weeklyChange: number + dailyChange: number | null + weeklyChange: number | null maxDrawdown: number bestDay: { date: string; change: number } worstDay: { date: string; change: number } @@ -102,7 +102,8 @@ const PerformanceChart: React.FC = ({ portfolioId }) => { }).format(value) } - const formatPercentage = (value: number) => { + const formatPercentage = (value: number | null | undefined) => { + if (value === null || value === undefined || isNaN(value)) return '—' return `${value >= 0 ? '+' : ''}${value.toFixed(2)}%` } @@ -238,13 +239,15 @@ const PerformanceChart: React.FC = ({ portfolioId }) => {
Total Return - {metrics.totalReturn >= 0 ? ( - - ) : ( - + {metrics.totalReturn !== null && metrics.totalReturn !== undefined && ( + metrics.totalReturn >= 0 ? ( + + ) : ( + + ) )}
-
= 0 ? 'text-green-600' : 'text-red-600'}`}> +
= 0 ? 'text-green-600' : 'text-red-600'}`}> {formatPercentage(metrics.totalReturn)}
@@ -252,13 +255,15 @@ const PerformanceChart: React.FC = ({ portfolioId }) => {
Daily Change - {metrics.dailyChange >= 0 ? ( - - ) : ( - + {metrics.dailyChange !== null && metrics.dailyChange !== undefined && ( + metrics.dailyChange >= 0 ? ( + + ) : ( + + ) )}
-
= 0 ? 'text-green-600' : 'text-red-600'}`}> +
= 0 ? 'text-green-600' : 'text-red-600'}`}> {formatPercentage(metrics.dailyChange)}
@@ -266,13 +271,15 @@ const PerformanceChart: React.FC = ({ portfolioId }) => {
Weekly Change - {metrics.weeklyChange >= 0 ? ( - - ) : ( - + {metrics.weeklyChange !== null && metrics.weeklyChange !== undefined && ( + metrics.weeklyChange >= 0 ? ( + + ) : ( + + ) )}
-
= 0 ? 'text-green-600' : 'text-red-600'}`}> +
= 0 ? 'text-green-600' : 'text-red-600'}`}> {formatPercentage(metrics.weeklyChange)}
@@ -340,4 +347,4 @@ const PerformanceChart: React.FC = ({ portfolioId }) => { ) } -export default PerformanceChart \ No newline at end of file +export default PerformanceChart