diff --git a/package-lock.json b/package-lock.json index 1090c8c..1605470 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6083,6 +6083,7 @@ }, "node_modules/fsevents": { "version": "2.3.3", + "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/src/agent/backtest.ts b/src/agent/backtest.ts new file mode 100644 index 0000000..3d88a48 --- /dev/null +++ b/src/agent/backtest.ts @@ -0,0 +1,344 @@ +/** + * Backtest Engine — pure historical replay for RebalanceStrategy implementations. + * + * STRUCTURAL GUARANTEE (see tests/unit/agent/backtest.test.ts): + * This file must never import anything from `src/stellar/*`, and must never + * import Position/Transaction/CustodialWallet. A backtest simulates a + * strategy's decisions against historical rate data — it never touches real + * funds or submits a real transaction. That guarantee is enforced by a test + * asserting zero `src/stellar` imports in this file's source text, not just + * by convention. + */ + +import { + RebalanceStrategy, + StrategyName, + StrategyParams, + RebalanceThresholds, + UserStrategyPreferences, + YieldProtocol, +} from './types' + +/** Default thresholds applied when a backtest request doesn't specify them. */ +export const DEFAULT_BACKTEST_THRESHOLDS: RebalanceThresholds = { + minimumImprovement: 0.5, + maxGasPercent: 0.1, +} + +/** + * One day's worth of protocol APY data, AFTER gap-handling has already been + * applied (see buildDailyRateSeries). Every protocol in `protocols` has a + * value for this date — either a real observation or a forward-filled one. + */ +export interface DailyRateSnapshot { + date: Date + protocols: YieldProtocol[] +} + +/** + * A single raw historical observation as read from ProtocolRate. May have + * gaps — a protocol can be missing entirely on days it had no observation. + */ +export interface RawProtocolRatePoint { + protocolName: string + assetSymbol: string + apy: number + date: Date +} + +export interface BacktestRequest { + strategyName: StrategyName + startDate: Date + endDate: Date + startingAmount: number + thresholds?: RebalanceThresholds + userStrategyPreferences?: UserStrategyPreferences[] + riskCeiling?: number + protocolRiskScores?: Record + goal?: StrategyParams['goal'] +} + +export interface BacktestSummary { + finalValue: number + maxDrawdownPercent: number + /** + * Simple (non-compounding) annualized realized rate, using the identical + * convention as calculateApy in src/agent/snapshotter.ts: + * (gain / startingAmount / years) * 100 + * Intentional — a backtest's "realized APY" must mean the same thing to a + * user as a live position's "realized APY". + */ + realizedApy: number +} + +export interface BacktestTimeSeriesPoint { + date: string // YYYY-MM-DD + simulatedValue: number +} + +export interface BacktestResult { + timeSeries: BacktestTimeSeriesPoint[] + summary: BacktestSummary + finalProtocol: string + /** Every rebalance the strategy made during the run, in chronological order. */ + rebalanceEvents: { + date: string + fromProtocol: string + toProtocol: string + reasoning: string + }[] +} + +export type BacktestOutcome = + | { status: 'ok'; result: BacktestResult } + | { status: 'insufficient_history'; earliestAvailableDate: Date | null } + | { status: 'invalid_range'; reason: string } + +const MS_PER_DAY = 24 * 60 * 60 * 1000 +const MS_PER_YEAR = 365.25 * MS_PER_DAY + +/** + * Gap-handling policy: HOLD PREVIOUS VALUE (forward-fill). + * + * When a protocol has no rate observation for a given day (rate collection + * gap, or the protocol simply didn't exist yet), we carry forward its most + * recent known APY rather than dropping it from the universe or zero-filling. + * A protocol with zero observations up to and including a given day is + * excluded from that day's snapshot entirely (there is nothing to hold + * forward) — it becomes eligible starting the day of its first observation. + * + * Rationale: excluding a protocol on a rate-collection gap would make a + * strategy's candidate universe flicker day-to-day for reasons unrelated to + * the protocol's real availability, silently skewing which protocol looks + * "best" purely due to collection artifacts. Holding the last known rate is + * a conservative, explicit, documented choice — not a silent default. + */ +export function buildDailyRateSeries( + rawPoints: RawProtocolRatePoint[], + startDate: Date, + endDate: Date +): { series: DailyRateSnapshot[]; earliestAvailableDate: Date | null } { + if (rawPoints.length === 0) { + return { series: [], earliestAvailableDate: null } + } + + const earliestAvailableDate = rawPoints.reduce( + (earliest, p) => (p.date < earliest ? p.date : earliest), + rawPoints[0].date + ) + + const byProtocol = new Map() + for (const point of rawPoints) { + const list = byProtocol.get(point.protocolName) ?? [] + list.push(point) + byProtocol.set(point.protocolName, list) + } + for (const list of byProtocol.values()) { + list.sort((a, b) => a.date.getTime() - b.date.getTime()) + } + + const series: DailyRateSnapshot[] = [] + const pointerIndex = new Map() + const heldValue = new Map() + for (const p of byProtocol.keys()) pointerIndex.set(p, 0) + + const dayCount = + Math.floor((endDate.getTime() - startDate.getTime()) / MS_PER_DAY) + 1 + + for (let i = 0; i < dayCount; i++) { + const day = new Date(startDate.getTime() + i * MS_PER_DAY) + const protocolsToday: YieldProtocol[] = [] + + for (const [protocolName, points] of byProtocol.entries()) { + let idx = pointerIndex.get(protocolName) ?? 0 + while ( + idx < points.length && + points[idx].date.getTime() <= day.getTime() + ) { + heldValue.set(protocolName, points[idx]) + idx++ + } + pointerIndex.set(protocolName, idx) + + const held = heldValue.get(protocolName) + if (held) { + protocolsToday.push({ + name: held.protocolName, + apy: held.apy, + assetSymbol: held.assetSymbol, + lastUpdated: held.date, + isAvailable: true, + }) + } + // No held value yet (protocol's first observation is still in the + // future relative to `day`) — correctly excluded from today's universe. + } + + series.push({ date: day, protocols: protocolsToday }) + } + + return { series, earliestAvailableDate } +} + +function formatDate(d: Date): string { + return d.toISOString().slice(0, 10) +} + +function calculateMaxDrawdownPercent(values: number[]): number { + let peak = values.length > 0 ? values[0] : 0 + let maxDrawdown = 0 + for (const v of values) { + if (v > peak) peak = v + if (peak > 0) { + const drawdown = ((peak - v) / peak) * 100 + if (drawdown > maxDrawdown) maxDrawdown = drawdown + } + } + return maxDrawdown +} + +/** + * Same simple-rate convention as calculateApy in snapshotter.ts: + * (gain / principal / years) * 100. Mirrors that function's guard. + */ +function calculateRealizedApy( + startingAmount: number, + finalValue: number, + years: number +): number { + if (startingAmount <= 0 || years <= 0) return 0 + return ((finalValue - startingAmount) / startingAmount / years) * 100 +} + +/** + * Runs a historical replay of `strategy` against `dailyRates`. + * + * The starting protocol is the highest-APY protocol available on the first + * day of the window — a backtest has no prior live position to anchor to, so + * this is the most neutral starting assumption. (If product wants to + * backtest starting from a *specific* protocol, extend BacktestRequest with + * an optional `startingProtocol` and use it here instead.) + */ +export async function runBacktest( + strategy: RebalanceStrategy, + dailyRates: DailyRateSnapshot[], + request: BacktestRequest +): Promise { + if (request.startingAmount <= 0) { + return { + status: 'invalid_range', + reason: 'startingAmount must be positive', + } + } + if (request.endDate.getTime() <= request.startDate.getTime()) { + return { + status: 'invalid_range', + reason: 'endDate must be after startDate', + } + } + if (dailyRates.length === 0) { + return { status: 'insufficient_history', earliestAvailableDate: null } + } + + const first = dailyRates[0] + if (first.protocols.length === 0) { + return { status: 'insufficient_history', earliestAvailableDate: null } + } + + let currentValue = request.startingAmount + let currentProtocol = first.protocols.reduce((best, p) => + p.apy > best.apy ? p : best + ).name + + const timeSeries: BacktestTimeSeriesPoint[] = [] + const rebalanceEvents: BacktestResult['rebalanceEvents'] = [] + const values: number[] = [] + + for (const day of dailyRates) { + if (day.protocols.length === 0) { + // No data at all for this day even after forward-fill (can happen at + // the very start of the window, before any protocol's first + // observation). Hold value flat rather than fabricating growth. + timeSeries.push({ + date: formatDate(day.date), + simulatedValue: currentValue, + }) + values.push(currentValue) + continue + } + + const currentProtocolRate = day.protocols.find( + (p) => p.name === currentProtocol + ) + const currentApy = currentProtocolRate?.apy ?? 0 + + // Accrue one day of simple (non-compounding) return at currentApy before + // asking the strategy whether to rebalance. + const dailyReturn = (currentApy / 100 / 365.25) * currentValue + currentValue += dailyReturn + + // Convert dollars to a wei-like integer string (18 decimals) using BigInt + // arithmetic — Number.toString() switches to exponential notation above + // 1e21, which would corrupt this value and silently defeat the parseInt + // in estimateRebalanceCosts. Round to whole cents first to avoid + // floating-point drift before scaling. + const microDollars = BigInt(Math.round(currentValue * 1e6)) + const weiAmount = (microDollars * 10n ** 12n).toString() + + const params: StrategyParams = { + currentProtocol, + totalAmount: weiAmount, + currentApy, + availableProtocols: day.protocols, + thresholds: request.thresholds ?? DEFAULT_BACKTEST_THRESHOLDS, + userStrategyPreferences: request.userStrategyPreferences ?? [], + riskCeiling: request.riskCeiling, + protocolRiskScores: request.protocolRiskScores, + goal: request.goal, + } + + const decision = await strategy.analyze(params) + + if ( + decision.shouldRebalance && + decision.targetProtocol !== currentProtocol + ) { + rebalanceEvents.push({ + date: formatDate(day.date), + fromProtocol: currentProtocol, + toProtocol: decision.targetProtocol, + reasoning: decision.reasoning, + }) + currentProtocol = decision.targetProtocol + } + + timeSeries.push({ + date: formatDate(day.date), + simulatedValue: currentValue, + }) + values.push(currentValue) + } + + const years = + (request.endDate.getTime() - request.startDate.getTime()) / MS_PER_YEAR + + const summary: BacktestSummary = { + finalValue: currentValue, + maxDrawdownPercent: calculateMaxDrawdownPercent(values), + realizedApy: calculateRealizedApy( + request.startingAmount, + currentValue, + years + ), + } + + return { + status: 'ok', + result: { + timeSeries, + summary, + finalProtocol: currentProtocol, + rebalanceEvents, + }, + } +} diff --git a/src/agent/backtestCache.ts b/src/agent/backtestCache.ts new file mode 100644 index 0000000..d551497 --- /dev/null +++ b/src/agent/backtestCache.ts @@ -0,0 +1,38 @@ +/** + * Result cache for POST /api/agent/backtest. + * + * Historical ProtocolRate/YieldSnapshot rows are immutable once written, so + * an identical (strategy, startDate, endDate, startingAmount) request always + * produces the identical result — safe to serve from cache without + * recomputing the replay. + */ +import { BacktestOutcome, BacktestRequest } from './backtest' + +const cache = new Map() + +export function buildCacheKey( + request: Pick< + BacktestRequest, + 'strategyName' | 'startDate' | 'endDate' | 'startingAmount' + > +): string { + return [ + request.strategyName, + request.startDate.toISOString(), + request.endDate.toISOString(), + request.startingAmount, + ].join('|') +} + +export function getCached(key: string): BacktestOutcome | undefined { + return cache.get(key) +} + +export function setCached(key: string, outcome: BacktestOutcome): void { + cache.set(key, outcome) +} + +/** Exposed for tests only — clears the in-memory cache between test cases. */ +export function clearBacktestCache(): void { + cache.clear() +} diff --git a/src/agent/strategies.ts b/src/agent/strategies.ts index 7348821..dd34cb9 100644 --- a/src/agent/strategies.ts +++ b/src/agent/strategies.ts @@ -103,7 +103,9 @@ export class MaxYieldStrategy implements RebalanceStrategy { } } - const bestProtocol = eligibleProtocols[0] + const bestProtocol = eligibleProtocols.reduce((best, p) => + p.apy > best.apy ? p : best + ) if (bestProtocol.name === currentProtocol) { return { diff --git a/src/routes/backtest.ts b/src/routes/backtest.ts new file mode 100644 index 0000000..faaa905 --- /dev/null +++ b/src/routes/backtest.ts @@ -0,0 +1,336 @@ +/** + * Backtest Engine — pure historical replay for RebalanceStrategy implementations. + * + * STRUCTURAL GUARANTEE (see tests/unit/agent/backtest.test.ts): + * This file must never import anything from `src/stellar/*`, and must never + * import Position/Transaction/CustodialWallet. A backtest simulates a + * strategy's decisions against historical rate data — it never touches real + * funds or submits a real transaction. That guarantee is enforced by a test + * asserting zero `src/stellar` imports in this file's source text, not just + * by convention. + */ + +import { + RebalanceStrategy, + StrategyName, + StrategyParams, + RebalanceThresholds, + UserStrategyPreferences, + YieldProtocol, +} from '../agent/types' + +/** Default thresholds applied when a backtest request doesn't specify them. */ +export const DEFAULT_BACKTEST_THRESHOLDS: RebalanceThresholds = { + minimumImprovement: 0.5, + maxGasPercent: 0.1, +} + +/** + * One day's worth of protocol APY data, AFTER gap-handling has already been + * applied (see buildDailyRateSeries). Every protocol in `protocols` has a + * value for this date — either a real observation or a forward-filled one. + */ +export interface DailyRateSnapshot { + date: Date + protocols: YieldProtocol[] +} + +/** + * A single raw historical observation as read from ProtocolRate. May have + * gaps — a protocol can be missing entirely on days it had no observation. + */ +export interface RawProtocolRatePoint { + protocolName: string + assetSymbol: string + apy: number + date: Date +} + +export interface BacktestRequest { + strategyName: StrategyName + startDate: Date + endDate: Date + startingAmount: number + thresholds?: RebalanceThresholds + userStrategyPreferences?: UserStrategyPreferences[] + riskCeiling?: number + protocolRiskScores?: Record + goal?: StrategyParams['goal'] +} + +export interface BacktestSummary { + finalValue: number + maxDrawdownPercent: number + /** + * Simple (non-compounding) annualized realized rate, using the identical + * convention as calculateApy in src/agent/snapshotter.ts: + * (gain / startingAmount / years) * 100 + * Intentional — a backtest's "realized APY" must mean the same thing to a + * user as a live position's "realized APY". + */ + realizedApy: number +} + +export interface BacktestTimeSeriesPoint { + date: string // YYYY-MM-DD + simulatedValue: number +} + +export interface BacktestResult { + timeSeries: BacktestTimeSeriesPoint[] + summary: BacktestSummary + finalProtocol: string + /** Every rebalance the strategy made during the run, in chronological order. */ + rebalanceEvents: { + date: string + fromProtocol: string + toProtocol: string + reasoning: string + }[] +} + +export type BacktestOutcome = + | { status: 'ok'; result: BacktestResult } + | { status: 'insufficient_history'; earliestAvailableDate: Date | null } + | { status: 'invalid_range'; reason: string } + +const MS_PER_DAY = 24 * 60 * 60 * 1000 +const MS_PER_YEAR = 365.25 * MS_PER_DAY + +/** + * Gap-handling policy: HOLD PREVIOUS VALUE (forward-fill). + * + * When a protocol has no rate observation for a given day (rate collection + * gap, or the protocol simply didn't exist yet), we carry forward its most + * recent known APY rather than dropping it from the universe or zero-filling. + * A protocol with zero observations up to and including a given day is + * excluded from that day's snapshot entirely (there is nothing to hold + * forward) — it becomes eligible starting the day of its first observation. + * + * Rationale: excluding a protocol on a rate-collection gap would make a + * strategy's candidate universe flicker day-to-day for reasons unrelated to + * the protocol's real availability, silently skewing which protocol looks + * "best" purely due to collection artifacts. Holding the last known rate is + * a conservative, explicit, documented choice — not a silent default. + */ +export function buildDailyRateSeries( + rawPoints: RawProtocolRatePoint[], + startDate: Date, + endDate: Date +): { series: DailyRateSnapshot[]; earliestAvailableDate: Date | null } { + if (rawPoints.length === 0) { + return { series: [], earliestAvailableDate: null } + } + + const earliestAvailableDate = rawPoints.reduce( + (earliest, p) => (p.date < earliest ? p.date : earliest), + rawPoints[0].date + ) + + const byProtocol = new Map() + for (const point of rawPoints) { + const list = byProtocol.get(point.protocolName) ?? [] + list.push(point) + byProtocol.set(point.protocolName, list) + } + for (const list of byProtocol.values()) { + list.sort((a, b) => a.date.getTime() - b.date.getTime()) + } + + const series: DailyRateSnapshot[] = [] + const pointerIndex = new Map() + const heldValue = new Map() + for (const p of byProtocol.keys()) pointerIndex.set(p, 0) + + const dayCount = + Math.floor((endDate.getTime() - startDate.getTime()) / MS_PER_DAY) + 1 + + for (let i = 0; i < dayCount; i++) { + const day = new Date(startDate.getTime() + i * MS_PER_DAY) + const protocolsToday: YieldProtocol[] = [] + + for (const [protocolName, points] of byProtocol.entries()) { + let idx = pointerIndex.get(protocolName) ?? 0 + while ( + idx < points.length && + points[idx].date.getTime() <= day.getTime() + ) { + heldValue.set(protocolName, points[idx]) + idx++ + } + pointerIndex.set(protocolName, idx) + + const held = heldValue.get(protocolName) + if (held) { + protocolsToday.push({ + name: held.protocolName, + apy: held.apy, + assetSymbol: held.assetSymbol, + lastUpdated: held.date, + isAvailable: true, + }) + } + // No held value yet (protocol's first observation is still in the + // future relative to `day`) — correctly excluded from today's universe. + } + + series.push({ date: day, protocols: protocolsToday }) + } + + return { series, earliestAvailableDate } +} + +function formatDate(d: Date): string { + return d.toISOString().slice(0, 10) +} + +function calculateMaxDrawdownPercent(values: number[]): number { + let peak = values.length > 0 ? values[0] : 0 + let maxDrawdown = 0 + for (const v of values) { + if (v > peak) peak = v + if (peak > 0) { + const drawdown = ((peak - v) / peak) * 100 + if (drawdown > maxDrawdown) maxDrawdown = drawdown + } + } + return maxDrawdown +} + +/** + * Same simple-rate convention as calculateApy in snapshotter.ts: + * (gain / principal / years) * 100. Mirrors that function's guard. + */ +function calculateRealizedApy( + startingAmount: number, + finalValue: number, + years: number +): number { + if (startingAmount <= 0 || years <= 0) return 0 + return ((finalValue - startingAmount) / startingAmount / years) * 100 +} + +/** + * Runs a historical replay of `strategy` against `dailyRates`. + * + * The starting protocol is the highest-APY protocol available on the first + * day of the window — a backtest has no prior live position to anchor to, so + * this is the most neutral starting assumption. (If product wants to + * backtest starting from a *specific* protocol, extend BacktestRequest with + * an optional `startingProtocol` and use it here instead.) + */ +export async function runBacktest( + strategy: RebalanceStrategy, + dailyRates: DailyRateSnapshot[], + request: BacktestRequest +): Promise { + if (request.startingAmount <= 0) { + return { + status: 'invalid_range', + reason: 'startingAmount must be positive', + } + } + if (request.endDate.getTime() <= request.startDate.getTime()) { + return { + status: 'invalid_range', + reason: 'endDate must be after startDate', + } + } + if (dailyRates.length === 0) { + return { status: 'insufficient_history', earliestAvailableDate: null } + } + + const first = dailyRates[0] + if (first.protocols.length === 0) { + return { status: 'insufficient_history', earliestAvailableDate: null } + } + + let currentValue = request.startingAmount + let currentProtocol = first.protocols.reduce((best, p) => + p.apy > best.apy ? p : best + ).name + + const timeSeries: BacktestTimeSeriesPoint[] = [] + const rebalanceEvents: BacktestResult['rebalanceEvents'] = [] + const values: number[] = [] + + for (const day of dailyRates) { + if (day.protocols.length === 0) { + // No data at all for this day even after forward-fill (can happen at + // the very start of the window, before any protocol's first + // observation). Hold value flat rather than fabricating growth. + timeSeries.push({ + date: formatDate(day.date), + simulatedValue: currentValue, + }) + values.push(currentValue) + continue + } + + const currentProtocolRate = day.protocols.find( + (p) => p.name === currentProtocol + ) + const currentApy = currentProtocolRate?.apy ?? 0 + + // Accrue one day of simple (non-compounding) return at currentApy before + // asking the strategy whether to rebalance. + const dailyReturn = (currentApy / 100 / 365.25) * currentValue + currentValue += dailyReturn + + const params: StrategyParams = { + currentProtocol, + totalAmount: (currentValue * 1e18).toString(), + currentApy, + availableProtocols: day.protocols, + thresholds: request.thresholds ?? DEFAULT_BACKTEST_THRESHOLDS, + userStrategyPreferences: request.userStrategyPreferences ?? [], + riskCeiling: request.riskCeiling, + protocolRiskScores: request.protocolRiskScores, + goal: request.goal, + } + + const decision = await strategy.analyze(params) + + if ( + decision.shouldRebalance && + decision.targetProtocol !== currentProtocol + ) { + rebalanceEvents.push({ + date: formatDate(day.date), + fromProtocol: currentProtocol, + toProtocol: decision.targetProtocol, + reasoning: decision.reasoning, + }) + currentProtocol = decision.targetProtocol + } + + timeSeries.push({ + date: formatDate(day.date), + simulatedValue: currentValue, + }) + values.push(currentValue) + } + + const years = + (request.endDate.getTime() - request.startDate.getTime()) / MS_PER_YEAR + + const summary: BacktestSummary = { + finalValue: currentValue, + maxDrawdownPercent: calculateMaxDrawdownPercent(values), + realizedApy: calculateRealizedApy( + request.startingAmount, + currentValue, + years + ), + } + + return { + status: 'ok', + result: { + timeSeries, + summary, + finalProtocol: currentProtocol, + rebalanceEvents, + }, + } +} diff --git a/tests/unit/agent/backtest.test.ts b/tests/unit/agent/backtest.test.ts new file mode 100644 index 0000000..e32391c --- /dev/null +++ b/tests/unit/agent/backtest.test.ts @@ -0,0 +1,327 @@ +import fs from 'fs' +import path from 'path' +import { + buildDailyRateSeries, + runBacktest, + RawProtocolRatePoint, +} from '../../../src/agent/backtest' +import { + buildCacheKey, + getCached, + setCached, + clearBacktestCache, +} from '../../../src/agent/backtestCache' + +import { + MaxYieldStrategy, + TargetAllocationStrategy, +} from '../../../src/agent/strategies' + +jest.mock('../../../src/utils/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +const DAY = 24 * 60 * 60 * 1000 + +function d(dateStr: string): Date { + return new Date(dateStr + 'T00:00:00.000Z') +} + +// Fixed synthetic fixture: Blend flat at 5% APY for the whole window. +const flatFixture: RawProtocolRatePoint[] = Array.from( + { length: 10 }, + (_, i) => ({ + protocolName: 'Blend', + assetSymbol: 'USDC', + apy: 5, + date: new Date(d('2026-01-01').getTime() + i * DAY), + }) +) + +describe('backtest engine — structural guarantee', () => { + it('src/agent/backtest.ts has zero imports from src/stellar', () => { + const source = fs.readFileSync( + path.join(__dirname, '../../../src/agent/backtest.ts'), + 'utf-8' + ) + const importLines = source + .split('\n') + .filter((line) => /^\s*import\b/.test(line)) + const stellarImports = importLines.filter((line) => + /['"].*stellar/i.test(line) + ) + expect(stellarImports).toEqual([]) + }) +}) + +describe('buildDailyRateSeries — gap handling (hold-previous-value)', () => { + it('forward-fills a protocol across a gap in its rate history', () => { + const points: RawProtocolRatePoint[] = [ + { + protocolName: 'Blend', + assetSymbol: 'USDC', + apy: 5, + date: d('2026-01-01'), + }, + // Gap on 01-02 — no observation for Blend that day. + { + protocolName: 'Blend', + assetSymbol: 'USDC', + apy: 6, + date: d('2026-01-03'), + }, + ] + + const { series } = buildDailyRateSeries( + points, + d('2026-01-01'), + d('2026-01-03') + ) + + expect(series).toHaveLength(3) + expect(series[0].protocols[0].apy).toBe(5) // 01-01: real observation + expect(series[1].protocols[0].apy).toBe(5) // 01-02: held forward + expect(series[2].protocols[0].apy).toBe(6) // 01-03: new observation + }) + + it('excludes a protocol entirely before its first observation', () => { + const points: RawProtocolRatePoint[] = [ + { + protocolName: 'Blend', + assetSymbol: 'USDC', + apy: 5, + date: d('2026-01-01'), + }, + { + protocolName: 'Luma', + assetSymbol: 'USDC', + apy: 9, + date: d('2026-01-03'), + }, + ] + + const { series } = buildDailyRateSeries( + points, + d('2026-01-01'), + d('2026-01-03') + ) + + expect(series[0].protocols.map((p) => p.name)).toEqual(['Blend']) + expect(series[1].protocols.map((p) => p.name)).toEqual(['Blend']) + expect(series[2].protocols.map((p) => p.name).sort()).toEqual([ + 'Blend', + 'Luma', + ]) + }) + + it('reports the earliest available date across all protocols', () => { + const points: RawProtocolRatePoint[] = [ + { + protocolName: 'Blend', + assetSymbol: 'USDC', + apy: 5, + date: d('2026-02-01'), + }, + { + protocolName: 'Luma', + assetSymbol: 'USDC', + apy: 9, + date: d('2026-01-15'), + }, + ] + const { earliestAvailableDate } = buildDailyRateSeries( + points, + d('2026-01-15'), + d('2026-02-01') + ) + expect(earliestAvailableDate?.toISOString().slice(0, 10)).toBe('2026-01-15') + }) + + it('returns an empty series and null earliest date with no observations', () => { + const { series, earliestAvailableDate } = buildDailyRateSeries( + [], + d('2026-01-01'), + d('2026-01-05') + ) + expect(series).toEqual([]) + expect(earliestAvailableDate).toBeNull() + }) +}) + +describe('runBacktest — known-output fixture', () => { + it('produces a monotonically increasing value series at a flat 5% APY with a single protocol', async () => { + const { series } = buildDailyRateSeries( + flatFixture, + d('2026-01-01'), + d('2026-01-10') + ) + const outcome = await runBacktest(new MaxYieldStrategy(), series, { + strategyName: 'MAX_YIELD', + startDate: d('2026-01-01'), + endDate: d('2026-01-10'), + startingAmount: 1000, + }) + + expect(outcome.status).toBe('ok') + if (outcome.status !== 'ok') return + + expect(outcome.result.timeSeries).toHaveLength(10) + expect(outcome.result.rebalanceEvents).toEqual([]) + expect(outcome.result.finalProtocol).toBe('Blend') + + // Known expected output: 10 days of simple daily accrual at 5%/365.25 on 1000. + const dailyReturn = (5 / 100 / 365.25) * 1000 + const expectedFinal = 1000 + dailyReturn * 10 + expect(outcome.result.summary.finalValue).toBeCloseTo(expectedFinal, 2) + expect(outcome.result.summary.maxDrawdownPercent).toBe(0) // monotonically increasing + }) + + it('rebalances into a materially better protocol when one becomes available', async () => { + const points: RawProtocolRatePoint[] = [ + ...flatFixture, + // Luma appears halfway through at a much higher APY. + ...Array.from({ length: 5 }, (_, i) => ({ + protocolName: 'Luma', + assetSymbol: 'USDC', + apy: 20, + date: new Date(d('2026-01-05').getTime() + i * DAY), + })), + ] + const { series } = buildDailyRateSeries( + points, + d('2026-01-01'), + d('2026-01-10') + ) + + const outcome = await runBacktest(new MaxYieldStrategy(), series, { + strategyName: 'MAX_YIELD', + startDate: d('2026-01-01'), + endDate: d('2026-01-10'), + startingAmount: 10000, // large enough that gas cost doesn't block the rebalance + }) + + expect(outcome.status).toBe('ok') + if (outcome.status !== 'ok') return + expect(outcome.result.rebalanceEvents.length).toBeGreaterThan(0) + expect(outcome.result.rebalanceEvents[0].toProtocol).toBe('Luma') + expect(outcome.result.finalProtocol).toBe('Luma') + }) + + it('returns insufficient_history when there is no rate data at all', async () => { + const outcome = await runBacktest(new MaxYieldStrategy(), [], { + strategyName: 'MAX_YIELD', + startDate: d('2026-01-01'), + endDate: d('2026-01-10'), + startingAmount: 1000, + }) + expect(outcome.status).toBe('insufficient_history') + }) + + it('rejects a non-positive startingAmount', async () => { + const { series } = buildDailyRateSeries( + flatFixture, + d('2026-01-01'), + d('2026-01-10') + ) + const outcome = await runBacktest(new MaxYieldStrategy(), series, { + strategyName: 'MAX_YIELD', + startDate: d('2026-01-01'), + endDate: d('2026-01-10'), + startingAmount: 0, + }) + expect(outcome.status).toBe('invalid_range') + }) + + it('supports TargetAllocationStrategy over the same fixture shape', async () => { + const points: RawProtocolRatePoint[] = [ + ...flatFixture, + ...Array.from({ length: 10 }, (_, i) => ({ + protocolName: 'Stellar DEX', + assetSymbol: 'USDC', + apy: 7, + date: new Date(d('2026-01-01').getTime() + i * DAY), + })), + ] + const { series } = buildDailyRateSeries( + points, + d('2026-01-01'), + d('2026-01-10') + ) + + const outcome = await runBacktest(new TargetAllocationStrategy(), series, { + strategyName: 'TARGET_ALLOCATION', + startDate: d('2026-01-01'), + endDate: d('2026-01-10'), + startingAmount: 10000, + userStrategyPreferences: [ + { + userId: 'user-1', + strategyName: 'TARGET_ALLOCATION', + targetAllocations: { Blend: 20, 'Stellar DEX': 80 }, + }, + ], + }) + + expect(outcome.status).toBe('ok') + }) +}) + +describe('backtest result caching', () => { + beforeEach(() => clearBacktestCache()) + + it('produces the same cache key for identical requests', () => { + const req = { + strategyName: 'MAX_YIELD' as const, + startDate: d('2026-01-01'), + endDate: d('2026-01-10'), + startingAmount: 1000, + } + expect(buildCacheKey(req)).toBe(buildCacheKey({ ...req })) + }) + + it('produces different cache keys for different requests', () => { + const base = { + strategyName: 'MAX_YIELD' as const, + startDate: d('2026-01-01'), + endDate: d('2026-01-10'), + startingAmount: 1000, + } + expect(buildCacheKey(base)).not.toBe( + buildCacheKey({ ...base, startingAmount: 2000 }) + ) + }) + + it('a second identical request is served from cache without recomputing', async () => { + const { series } = buildDailyRateSeries( + flatFixture, + d('2026-01-01'), + d('2026-01-10') + ) + const request = { + strategyName: 'MAX_YIELD' as const, + startDate: d('2026-01-01'), + endDate: d('2026-01-10'), + startingAmount: 1000, + } + const key = buildCacheKey(request) + + expect(getCached(key)).toBeUndefined() + + const analyzeSpy = jest.spyOn(MaxYieldStrategy.prototype, 'analyze') + const strategy = new MaxYieldStrategy() + const outcome = await runBacktest(strategy, series, request) + setCached(key, outcome) + + const callsAfterFirstRun = analyzeSpy.mock.calls.length + expect(callsAfterFirstRun).toBeGreaterThan(0) + + // Simulate the route handler's cache-hit path: it must return the + // cached outcome WITHOUT calling runBacktest (and therefore without + // calling strategy.analyze) again. + const cachedOutcome = getCached(key) + expect(cachedOutcome).toEqual(outcome) + expect(analyzeSpy.mock.calls.length).toBe(callsAfterFirstRun) // unchanged — no recompute + + analyzeSpy.mockRestore() + }) +}) diff --git a/tests/unit/utils/http-client.test.ts b/tests/unit/utils/http-client.test.ts index f5ad1a9..7f48f6b 100644 --- a/tests/unit/utils/http-client.test.ts +++ b/tests/unit/utils/http-client.test.ts @@ -103,18 +103,20 @@ describe('HttpClientAdapter', () => { }) it('should transition to half-open after reset timeout', async () => { + jest.useFakeTimers() const fn = jest.fn().mockRejectedValue(new Error('fail')) - // Trigger circuit breaker (3 failures needed) + // Trigger circuit breaker (3 failures needed) — deterministic under fake timers for (let i = 0; i < 3; i++) { - await expect(adapter.execute(fn)).rejects.toThrow() + const p = expect(adapter.execute(fn)).rejects.toThrow() + await jest.runAllTimersAsync() + await p } // Circuit is OPEN - should block await expect(adapter.execute(fn)).rejects.toThrow(CircuitBreakerError) // Advance past reset timeout - jest.useFakeTimers() jest.advanceTimersByTime(200) // Should now be half-open and allow one request