From 34548bf40e00b3b83be703918721093adc86d8f6 Mon Sep 17 00:00:00 2001 From: brightpixel Date: Sat, 22 Aug 2026 10:29:09 +0100 Subject: [PATCH 01/11] add price_cache table migration stores the last known good price per asset so the reflector service can fall back to real data instead of random noise when the oracle is down. survives backend restarts. --- .../src/db/migrations/006_add_price_cache.down.sql | 1 + .../src/db/migrations/006_add_price_cache.up.sql | 13 +++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 backend/src/db/migrations/006_add_price_cache.down.sql create mode 100644 backend/src/db/migrations/006_add_price_cache.up.sql 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); From 3409e959c0e5a3e73dff6c244fd4795de1a59b75 Mon Sep 17 00:00:00 2001 From: brightpixel Date: Sat, 22 Aug 2026 10:31:45 +0100 Subject: [PATCH 02/11] add price cache repository provides upsert, get, and bulk query functions for the price_cache table. upsert uses ON CONFLICT so repeated writes for the same asset just update the price and timestamp. --- backend/src/db/priceCacheDb.ts | 61 ++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 backend/src/db/priceCacheDb.ts 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 +} From 87eb8227b048acf7bfb52b1637b62d79e1758aaf Mon Sep 17 00:00:00 2001 From: brightpixel Date: Sat, 22 Aug 2026 10:34:16 +0100 Subject: [PATCH 03/11] add stale flag to PriceData type lets downstream consumers (rebalancing, risk, frontend) know when a price came from cache rather than a live oracle. also adds 'cached' to the source union type. --- backend/src/types/index.ts | 165 +++++++++++++++++++------------------ 1 file changed, 83 insertions(+), 82 deletions(-) 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[] +} From 3eb8ca248ec01b9d46039c93acd9afb8b52e471b Mon Sep 17 00:00:00 2001 From: brightpixel Date: Sat, 22 Aug 2026 10:41:05 +0100 Subject: [PATCH 04/11] replace random price variation with database-cached fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit when the oracle is down, reflector service now checks the price_cache table for the last known good price instead of adding random noise. prices are persisted to the database after every successful fetch so they survive restarts. fallback hierarchy: 1. in-memory cache (fast, short-lived) 2. database cache (survives restarts, has staleness tracking) 3. hardcoded defaults (last resort, marked as stale) stale threshold is 1 hour — downstream consumers can check the stale flag to decide whether to trade on the data. --- backend/src/services/reflector.ts | 91 ++++++++++++++++++++----------- 1 file changed, 59 insertions(+), 32 deletions(-) diff --git a/backend/src/services/reflector.ts b/backend/src/services/reflector.ts index 8c76a9a..81506b9 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 } 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,43 @@ export class ReflectorService { return history } - private getFallbackPrices(): PricesMap { - console.warn('[FALLBACK] Using fallback prices - all sources failed') + private async getFallbackPrices(): Promise { + 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 +574,25 @@ 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 + } + getCacheStatus(): Record { const status: Record = {} this.priceCache.forEach((value, key) => { From 8ea22ac3a22b325a761808171a5d38f701d57039 Mon Sep 17 00:00:00 2001 From: brightpixel Date: Sat, 22 Aug 2026 10:42:41 +0100 Subject: [PATCH 05/11] skip rebalancing when prices are stale before checking any portfolio, the rebalancing service now fetches current prices and checks for staleness. if any price is marked stale (from cache older than 1 hour), the entire rebalance cycle is skipped. this prevents phantom rebalancing triggers from random price noise or stale oracle data. --- backend/src/services/rebalancing.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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) From e165b7174838f17930819bfe58fd1d139c469a9f Mon Sep 17 00:00:00 2001 From: brightpixel Date: Sat, 22 Aug 2026 10:43:36 +0100 Subject: [PATCH 06/11] pause risk calculations when prices are stale updatePriceData now returns early with no alerts if any price is marked stale. this prevents corrupted volatility, VaR, and correlation calculations from feeding into circuit breakers and risk scores. --- backend/src/services/riskManagements.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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() From c3396d4df79bdee5220f69babcf398494c4ab56f Mon Sep 17 00:00:00 2001 From: brightpixel Date: Sat, 22 Aug 2026 10:49:39 +0100 Subject: [PATCH 07/11] show stale data warning in dashboard and disable rebalance on cached prices When the price oracle is down and we fall back to cached data, the dashboard now shows a yellow banner explaining the situation. The 'rebalance now' button is also disabled so users don't execute trades against stale prices. --- frontend/src/components/Dashboard.tsx | 38 ++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 4 deletions(-) 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 }) => {