From e21d0b588f0a179e7f340cfc11e647a8414a9c9c Mon Sep 17 00:00:00 2001 From: Assad Isah Date: Sat, 27 Jun 2026 07:44:54 +0000 Subject: [PATCH] feat: implement Soroban transfer cost forecasting service (#475) Add EMA-based cost forecasting for Stellar/Soroban bridge transfers. Ingests historical fee samples, classifies trends (improving/stable/declining), and generates composite forecasts with confidence scores and CI bounds. --- .../stellar/cost-forecasting.service.spec.ts | 188 ++++++++++++++ .../costs/stellar/cost-forecasting.service.ts | 242 ++++++++++++++++++ src/forecasting/costs/stellar/index.ts | 1 + 3 files changed, 431 insertions(+) create mode 100644 src/forecasting/costs/stellar/cost-forecasting.service.spec.ts create mode 100644 src/forecasting/costs/stellar/cost-forecasting.service.ts create mode 100644 src/forecasting/costs/stellar/index.ts diff --git a/src/forecasting/costs/stellar/cost-forecasting.service.spec.ts b/src/forecasting/costs/stellar/cost-forecasting.service.spec.ts new file mode 100644 index 00000000..863e6afa --- /dev/null +++ b/src/forecasting/costs/stellar/cost-forecasting.service.spec.ts @@ -0,0 +1,188 @@ +import { CostSample, StellarCostForecastingService } from './cost-forecasting.service'; + +const makeSample = (routeId: string, feeStroops: number, success = true, offsetMs = 0): CostSample => ({ + routeId, + feeStroops, + success, + timestamp: new Date(Date.now() - 1000 * 60 * 60 + offsetMs), +}); + +const makeTrend = (routeId: string, start: number, end: number, step: number): CostSample[] => { + const samples: CostSample[] = []; + const direction = end >= start ? 1 : -1; + const signedStep = Math.abs(step) * direction; + const total = Math.max(2, Math.floor(Math.abs(end - start) / Math.abs(step)) + 1); + for (let i = 0; i < total; i++) { + samples.push({ + routeId, + feeStroops: start + signedStep * i, + success: true, + timestamp: new Date(Date.now() - 1000 * 60 * (total - i)), + }); + } + return samples; +}; + +describe('StellarCostForecastingService', () => { + let service: StellarCostForecastingService; + + beforeEach(() => { + service = new StellarCostForecastingService(); + }); + + describe('constructor validation', () => { + it('throws on invalid emaAlpha', () => { + expect(() => new StellarCostForecastingService({ emaAlpha: 0 })).toThrow(); + expect(() => new StellarCostForecastingService({ emaAlpha: 1.5 })).toThrow(); + }); + + it('throws on invalid trendThreshold', () => { + expect(() => new StellarCostForecastingService({ trendThreshold: -0.1 })).toThrow(); + }); + + it('throws on invalid recentWindowRatio', () => { + expect(() => new StellarCostForecastingService({ recentWindowRatio: 0 })).toThrow(); + expect(() => new StellarCostForecastingService({ recentWindowRatio: 1 })).toThrow(); + }); + }); + + describe('analyzeFeeTrends', () => { + it('stores samples and returns aggregate analysis', () => { + const samples = makeTrend('r1', 200, 600, 50); + const analysis = service.analyzeFeeTrends('r1', samples); + + expect(analysis).not.toBeNull(); + expect(analysis!.routeId).toBe('r1'); + expect(analysis!.sampleSize).toBe(samples.length); + expect(analysis!.averageFeeStroops).toBeGreaterThan(0); + expect(analysis!.p95FeeStroops).toBeGreaterThan(0); + expect(analysis!.standardDeviationStroops).toBeGreaterThanOrEqual(0); + }); + + it('returns null when all samples are invalid', () => { + const bad = [{ routeId: 'r1', feeStroops: -1, success: true, timestamp: new Date() }]; + expect(service.analyzeFeeTrends('r1', bad)).toBeNull(); + }); + + it('drops invalid samples silently', () => { + const samples: CostSample[] = [ + makeSample('r1', 100), + { routeId: 'r1', feeStroops: NaN, success: true, timestamp: new Date() }, + { routeId: 'r1', feeStroops: -50, success: true, timestamp: new Date() }, + { routeId: 'r1', feeStroops: 200, success: true, timestamp: new Date('invalid') }, + ]; + const result = service.analyzeFeeTrends('r1', samples); + expect(result!.sampleSize).toBe(1); + }); + + it('throws on empty routeId', () => { + expect(() => service.analyzeFeeTrends('', [])).toThrow(); + }); + + it('throws on non-array metrics', () => { + expect(() => service.analyzeFeeTrends('r1', null as any)).toThrow(); + }); + + it('caps samples at maxSamples', () => { + const small = new StellarCostForecastingService({ maxSamples: 3 }); + const samples = [1, 2, 3, 4, 5].map((i) => makeSample('r1', i * 100, true, i * 1000)); + small.analyzeFeeTrends('r1', samples); + expect(small.getStoredSamples('r1').length).toBe(3); + }); + }); + + describe('predictCostTrends', () => { + it('returns null when no samples exist', () => { + expect(service.predictCostTrends('unknown')).toBeNull(); + }); + + it('returns a trend forecast after ingesting samples', () => { + const samples = makeTrend('r1', 500, 300, 50); + service.analyzeFeeTrends('r1', samples); + const forecast = service.predictCostTrends('r1'); + + expect(forecast).not.toBeNull(); + expect(forecast!.routeId).toBe('r1'); + expect(forecast!.predictedFeeStroops).toBeGreaterThan(0); + expect(forecast!.sampleSize).toBe(samples.length); + expect(['improving', 'stable', 'declining']).toContain(forecast!.trend); + }); + + it('classifies a declining fee trend as improving', () => { + const samples = makeTrend('r1', 1000, 200, 100); + service.analyzeFeeTrends('r1', samples); + const forecast = service.predictCostTrends('r1'); + expect(forecast!.trend).toBe('improving'); + }); + + it('classifies an increasing fee trend as declining', () => { + const samples = makeTrend('r1', 200, 1000, 100); + service.analyzeFeeTrends('r1', samples); + const forecast = service.predictCostTrends('r1'); + expect(forecast!.trend).toBe('declining'); + }); + + it('confidence interval lower bound is non-negative', () => { + service.analyzeFeeTrends('r1', [makeSample('r1', 100)]); + const forecast = service.predictCostTrends('r1'); + expect(forecast!.confidenceIntervalStroops[0]).toBeGreaterThanOrEqual(0); + }); + + it('confidence interval lower <= upper', () => { + const samples = makeTrend('r1', 100, 500, 50); + service.analyzeFeeTrends('r1', samples); + const f = service.predictCostTrends('r1')!; + expect(f.confidenceIntervalStroops[0]).toBeLessThanOrEqual(f.confidenceIntervalStroops[1]); + }); + }); + + describe('generateForecast', () => { + it('returns zero-confidence forecast with no data', () => { + const f = service.generateForecast('r1'); + expect(f.predictedFeeStroops).toBe(0); + expect(f.confidenceScore).toBe(0); + expect(f.trend).toBeNull(); + expect(f.historical).toBeNull(); + }); + + it('returns a valid composite forecast after ingesting samples', () => { + const samples = makeTrend('r1', 300, 600, 50); + service.analyzeFeeTrends('r1', samples); + const f = service.generateForecast('r1'); + + expect(f.routeId).toBe('r1'); + expect(f.predictedFeeStroops).toBeGreaterThan(0); + expect(f.confidenceScore).toBeGreaterThan(0); + expect(f.confidenceScore).toBeLessThanOrEqual(100); + expect(f.trend).not.toBeNull(); + expect(f.historical).not.toBeNull(); + }); + + it('confidence score increases with more samples', () => { + const few = makeTrend('r1', 100, 200, 20); + const many = [...makeTrend('r2', 100, 200, 5)]; + + service.analyzeFeeTrends('r1', few); + service.analyzeFeeTrends('r2', many); + + const f1 = service.generateForecast('r1'); + const f2 = service.generateForecast('r2'); + expect(f2.confidenceScore).toBeGreaterThanOrEqual(f1.confidenceScore); + }); + }); + + describe('reset and getStoredSamples', () => { + it('clears all samples on reset', () => { + service.analyzeFeeTrends('r1', [makeSample('r1', 100)]); + service.reset(); + expect(service.getStoredSamples('r1')).toHaveLength(0); + }); + + it('returns a copy, not a reference', () => { + service.analyzeFeeTrends('r1', [makeSample('r1', 100)]); + const copy = service.getStoredSamples('r1'); + copy[0].feeStroops = 999; + expect(service.getStoredSamples('r1')[0].feeStroops).toBe(100); + }); + }); +}); diff --git a/src/forecasting/costs/stellar/cost-forecasting.service.ts b/src/forecasting/costs/stellar/cost-forecasting.service.ts new file mode 100644 index 00000000..51b49577 --- /dev/null +++ b/src/forecasting/costs/stellar/cost-forecasting.service.ts @@ -0,0 +1,242 @@ +import { Injectable, Logger } from '@nestjs/common'; + +export interface CostSample { + routeId: string; + timestamp: Date; + /** Total fee in stroops (1 XLM = 10_000_000 stroops). */ + feeStroops: number; + success: boolean; +} + +export interface CostHistoricalAnalysis { + routeId: string; + sampleSize: number; + averageFeeStroops: number; + p95FeeStroops: number; + standardDeviationStroops: number; + oldestSampleAt?: Date; + newestSampleAt?: Date; +} + +export type TrendDirection = 'improving' | 'stable' | 'declining'; + +export interface CostTrendForecast { + routeId: string; + predictedFeeStroops: number; + emaFeeStroops: number; + recentAverageFeeStroops: number; + historicalAverageFeeStroops: number; + confidenceIntervalStroops: [number, number]; + trend: TrendDirection; + trendDelta: number; + sampleSize: number; + generatedAt: Date; +} + +export interface CostForecast { + routeId: string; + predictedFeeStroops: number; + confidenceScore: number; + trend: CostTrendForecast | null; + historical: CostHistoricalAnalysis | null; + generatedAt: Date; +} + +export interface StellarCostForecastingOptions { + maxSamples?: number; + emaAlpha?: number; + trendThreshold?: number; + recentWindowRatio?: number; +} + +const DEFAULTS: Required = { + maxSamples: 10_000, + emaAlpha: 0.3, + trendThreshold: 0.05, + recentWindowRatio: 0.25, +}; + +/** + * Forecasting engine for Soroban transfer costs. + * + * Ingests historical fee samples and projects forward-looking cost + * forecasts using exponential moving averages with trend classification. + */ +@Injectable() +export class StellarCostForecastingService { + private readonly logger = new Logger(StellarCostForecastingService.name); + private readonly opts: Required; + private readonly samples = new Map(); + + constructor(options: StellarCostForecastingOptions = {}) { + this.opts = { ...DEFAULTS, ...options }; + if (this.opts.emaAlpha <= 0 || this.opts.emaAlpha > 1) { + throw new Error('emaAlpha must be in (0, 1]'); + } + if (this.opts.trendThreshold < 0) { + throw new Error('trendThreshold must be non-negative'); + } + if (this.opts.recentWindowRatio <= 0 || this.opts.recentWindowRatio >= 1) { + throw new Error('recentWindowRatio must be in (0, 1)'); + } + } + + /** + * Ingest fee samples for a route. Invalid entries are dropped. + * Returns `null` when all samples fail validation. + */ + analyzeFeeTrends(routeId: string, metrics: CostSample[]): CostHistoricalAnalysis | null { + this.validateRouteId(routeId); + if (!Array.isArray(metrics)) throw new Error('metrics must be an array'); + + const cleaned = this.sanitize(metrics); + cleaned.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + const trimmed = + cleaned.length > this.opts.maxSamples + ? cleaned.slice(cleaned.length - this.opts.maxSamples) + : cleaned; + + this.samples.set(routeId, trimmed); + this.logger.log( + `Analyzed ${trimmed.length} cost samples for route ${routeId}` + + ` (dropped ${metrics.length - cleaned.length} invalid)`, + ); + + return trimmed.length === 0 ? null : this.computeHistorical(routeId); + } + + /** Project future fees using EMA over stored samples. Returns `null` with no data. */ + predictCostTrends(routeId: string): CostTrendForecast | null { + this.validateRouteId(routeId); + const series = this.samples.get(routeId); + if (!series?.length) { + this.logger.warn(`No cost samples for route ${routeId}`); + return null; + } + + const recentSize = this.recentWindowSize(series.length); + const recent = series.slice(series.length - recentSize); + const historical = series.slice(0, series.length - recentSize); + + const recentAvg = mean(recent.map((s) => s.feeStroops)); + const historicalAvg = historical.length ? mean(historical.map((s) => s.feeStroops)) : recentAvg; + + const ema = this.computeEma(series); + const stdDev = stdDeviation(recent.map((s) => s.feeStroops)); + const margin = 1.96 * (series.length > 1 ? stdDev / Math.sqrt(series.length) : 0); + + // Positive delta means fees decreased (improving for users). + const trendDelta = historicalAvg > 0 ? (historicalAvg - recentAvg) / historicalAvg : 0; + + return { + routeId, + predictedFeeStroops: round(ema), + emaFeeStroops: round(ema), + recentAverageFeeStroops: round(recentAvg), + historicalAverageFeeStroops: round(historicalAvg), + confidenceIntervalStroops: [round(Math.max(0, ema - margin)), round(ema + margin)], + trend: classifyTrend(trendDelta, this.opts.trendThreshold), + trendDelta: round(trendDelta, 4), + sampleSize: series.length, + generatedAt: new Date(), + }; + } + + /** Composite forecast combining trend and historical analysis. */ + generateForecast(routeId: string): CostForecast { + this.validateRouteId(routeId); + const trend = this.predictCostTrends(routeId); + const sampleSize = this.samples.get(routeId)?.length ?? 0; + const historical = sampleSize > 0 ? this.computeHistorical(routeId) : null; + + return { + routeId, + predictedFeeStroops: trend?.predictedFeeStroops ?? 0, + confidenceScore: this.computeConfidence(sampleSize, trend), + trend, + historical, + generatedAt: new Date(), + }; + } + + reset(): void { + this.samples.clear(); + } + + getStoredSamples(routeId: string): CostSample[] { + return (this.samples.get(routeId) ?? []).map((s) => ({ ...s, timestamp: new Date(s.timestamp) })); + } + + private computeHistorical(routeId: string): CostHistoricalAnalysis { + const series = this.samples.get(routeId) ?? []; + const fees = series.map((s) => s.feeStroops); + const sorted = [...fees].sort((a, b) => a - b); + const p95Idx = Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95)); + return { + routeId, + sampleSize: series.length, + averageFeeStroops: round(mean(fees)), + p95FeeStroops: round(sorted[p95Idx]), + standardDeviationStroops: round(stdDeviation(fees), 2), + oldestSampleAt: series[0]?.timestamp, + newestSampleAt: series[series.length - 1]?.timestamp, + }; + } + + private computeConfidence(sampleSize: number, trend: CostTrendForecast | null): number { + if (sampleSize === 0 || !trend) return 0; + const sampleScore = Math.min(50, Math.round(Math.log10(sampleSize + 1) * 25)); + const intervalWidth = trend.confidenceIntervalStroops[1] - trend.confidenceIntervalStroops[0]; + const normalized = trend.predictedFeeStroops > 0 ? Math.min(1, intervalWidth / trend.predictedFeeStroops) : 0; + const stabilityBonus = Math.round(50 * Math.max(0, 1 - normalized)); + return Math.min(100, sampleScore + stabilityBonus); + } + + private computeEma(series: CostSample[]): number { + const alpha = this.opts.emaAlpha; + let ema = series[0].feeStroops; + for (let i = 1; i < series.length; i++) { + ema = alpha * series[i].feeStroops + (1 - alpha) * ema; + } + return ema; + } + + private sanitize(metrics: CostSample[]): CostSample[] { + return metrics.filter((m) => { + if (!m) return false; + if (typeof m.feeStroops !== 'number' || !Number.isFinite(m.feeStroops)) return false; + if (m.feeStroops < 0) return false; + if (!(m.timestamp instanceof Date) || Number.isNaN(m.timestamp.getTime())) return false; + return true; + }).map((m) => ({ ...m, timestamp: new Date(m.timestamp.getTime()), success: Boolean(m.success) })); + } + + private recentWindowSize(n: number): number { + return Math.max(Math.min(2, n), Math.floor(n * this.opts.recentWindowRatio)); + } + + private validateRouteId(routeId: string): void { + if (!routeId?.trim()) throw new Error('routeId must be a non-empty string'); + } +} + +function mean(values: number[]): number { + return values.length === 0 ? 0 : values.reduce((a, v) => a + v, 0) / values.length; +} + +function stdDeviation(values: number[]): number { + if (values.length < 2) return 0; + const m = mean(values); + return Math.sqrt(values.reduce((a, v) => a + (v - m) ** 2, 0) / values.length); +} + +function round(value: number, places = 0): number { + if (!Number.isFinite(value)) return 0; + const f = 10 ** places; + return Math.round(value * f) / f; +} + +function classifyTrend(delta: number, threshold: number): TrendDirection { + if (!Number.isFinite(delta) || Math.abs(delta) < threshold) return 'stable'; + return delta > 0 ? 'improving' : 'declining'; +} diff --git a/src/forecasting/costs/stellar/index.ts b/src/forecasting/costs/stellar/index.ts new file mode 100644 index 00000000..8924c835 --- /dev/null +++ b/src/forecasting/costs/stellar/index.ts @@ -0,0 +1 @@ +export * from './cost-forecasting.service';