Skip to content
Open
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
203 changes: 203 additions & 0 deletions src/lib/compounding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* Gas-aware auto-compounding economics — pure, side-effect-free.
*
* Shared core for both the agent's compound gate and the app's projection card.
* The agent compounds accrued yield back into principal only when it is
* net-profitable after cost, so small treasuries compound rarely and large ones
* often — the cadence falls out of the numbers, it is never hardcoded.
*
* See docs plan: "Gas-aware auto-compounding". All amounts are in base-token units
* (e.g. USDC); `apr` is an annual fraction (0.05 = 5%).
*/

const DAYS_PER_YEAR = 365

export interface CompoundingConfig {
/** Position size in base-token units. */
principal: number
/** Annual rate as a fraction, e.g. 0.05 for 5%. */
apr: number
/** Cost of one compound transaction (gas) in base-token units. */
gasCost: number
/** Fixed protocol/relayer fee per compound, base-token units. Default 0. */
fixedFee?: number
/** Performance fee as a fraction of net yield, taken at compound. Default 0. */
performanceFeeRate?: number
/** Safety multiple: compound only when accrued >= costMultiple * cost. Default 10. */
costMultiple?: number
}

const DEFAULT_COST_MULTIPLE = 10

function validate(config: CompoundingConfig): void {
if (config.principal < 0) throw new Error('principal must be >= 0')
if (config.apr < 0) throw new Error('apr must be >= 0')
if (config.gasCost < 0) throw new Error('gasCost must be >= 0')
if ((config.costMultiple ?? DEFAULT_COST_MULTIPLE) <= 0) throw new Error('costMultiple must be > 0')
}

/** Daily yield accrued on the current principal (linear within a day). */
export function dailyAccrual(config: CompoundingConfig): number {
validate(config)
return (config.principal * config.apr) / DAYS_PER_YEAR
}

/** Absolute cost of one compound: gas + fixed fee. */
export function costPerCompound(config: CompoundingConfig): number {
validate(config)
return config.gasCost + (config.fixedFee ?? 0)
}

/**
* The gate. True when the accrued yield clears the cost multiple — i.e. gas + fee
* is a small enough fraction of what is being reinvested to be worth it.
*/
export function shouldCompound(accrued: number, config: CompoundingConfig): boolean {
const cost = costPerCompound(config)
if (accrued <= 0) return false
if (cost <= 0) return true
const m = config.costMultiple ?? DEFAULT_COST_MULTIPLE
return accrued >= m * cost
}

/**
* Break-even interval in days: how long until accrued yield first clears the gate,
* starting from zero. Inversely proportional to position size. Infinity if no yield.
*/
export function breakEvenIntervalDays(config: CompoundingConfig): number {
const perDay = dailyAccrual(config)
if (perDay <= 0) return Infinity
const m = config.costMultiple ?? DEFAULT_COST_MULTIPLE
return (m * costPerCompound(config)) / perDay
}

/**
* Days until the next compound, given yield already accrued since the last one.
* Zero if the gate is already cleared. For the app's "next compound ~in X days".
*/
export function nextCompoundEstimateDays(config: CompoundingConfig, accruedSoFar = 0): number {
const perDay = dailyAccrual(config)
if (perDay <= 0) return Infinity
const m = config.costMultiple ?? DEFAULT_COST_MULTIPLE
const remaining = m * costPerCompound(config) - accruedSoFar
return remaining <= 0 ? 0 : remaining / perDay
}

/**
* Amount reinvested into principal when compounding `accrued`: yield minus the
* absolute cost, minus the performance fee on the net. Floored at 0.
*/
export function netReinvested(accrued: number, config: CompoundingConfig): number {
const net = accrued - costPerCompound(config)
if (net <= 0) return 0
return net * (1 - (config.performanceFeeRate ?? 0))
}

/** Value of holding without ever compounding: linear yield, no gas spent. */
export function projectSimple(config: CompoundingConfig, horizonDays: number): number {
validate(config)
if (horizonDays < 0) throw new Error('horizonDays must be >= 0')
return config.principal * (1 + (config.apr * horizonDays) / DAYS_PER_YEAR)
}

