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
47 changes: 47 additions & 0 deletions prisma/migrations/20260828160000_smart_dca_engine/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
-- CreateEnum
CREATE TYPE "ContributionPolicy" AS ENUM ('FIXED', 'ADAPTIVE');

-- CreateEnum
CREATE TYPE "CatchUpMode" AS ENUM ('SKIP', 'ACCUMULATE', 'RETRY');

-- CreateEnum
CREATE TYPE "RecurringDepositRunStatus" AS ENUM ('EXECUTED', 'SKIPPED', 'FAILED', 'PENDING_APPROVAL', 'PARTIAL');

-- AlterTable: Extend RecurringDepositPlan with Smart DCA fields
ALTER TABLE "recurring_deposit_plans" ADD COLUMN "policy" "ContributionPolicy" NOT NULL DEFAULT 'FIXED';
ALTER TABLE "recurring_deposit_plans" ADD COLUMN "catchUpMode" "CatchUpMode" NOT NULL DEFAULT 'RETRY';
ALTER TABLE "recurring_deposit_plans" ADD COLUMN "pauseOnDrawdownPct" DOUBLE PRECISION;
ALTER TABLE "recurring_deposit_plans" ADD COLUMN "doubleOnDrawdown" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "recurring_deposit_plans" ADD COLUMN "accumulatedRuns" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "recurring_deposit_plans" ADD COLUMN "consecutiveFailures" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "recurring_deposit_plans" ADD COLUMN "autoPauseReason" TEXT;
ALTER TABLE "recurring_deposit_plans" ADD COLUMN "allocationMap" JSONB;

