Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion backend/src/db/analyticsDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ export interface AnalyticsSnapshotRow {
balances: unknown
}

export interface AnalyticsSnapshot {
portfolioId: string
timestamp: string
totalValue: number
allocations: Record<string, number>
balances: Record<string, number>
}

export async function dbInsertAnalyticsSnapshot(
portfolioId: string,
totalValue: number,
Expand All @@ -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<AnalyticsSnapshot[]> {
const result = await query<AnalyticsSnapshotRow>(
`SELECT * FROM analytics_snapshots WHERE portfolio_id = $1 AND timestamp > NOW() - INTERVAL '1 day' * $2 ORDER BY timestamp ASC`,
[portfolioId, days]
Expand All @@ -34,3 +42,55 @@ export async function dbGetAnalyticsSnapshots(portfolioId: string, days: number)
balances: (r.balances as Record<string, number>) ?? {}
}))
}

export async function dbGetLatestSnapshot(portfolioId: string): Promise<AnalyticsSnapshot | null> {
const result = await query<AnalyticsSnapshotRow>(
`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<string, number>) ?? {},
balances: (r.balances as Record<string, number>) ?? {}
}
}

export async function dbGetClosestSnapshot(
portfolioId: string,
targetDate: Date,
maxDiffMs: number = 2 * 60 * 60 * 1000
): Promise<AnalyticsSnapshot | null> {
const result = await query<AnalyticsSnapshotRow & { diff_sec: number }>(
`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<string, number>) ?? {},
balances: (r.balances as Record<string, number>) ?? {}
}
}

export async function dbGetSnapshotAt(
portfolioId: string,
targetDate: Date
): Promise<AnalyticsSnapshot | null> {
return dbGetClosestSnapshot(portfolioId, targetDate)
}

export const getLatestSnapshot = dbGetLatestSnapshot
export const getClosestSnapshot = dbGetClosestSnapshot
export const getSnapshotAt = dbGetSnapshotAt
142 changes: 129 additions & 13 deletions backend/src/services/analyticsService.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 }
Expand All @@ -26,16 +32,13 @@ class AnalyticsService {
private lastSnapshotTimes: Map<string, number> = 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()

Expand All @@ -49,7 +52,7 @@ class AnalyticsService {

async captureSnapshot(portfolioId: string, prices?: Record<string, any>) {
try {
const portfolio = portfolioStorage.getPortfolio(portfolioId)
const portfolio = await portfolioStorage.getPortfolio(portfolioId)
if (!portfolio) return

const now = Date.now()
Expand Down Expand Up @@ -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 })
Expand All @@ -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<number | null> {
return this.calculatePeriodChange(portfolioId, 24 * 60 * 60 * 1000, 2 * 60 * 60 * 1000)
}

async calculateWeekChange(portfolioId: string): Promise<number | null> {
return this.calculatePeriodChange(portfolioId, 7 * 24 * 60 * 60 * 1000, 12 * 60 * 60 * 1000)
}

async calculateMonthChange(portfolioId: string): Promise<number | null> {
return this.calculatePeriodChange(portfolioId, 30 * 24 * 60 * 60 * 1000, 24 * 60 * 60 * 1000)
}

async calculateAllTimeChange(portfolioId: string): Promise<number | null> {
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<number | null> {
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 },
Expand Down Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions backend/src/test/analyticsService.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
20 changes: 16 additions & 4 deletions frontend/src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -436,8 +436,20 @@ const Dashboard: React.FC<DashboardProps> = ({ onNavigate, publicKey }) => {
${portfolioData?.totalValue?.toLocaleString() || '0'}
</div>
<div className="flex items-center mt-1">
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-500 font-medium">+{portfolioData?.dayChange || 0}%</span>
{portfolioData?.dayChange !== null && portfolioData?.dayChange !== undefined ? (
<>
{portfolioData.dayChange >= 0 ? (
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
) : (
<TrendingDown className="w-4 h-4 text-red-500 mr-1" />
)}
<span className={portfolioData.dayChange >= 0 ? 'text-green-500 font-medium' : 'text-red-500 font-medium'}>
{portfolioData.dayChange >= 0 ? '+' : ''}{portfolioData.dayChange.toFixed(2)}%
</span>
</>
) : (
<span className="text-gray-500 dark:text-gray-400 font-medium">—</span>
)}
<span className="text-gray-500 dark:text-gray-400 ml-2">Today</span>
</div>
</div>
Expand Down Expand Up @@ -587,4 +599,4 @@ const Dashboard: React.FC<DashboardProps> = ({ onNavigate, publicKey }) => {
)
}

export default Dashboard
export default Dashboard
Loading