Skip to content
Merged
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
42 changes: 42 additions & 0 deletions backend/src/api/routes.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };
1 change: 1 addition & 0 deletions backend/src/db/migrations/006_add_price_cache.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS price_cache;
13 changes: 13 additions & 0 deletions backend/src/db/migrations/006_add_price_cache.up.sql
Original file line number Diff line number Diff line change
@@ -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);
61 changes: 61 additions & 0 deletions backend/src/db/priceCacheDb.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<CachedPrice | null> {
const result = await query<CachedPrice>(
'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<CachedPrice[]> {
const result = await query<CachedPrice>(
'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<number> {
const result = await query(
'DELETE FROM price_cache WHERE fetched_at < NOW() - INTERVAL \'1 millisecond\' * $1',
[maxAgeMs]
)
return result.rowCount ?? 0
}
1 change: 1 addition & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
17 changes: 16 additions & 1 deletion backend/src/services/rebalancing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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<boolean> {
try {
return await this.stellarService.checkRebalanceNeeded(portfolioId)
Expand Down
112 changes: 80 additions & 32 deletions backend/src/services/reflector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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')
}
Expand All @@ -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()
}
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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 }
Expand Down Expand Up @@ -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<PricesMap> {
// 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 },
}
}

Expand Down Expand Up @@ -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<void> {
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<number> {
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<string, any> {
const status: Record<string, any> = {}
this.priceCache.forEach((value, key) => {
Expand Down
7 changes: 7 additions & 0 deletions backend/src/services/riskManagements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading
Loading