export interface CompoundedProjection {
/** User's value at the horizon (grown principal + un-harvested accrual). */
finalValue: number
/** Number of compounds performed. */
compounds: number
/** Total gas + fixed fee spent across all compounds. */
totalCost: number
/** Total performance fee paid to the platform. */
totalPerformanceFee: number
/** Annualised effective rate implied by finalValue vs principal. */
effectiveApr: number
}

/**
* Realistic compounded projection: a daily simulation that accrues yield on the
* live principal and compounds whenever the gate fires, deducting cost and fee.
* Gas is modelled as coming out of harvested yield (conservative for display).
*/
export function projectCompounded(config: CompoundingConfig, horizonDays: number): CompoundedProjection {
validate(config)
if (horizonDays < 0) throw new Error('horizonDays must be >= 0')

const days = Math.floor(horizonDays)
const feeRate = config.performanceFeeRate ?? 0
let principal = config.principal
let accrued = 0
let compounds = 0
let totalCost = 0
let totalPerformanceFee = 0

for (let d = 0; d < days; d += 1) {
accrued += (principal * config.apr) / DAYS_PER_YEAR
const stepConfig: CompoundingConfig = { ...config, principal }
if (shouldCompound(accrued, stepConfig)) {
const cost = costPerCompound(stepConfig)
const gross = accrued - cost
const fee = gross > 0 ? gross * feeRate : 0
principal += netReinvested(accrued, stepConfig)
totalCost += cost
totalPerformanceFee += fee
accrued = 0
compounds += 1
}
}

const finalValue = principal + accrued
const years = days / DAYS_PER_YEAR
const effectiveApr = years > 0 && config.principal > 0
? Math.pow(finalValue / config.principal, 1 / years) - 1
: 0

return { finalValue, compounds, totalCost, totalPerformanceFee, effectiveApr }
}

export interface ProjectionPoint {
day: number
simple: number
compounded: number
}

/**
* Sampled curve of simple vs compounded value over the horizon, for the app card.
* `points` is the number of samples (inclusive of day 0 and the horizon).
*/
export function projectionCurve(
config: CompoundingConfig,
horizonDays: number,
points = 24,
): ProjectionPoint[] {
validate(config)
if (horizonDays <= 0 || points < 2) {
return [{ day: 0, simple: config.principal, compounded: config.principal }]
}

const days = Math.floor(horizonDays)
const sampleEvery = days / (points - 1)

let principal = config.principal
let accrued = 0
const out: ProjectionPoint[] = [{ day: 0, simple: config.principal, compounded: config.principal }]
let nextSampleAt = sampleEvery

for (let d = 1; d <= days; d += 1) {
accrued += (principal * config.apr) / DAYS_PER_YEAR
const stepConfig: CompoundingConfig = { ...config, principal }
if (shouldCompound(accrued, stepConfig)) {
principal += netReinvested(accrued, stepConfig)
accrued = 0
}
if (d >= nextSampleAt || d === days) {
out.push({
day: d,
simple: projectSimple(config, d),
compounded: principal + accrued,
})
nextSampleAt += sampleEvery
}
}

return out
}
135 changes: 135 additions & 0 deletions test/unit/compounding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* The property this suite protects: the compound gate is profitability-driven, so a
* small treasury waits (gas would eat the harvest) and a large one runs often. If
* that inverts, the agent starts compounding at a loss for small positions. The two
* worked examples below are the ones the team signed off on.
*
* Run: bun test test/unit
*/
import { describe, test, expect } from 'bun:test'
import {
dailyAccrual,
costPerCompound,
shouldCompound,
breakEvenIntervalDays,
nextCompoundEstimateDays,
netReinvested,
projectSimple,
projectCompounded,
projectionCurve,
type CompoundingConfig,
} from '../../src/lib/compounding'

// 10K position yielding ~$0.20/day; gas $0.15. Threshold M=10 -> compound weekly.
const SMALL: CompoundingConfig = {
principal: 10_000,
apr: (0.2 * 365) / 10_000, // ~0.73% APR, i.e. $0.20/day
gasCost: 0.15,
costMultiple: 10,
}

// 10M position yielding ~$2,000/day; gas $0.15. Threshold cleared in minutes.
const LARGE: CompoundingConfig = {
principal: 10_000_000,
apr: (2000 * 365) / 10_000_000, // ~7.3% APR, i.e. $2,000/day
gasCost: 0.15,
costMultiple: 10,
}