-- CreateTable: RecurringDepositRun (per-run ledger)
CREATE TABLE "recurring_deposit_runs" (
"id" TEXT NOT NULL,
"planId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"baselineAmount" DECIMAL(36,18) NOT NULL,
"appliedAmount" DECIMAL(36,18) NOT NULL,
"regimeSnapshot" JSONB,
"reasoning" TEXT,
"status" "RecurringDepositRunStatus" NOT NULL DEFAULT 'EXECUTED',
"txHash" TEXT,
"errorMessage" TEXT,
"allocationLegs" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "recurring_deposit_runs_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "recurring_deposit_runs_planId_idx" ON "recurring_deposit_runs"("planId");
CREATE INDEX "recurring_deposit_runs_userId_idx" ON "recurring_deposit_runs"("userId");
CREATE INDEX "recurring_deposit_runs_createdAt_idx" ON "recurring_deposit_runs"("createdAt");

-- AddForeignKey
ALTER TABLE "recurring_deposit_runs" ADD CONSTRAINT "recurring_deposit_runs_planId_fkey" FOREIGN KEY ("planId") REFERENCES "recurring_deposit_plans"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "recurring_deposit_runs" ADD CONSTRAINT "recurring_deposit_runs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
89 changes: 87 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,33 @@ enum RecurringDepositPlanStatus {
CANCELLED
}

/// Contribution policy for recurring deposits (#311).
/// FIXED = current behavior (default, backward compatible).
/// ADAPTIVE = volatility-aware scaling with pause-on-drawdown.
enum ContributionPolicy {
FIXED
ADAPTIVE
}

/// How to handle a plan whose run was skipped (#311).
enum CatchUpMode {
/// Skip the missed run, continue on schedule.
SKIP
/// Accumulate missed runs and execute them on the next scheduled window.
ACCUMULATE
/// Retry the skipped run on the next sweep (default).
RETRY
}

/// Status of an individual recurring deposit run (#311).
enum RecurringDepositRunStatus {
EXECUTED
SKIPPED
FAILED
PENDING_APPROVAL
PARTIAL
}

// Where an acquisition/disposal USD price came from (#284). Only stablecoins
// are priced in v1; anything else is stored with a null price and surfaced as
// unpriced in the tax report — never silently zeroed.
Expand Down Expand Up @@ -232,6 +259,7 @@ model User {
referralCode ReferralCode?
referralConversion ReferralConversion?
recurringDepositPlans RecurringDepositPlan[]
recurringDepositRuns RecurringDepositRun[]
alertRules AlertRule[]
costBasisLots CostBasisLot[]
lotDisposals LotDisposal[]
Expand Down Expand Up @@ -1069,16 +1097,73 @@ model RecurringDepositPlan {
status RecurringDepositPlanStatus @default(ACTIVE)
lastRunAt DateTime?
lastRunStatus String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

// ── Smart DCA fields (#311) ────────────────────────────────────────────
/// Contribution policy: FIXED (default) or ADAPTIVE (volatility-aware).
policy ContributionPolicy @default(FIXED)
/// Catch-up mode for skipped runs: RETRY (default), SKIP, or ACCUMULATE.
catchUpMode CatchUpMode @default(RETRY)
/// Pause-on-drawdown: skip (or double) when portfolio drawdown exceeds this %.
/// Null = no drawdown pause (default for FIXED plans).
pauseOnDrawdownPct Float?
/// When paused on drawdown, double the contribution instead of skipping.
doubleOnDrawdown Boolean @default(false)
/// Accumulated missed runs (ACCUMULATE catch-up mode). Capped at 10.
accumulatedRuns Int @default(0)
/// Consecutive failure count for auto-pause backoff.
consecutiveFailures Int @default(0)
/// Auto-pause reason (user-visible) when consecutiveFailures exceeds threshold.
autoPauseReason String?
/// Optional multi-protocol allocation as JSON: { "Protocol": weight% }.
/// Single-protocol plans are a single-entry map — no schema break.
allocationMap Json?

user User @relation(fields: [userId], references: [id], onDelete: Cascade)
runs RecurringDepositRun[]

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([userId])
@@index([status, nextRunAt])
@@map("recurring_deposit_plans")
}

/// Per-run ledger for recurring deposits (#311).
/// Records baseline amount, applied amount, regime snapshot, and reasoning
/// for every execution — the policy audit trail (AgentLog is the deposit
/// audit trail).
model RecurringDepositRun {
id String @id @default(uuid())
planId String
userId String
/// Baseline amount before adaptive scaling.
baselineAmount Decimal @db.Decimal(36, 18)
/// Actual amount deposited (after regime scaling, drawdown check).
appliedAmount Decimal @db.Decimal(36, 18)
/// Regime metrics snapshot at time of run (JSON: volatility, drawdown, etc.).
regimeSnapshot Json?
/// Human-readable reasoning for any deviation from baseline.
reasoning String?
/// Status of this individual run.
status RecurringDepositRunStatus @default(EXECUTED)
/// Transaction hash if deposit was executed.
txHash String?
/// Error message if run failed.
errorMessage String?
/// Allocation legs (JSON): [{ protocol, amount, txHash?, error? }].
allocationLegs Json?
createdAt DateTime @default(now())

plan RecurringDepositPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([planId])
@@index([userId])
@@index([createdAt])
@@map("recurring_deposit_runs")
}

/// Cost-basis lot for tax reporting (#284). Exactly one lot per confirmed
/// on-chain DEPOSIT Transaction (`transactionId` unique — the idempotency
/// anchor under event replay). `remainingAmount` is decremented by FIFO
Expand Down
179 changes: 179 additions & 0 deletions src/deposits/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* Recurring Deposit Preview / Simulation (#311).
*
* A deterministic simulation of the next N runs under the plan's policy.
* Explicitly labeled as simulation, not a guarantee.
*
* Uses current ProtocolRate data and the same non-compounding APY convention
* as calculateApy so numbers are consistent with the rest of the product.
*
* ─── CORRECTNESS ─────────────────────────────────────────────────────────────
*
* The preview renders the policy's inputs (regime math, drawdown state,
* allocation math) so the user can audit the adaptive logic before it
* touches money. It is a WHAT-IF tool, not a forecast.
*/

import {
computeContribution,
computeDrawdownPercent,
computeNextRunAfterSkip,
cadenceToDays,
type SmartDcaConfig,
type ContributionDecision,
type RegimeInput,
type DrawdownInput,
} from './smartDcaPolicy'
import { addCadence } from '../utils/cadence'

export interface PreviewRun {
/** Run number (1-based). */
runNumber: number
/** Scheduled date for this run. */
scheduledDate: string // YYYY-MM-DD
/** Baseline amount before adaptive scaling. */
baselineAmount: number
/** Final amount after scaling/drawdown/allocation. */
appliedAmount: number
/** Whether this run would be skipped due to drawdown. */
wouldSkip: boolean
/** Volatility regime for this run (ADAPTIVE only). */
regime: string | null
/** Scaling factor applied (ADAPTIVE only). */
scaleFactor: number | null
/** Drawdown percentage at time of this run. */
drawdownPct: number
/** Human-readable reasoning for this run's amount. */
reasoning: string
/** Allocation legs if multi-protocol. */
allocationLegs: { protocol: string; weightPercent: number; amount: number }[]
}

export interface PreviewResult {
/** Plan ID. */
planId: string
/** Number of runs simulated. */
runsCount: number
/** Total projected contribution across all simulated runs. */
totalContribution: number
/** Simulated runs. */
runs: PreviewRun[]
/** Model disclaimer — always present. */
disclaimer: string
/** Whether this is a simulation. */
isSimulation: true
}

/**
* Generate a deterministic preview of the next N runs for a recurring
* deposit plan. The preview uses the plan's current configuration and
* projects forward using the same cadence and policy logic.
*
* @param plan - The plan configuration (amount, cadence, policy, etc.).
* @param baselineAmount - The plan's baseline amount.
* @param regimeInput - Current regime data (trailing values). Null = insufficient history.
* @param drawdownInput - Current drawdown state. Null = no drawdown data.
* @param numRuns - Number of future runs to simulate (default 12).
* @param startDate - Starting date for the simulation (default: now).
* @returns Deterministic preview result with disclaimer.
*/
export function generatePreview(
plan: {
id: string
policy: SmartDcaConfig['policy']
catchUpMode: SmartDcaConfig['catchUpMode']
pauseOnDrawdownPct: SmartDcaConfig['pauseOnDrawdownPct']
doubleOnDrawdown: SmartDcaConfig['doubleOnDrawdown']
accumulatedRuns: SmartDcaConfig['accumulatedRuns']
consecutiveFailures: SmartDcaConfig['consecutiveFailures']
allocationMap: SmartDcaConfig['allocationMap']
cadence: 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY'
amount: number
},
regimeInput: RegimeInput | null,
drawdownInput: DrawdownInput | null,
numRuns: number = 12,
startDate: Date = new Date()
): PreviewResult {
const cadenceDays = cadenceToDays(plan.cadence)
const config: SmartDcaConfig = {
policy: plan.policy,
catchUpMode: plan.catchUpMode,
pauseOnDrawdownPct: plan.pauseOnDrawdownPct,
doubleOnDrawdown: plan.doubleOnDrawdown,
accumulatedRuns: plan.accumulatedRuns,
consecutiveFailures: plan.consecutiveFailures,
allocationMap: plan.allocationMap,
}

const runs: PreviewRun[] = []
let totalContribution = 0
let currentNextRunAt = addCadence(plan.cadence, startDate)
let currentAccumulated = plan.accumulatedRuns

for (let i = 0; i < numRuns; i++) {
// For the preview, we use the same regime/drawdown for each run
// (a real implementation would update these each run, but the preview
// uses current state as a reasonable approximation).
const decision: ContributionDecision = computeContribution(
{ ...config, accumulatedRuns: currentAccumulated },
plan.amount,
regimeInput,
drawdownInput,
currentNextRunAt
)

const wouldSkip = decision.appliedAmount === 0 && decision.pausedOnDrawdown
const drawdownPct = drawdownInput
? computeDrawdownPercent(
drawdownInput.peakValue,
drawdownInput.currentValue
)
: 0

runs.push({
runNumber: i + 1,
scheduledDate: currentNextRunAt.toISOString().slice(0, 10),
baselineAmount: plan.amount,
appliedAmount: decision.appliedAmount,
wouldSkip,
regime: decision.regime,
scaleFactor: decision.scaleFactor,
drawdownPct,
reasoning: decision.reasoning,
allocationLegs: decision.allocationLegs,
})

totalContribution += decision.appliedAmount

// Advance to next run
if (wouldSkip) {
const next = computeNextRunAfterSkip(
plan.catchUpMode,
currentNextRunAt,
cadenceDays,
currentAccumulated
)
currentNextRunAt = next.nextRunAt
currentAccumulated = next.accumulatedRuns
} else {
currentNextRunAt = addCadence(plan.cadence, currentNextRunAt)
// Reset accumulated after a successful run
if (plan.catchUpMode === 'ACCUMULATE') {
currentAccumulated = 0
}
}
}

return {
planId: plan.id,
runsCount: runs.length,
totalContribution,
runs,
disclaimer:
'This is a simulation of future deposits based on current plan settings and market data. ' +
'Actual deposits may differ due to market conditions, balance availability, and protocol changes. ' +
'This is not a guarantee of future performance.',
isSimulation: true as const,
}
}
Loading
Loading