diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index 238b74d..d3a8d99 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -1,4 +1,5 @@ import { Router, Request, Response } from "express"; +import type { PriceData } from "../types/index.js"; import { StellarService } from "../services/stellar.js"; import { ReflectorService } from "../services/reflector.js"; import { RebalanceHistoryService } from "../services/rebalanceHistory.js"; @@ -454,6 +455,38 @@ router.get("/prices", async (req, res) => { } }); +// Price freshness status - lightweight check for frontend +router.get("/prices/status", async (req, res) => { + try { + const prices = await reflectorService.getCurrentPrices(); + const entries = Object.entries(prices) as [string, PriceData][]; + const staleCount = entries.filter(([, p]) => p.stale).length; + const totalCount = entries.length; + const anyStale = staleCount > 0; + + const sources = [...new Set(entries.map(([, p]) => p.source))]; + + res.json({ + success: true, + healthy: !anyStale, + stale: anyStale, + staleAssets: staleCount, + totalAssets: totalCount, + sources, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error("[ERROR] Price status check failed:", error); + res.status(500).json({ + success: false, + healthy: false, + stale: true, + error: "Failed to check price status", + timestamp: new Date().toISOString(), + }); + } +}); + // Enhanced prices endpoint with risk analysis router.get("/prices/enhanced", async (req, res) => { try { @@ -1494,4 +1527,13 @@ router.get("/queue/health", async (req, res) => { } }); +// Periodic cleanup of stale price cache entries (every 6 hours) +setInterval(async () => { + try { + await reflectorService.cleanupStaleCache(); + } catch (err) { + console.error("[CACHE-CLEANUP] Failed:", err); + } +}, 6 * 60 * 60 * 1000); + export { router as portfolioRouter }; diff --git a/backend/src/db/migrations/006_add_price_cache.down.sql b/backend/src/db/migrations/006_add_price_cache.down.sql new file mode 100644 index 0000000..dc812ee --- /dev/null +++ b/backend/src/db/migrations/006_add_price_cache.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS price_cache; diff --git a/backend/src/db/migrations/006_add_price_cache.up.sql b/backend/src/db/migrations/006_add_price_cache.up.sql new file mode 100644 index 0000000..c123ff3 --- /dev/null +++ b/backend/src/db/migrations/006_add_price_cache.up.sql @@ -0,0 +1,13 @@ +-- Price cache: stores the last known good price for each asset. +-- Survives backend restarts so ReflectorService can fall back to +-- real prices instead of random noise when the oracle is down. + +CREATE TABLE IF NOT EXISTS price_cache ( + asset VARCHAR(32) PRIMARY KEY, + price NUMERIC(20, 8) NOT NULL, + source VARCHAR(32) NOT NULL, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index on fetched_at so we can quickly find stale entries +CREATE INDEX IF NOT EXISTS idx_price_cache_fetched ON price_cache(fetched_at DESC); diff --git a/backend/src/db/priceCacheDb.ts b/backend/src/db/priceCacheDb.ts new file mode 100644 index 0000000..98e2aaf --- /dev/null +++ b/backend/src/db/priceCacheDb.ts @@ -0,0 +1,61 @@ +import { query } from './client.js' + +export interface CachedPrice { + asset: string + price: number + source: string + fetched_at: Date +} + +/** + * Upserts a price into the cache. If the asset already exists, + * updates the price and fetched_at timestamp. + */ +export async function upsertPrice( + asset: string, + price: number, + source: string +): Promise { + await query( + `INSERT INTO price_cache (asset, price, source, fetched_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (asset) DO UPDATE + SET price = EXCLUDED.price, + source = EXCLUDED.source, + fetched_at = EXCLUDED.fetched_at`, + [asset, price, source] + ) +} + +/** + * Gets a cached price for an asset. Returns null if not found. + */ +export async function getCachedPrice(asset: string): Promise { + const result = await query( + 'SELECT asset, price, source, fetched_at FROM price_cache WHERE asset = $1', + [asset] + ) + return result.rows[0] ?? null +} + +/** + * Gets all cached prices. Useful for bulk lookups when the oracle is down. + */ +export async function getAllCachedPrices(): Promise { + const result = await query( + 'SELECT asset, price, source, fetched_at FROM price_cache' + ) + return result.rows +} + +/** + * Deletes cached prices older than the given age in milliseconds. + * Can be used for cleanup if needed. + */ +export async function deleteStalePrices(maxAgeMs: number): Promise { + const result = await query( + 'DELETE FROM price_cache WHERE fetched_at < NOW() - INTERVAL \'1 millisecond\' * $1', + [maxAgeMs] + ) + return result.rowCount ?? 0 +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 4f3d071..1196207 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -300,6 +300,7 @@ server.listen(port, async () => { console.error('[CHAIN-INDEXER] Failed to start:', error) } + console.log('Available endpoints:') console.log(` Health: http://localhost:${port}/health`) console.log(` CORS Test: http://localhost:${port}/test/cors`) diff --git a/backend/src/services/rebalancing.ts b/backend/src/services/rebalancing.ts index ce7684c..8b49fc6 100644 --- a/backend/src/services/rebalancing.ts +++ b/backend/src/services/rebalancing.ts @@ -4,7 +4,7 @@ import { StellarService } from '../services/stellar.js' import { ReflectorService } from '../services/reflector.js' import { portfolioStorage } from '../services/portfolioStorage.js' import { logger } from '../utils/logger.js' -import type { Portfolio } from '../types/index.js' +import type { Portfolio, PricesMap } from '../types/index.js' export class RebalancingService { private stellarService: StellarService @@ -27,6 +27,13 @@ export class RebalancingService { private async checkAllPortfolios() { try { + // Check if prices are stale before doing any rebalancing + const prices = await this.reflectorService.getCurrentPrices() + if (this.hasStalePrices(prices)) { + logger.warn('[Rebalancing] Skipping rebalance check — stale prices detected') + return + } + const portfolios = await this.getActivePortfolios() for (const portfolio of portfolios) { @@ -48,6 +55,14 @@ export class RebalancingService { } } + /** + * Returns true if any price in the map is marked as stale. + * We don't want to trigger rebalancing on bad data. + */ + private hasStalePrices(prices: PricesMap): boolean { + return Object.values(prices).some(p => p.stale === true) + } + private async checkRebalanceLogic(portfolioId: string): Promise { try { return await this.stellarService.checkRebalanceNeeded(portfolioId) diff --git a/backend/src/services/reflector.ts b/backend/src/services/reflector.ts index 8c76a9a..556a1eb 100644 --- a/backend/src/services/reflector.ts +++ b/backend/src/services/reflector.ts @@ -10,10 +10,14 @@ import { import type { PricesMap, PriceData } from '../types/index.js' import { getFeatureFlags } from '../config/featureFlags.js' import { logger } from '../utils/logger.js' +import { upsertPrice, getAllCachedPrices, deleteStalePrices } from '../db/priceCacheDb.js' // Reflector oracle prices are scaled by 10^7 const REFLECTOR_PRICE_SCALE = 1e7 +// Prices older than this are considered stale (1 hour) +const STALE_THRESHOLD_MS = 60 * 60 * 1000 + // Dummy source account used only for Soroban simulation (no funds needed) const SIMULATION_SOURCE_ACCOUNT = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN' @@ -75,7 +79,7 @@ export class ReflectorService { return cachedPrices } if (getFeatureFlags().allowFallbackPrices) { - return this.getFallbackPrices() + return await this.getFallbackPrices() } throw new Error('Price request rate-limited and ALLOW_FALLBACK_PRICES is disabled') } @@ -100,7 +104,7 @@ export class ReflectorService { throw new Error('Price sources unavailable and ALLOW_FALLBACK_PRICES is disabled') } - return this.getFallbackPrices() + return await this.getFallbackPrices() } } @@ -204,6 +208,7 @@ export class ReflectorService { // Cache and return Reflector prices directly for (const [asset, data] of Object.entries(reflectorPrices)) { this.priceCache.set(asset, { data, timestamp: Date.now() }) + void this.persistPriceToDb(asset, data.price, data.source ?? 'reflector') } return reflectorPrices } @@ -307,6 +312,7 @@ export class ReflectorService { data: priceData, timestamp: Date.now() }) + void this.persistPriceToDb(asset, priceData.price, priceData.source ?? 'coingecko') console.log(`[SUCCESS] Fresh ${asset} price: $${priceData.price} (${priceData.change > 0 ? '+' : ''}${priceData.change.toFixed(2)}%)`) } else { @@ -317,6 +323,7 @@ export class ReflectorService { // Cache Reflector prices alongside CoinGecko prices for (const [asset, data] of Object.entries(reflectorPrices)) { this.priceCache.set(asset, { data, timestamp: Date.now() }) + void this.persistPriceToDb(asset, data.price, data.source ?? 'reflector') } const merged = { ...reflectorPrices, ...coinGeckoPrices } @@ -475,42 +482,46 @@ export class ReflectorService { return history } - private getFallbackPrices(): PricesMap { - console.warn('[FALLBACK] Using fallback prices - all sources failed') + private async getFallbackPrices(): Promise { + // NOTE: callers in getCurrentPrices() already check ALLOW_FALLBACK_PRICES + // and throw before reaching this method when the flag is off. If that + // guard is ever removed, this method should check the flag itself. + logger.warn('[FALLBACK] All price sources failed, checking database cache') - // Add some randomness to make fallback prices look more realistic - const addVariation = (basePrice: number) => { - const variation = (Math.random() - 0.5) * 0.02 // ±1% variation - return basePrice * (1 + variation) + try { + const cachedRows = await getAllCachedPrices() + if (cachedRows.length > 0) { + const fallback: PricesMap = {} + for (const row of cachedRows) { + const stale = this.isStale(row.fetched_at) + fallback[row.asset] = { + price: row.price, + change: 0, + timestamp: Math.floor(row.fetched_at.getTime() / 1000), + source: 'cached', + stale, + } + if (stale) { + logger.warn(`[FALLBACK] ${row.asset} price is stale (fetched ${row.fetched_at.toISOString()})`) + } else { + logger.info(`[FALLBACK] Using cached ${row.asset} price: $${row.price}`) + } + } + return fallback + } + } catch (err) { + logger.warn('[FALLBACK] Failed to read cached prices from DB:', err) } + // Last resort: hardcoded prices with no variation + logger.warn('[FALLBACK] No cached prices in DB, using hardcoded defaults') const now = Math.floor(Date.now() / 1000) return { - XLM: { - price: addVariation(0.354), - change: (Math.random() - 0.5) * 4, // Random change ±2% - timestamp: now, - source: 'fallback' - }, - USDC: { - price: addVariation(1.0), - change: (Math.random() - 0.5) * 0.1, // Minimal change for stablecoin - timestamp: now, - source: 'fallback' - }, - BTC: { - price: addVariation(110000), - change: (Math.random() - 0.5) * 6, // Random change ±3% - timestamp: now, - source: 'fallback' - }, - ETH: { - price: addVariation(4200), - change: (Math.random() - 0.5) * 5, // Random change ±2.5% - timestamp: now, - source: 'fallback' - } + XLM: { price: 0.354, change: 0, timestamp: now, source: 'fallback', stale: true }, + USDC: { price: 1.0, change: 0, timestamp: now, source: 'fallback', stale: true }, + BTC: { price: 110000, change: 0, timestamp: now, source: 'fallback', stale: true }, + ETH: { price: 4200, change: 0, timestamp: now, source: 'fallback', stale: true }, } } @@ -566,6 +577,43 @@ export class ReflectorService { console.log('[DEBUG] Price cache cleared') } + /** + * Persists a price to the database so it survives restarts. + * Called after successful fetches from Reflector or CoinGecko. + */ + private async persistPriceToDb(asset: string, price: number, source: string): Promise { + try { + await upsertPrice(asset, price, source) + } catch (err) { + logger.warn(`[Reflector] Failed to persist price for ${asset} to DB:`, err) + } + } + + /** + * Checks if a cached price is stale (older than STALE_THRESHOLD_MS). + */ + private isStale(fetchedAt: Date): boolean { + return Date.now() - fetchedAt.getTime() > STALE_THRESHOLD_MS + } + + /** + * Removes cached prices older than 24 hours from the database. + * Called periodically to prevent unbounded table growth. + */ + async cleanupStaleCache(): Promise { + const MAX_CACHE_AGE_MS = 24 * 60 * 60 * 1000 // 24 hours + try { + const deleted = await deleteStalePrices(MAX_CACHE_AGE_MS) + if (deleted > 0) { + logger.info(`[Reflector] Cleaned up ${deleted} stale price cache entries`) + } + return deleted + } catch (err) { + logger.warn('[Reflector] Failed to cleanup stale cache:', err) + return 0 + } + } + getCacheStatus(): Record { const status: Record = {} this.priceCache.forEach((value, key) => { diff --git a/backend/src/services/riskManagements.ts b/backend/src/services/riskManagements.ts index 6ef80fb..1d36a93 100644 --- a/backend/src/services/riskManagements.ts +++ b/backend/src/services/riskManagements.ts @@ -76,6 +76,13 @@ export class RiskManagementService { } updatePriceData(prices: PricesMap): RiskAlert[] { + // Don't update risk models with stale data — it would corrupt + // volatility, VaR, and correlation calculations + const hasStale = Object.values(prices).some(p => p.stale === true) + if (hasStale) { + return [] + } + const alerts: RiskAlert[] = [] const timestamp = Date.now() diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 5ec0d5a..34b6318 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -3,8 +3,9 @@ export interface PriceData { price: number change: number timestamp: number - source?: 'reflector' | 'coingecko_pro' | 'coingecko_free' | 'external' | 'fallback' + source?: 'reflector' | 'coingecko_pro' | 'coingecko_free' | 'external' | 'fallback' | 'cached' volume?: number + stale?: boolean } // Price map type - using type alias as expected by risk management service @@ -40,23 +41,23 @@ export class ConflictError extends Error { } // Rebalance event interface -export interface RebalanceEvent { - id: string - portfolioId: string - timestamp: string +export interface RebalanceEvent { + id: string + portfolioId: string + timestamp: string trigger: string trades: number - gasUsed: string - status: 'completed' | 'failed' | 'pending' - eventSource?: 'offchain' | 'simulated' | 'onchain' - onChainConfirmed?: boolean - onChainEventType?: string - onChainTxHash?: string - onChainLedger?: number - onChainContractId?: string - onChainPagingToken?: string - isSimulated?: boolean - details?: { + gasUsed: string + status: 'completed' | 'failed' | 'pending' + eventSource?: 'offchain' | 'simulated' | 'onchain' + onChainConfirmed?: boolean + onChainEventType?: string + onChainTxHash?: string + onChainLedger?: number + onChainContractId?: string + onChainPagingToken?: string + isSimulated?: boolean + details?: { fromAsset?: string toAsset?: string amount?: number @@ -71,20 +72,20 @@ export interface RebalanceEvent { } // Risk management interfaces -export interface RiskMetrics { - volatility: number - concentrationRisk: number - liquidityRisk: number - correlationRisk: number - overallRiskLevel: 'low' | 'medium' | 'high' | 'critical' - ewmaVolatility: number - var95: number - cvar95: number - maxDrawdown: number - drawdownBand: 'normal' | 'elevated' | 'critical' - correlations: Record> - sampleSize: number -} +export interface RiskMetrics { + volatility: number + concentrationRisk: number + liquidityRisk: number + correlationRisk: number + overallRiskLevel: 'low' | 'medium' | 'high' | 'critical' + ewmaVolatility: number + var95: number + cvar95: number + maxDrawdown: number + drawdownBand: 'normal' | 'elevated' | 'critical' + correlations: Record> + sampleSize: number +} export interface RiskAlert { type: 'volatility' | 'concentration' | 'liquidity' | 'correlation' | 'circuit_breaker' @@ -174,15 +175,15 @@ export interface SystemStatus { enabled: boolean alertsActive: boolean } - services: { - priceFeeds: boolean - riskManagement: boolean - webSockets: boolean - autoRebalancing: boolean - stellarNetwork: boolean - } - featureFlags?: Record -} + services: { + priceFeeds: boolean + riskManagement: boolean + webSockets: boolean + autoRebalancing: boolean + stellarNetwork: boolean + } + featureFlags?: Record +} // Additional utility types export type AssetCode = 'XLM' | 'BTC' | 'ETH' | 'USDC' @@ -194,46 +195,46 @@ export interface RebalanceRequest { threshold: number } -export interface RebalanceResult { - trades: number - plannedTrades?: number - gasUsed: string - timestamp: string - status: 'success' | 'partial' | 'failed' - newBalances: Record - riskAlerts?: RiskAlert[] - eventId?: string - executedTrades?: RebalanceExecutionTrade[] - partialFills?: RebalanceExecutionTrade[] - failedTrades?: RebalanceExecutionTrade[] - failureReasons?: string[] - rollback?: RebalanceRollback - totalSlippageBps?: number -} - -export interface RebalanceExecutionTrade { - tradeId: string - fromAsset: string - toAsset: string - requestedAmount: number - executedAmount: number - estimatedReceivedAmount: number - remainingAmount: number - referencePrice: number - priceLimit: number - spreadBps: number - slippageBps: number - liquidityCoverage: number - status: 'executed' | 'partial' | 'failed' | 'skipped' - txHash?: string - rollbackTxHash?: string - rolledBack?: boolean - failureReason?: string -} - -export interface RebalanceRollback { - attempted: boolean - success: boolean - rolledBackTrades: number - failures: string[] -} +export interface RebalanceResult { + trades: number + plannedTrades?: number + gasUsed: string + timestamp: string + status: 'success' | 'partial' | 'failed' + newBalances: Record + riskAlerts?: RiskAlert[] + eventId?: string + executedTrades?: RebalanceExecutionTrade[] + partialFills?: RebalanceExecutionTrade[] + failedTrades?: RebalanceExecutionTrade[] + failureReasons?: string[] + rollback?: RebalanceRollback + totalSlippageBps?: number +} + +export interface RebalanceExecutionTrade { + tradeId: string + fromAsset: string + toAsset: string + requestedAmount: number + executedAmount: number + estimatedReceivedAmount: number + remainingAmount: number + referencePrice: number + priceLimit: number + spreadBps: number + slippageBps: number + liquidityCoverage: number + status: 'executed' | 'partial' | 'failed' | 'skipped' + txHash?: string + rollbackTxHash?: string + rolledBack?: boolean + failureReason?: string +} + +export interface RebalanceRollback { + attempted: boolean + success: boolean + rolledBackTrades: number + failures: string[] +} diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index dbb9e54..2e61fd7 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, AlertCircle, RefreshCw, ArrowLeft, ExternalLink, AlertTriangle } from 'lucide-react' import ThemeToggle from './ThemeToggle' import { useTheme } from '../context/ThemeContext' import AssetCard from './AssetCard' @@ -27,6 +27,7 @@ const Dashboard: React.FC = ({ onNavigate, publicKey }) => { const [loading, setLoading] = useState(true) const [rebalancing, setRebalancing] = useState(false) const [priceSource, setPriceSource] = useState('loading...') + const [pricesStale, setPricesStale] = useState(false) const [activeTab, setActiveTab] = useState<'overview' | 'analytics' | 'notifications' | 'test-notifications'>('overview') const { isDark } = useTheme() @@ -91,14 +92,19 @@ const Dashboard: React.FC = ({ onNavigate, publicKey }) => { // Transform to expected format if needed const transformedPrices: any = {} + let anyStale = false Object.entries(priceData).forEach(([asset, data]) => { + const d = data as any transformedPrices[asset] = { - price: (data as any).price, - change: (data as any).change || 0 + price: d.price, + change: d.change || 0, + stale: d.stale || false } + if (d.stale) anyStale = true }) setPrices(transformedPrices) + setPricesStale(anyStale) setPriceSource('CoinGecko Browser API') } catch (error) { console.error('Failed to fetch prices from browser service:', error) @@ -381,6 +387,25 @@ const Dashboard: React.FC = ({ onNavigate, publicKey }) => { + {/* Stale Data Warning */} + {pricesStale && ( + + +
+

+ Using cached price data +

+

+ Live price feeds are temporarily unavailable. Portfolio values and rebalancing reflect the last known prices. +

+
+
+ )} + {/* Debug Info */} {(import.meta as any).env?.DEV && (
@@ -480,7 +505,7 @@ const Dashboard: React.FC = ({ onNavigate, publicKey }) => {