Two-stage depeg detection pipeline for stablecoins. Stage 1 (detection) runs every 15 minutes as part of the sync-stablecoins cron. Stage 2 (confirmation) runs immediately after, promoting or rejecting candidates that require multi-source agreement: large-cap coins, low-confidence primary-price inputs, and extreme moves.
Thresholds & Constants
| Constant | Value | Purpose |
|---|---|---|
DEPEG_THRESHOLD_BPS | 100 (1%) | USD peg deviation threshold |
DEPEG_THRESHOLD_BPS_NON_USD | 150 (1.5%) | Non-USD peg threshold (accounts for FX noise + thin liquidity) |
DEPEG_CONFIRMATION_SUPPLY_THRESHOLD | $1,000,000,000 | Coins above this require multi-source confirmation |
DEPEG_PENDING_MIN_AGE_SEC | 900 (15 min) | Minimum time before a pending record can be promoted |
DEPEG_PENDING_EXPIRY_SEC | 2700 (45 min) | Maximum time before a pending record expires |
DEPEG_SECONDARY_THRESHOLD_RATIO | 0.5 | Secondary source agreement bar (50% of primary threshold) |
DEPEG_PRIMARY_PRICE_MAX_AGE_SEC | 1800 (30 min) | Primary prices older than this require confirmation |
DEPEG_EXTREME_MOVE_BPS | 5000 (50%) | Severe move threshold routed through dedicated confirmation lane |
DEX_FRESHNESS_SEC | 2100 (35 min) | DEX prices older than this are ignored |
DEX_PRICE_CHECK_DEPEG_MIN_TVL_USD | 1,000,000 | Minimum aggregate DEX source TVL required before depeg logic trusts a DEX row |
DEPEG_DEX_PROTOCOL_CORROBORATION_MIN | 2 protocol groups | Minimum protocol-level DEX corroborations required before aggregate DEX rows can directly suppress or resolve live depeg state |
getDepegThresholdBps(pegType) returns 100 for peggedUSD, 150 for all other peg types.
Database Schema
depeg_events (migration 0006)
CREATE TABLE IF NOT EXISTS depeg_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stablecoin_id TEXT NOT NULL,
symbol TEXT NOT NULL,
peg_type TEXT NOT NULL,
direction TEXT NOT NULL, -- "above" | "below"
peak_deviation_bps INTEGER NOT NULL,
started_at INTEGER NOT NULL, -- Unix seconds
ended_at INTEGER, -- NULL = ongoing
start_price REAL NOT NULL,
peak_price REAL,
recovery_price REAL,
peg_reference REAL NOT NULL,
source TEXT NOT NULL DEFAULT 'live', -- "live" | "backfill"
confirmation_sources TEXT, -- JSON/provenance for promoted pending rows
pending_reason TEXT -- reason flags carried from depeg_pending
);
CREATE INDEX idx_depeg_stablecoin ON depeg_events(stablecoin_id);
CREATE INDEX idx_depeg_started ON depeg_events(started_at DESC);
Uniqueness and open-event indexes (migration 0008):
CREATE UNIQUE INDEX idx_depeg_unique ON depeg_events(stablecoin_id, started_at, source);
CREATE INDEX idx_depeg_open ON depeg_events(stablecoin_id) WHERE ended_at IS NULL;
depeg_pending (migration 0023)
CREATE TABLE IF NOT EXISTS depeg_pending (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stablecoin_id TEXT NOT NULL,
symbol TEXT NOT NULL,
peg_type TEXT NOT NULL,
direction TEXT NOT NULL,
first_seen_bps INTEGER NOT NULL,
first_seen_at INTEGER NOT NULL,
first_price REAL NOT NULL,
peg_reference REAL NOT NULL,
reason TEXT NOT NULL DEFAULT 'large-cap', -- "large-cap" | "low-confidence" | "extreme-move" or "+"-joined flags
last_seen_bps INTEGER,
last_seen_at INTEGER,
last_price REAL,
peak_seen_bps INTEGER,
peak_price REAL,
updated_at INTEGER
);
CREATE UNIQUE INDEX idx_depeg_pending_coin ON depeg_pending(stablecoin_id);
One row per coin maximum. Holds depeg candidates awaiting multi-source confirmation. Migration 0061 adds the reason column so operators can distinguish large-cap confirmations from ambiguous-price and extreme-move confirmations. Migration 0091 adds last-seen and peak-seen tracking columns so pending rows preserve current and worst observed evidence while they await promotion or expiry. Migration 0105 adds confirmation_sources and pending_reason to promoted depeg_events rows for ex-post provenance.
Migration 0016
Cleaned up non-USD depeg events with peak_deviation_bps < 150 when the non-USD threshold was raised from 100 to 150.
Cron Scheduling
Detection runs as part of the */15 * * * * sync cycle. After syncStablecoins() enriches prices, it calls:
detectDepegEvents(db, peggedAssets, fxFallbackRates, signal, coingeckoApiKey)-- detectionconfirmPendingDepegs(db, peggedAssets, fxFallbackRates, signal, coingeckoApiKey)-- confirmation
Both calls are in worker/src/cron/sync-stablecoins/post-enrichment.ts (invoked from the parent sync-stablecoins.ts orchestrator). Errors from either are captured in the sync metadata as depegErrors array but do not fail the parent cron.
The API layer reuses this event dataset through worker/src/lib/peg-analytics.ts (derivePegAnalyticsSnapshot()), which builds shared eventsByCoin and pegDataById maps for both /api/peg-summary and /api/report-cards.
Stage 1 -- Detection
Initialization
- Load PSI-eligible stablecoins into
metaByIdmap - Derive peg rates (handles FX lookups once)
- Load DEX prices from
dex_pricestable (silently skip if table missing) - Merge duplicate open events: for each coin with multiple open events, keep earliest, absorb worst peak, delete rest
dex_prices rows are only trusted for depeg logic when they are both fresh (updated_at < 35 min) and deep enough (source_total_tvl >= $1M). Thin DEX rows remain visible in storage for analytics, but they do not suppress or confirm events.
The stablecoin detail page can still show the live price deviation for a tracked coin below the live depeg-event floor, but that state is explicitly labelled as coverage-limited. Low-cap tracked coins can therefore look off-peg in the detail UI without opening a depeg_events row.
Per-Asset Processing
Validation gates (skip if any fail):
- Must be in
PSI_ELIGIBLE_STABLECOINS - Not a NAV token (
meta.flags.navToken) - Price valid: non-null, is a number, not NaN, > 0
- Supply >= $1M (via
sumPegBuckets) for live event recording - Peg reference valid: finite and > 0
- Non-USD fiat peg references only mutate live state when they come from cached FX fallback or a median built from at least 3 live contributors; thin peer medians and empty live peer sets fail closed for that cycle
- Supported non-USD fiat pegs with reliable CoinGecko native pairs also consult a fresh direct native-peg quote before mutating live state; a native quote back inside threshold or pointing the other way vetoes the derived USD/FX move for that cycle
Primary-price trust gates:
authoritative: freshhigh/single-sourcecurrent-sync pricesconfirm_required: cached, fallback, low-confidence, or stale primary pricesunusable: invalid/missing/non-finite price
Deviation calculation:
bps = Math.round(((price / pegRef) - 1) * 10000)
direction = bps >= 0 ? "above" : "below"
Three State Paths
Path A -- Deviation >= threshold AND event already open
- If a fresh direct native-peg quote is back inside threshold: close the live row immediately as recovered, but leave
recovery_price = NULLbecause the stored USD price still contradicts the native close - If a fresh direct native-peg quote still shows a depeg but in a conflicting direction: fail closed and keep the existing row unchanged
- If direction changed and the primary price is authoritative (or a trusted aggregate DEX row is corroborated by at least 2 protocol-level DEX groups in the replacement direction): close the old event and open the replacement immediately
- If direction changed but the primary price is
confirm_required: retire the stale live row immediately and route the replacement move throughdepeg_pendinginstead of leaving the wrong direction active - Same direction: mark as legitimately open (add to
seenset); update peak only when the primary input is authoritative or a corroborated trusted DEX row corroborates the move - Same-direction DEX disagreement is now advisory only: detection logs the mismatch but does not auto-close the event from that contradiction alone
Path B -- Deviation >= threshold AND no event open
- If the peg reference is a thin non-USD fiat peer median without FX fallback: skip live-state mutation for this cycle
- If a supported direct native-peg quote is back inside threshold or shows the opposite side of the peg: suppress the new event for this cycle
- If supply >= $1B: insert into
depeg_pendingfor multi-source confirmation (reason = "large-cap"unless another reason is more specific) - If primary trust is
confirm_required: insert intodepeg_pendingwithreason = "low-confidence" - If
abs(bps) >= 5000: route through the extreme-move lane (reason = "extreme-move"). Corroborated trusted DEX depeg agreement may still promote the move immediately for non-large-cap coins - Otherwise (authoritative primary input, non-large-cap, non-extreme): use corroborated trusted DEX recovery suppression and insert into
depeg_eventsimmediately
Whenever a row is written to depeg_pending, the worker now upserts directional state instead of treating the table as write-once:
- same direction: preserve
first_seen_*, refreshlast_seen_*, and updatepeak_seen_*when the move worsens - opposite direction: reset the row as a new incident instead of preserving stale first-seen direction metadata
Path C -- Deviation < threshold AND event open
- If a supported direct native-peg quote still shows the same-direction depeg: keep the event open and ignore the derived recovery
- Close immediately when the primary price is authoritative, or when a fresh non-cached multi-source primary cluster is already back inside threshold
- If the remaining primary input is ambiguous, close only when a trusted aggregate DEX row also shows recovery, at least 2 protocol-level DEX groups are also back inside threshold, and no qualifying challenger pool still shows the old depeg direction
- Otherwise keep the event open rather than letting cached/low-confidence prices silently resolve it
Orphan Cleanup
After the main loop, load all open events. Close any that were not in the seen set and were not created during the current run. These are true "orphans" -- the coin was removed from tracking or exited the PSI-eligible set. Tracked coins are intentionally kept open through transient missing-price or ambiguous-input cycles and are not force-closed just because one run lacked a trusted recovery signal. Orphans are closed with recovery_price = NULL.
Stage 2 -- Confirmation
Processes all rows in depeg_pending. Applies to large-cap coins, low-confidence primary-price candidates, and extreme-move candidates.
Per-Record Processing
Guards (delete pending + skip):
- Invalid
peg_reference(<= 0) - Open event already exists for this coin (another path created it)
Recovery check: if the current authoritative primary price is valid and deviation now < threshold, delete pending (transient noise). Ambiguous primary prices do not clear pending rows on their own.
Age checks:
- If age < 15 minutes: skip (wait for next cycle)
- If age > 45 minutes: delete (expired without confirmation)
Secondary Source Checks
Off-chain check:
- Preferred path for supported non-USD fiat pegs: use a fresh direct native-peg quote (for example
BRZ/BRLorEURC/EUR) and compare that quote directly to the native1.0peg - Default path: fetch CoinGecko
/simple/pricefor the coin'sgeckoId - If the current primary price already comes from CoinGecko (
priceSource.startsWith("coingecko")), switch the confirmer to DefiLlamacoins.llama.fi/prices/current/coingecko:{geckoId}instead of querying CoinGecko again - Calculate deviation against
peg_reference - Counts as confirmation only when deviation >=
secondaryBar(50% of primary threshold) and it points in the same direction as the pending incident - Non-fatal: if fetch fails, the off-chain agreement remains
null
CEX ticker check:
- Fetches the configured Binance USD-pair batch (currently
USDTUSDandUSDCUSD) as an additional secondary confirmation source, then looks up the pending coin by symbol - Only attempted for symbols present in the configured Binance market set
- Counts as confirmation only when deviation >=
secondaryBarand points in the same direction as the pending incident - Non-fatal: if the Binance fetch fails, the CEX agreement remains
null
DEX check:
- Read from
dex_pricestable (same data as Stage 1) - Must be within 35-minute freshness window and have aggregate source TVL >= $1M
- Counts as confirmation only when deviation >=
secondaryBarand points in the same direction as the pending incident
Pool challenger check:
- Loads qualifying individual DEX pool challengers from the published challenger snapshot tables via
loadDexPoolChallengers(...) - Uses the same freshness / minimum-TVL guardrail family as the depeg helper layer
- Counts as confirmation only when at least two qualifying pools diverge by
secondaryBarin the same direction, or a single qualifying pool with>= $5MTVL does so. A single thin pool can no longer promote a pending depeg on its own. - Non-fatal: missing challenger tables or incomplete published snapshots fall back through the helper's legacy path and still yield
null/falsesafely
Decision Matrix
| Same-direction off-chain | Same-direction CEX | Same-direction DEX | Same-direction pool | Contradiction seen | Action |
|---|---|---|---|---|---|
| true | any | any | any | any | PROMOTE to depeg_events, except low-confidence rows still need CEX, aggregate DEX, or pool confirmation |
| any | true | any | any | any | PROMOTE to depeg_events |
| any | any | true | any | any | PROMOTE to depeg_events |
| any | any | any | true | any | PROMOTE to depeg_events (pool-only path requires 2 pools or one pool with >= $5M TVL) |
| true | false/null | false/null | false/null | any | Keep pending when reason includes low-confidence; low-confidence rows need a hard secondary source (CEX, aggregate DEX, or pool challenger) |
| false | any | false | any | true | REJECT (off-chain and aggregate DEX both oppose the pending direction) |
| false | any | null | false/null | true | REJECT (directional contradiction with no same-direction rescue signal) |
| null | null | false | false/null | true | REJECT (available secondary evidence points the other way) |
| null | null | null | null | false | Keep pending (retry next cycle) |
Promotion inserts into depeg_events with started_at = original first_seen_at, direction = the active pending direction, and peak = worst of the stored pending peak vs the current trustworthy confirmer state, then deletes from depeg_pending.
Historical Backfill Validation
Historical backfills in worker/src/api/backfill-depegs.ts do not reuse the exact same guard as live DEX or fallback enrichment, but they now consult the same authoritative-price provider registry as live sync before falling back to market history.
Backfill rewrites are atomic: the delete of prior source='backfill' rows and the insert of replacement rows now share a single D1 batch() call, so a worker interruption mid-rewrite can no longer leave a coin with zero depeg rows.
Supported non-USD fiat backfills now prefer direct CoinGecko native-fiat history first and compare that series against the native 1.0 peg. In that native-fiat mode, replay uses daily points plus a two-point confirmation window across 36 hours before opening a normal event, while still preserving extreme single-point crashes of >= 5000 bps. Only when that native history is unavailable does the replay fall back to USD-denominated CoinGecko/DefiLlama history plus the historical FX reference.
POST /api/backfill-depegs?dry-run=true also accepts startDay / endDay for bounded replay audits, plus optional contextDays to widen the replay pad around that UTC window. The handler compares only the overlapping stored source='backfill' rows, which makes long-history repairs practical without waiting for a full-coin HTTP request.
When a coin has an authoritative historical provider (for example, protocol redemption quotes replayed at historical blocks), backfill uses that provider first. If the provider cannot return enough historical coverage, the handler preserves existing source='backfill' rows instead of rebuilding from a weaker market-data source.
Instead, extractDepegEvents() now validates each price point in historical_backfill mode against the direct peg reference for that timestamp:
- USD and other fixed pegs can preserve catastrophic downside moves when the historical peg reference itself is valid
- low-nominal FX pegs such as JPY are judged against the actual historical FX reference, not a generic live-only fallback
- commodity tokens use
commodityOunceswhen converting the historical gold/silver peg reference into per-token units
This keeps confirmed historical crashes visible without weakening the stricter live-source filters used to protect sync and DEX ingestion.
Event Lifecycle
Price crosses threshold
|
+-- Supply < $1B, authoritative primary, non-extreme move
| |
| +-- Trusted DEX fresh and below threshold --> Suppress (skip)
| |
| +-- Otherwise --> INSERT depeg_events (source='live')
|
+-- Supply >= $1B, low-confidence primary, or extreme move --> INSERT depeg_pending
|
(next cycle, 15+ min later)
|
any secondary agrees? (off-chain, CEX, aggregate DEX, or pool challenger)
|
yes ---------- + ---------- no
| |
PROMOTE to authoritative recovery,
depeg_events expiry, or decisive disagreement
-> delete pending
Low-confidence pending rows are stricter: off-chain agreement alone does not promote them; they need CEX, aggregate DEX, or pool-challenger confirmation.
Special case:
- Extreme move + trusted DEX in the same direction can promote immediately without waiting for the pending retry
While event is open:
- Peak deviation updated if worse price seen
- Direction change with authoritative or DEX-confirmed input: close old, open new
- Direction change with `confirm_required` input: close old, insert replacement pending candidate
- Trusted DEX disagreement on the same side is logged, but does not by itself close the event
- Price recovers below threshold: close only when the primary recovery is authoritative, or when trusted aggregate DEX recovery has enough protocol corroboration and no challenger veto; otherwise keep the event open
Orphan cleanup:
- Open event for a coin no longer tracked by Pharos: close with recovery_price=NULL
- Open event for a tracked coin not observed in the current run: keep open to avoid false recoveries during upstream gaps
Types
DepegRow to DepegEvent
rowToDepegEvent() converts D1 snake_case rows to frontend camelCase. It validates:
directionmust be"above"or"below"(defaults to"below")sourcemust be"live"or"backfill"(defaults to"live")
Frontend type (shared/types/index.ts):
interface DepegEvent {
id: number
stablecoinId: string
symbol: string
pegType: string
direction: "above" | "below"
peakDeviationBps: number
startedAt: number
endedAt: number | null
startPrice: number
peakPrice: number | null
recoveryPrice: number | null
pegReference: number
source: "live" | "backfill"
}
API
GET /api/depeg-events
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
stablecoin | string | -- | Filter by stablecoin_id |
active | string | -- | If "true", only events where ended_at IS NULL |
limit | number | 100 | Max results; out-of-range values outside 1..1000 are rejected |
offset | number | 0 | Pagination offset |
Response:
{
"events": [{ "...DepegEvent fields..." }],
"total": 42,
"methodology": { "version": "...", "versionLabel": "...", "currentVersion": "...", "currentVersionLabel": "...", "changelogPath": "/methodology/depeg-changelog/", "asOf": 1740000000 }
}
Cache: realtime profile (s-maxage=60, max-age=10). Freshness headers use the latest successful sync-stablecoins timestamp, falling back to the latest event startedAt when cron history is unavailable; TTL remains 900s.
Frontend
Hook: useDepegEvents (use-depeg-events.ts)
- Fetches
/api/depeg-eventswith optional?stablecoin=filter - TanStack Query:
staleTime= 15 min,refetchInterval= 30 min - Companion hook
useInfiniteDepegEvents({ stablecoinId?, autoLoadAll? })pages through/api/depeg-events?limit=100&offset=...; oversizedlimitvalues are rejected rather than silently clamped /depeguses the unfiltered infinite hook for the global recent-events feed- Stablecoin detail pages use the filtered infinite hook with
autoLoadAllso the hero can read the full recorded-eventtotalwhile the history table hydrates every page in the background
Component: DepegFeed (depeg-feed.tsx)
- Responsive recent-events feed with progressive history pagination
- Sorted by
startedAtDESC - Shows: logo, symbol, peak deviation (red >= 500bps, amber < 500bps), direction badge, LIVE pulsing indicator if ongoing, date, duration
- Click navigates to
/stablecoin/{id}
Depeg dashboard stat context
DepegTrackerStats (src/components/depeg-tracker-stats.tsx) now uses the shared contextual methodology pattern on key cards (Active Depegs, Coins at Peg, Median Deviation, Worst Current) so users can read the live-event semantics in place instead of jumping straight to the long-form methodology.
Component: DepegHistory (depeg-history.tsx)
- Stablecoin-detail depeg history table backed by the filtered infinite hook
- Hero peg-score card now shows the full recorded-event count from
/api/depeg-events.total; when that differs from the peg-score window count, the UI explicitly labels the 4-year score-window subset - Background-hydrates the full per-coin history, then paginates the rendered table client-side at 25 rows per page
- Summary metrics: recorded event count, worst deviation, current streak (days at peg or "Depegged now")
- Table columns: Date, Direction (badge), Peak Deviation (signed, colored), Duration (or "Ongoing"), Start Price, Peak Price, Recovery Price
- Uses
computePegStability()once the full per-coin history has loaded
Peg Stability Metrics (peg-stability.ts)
| Metric | Description |
|---|---|
pegPct | Percentage of tracked history at peg (merges overlapping intervals) |
trackingSpan | Human-readable span ("3y 8mo", "45d") |
limited | true if < 7 days history |
currentStreakDays | Days since last event ended |
depeggedNow | Boolean (any ongoing event?) |
Peg Score (peg-score.ts)
Used in report cards. Formula:
pegPct = (1 - totalDepegSec / spanSec) * 100
severityScore = 100 - sum of per-event penalties
per-event penalty = max(durationPenalty, magnitudeFloor)
durationPenalty = (peakBps/100) * (durationDays/30) * recencyWeight
magnitudeFloor = (peakBps/2000) * recencyWeight
spreadPenalty = min(15, (stddev of peaks / 1000) * 15)
activeDepegPenalty = if ongoing: min(50, max(5, |peakBps| / 50))
pegScore = max(0, min(100, round(0.5*pegPct + 0.5*severityScore - activeDepegPenalty - spreadPenalty)))
Tracking window: coinTrackingStart() prefers a curated launch date when one is available and otherwise falls back to the coin's earliest supply_history snapshot
(queried via getFirstSeenDates()) so young coins aren't diluted across a phantom 4-year window.
Falls back to earliest depeg event, then to the 4-year lookback cap.
Magnitude floor: Every depeg event carries a minimum severity penalty proportional to its peak deviation, regardless of how brief. This prevents hundreds of short high-magnitude depegs from being scored as nearly free.
Active depeg penalty: Floor of 5, scales at |peakBps| / 50, capped at 50.
A 500 bps ongoing depeg costs 10 points; 2500+ bps hits the cap.
Returns null if < 7 days tracking. Scores based on 7–30 days are flagged as "Early score" in the UI.
Edge Cases & Guardrails
| Scenario | Handling |
|---|---|
| Duplicate events | Unique index (stablecoin_id, started_at, source) + merge at run start |
| NAV tokens | Skipped (expected to appreciate, depeg detection N/A) |
| Supply < $1M | Skipped for live event recording (prevents micro-cap noise); detail UI may still show current price deviation with an explicit coverage-limited note |
| Missing/invalid prices | Multiple null/NaN/<= 0 checks |
| Peg reference validation | Must be finite and > 0 |
| DEX freshness | Prices > 35 min old ignored |
| Orphaned events | Closed with recovery_price = NULL when coin drops off tracking |
| Non-USD threshold | 150bps accounts for FX noise and thin liquidity |
File Index
| File | Role |
|---|---|
worker/src/cron/detect-depegs.ts | Stage 1: detection, peak tracking, DEX cross-validation, orphan cleanup |
worker/src/cron/confirm-pending-depegs.ts | Stage 2: multi-source confirmation for large coins |
worker/src/cron/sync-stablecoins.ts | Parent cron that calls both stages after price enrichment |
worker/src/lib/depeg-helpers.ts | DepegRow type, rowToDepegEvent(), loadDexPriceRows(), buildInsertDepegEventStmt() |
worker/src/api/depeg-events.ts | GET /api/depeg-events handler |
worker/migrations/0000_baseline.sql | Baseline depeg_events / depeg_pending schema, including the historical dedupe and non-USD cleanup changes |
shared/types/index.ts | DepegEvent frontend type |
shared/lib/peg-score.ts | Peg score computation for report cards |
src/lib/peg-stability.ts | Peg stability metrics (pegPct, streak, tracking span) |
src/hooks/use-depeg-events.ts | TanStack Query hook |
src/components/depeg-feed.tsx | Recent events grid (homepage) |
src/components/depeg-history.tsx | Event history table (detail page) |