describe('accrual and cost', () => {
test('dailyAccrual matches the position economics', () => {
expect(dailyAccrual(SMALL)).toBeCloseTo(0.2, 6)
expect(dailyAccrual(LARGE)).toBeCloseTo(2000, 3)
})

test('costPerCompound sums gas and fixed fee', () => {
expect(costPerCompound(SMALL)).toBeCloseTo(0.15, 6)
expect(costPerCompound({ ...SMALL, fixedFee: 0.05 })).toBeCloseTo(0.2, 6)
})
})

describe('the gate', () => {
test('waits below the threshold, fires at or above it', () => {
expect(shouldCompound(1.49, SMALL)).toBe(false) // 10 * 0.15 = 1.50
expect(shouldCompound(1.5, SMALL)).toBe(true)
expect(shouldCompound(0, SMALL)).toBe(false)
})

test('daily gas would eat the small harvest, so it does NOT fire daily', () => {
// one day of accrual on the small position is $0.20, threshold is $1.50
expect(shouldCompound(dailyAccrual(SMALL), SMALL)).toBe(false)
})

test('one day of accrual on the large position clears the gate immediately', () => {
expect(shouldCompound(dailyAccrual(LARGE), LARGE)).toBe(true)
})
})

describe('cadence — small waits, large runs', () => {
test('small treasury compounds about weekly', () => {
const days = breakEvenIntervalDays(SMALL)
expect(days).toBeCloseTo(7.5, 1) // 1.50 / 0.20
expect(days).toBeGreaterThan(1)
})

test('large treasury compounds well within a day', () => {
expect(breakEvenIntervalDays(LARGE)).toBeLessThan(1)
})

test('cadence is inversely proportional to position size', () => {
expect(breakEvenIntervalDays(SMALL)).toBeGreaterThan(breakEvenIntervalDays(LARGE))
})

test('nextCompoundEstimate shrinks as yield accrues', () => {
const fromZero = nextCompoundEstimateDays(SMALL, 0)
const halfway = nextCompoundEstimateDays(SMALL, 0.75)
expect(fromZero).toBeCloseTo(7.5, 1)
expect(halfway).toBeLessThan(fromZero)
expect(nextCompoundEstimateDays(SMALL, 2)).toBe(0) // already past threshold
})
})

describe('net reinvested and fees', () => {
test('deducts cost and performance fee from the harvest', () => {
// accrued 2.00, cost 0.15, 10% perf fee -> (2.00 - 0.15) * 0.9 = 1.665
expect(netReinvested(2, { ...SMALL, performanceFeeRate: 0.1 })).toBeCloseTo(1.665, 6)
})

test('never reinvests a negative amount', () => {
expect(netReinvested(0.1, SMALL)).toBe(0) // below the gas cost
})
})

describe('projections', () => {
test('simple projection is linear yield with no gas', () => {
expect(projectSimple(LARGE, 365)).toBeCloseTo(10_000_000 * (1 + LARGE.apr), 2)
})

test('compounding beats simple for a large position over a year', () => {
const simple = projectSimple(LARGE, 365)
const { finalValue, compounds } = projectCompounded(LARGE, 365)
expect(finalValue).toBeGreaterThan(simple)
expect(compounds).toBeGreaterThan(300) // roughly daily
})

test('small position compounds rarely (agent refuses to burn gas)', () => {
const { compounds } = projectCompounded(SMALL, 365)
expect(compounds).toBeLessThan(60) // ~weekly at most, not daily
expect(compounds).toBeGreaterThan(0)
})

test('effectiveApr exceeds the nominal apr when compounding is active', () => {
const { effectiveApr } = projectCompounded(LARGE, 365)
expect(effectiveApr).toBeGreaterThan(LARGE.apr)
})

test('projectionCurve is monotonic and ends at the horizon', () => {
const curve = projectionCurve(LARGE, 365, 12)
expect(curve[0].day).toBe(0)
expect(curve[curve.length - 1].day).toBe(365)
for (let i = 1; i < curve.length; i += 1) {
expect(curve[i].compounded).toBeGreaterThanOrEqual(curve[i - 1].compounded)
expect(curve[i].compounded).toBeGreaterThanOrEqual(curve[i].simple)
}
})
})