Skip to main content
Pharos
PHAROSlive stablecoin signals

Depeg Detection

Two-stage detection, confirmation, and peg score inputs.

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

ConstantValuePurpose
DEPEG_THRESHOLD_BPS100 (1%)USD peg deviation threshold
DEPEG_THRESHOLD_BPS_NON_USD150 (1.5%)Non-USD peg threshold (accounts for FX noise + thin liquidity)
DEPEG_CONFIRMATION_SUPPLY_THRESHOLD$1,000,000,000Coins above this require multi-source confirmation
DEPEG_PENDING_MIN_AGE_SEC900 (15 min)Minimum time before a pending record can be promoted
DEPEG_PENDING_EXPIRY_SEC2700 (45 min)Maximum time before a pending record expires
DEPEG_SECONDARY_THRESHOLD_RATIO0.5Secondary source agreement bar (50% of primary threshold)
DEPEG_PRIMARY_PRICE_MAX_AGE_SEC1800 (30 min)Primary prices older than this require confirmation
DEPEG_EXTREME_MOVE_BPS5000 (50%)Severe move threshold routed through dedicated confirmation lane
DEX_FRESHNESS_SEC2100 (35 min)DEX prices older than this are ignored
DEX_PRICE_CHECK_DEPEG_MIN_TVL_USD1,000,000Minimum aggregate DEX source TVL required before depeg logic trusts a DEX row
DEPEG_DEX_PROTOCOL_CORROBORATION_MIN2 protocol groupsMinimum 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:

  1. detectDepegEvents(db, peggedAssets, fxFallbackRates, signal, coingeckoApiKey) -- detection
  2. confirmPendingDepegs(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

  1. Load PSI-eligible stablecoins into metaById map
  2. Derive peg rates (handles FX lookups once)
  3. Load DEX prices from dex_prices table (silently skip if table missing)
  4. 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: fresh high / single-source current-sync prices
  • confirm_required: cached, fallback, low-confidence, or stale primary prices
  • unusable: 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 = NULL because 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 through depeg_pending instead of leaving the wrong direction active
  • Same direction: mark as legitimately open (add to seen set); 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_pending for multi-source confirmation (reason = "large-cap" unless another reason is more specific)
  • If primary trust is confirm_required: insert into depeg_pending with reason = "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_events immediately

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_*, refresh last_seen_*, and update peak_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):

  1. Invalid peg_reference (<= 0)
  2. 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/BRL or EURC/EUR) and compare that quote directly to the native 1.0 peg
  • Default path: fetch CoinGecko /simple/price for the coin's geckoId
  • If the current primary price already comes from CoinGecko (priceSource.startsWith("coingecko")), switch the confirmer to DefiLlama coins.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 USDTUSD and USDCUSD) 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 >= secondaryBar and 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_prices table (same data as Stage 1)
  • Must be within 35-minute freshness window and have aggregate source TVL >= $1M
  • Counts as confirmation only when deviation >= secondaryBar and 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 secondaryBar in the same direction, or a single qualifying pool with >= $5M TVL 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/false safely

Decision Matrix

Same-direction off-chainSame-direction CEXSame-direction DEXSame-direction poolContradiction seenAction
trueanyanyanyanyPROMOTE to depeg_events, except low-confidence rows still need CEX, aggregate DEX, or pool confirmation
anytrueanyanyanyPROMOTE to depeg_events
anyanytrueanyanyPROMOTE to depeg_events
anyanyanytrueanyPROMOTE to depeg_events (pool-only path requires 2 pools or one pool with >= $5M TVL)
truefalse/nullfalse/nullfalse/nullanyKeep pending when reason includes low-confidence; low-confidence rows need a hard secondary source (CEX, aggregate DEX, or pool challenger)
falseanyfalseanytrueREJECT (off-chain and aggregate DEX both oppose the pending direction)
falseanynullfalse/nulltrueREJECT (directional contradiction with no same-direction rescue signal)
nullnullfalsefalse/nulltrueREJECT (available secondary evidence points the other way)
nullnullnullnullfalseKeep 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 commodityOunces when 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:

  • direction must be "above" or "below" (defaults to "below")
  • source must 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:

ParamTypeDefaultDescription
stablecoinstring--Filter by stablecoin_id
activestring--If "true", only events where ended_at IS NULL
limitnumber100Max results; out-of-range values outside 1..1000 are rejected
offsetnumber0Pagination 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-events with optional ?stablecoin= filter
  • TanStack Query: staleTime = 15 min, refetchInterval = 30 min
  • Companion hook useInfiniteDepegEvents({ stablecoinId?, autoLoadAll? }) pages through /api/depeg-events?limit=100&offset=...; oversized limit values are rejected rather than silently clamped
  • /depeg uses the unfiltered infinite hook for the global recent-events feed
  • Stablecoin detail pages use the filtered infinite hook with autoLoadAll so the hero can read the full recorded-event total while the history table hydrates every page in the background

Component: DepegFeed (depeg-feed.tsx)

  • Responsive recent-events feed with progressive history pagination
  • Sorted by startedAt DESC
  • 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)

MetricDescription
pegPctPercentage of tracked history at peg (merges overlapping intervals)
trackingSpanHuman-readable span ("3y 8mo", "45d")
limitedtrue if < 7 days history
currentStreakDaysDays since last event ended
depeggedNowBoolean (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

ScenarioHandling
Duplicate eventsUnique index (stablecoin_id, started_at, source) + merge at run start
NAV tokensSkipped (expected to appreciate, depeg detection N/A)
Supply < $1MSkipped for live event recording (prevents micro-cap noise); detail UI may still show current price deviation with an explicit coverage-limited note
Missing/invalid pricesMultiple null/NaN/<= 0 checks
Peg reference validationMust be finite and > 0
DEX freshnessPrices > 35 min old ignored
Orphaned eventsClosed with recovery_price = NULL when coin drops off tracking
Non-USD threshold150bps accounts for FX noise and thin liquidity

File Index

FileRole
worker/src/cron/detect-depegs.tsStage 1: detection, peak tracking, DEX cross-validation, orphan cleanup
worker/src/cron/confirm-pending-depegs.tsStage 2: multi-source confirmation for large coins
worker/src/cron/sync-stablecoins.tsParent cron that calls both stages after price enrichment
worker/src/lib/depeg-helpers.tsDepegRow type, rowToDepegEvent(), loadDexPriceRows(), buildInsertDepegEventStmt()
worker/src/api/depeg-events.tsGET /api/depeg-events handler
worker/migrations/0000_baseline.sqlBaseline depeg_events / depeg_pending schema, including the historical dedupe and non-USD cleanup changes
shared/types/index.tsDepegEvent frontend type
shared/lib/peg-score.tsPeg score computation for report cards
src/lib/peg-stability.tsPeg stability metrics (pegPct, streak, tracking span)
src/hooks/use-depeg-events.tsTanStack Query hook
src/components/depeg-feed.tsxRecent events grid (homepage)
src/components/depeg-history.tsxEvent history table (detail page)