Two-stage depeg detection pipeline for stablecoins. Stage 1 runs every 15 minutes within sync-stablecoins and writes admitted, unsuppressed onsets to depeg_pending. Stage 2 runs immediately after; promotion requires at least 15 minutes beyond the full trigger threshold and the applicable source-trust rule.
Agent navigation — Grep the heading you need: Methodology Versioning · Downstream: Depeg Duration Resolver · Thresholds & Constants · Database Schema · Build-time event archive · Cron Scheduling · Stage 1 -- Detection · Stage 2 -- Confirmation · Historical Backfill Validation · Event Lifecycle · Types · API · Peg Stability Metrics (
peg-stability.ts) · Peg Score (peg-score.ts) · Edge Cases & Guardrails.
Methodology Versioning
- Current methodology version: <!-- GENERATED-START: methodology-version-depeg-detection -->
v6.34<!-- GENERATED-END: methodology-version-depeg-detection --> - Runtime/version source:
shared/lib/methodology-versions/registry.ts - Public changelog route:
/methodology/depeg-changelog/ - Structured changelog:
shared/data/methodology-changelogs/depeg-dews/
The shared v6.34 release preserves native/USD quote domains, rejects mismatched identities and incomparable peaks, and excludes policy-rejected pool peaks. Pending confirmation's single-$5M-pool exception obeys the opposing-group vote; live recovery retains its high-TVL veto. Historical supply gates use timestamp-valued USD supply and at-peg summaries use raw detector precision. Projection failures withhold recomputed analytics; retained snapshots keep their original clock. Archives and Worst Depeg are not display-capped. Numeric triggers and weights are unchanged. Activation is provisional at 2026-10-11 00:00 UTC, re-dated at release, with DEWS replay prerequisites.
The shared v6.33 release of 2026-10-08 changes only DEWS weekly DEX-history admission; depeg onset, confirmation, recovery and PegScore rules are unchanged. Its release remains conditional on owner-approved production-history replay described in DEWS.
Downstream: Depeg Duration Resolver
Confirmed events feed DDR's canonical incidents and immutable public forecasts or no-calls; DDR does not detect depegs independently or issue retroactive forecasts after a pre-lock resolution. See the live forecast and grading board. The forecast-lock contract is owned by docs/depeg-resolver.md, not this detector.
Thresholds & Constants
| Constant | Value | Purpose |
|---|---|---|
DEPEG_THRESHOLD_BPS | <!-- GENERATED-START: depeg-threshold-usd -->100 (1%)<!-- GENERATED-END: depeg-threshold-usd --> | USD peg deviation threshold |
DEPEG_THRESHOLD_BPS_NON_USD | <!-- GENERATED-START: depeg-threshold-non-usd -->150 (1.5%)<!-- GENERATED-END: depeg-threshold-non-usd --> | Non-USD peg threshold (accounts for FX noise + thin liquidity) |
DEPEG_CONFIRMATION_SUPPLY_THRESHOLD | <!-- GENERATED-START: depeg-confirmation-supply -->$1,000,000,000<!-- GENERATED-END: depeg-confirmation-supply --> | Adds the large-cap source-confirmation reason flag |
DEPEG_CONFIRMATION_SOFT_SUPPLY_THRESHOLD | $750,000,000 | Adds the large-cap flag when source depth is below 2 or severity is at least 2x the peg threshold |
DEPEG_CONFIRMATION_WEAK_SEVERE_SUPPLY_THRESHOLD | $500,000,000 | Adds the large-cap flag when both source depth is below 2 and severity is at least 2x the peg threshold |
DEPEG_PENDING_MIN_AGE_SEC | <!-- GENERATED-START: depeg-pending-min-age -->900 (15 min)<!-- GENERATED-END: depeg-pending-min-age --> | Minimum continuous onset or recovery confirmation window |
DEPEG_MAX_CONTINUOUS_OBSERVATION_GAP_SEC | 1200 (20 min) | Largest gap between consecutive qualifying onset/recovery observations or trusted off-peg duration endpoints. Wider than ordinary 900-second producer jitter, below two intervals; a missed run starts a new observation interval |
DEPEG_PENDING_EXPIRY_SEC | <!-- GENERATED-START: depeg-pending-expiry -->2700 (45 min)<!-- GENERATED-END: depeg-pending-expiry --> | Base time before a pending record can expire |
DEPEG_PENDING_EXTENDED_EXPIRY_SEC | 8100 (135 min) | Extended limit when primary evidence still points same-direction or confirmation sources are unavailable/circuit-open |
DEPEG_PENDING_SEVERE_EXPIRY_SEC | 10800 (180 min) | Severe/extreme-move limit; expiry records unconfirmed-severe |
DEPEG_SECONDARY_THRESHOLD_RATIO | <!-- GENERATED-START: depeg-secondary-threshold-ratio -->1.0<!-- GENERATED-END: depeg-secondary-threshold-ratio --> | Secondary source agreement uses the full trigger threshold |
DEPEG_RECOVERY_THRESHOLD_RATIO | 0.5 | Recovery must reach half the trigger threshold before its confirmation window starts |
DEPEG_PRIMARY_PRICE_MAX_AGE_SEC | <!-- GENERATED-START: depeg-primary-price-max-age -->1800 (30 min)<!-- GENERATED-END: depeg-primary-price-max-age --> | Primary prices older than this are marked confirm_required |
DEPEG_EXTREME_MOVE_BPS | <!-- GENERATED-START: depeg-extreme-move -->5000 (50%)<!-- GENERATED-END: depeg-extreme-move --> | Adds the severe/extreme pending reason and extended expiry policy |
DEX_FRESHNESS_SEC | <!-- GENERATED-START: depeg-dex-freshness -->4500 (75 min)<!-- GENERATED-END: depeg-dex-freshness --> | Hourly DEX prices older than this are ignored |
DEX_PRICE_CHECK_DEPEG_MIN_TVL_USD | <!-- GENERATED-START: depeg-dex-min-tvl -->1,000,000<!-- GENERATED-END: depeg-dex-min-tvl --> | Minimum aggregate DEX source TVL required before depeg logic trusts a DEX row |
DEPEG_DEX_PROTOCOL_CORROBORATION_MIN | 2 protocol groups | Aggregate DEX corroboration minimum; per-source evidence uses DEX_PROTOCOL_SOURCE_FRESHNESS_SEC (35 min), distinct from the aggregate row's 75 min |
POOL_CHALLENGE_CONFIRM_MIN | 2 protocol/source-family groups | Challenger group minimum; pools require POOL_CHALLENGE_MIN_TVL ($100k). Confirming/diverging groups must match or outnumber opposing/recovery groups, except the recovery high-TVL veto below |
POOL_CHALLENGE_HIGH_TVL_USD | $5,000,000 | Pending confirmation: replaces the second-group minimum but not the opposing vote. Live recovery: a single qualifying pool vetoes directly |
getDepegThresholdBps(pegType) returns 100 for peggedUSD, 150 for all other peg types.
Database Schema
Exact DDL and indexes are owned by worker/migrations/0000_baseline.sql plus the
active tail in worker/migrations/MANIFEST.md. The baseline alone is not the
cumulative schema: 0232_depeg_recovery_continuity.sql adds recovery continuity,
0252_depeg_audit_verdict_vocabulary.sql guards audit verdicts, and
0255_depeg_trusted_price_coverage.sql adds nullable trusted-price coverage.
The semantics below distinguish recovery, unobserved time and immutable audit evidence.
depeg_events
Confirmed live/backfill rows retain coin/peg/direction identity, onset and nullable
closure, observed peak prices, confirmation provenance and trusted-price coverage.
Uniqueness is (stablecoin_id, started_at, source); open rows have ended_at = NULL.
close_reason distinguishes real recovery from non-recovery terminal boundaries:
recovered-primary,recovered-dex,recovered-nativecoverage-lost-supply(only an observed sub-floor supply, including an explicit zero; unavailable supply never closes a row)superseded-directionorphan-tracking-removed
Live non-USD events opened from native-fiat quotes retain native prices and peg_reference = 1. Admission matches asset/currency identities; peaks exclude mismatched domains. Native confirmers of USD-origin pending rows use the admitted USD reference. Native-domain open rows require an admitted native quote to progress; USD prices never become their recovery price.
Pending onset survives only matching direction, continuity, peg identity and quote domain. USD-primary / native-origin transitions or peg changes reset onset, reference and prices; other reason-flag changes preserve continuity. Historical provenance remains authoritative; unknown conversions are not invented.
Current deviation and reference are independent of circulating supply: any observed non-NAV price with an authoritative reference can populate currentDeviationBps and pegReference, including below $1M or when supply is unknown. The supply floor remains an event-creation/coverage gate, not a price-observation gate. Nominal par and unavailable prices remain unobserved; depegEventCoverageLimited still warns that no new events are created for a positive sub-floor supply.
depeg_pending
At most one candidate per coin holds its first/last/peak observations, quote reference, direction, peg identity and trust/severity reasons until a terminal confirmation decision. Continuity rules below govern whether onset is preserved.
depeg_pending_outcomes (migration 0126)
The append-only outcome schema is in the baseline; outcome distinguishes
promoted, rejected, expired, recovered, superseded and unconfirmed-severe.
Append-only terminal ledger for every candidate that leaves depeg_pending. Each row is written in the same batch that deletes the pending row (buildOutcomeAndDeleteStmts() in worker/src/cron/pending-depeg-confirmation.ts), so the candidate transition and its evidence land together. Because depeg_pending holds at most one row per coin and is deleted on every terminal path, this is the only record of candidates that never became events -- rejections, expiries, recoveries, and rows superseded by an already-open event leave no trace in depeg_events.
The four source columns record which confirmation families agreed, opposed, were unavailable, or were skipped behind an open circuit at decision time, which is what distinguishes "no evidence was reachable" from "evidence disagreed" when auditing a REJECT or an unconfirmed-severe expiry after the fact.
DDR depends on the outcome = 'promoted' rows: loadPendingPromotionConfirmationTimes() in worker/src/cron/depeg-resolver/incident-state.ts matches them to events on (stablecoin_id, peg_type, direction, first_seen_at = started_at) and uses the latest outcome_at as the incident confirmedAt. That timestamp decides late_confirmation lock timing, so deleting or rewriting promoted rows changes DDR lock verdicts for historical incidents.
Retention policy: never pruned. prune-cron-history does not touch this table, and no other job deletes from it; only growth telemetry reads it (worker/src/lib/status/d1-usage.ts). Growth is bounded by candidate volume, not by a cutoff.
depeg_event_provenance (migration 0127)
Side-table provenance for depeg rows. Legacy depeg_events rows remain valid when no provenance row exists. The public API reads through depeg_events_with_provenance, which projects a compact provenance object without exposing raw diagnostics.
Stored fields include source kind, replay run ID/version, source price providers, quote mode, peg-reference source, supply source, confirmation policy, confirmation point count, market diagnostics, policy adjustments, confidence tier, audit verdict, and created/updated timestamps.
Audit vocabulary is owned by shared/types/depeg-audit.ts: confirmed, disputed, false_positive, no_data, and repaired. Archived event parsing rejects unknown verdicts rather than coercing them to null. Migration 0252_depeg_audit_verdict_vocabulary.sql rejects unknown insert/update values while preserving stored archives. Null continues to mean legacy unaudited evidence.
depeg_backfill_runs (migration 0128)
Backfill replay manifest table. Each mutating replay records stablecoin/window, source type, expected event count, expected fingerprint, removed/added/inserted counts, status (started, complete, or incomplete), timestamps, and any failure message. Chunked insert failures mark the run incomplete so operators can repair or re-run instead of assuming the historical slice is complete.
Retention policy: depeg_backfill_runs is a backfill audit archive kept forever. Replays are rare, and the run manifest is the durable evidence for repair provenance, expected fingerprints, and incomplete-run follow-up.
Non-USD threshold cleanup (folded into baseline)
Historical non-USD rows below the current 150 bps threshold are excluded by the baseline cleanup; threshold history belongs to the structured methodology changelog.
Build-time event archive
The Pages build snapshot is maintained by scripts/maintenance/sync-depeg-events.ts under data/depeg-events/. index.json is the compact route-bearing projection used to enumerate permanent /depeg/<event>/ pages and build the command-palette payload. It carries each route's slug, coin identity, direction, start date, and peak severity. The full event ledger is partitioned into UTC-year shards (YYYY.json); feeds, public dataset generation, and page hydration read those shards. A refresh therefore changes the index and only the affected year shard instead of rewriting one monolithic archive file.
Synchronization follows the API cursor until exhaustion, not a display-sized page cap. An outstanding cursor at the 1,000-page safety ceiling, an empty continuation page, or a unique-event count that disagrees with the initial API total is a SnapshotIntegrityError before any output write. Fetch-failure fallback cannot turn these integrity failures into successful refreshes; the Pages release wrapper retains the committed directory on producer failure. Published static-page preservation checks remain in force.
Every successful sync also writes data/depeg-events/metadata/capture.json: the SHA-256 identity of the exact yearly shard bytes, event count, API total when supplied, source URL, methodology label, and observation start/end clocks. Capture metadata lives in a subdirectory so top-level JSON archive enumeration sees only the route index and event shards; synchronization removes the superseded top-level capture.json. Keeping metadata within the archive directory also preserves the Pages release wrapper's directory-level copy and replacement; the release archive allowlist admits only this metadata directory and its capture file, not arbitrary metadata contents. Dataset generation validates this identity before using the ledger; legacy shards without a capture must be synchronized first. The ledger is a current capture, not a reconstruction of event peaks or recoveries at an older market snapshot's clock. Dated depeg-history exports therefore publish explicitly retrospective windowed history with this capture's provenance, not as-of market observations; the public API guide explains the surface split.
Cron Scheduling
Detection runs as part of the */15 * * * * sync cycle. After syncStablecoins() enriches prices, it calls:
detectDepegEvents(db, peggedAssets, fxFallbackRates, signal, coingeckoApiKey, nativePegSession)-- detectionconfirmPendingDepegs(db, peggedAssets, fxFallbackRates, signal, coingeckoApiKey, binanceSession, nativePegSession)-- 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. The half-hourly, DEX-publication-triggered prepare-safety-score-v9-input job is the only writer of the producer-published peg-analytics D1 cache row and the exact V9 peg-provenance seed. /api/peg-summary accepts the analytics row for up to 30 minutes (one producer interval) and falls back to direct compute on a miss or stale/invalid row. GET /api/report-cards/v9 reads only the separately accepted canonical V9 publication.
Current peg-summary observations are rederived from the latest stablecoins snapshot through deriveCurrentPegObservationMap, not from a numeric price plus an older cached analytics reference. Nominal or missing primary observations publish currentPriceUnavailable: true and currentDeviationBps: null; they are excluded from observed at-peg counts. Historical analytics fields remain attached to their accepted generation. If canonical incident projections cannot be read during a fallback recompute, the endpoint retains an available accepted analytics snapshot with incident-projection-read-failed and no-store, or returns explicit 503 when none exists.
If a retained historical analytics row has no current observation-map entry, /api/peg-summary publishes pegReference: null with pegReferenceUnavailable: true; missing live evidence must not be labelled as an available reference.
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: same-direction duplicates keep the earliest row and absorb only same-direction peaks; if opposite directions are open, the newest direction remains live and older direction rows close with
close_reason = 'superseded-direction'andrecovery_price = NULL
Open-event hydration explicitly joins depeg_event_provenance.quote_mode. This structured quote domain is authoritative for both live and backfilled rows: native-peg events keep native-currency peak and recovery prices with reference 1, while USD-domain rows use USD prices. Only legacy live non-USD events without quote-mode provenance use the reference-1 native heuristic.
dex_prices rows are only trusted for depeg logic when they are both fresh (updated_at < 75 min, covering the hourly producer plus one bounded delay) 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 show live deviation below the event-creation floor with an explicit coverage-limited label. An existing row closes as coverage-lost-supply with recovery_price = NULL only on observed sub-floor supply, including zero. Unavailable supply opens no new row and leaves the existing lifecycle and peak unchanged, but records blind price coverage rather than extending trusted intervals.
Per-Asset Processing
Validation gates (skip if any fail):
- Must be in
PSI_ELIGIBLE_STABLECOINS - Not a NAV token (
meta.flags.navToken) - Price valid: observed, finite and > 0
- Supply >= $1M (via
getCirculatingRawOrNull()) for live event recording. Observed sub-floor supply, including zero, closes existing rows ascoverage-lost-supplywithrecovery_price = NULL; unavailable supply keeps the lifecycle open and records blind coverage - Peg reference valid: finite and > 0
- Non-USD fiat peg references use the live FX rate whenever it is available. A peer median is only a fallback when at least 3 live contributors remain; 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 native-currency quote before mutating live state; a native quote inside the recovery band or pointing the other way vetoes the derived USD/FX onset for that cycle, while a threshold-crossing native quote can initiate a pending candidate when the primary USD-vs-reference path is still inside threshold
Primary-price trust gates:
authoritative: fresh observedsingle-sourcewith exactly one policy-admitted authoritative source, orhighwith at least two authoritative sources or one upstream-capable authoritative source (classifyPrimaryDepegTrust()inworker/src/lib/depeg-trust-policy.ts)confirm_required: cached, fallback, low-confidence, stale, or otherwise source-policy-ineligible primary pricesunusable: invalid/missing/non-finite price, or a non-observed price (priceObservedAtMode: "nominal_reference", an unsupported mode, or anyprotocol-parsource component). Since pricing v6.38 the nominal par routes publish par this way whenever no trusted market quote is admitted, so par can neither open, confirm nor recover an event; an open row stays open (tracked coins are never orphan-closed) until observed price evidence decides it, while a trusted market discount on those assets is evaluated like any other primary price
Before those gates are applied, priceSource and agreeSources are normalized through the pricing-source registry. Composite labels are expanded into their component source keys, unknown sources do not become pool-challenge eligible by accident, and each known key resolves to its registered depegSourceFamily. CoinGecko variants, DefiLlama list/detail/contract variants, and CoinMarketCap-style list aggregators are therefore not counted as independent hard corroboration just because their labels differ; promoted DEX protocol lanes and hard market/oracle/protocol sources keep provider- or protocol-specific families.
Deviation calculation:
rawBps = ((price / pegRef) - 1) * 10000
bps = Math.round(rawBps) // persisted and displayed value
direction = rawBps >= 0 ? "above" : "below"
Threshold decisions use abs(rawBps) so a value that merely rounds to the boundary cannot open or confirm an event.
Three State Paths
Path A -- Deviation beyond the trigger threshold AND event already open
- If a fresh native-currency quote is inside the recovery band, route the row through the recovery confirmation path rather than closing immediately
- If threshold-crossing USD-primary and native quotes conflict: suppress mutation and keep the lifecycle open
- An admitted native-domain quote can drive that row's direction replacement. For USD-domain rows, replacement requires authoritative primary input or trusted aggregate DEX corroboration from at least 2 protocol groups; close the old row and queue the replacement
- Otherwise,
confirm_requireddirection changes keep the old-direction lifecycle open and log a warning - 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 - For a live event opened in the native quote domain (
peg_reference = 1on a non-USD peg), update the peak only from a same-direction native quote; never write the USD primary price into that row - 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 beyond the trigger threshold AND no event open
- If the peg reference is a thin non-USD fiat peer median without live FX: skip live-state mutation for this cycle
- If a supported native-currency quote is inside the recovery band or shows the opposite side of the peg: suppress the new event for this cycle
- If the primary USD-vs-reference deviation is inside threshold but a fresh supported native-currency quote crosses threshold, store that quote against a
1.0peg reference indepeg_pending - Every remaining onset is inserted or refreshed in
depeg_pendingwithreason = "confirmation-window". Large-cap, low-confidence, extreme-move, and native-origin flags are appended when applicable; none bypass the 15-minute window. - Corroborated trusted DEX recovery can still suppress a new candidate before the pending write.
Whenever a row is written to depeg_pending, the worker now upserts directional state instead of treating the table as write-once:
- same direction with no more than 1200 seconds since the prior qualifying observation: preserve
first_seen_*, refreshlast_seen_*, and updatepeak_seen_*when the move worsens - same direction after a gap greater than 1200 seconds: reset first/last/peak fields to the new observation; the blind interval cannot count toward confirmation
- opposite direction: reset the row as a new incident instead of preserving stale first-seen direction metadata
The 1200-second continuity tolerance allows ordinary producer jitter beyond the 900-second schedule but remains below two intervals. A missed run must reset onset: a blind interval cannot prove persistence.
Detection persistence commits all mutations for one stablecoin as one ordered atomic transition, then proceeds to the next asset.
Path C -- Deviation inside the trigger threshold AND event open
- If a supported CoinGecko native-currency quote still shows the same-direction depeg: keep the event open and ignore the derived recovery
- If a fresh trusted aggregate DEX row still crosses the depeg threshold in the existing event direction, with at least 2 protocol-level DEX groups corroborating that direction: keep the event open and ignore the primary recovery print
- Qualifying pool challengers veto primary recovery when either one same-direction pool has at least $5M TVL, or at least 2 independent protocol/source-family groups match or outnumber groups corroborating recovery. The single-high-TVL recovery veto is independent of that group vote; only the ordinary group-based path rejects a diverging minority.
- Challenger snapshots retain publication timestamps, not original pool-price observation timestamps. Only diverging/corroborating group counts and the high-TVL veto are derived; no positive recovery price is computed. Ambiguous primary recovery requires the fresh trusted aggregate DEX lane until original pool-price time provenance is retained end to end.
- A qualifying recovery must be at or inside 50% of the trigger threshold: 50 bps for USD pegs and 75 bps for non-USD pegs. The first qualifying observation sets both recovery timestamps; each consecutive qualifying observation refreshes
recovery_last_seen_at. The row closes only after total recovery age reaches 15 minutes and the gap from the prior qualified observation is no more than 1200 seconds. - A later qualifying recovery after a gap greater than 1200 seconds resets both recovery timestamps to the new observation. Missing data leaves the event open, but the missing interval never proves recovery.
- A reading between the recovery and trigger thresholds is a deadband: keep the event open and clear any partial recovery timer.
- Native-domain rows recover only with a qualifying native quote, persisted with
close_reason = 'recovered-native'. Missing native evidence keeps the lifecycle open even if USD primary or DEX prices suggest recovery. - Authoritative or fresh multi-source primary recovery can advance the timer. Ambiguous primary recovery requires a trusted aggregate DEX row, at least 2 corroborating DEX protocol groups, and no qualifying pool veto.
- Any renewed same-direction depeg or contradictory trusted evidence clears the partial recovery timer.
Orphan Cleanup
After the main loop, load open events with the MAX_OPEN_DEPEG_EVENTS = 200 bound. If the read reaches that bound, skip orphan cleanup and emit a structured degraded warning rather than operating on a truncated set. Otherwise, close any rows that were not in the seen set and were not created during the current run. Rows for frozen coins are also exempt and are never force-closed -- preserved historical data must not be falsified by a lifecycle change (shouldCloseOrphanedDepeg in worker/src/cron/depeg-detection/repair.ts). 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 close_reason = 'orphan-tracking-removed' and recovery_price = NULL.
Stage 2 -- Confirmation
Processes all rows in depeg_pending. Every live onset, including small-cap, multi-source, native-origin, and extreme candidates, enters this stage.
Per-Record Processing
Guards (delete pending + skip):
- Invalid
peg_reference(<= 0) - Open event already exists for this coin (another path created it)
Recovery checks run before the age gate. A fresh native quote strictly inside the full trigger threshold clears pending as recovered; authoritative primary recovery does likewise unless native evidence still confirms the pending direction. Ambiguous primary prices alone cannot clear a row. Native recovery evidence does not let a native-origin quote confirm its own promotion.
Native-origin candidates keep their native quote as the event-price domain, but that quote cannot confirm itself. At confirmation time, Pharos normalizes the fresh canonical USD price through the authoritative FX reference and admits only source families outside CoinGecko. One such independent canonical family must agree with the native-domain direction and full threshold before promotion; an at-peg or opposite-direction normalized canonical reading rejects the candidate after the temporal window. Missing, stale, fallback, cached, or CoinGecko-only canonical evidence leaves the row pending rather than promoting it.
Age checks:
- If age < 15 minutes: skip (wait for next cycle)
- If age is past the 45-minute base expiry, delete only when the dynamic final limit is also exceeded. The limit extends to 135 minutes when the current authoritative primary still points in the pending direction or confirmation sources are unavailable/circuit-open, and to 180 minutes for extreme-move pending rows.
Secondary Source Checks
Off-chain check:
- Preferred path for supported non-USD fiat pegs: use a fresh CoinGecko native-currency quote (for example
BRZ/BRLorEURC/EUR) and compare that quote to the native1.0peg - A candidate initiated by that native quote additionally requires a fresh canonical USD price from at least one non-CoinGecko source family, normalized through the authoritative FX reference, to cross the full native-domain threshold in the same direction. Repeated CoinGecko native observations provide temporal persistence only, not independent source confirmation.
- Default path: CoinGecko can confirm a primary that does not already contain the CoinGecko source family. A CoinGecko-family primary gets no off-chain confirmer because DefiLlama's
coingecko:{id}price is a CoinGecko mirror, not an independent observation. - CoinGecko confirmation uses
/simple/pricewithprecision=fullandinclude_last_updated_at=true. Missing, stale, or future-dated observations are ignored, and non-OK response bodies are canceled before later confirmation fetches. - Calculate deviation against the current peg reference recomputed during confirmation only when that reference passes the same authority gate as Stage 1; thin non-USD fiat references without FX fallback fall back to the stored pending-row
peg_referencewhen valid, or wait without mutating when no safe reference is available - Counts as confirmation only when deviation reaches the full trigger threshold and points in the same direction as the pending incident
- Non-fatal: if fetch fails, the off-chain agreement remains
null - Canonical persisted keys are
temporal:15m,coingecko-confirm, ornative:<peg>.
CEX ticker check:
- Fetches the active configured Binance market 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 reaches the full trigger threshold 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_pricestable (same data as Stage 1) - Must be within the 75-minute hourly-producer freshness window and have aggregate source TVL >= $1M
- Aggregate DEX confirmation now also requires at least
DEPEG_DEX_PROTOCOL_CORROBORATION_MINindependent protocol groups from fresh per-sourceprice_sources_json; one protocol cannot promote, recover, or decisively contradict a pending row by itself - Counts as confirmation only when deviation reaches the full trigger threshold and points in the same direction as the pending incident
- Persisted confirmation keys use
dex:<protocol>.
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 distinct protocol/source-family groups reach the full trigger threshold in the same direction, or a single qualifying pool with
>= $5MTVL does so, and the confirming groups are at least as numerous as the groups contradicting the pending direction (opposite-direction or back inside the bar). Multiple same-protocol pools from the same source family count as one group. - The single-high-TVL exception replaces only the two-group minimum, never the opposing-group vote: one confirming group can be unopposed or tied with one opposing group, but cannot confirm against two opposing groups.
- Non-fatal: missing challenger tables or incomplete published snapshots fall back through the helper's legacy path and still yield
null/falsesafely - Persisted confirmation keys use
pool:<protocol>:<sourceFamily>.
Decision Matrix
| Temporal persistence | Source confirmation | Contradiction | Action |
|---|---|---|---|
| Less than 15 minutes | any | no terminal guard or recovery | Keep pending |
| Full 15 minutes beyond the trigger threshold | fresh primary cluster spanning at least 2 independent families | none decisive | PROMOTE |
| Full 15 minutes beyond the trigger threshold | independent CoinGecko, CEX, corroborated aggregate DEX, or qualifying pool evidence | none decisive | PROMOTE |
| Full 15 minutes beyond the trigger threshold | native-origin candidate plus a fresh non-CoinGecko canonical USD family normalized through authoritative FX, both beyond threshold in the same direction | none decisive | PROMOTE |
| Full 15 minutes beyond the trigger threshold | native-origin candidate only | normalized independent canonical price is at peg or points in the opposite direction | REJECT |
| Full 15 minutes | only soft off-chain evidence on a low-confidence row | none | Keep pending; require CEX, aggregate DEX, or pool confirmation |
| At least 15 minutes | any | decisive opposing evidence with no same-direction rescue | REJECT under the safeguards below |
| any | none | none | Keep pending until dynamic expiry, then expire or record unconfirmed-severe |
Primary-still-depegged safeguard: the REJECT rows above assume the refreshed authoritative primary price no longer shows the pending direction. When it still does (primarySameDirectionDepegged), a single opposing secondary source cannot reject the row -- rejection then requires at least two independent hard-opposing sources (reason two-hard-opposing-sources:...); otherwise one opposing source suffices (reason secondary-evidence-opposes).
Promotion inserts into depeg_events with started_at = the current continuous episode's first_seen_at, direction = the active pending direction, the refreshed authoritative peg_reference (or the stored pending reference when the refreshed non-USD fiat reference is not authoritative), canonical confirmation_sources beginning with temporal:15m, and peak = worst of the stored pending peak, current same-domain authoritative price, and trustworthy same-direction confirmer prices, then atomically inserts the outcome and deletes from depeg_pending as one candidate transition.
Native-currency confirmers on USD-origin candidates normalize their price by multiplying the native quote (reference 1) by the candidate's admitted USD peg reference before peak selection; native-origin candidates retain native prices and reference 1. Pool peak candidates are exposed and consumed only when the pool confirmation policy accepts them, so rejected minority pools cannot deepen a primary-led promotion or supply uncredited event peaks.
Pending rows that pass the 45-minute base expiry but still have same-direction primary evidence, unavailable sources, or open confirmation circuits remain pending until their final dynamic limit. Rows that exceed that final limit are deleted with a recorded pending outcome; extreme-move expiries use unconfirmed-severe instead of the generic expired label.
Historical Backfill Validation
Historical backfills in worker/scripts/backfills/backfill-depegs.ts consult the authoritative-provider registry before market history, but use a distinct episode policy: rounded absolute bps for triggers, a flat timestamp-valued $1B large-cap confirmation cutoff, and closure on the first sample inside the full trigger threshold. They do not inherit live tiered large-cap flags or the half-threshold/15-minute recovery window.
Backfill rewrites delete prior source='backfill' rows even when a trusted replay finds zero replacement events. Dry-runs preview that same removal scope through removedBackfillEventCount. For non-empty replacements, the delete and first insert chunk share one D1 batch() call (up to the D1 100-statement batch limit: delete + 99 inserts). Additional inserts are written in later chunks, so large replacements are bounded and restartable but not a single all-rows transaction.
Replay now drops recomputed episodes that reviewed data or an existing live row already covers, inside executeBackfillForCoin (worker/scripts/backfills/backfill-depegs/execution.ts; overlap predicate backfillEpisodeCoveredByLiveEvent in worker/scripts/backfills/backfill-depegs-window.ts):
- Reviewed suppression. An episode whose inclusive
[startedAt, endedAt]interval overlaps a matching registry entry for the same coin and direction is skipped. - Live overlap. An episode that overlaps an existing
source='live'row of the same coin and direction (episodeStart <= liveEnd && liveStart <= episodeEnd, inclusive; an open row collapses to its start second) is skipped. Live detection polls at minute cadence while replay consumes hourly samples, so same-side overlapping windows are one market episode counted twice; intervals one second apart are disjoint.
Both rules run before the dry-run/apply split, so the preview, expectedEventCount/expectedFingerprint, and the inserted rows stay mutually consistent. Each skip logs backfill-depegs-episode-skipped with skipReason: reviewed-suppression or live-overlap. Neither rule deletes anything by itself: the replayed window's delete still removes stale source='backfill' rows, live rows are never removed by replay, and the skip only prevents the twin from being re-inserted.
Mutating backfills now persist replay-run status and event provenance. Backfilled rows receive replay version, provider roster, quote mode, peg-reference source, supply source, confirmation policy, confidence tier, and compact public provenance. Existing rows without provenance are still accepted by API mappers and PegScore.
Historical supply admission prefers DefiLlama totalCirculatingUSD. Native-only totalCirculating / circulating snapshots are converted through the timestamp's historical FX or commodity peg reference (including per-token commodityOunces) before applying the $1M event floor or large-cap confirmation threshold; native units are never compared directly to USD thresholds. Absent, empty, invalid, or unconvertible records do not become zero snapshots; explicit finite zero remains observed zero.
When DefiLlama returns no historical rows, replay applies the live $1M event floor using the current stablecoins-cache supply for that asset. If neither historical nor current supply is available, the backfill preserves existing rows instead of silently replaying market prices without a supply floor. A non-empty historical response with no usable supply snapshots is treated as degraded and preserves existing rows even when current supply exists. The same current fallback supply controls large-cap confirmation behavior for genuinely absent-history assets.
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.
npx tsx worker/scripts/one-shot-backfill.ts backfill-depegs --query 'dry-run=true' also accepts startDay / endDay for bounded replay audits, plus optional contextDays to widen the replay pad around that UTC window. The job compares only overlapping stored source='backfill' rows; live replacement additionally requires --execute --allow-atomic-import and a coordinated maintenance window acknowledging temporary D1 unavailability. Transport and interruption handling are owned by docs/operator procedures/one-shot-backfills.md#transport-safety.
For commodity-pegged assets, the peer-median reference fetch is bounded to the same replay pad and only fetches the needed gold or silver source family instead of rebuilding full hourly history for every tracked commodity token.
Commodity lookup series and their current-rate fallback stay in per-ounce units until a single final commodityOunces conversion. Missing historical spot data therefore uses the current per-ounce rate times the token weight once, never an already per-token fallback weighted again. If neither a historical series anchor nor a current commodity reference is available, replay skips the coin as missing-fx-reference and preserves its existing events.
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.
Reviewed replay suppression registry
shared/data/depegs/backfill-replay-suppressions.ts records operator verdicts that a recomputed backfill episode is a price-feed artifact rather than a market depeg. It is what keeps a reviewed artifact deletion durable: without it, the next admin replay would delete and recompute the same window and re-persist the artifact.
| Field | Type | Meaning |
|---|---|---|
coinId | string | Tracked stablecoin id (validated against shared/data/stablecoins/canonical-order.json) |
direction | "above" | "below" | Depeg side the verdict applies to (shared DepegDirectionSchema vocabulary) |
windowStart / windowEnd | number | Inclusive UTC window bounds in Unix seconds; windowStart < windowEnd |
reason | string | The reviewed verdict, stated as the artifact conclusion |
evidenceUrls | string[] | Primary forensics (on-chain transaction links, price-feed pages); https only |
reviewedAt | string | Review date, YYYY-MM-DD |
Matching is inclusive interval overlap between the recomputed episode [startedAt, endedAt ?? startedAt] and the entry window, scoped to (coinId, direction).
An episode is an artifact only when no executed trade on any observed venue printed at the stored deviation. Historical backfill admits a single sample for sub-$1B assets, so a thin venue that genuinely traded at the deviation is a real print and must not be suppressed; a deep-pool state quote at par does not by itself refute a thin-venue trade.
The module validates the corpus at import time and fails closed. shared/data/depegs/__tests__/backfill-replay-suppressions.test.ts additionally guards tracked coin ids, the shared direction vocabulary, ordered integer windows, https evidence URLs, YYYY-MM-DD review dates, non-overlapping same-coin/same-direction windows, and the behavioral contract of the seeded USN windows.
Registry edits and primary-evidence review belong to docs/operator procedures/depeg-artifact-removal.md#recording-the-verdict; the seeded reviewed windows live in the registry, not a second list here.
Operator operator procedure
Reviewed artifact removal deploys suppression and live-overlap protection before deleting rows, then rebuilds PSI and preserves published URL redirects. Sealed DDR predictions require append-only repair instead of direct deletion. The command sequence, complete-id comparison and interruption handling are owned by docs/operator procedures/depeg-artifact-removal.md.
Event Lifecycle
Price crosses threshold
|
+-- Corroborated trusted DEX recovery --> suppress candidate
|
+-- Otherwise --> INSERT/UPDATE depeg_pending
|
remain beyond full threshold
for at least 15 minutes?
|
yes -------+------- no
| |
source rule passes? keep pending
|
yes ---+--- no
| |
PROMOTE to keep pending, recover,
depeg_events reject, or expire
Low-confidence pending rows are stricter: off-chain agreement alone does not promote them; they need CEX, aggregate DEX, or pool-challenger confirmation.
While event is open:
- Peak deviation updated if worse price seen
- Direction change with authoritative, DEX-confirmed, or admitted native-domain input: close old, queue new pending candidate
- USD-domain direction change with only `confirm_required` input: keep old-direction lifecycle open until authoritative or DEX-confirmed input arrives
- Trusted DEX disagreement on the same side is logged, but does not by itself close the event
- Price reaches the 50% recovery band: start or continue a 15-minute timer with admitted native recovery, authoritative primary recovery, or corroborated trusted aggregate DEX recovery without a qualifying pool veto
- Price returns to the deadband or depeg range: clear the recovery timer and keep the event open
Orphan cleanup:
- If a bounded open-event read reaches `MAX_OPEN_DEPEG_EVENTS = 200`, skip the affected detection, confirmation, or orphan-cleanup pass and emit a structured degraded warning
- Open event for a coin no longer tracked by Pharos: close with `close_reason='orphan-tracking-removed'` and `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"(an invalid value throws)sourcemust be"live"or"backfill"(an invalid value throws)closeReasonisnullfor open/legacy rows or one of the validated terminal reason strings above
DepegEventSchema and inferred DepegEvent in shared/types/market.ts own the
public shape, including nullable coverage and compact provenance. Legacy rows
without provenance remain valid; absence is not a positive audit verdict.
API
GET /api/depeg-events
The public API guide and docs/api-reference.md#get-apidepeg-events
own query parameters, pagination, response schemas and defaults. This endpoint
projects canonical public incidents; optional pending rows remain unconfirmed.
Malformed booleans, pagination values, and cursors return 400 before projection reads. Valid requests return 503 for unreadable (incident-projection-read-failed) or malformed (incident-projection-invalid) canonical projections; raw history is never substituted. A successfully read empty projection remains available.
Responses carry events, pagination/count precision and methodology provenance, plus pending candidates only when requested. Unknown coverage remains explicit.
total counts public incident rows, not necessarily individual threshold crossings. The endpoint does not emit derived counts; clients sum constituentEventCount across the fully loaded incident history for threshold-crossing totals.
Exact totals reuse one D1 summary (cache['depeg-events:counts:v1']) grouped by stablecoin and active/closed membership, rather than recounting the full provenance view for every page/filter. Its publication generation is the committed public membership: migration 0271 invalidates it atomically on event insertion/deletion, identity/stablecoin changes, active/closed transitions, and DDR incident/link changes that affect supersession. Peak observations and closed-to-closed timestamp corrections do not change counts. This covers live detection, pending confirmation, historical backfills/replays, audit deletion and DDR repairs without relying on the later sync-stablecoins completion clock. Provenance is a one-to-one left join and does not change row totals. The first exact read after invalidation rebuilds the grouped summary; cache population, filtered total and the page share one transactional D1 batch. Concurrent mutations cannot mix an older cached total with a newer page. includeTotal=false neither populates nor reads the summary and preserves observed lower-bound/cursor behavior. The summary is derived count data, never a freshness authority.
Each pending row's expiresAt is the base expiry, firstSeenAt + DEPEG_PENDING_EXPIRY_SEC. The confirmation cron keeps a row pending past it under the extended and severe limits (see the timing table), so lastSeenAt may legitimately exceed expiresAt; the shared contract requires only lastSeenAt >= firstSeenAt and expiresAt > firstSeenAt.
When DDR has linked multiple raw rows into one active repaired incident, the endpoint returns the incident's current event row, excludes superseded source rows from the active projection, projects the public startedAt/startPrice from the first linked row, and reports the number of linked rows in constituentEventCount. Unprojected rows use constituentEventCount = 1.
Rows may include a nullable provenance object with public replay/audit metadata (sourceKind, replayRunId, replayVersion, sourcePriceProviders, quoteMode, pegReferenceSource, supplySource, confirmationPolicy, confirmationPointCount, confidenceTier, auditVerdict, pegScoreEligible, updatedAt). Legacy rows return provenance: null.
Cache: producer-backed profile (s-maxage=300, max-age=60, stale-while-revalidate=300). Freshness headers use the confirmed sync-stablecoins output timestamp with a 900s budget. Missing, unreadable, or invalid producer history returns no-store and unavailable freshness; event onset times are never a freshness fallback.
Frontend
The Depeg & Recovery board separates live incidents,
forecast grading and resolved history. The permanent archive
retains qualifying published event URLs; sub-threshold detections remain in the
API and per-coin history. Route/crawl contracts live in docs/depeg-page.md#event-history-and-crawlability;
detail-history presentation lives in docs/stablecoin-detail-page.md.
Peg Stability Metrics (peg-stability.ts)
computePegStability() in src/lib/peg-stability.ts supports detail-history
streak/current-event metrics over full available history. It does not use
PegScore's four-year clamp; do not present its full-history occupancy/span as
the scored window.
Peg Score (peg-score.ts)
The public PegScore methodology
owns the formula and components; shared/lib/peg-score.ts owns execution.
Detection contributes eligible episodes, observed price intervals and audit provenance:
false_positiveanddisputedevents are excluded.no_dataremains eligible here but is excluded by DDR's separate policy. Unknown non-null verdicts fail closed; null legacy verdicts remain eligible. Low-confidence events retain occupancy impact with half-weight severity/spread; quality counters expose this.coinTrackingStart()honors reviewed replay/continuous-live coverage first, otherwise launch date, then the earliest supply or durable valid-price anchor. Without these, the earliest event is the fallback; no anchor or events means NR. An assumed age anchor does not prove earlier incident-free coverage.- Scoring uses the four-year clamp and requires seven known days after unknown event spans are removed. Fully blind occupancy is null, never 100% at peg.
- The 90-day companion starts at the later of its window boundary or tracking anchor and reports observed days, incidents, threshold crossings, peg percentage and coverage limitation under the same audit exclusions.
- An open lifecycle alone cannot activate the current-depeg penalty: it needs a trusted off-peg observation, no explicit gap and age ≤1200 seconds. Historical observed peaks still contribute severity/spread when current coverage is unavailable.
Edge Cases & Guardrails
| Scenario | Handling |
|---|---|
| Duplicate events | Unique index (stablecoin_id, started_at, source) + run-start repair; same-direction rows merge, opposite-direction rows close older directions without absorbing opposite-sign peaks |
| 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; existing rows close with close_reason = 'coverage-lost-supply' only on an observed sub-floor supply |
| Supply unavailable (absent/empty/invalid buckets) | No new event; an existing lifecycle stays open without peak/recovery progress, records blind price coverage, and emits a per-asset warning |
| Missing/invalid prices | Multiple null/NaN/<= 0 checks |
| Peg reference validation | Must be finite and > 0 |
| DEX freshness | Prices > 75 min old ignored |
| Orphaned events | Closed with close_reason = 'orphan-tracking-removed' and recovery_price = NULL when coin drops off tracking |
| Non-USD threshold | 150bps accounts for FX noise and thin liquidity |
Trusted-price coverage and unresolved legacy spans (6.31)
Migration 0255_depeg_trusted_price_coverage.sql adds nullable columns only; it records no historical observations. Detection persists trusted off-peg intervals, known atParIntervals, the latest trusted price clock, and lastObservationKind (trusted-off-peg, trusted-at-par, or blind). A trusted at-par observation advances the trusted clock but breaks off-peg continuity; gapStartedAt therefore marks a break in off-peg continuity, not necessarily blind time. Missing/unusable prices, unavailable supply/reference context, omitted assets, and gaps over 1200 seconds break observation continuity. State transitions and resumption start zero-length endpoints; only subsequent continuous observations in the same trusted state extend their intervals. Pending promotion starts evidence at confirmation, not inside unrecorded pending history. Malformed stored coverage warns with the row ID and becomes unknown; producer writes remain strictly validated. Unchanged omitted-asset blind coverage does not trigger another UPDATE.
For pre-migration open events, the entire uninstrumented span is explicitly unknown. The first post-migration trusted observation does not backfill the preceding span. Closed legacy and historical replay rows retain their recorded episode durations; their internal price-gap boundaries remain unreconstructable. Instrumented rows retain coverage intervals when closed. Recovery time bounded by consecutive trusted at-par endpoints is known at-par time; unrecorded transition/recovery spans remain unknown rather than automatically off peg.
The single mergeDepegSeconds authority counts recorded off-peg intervals only. The complement of both off-peg and at-par intervals inside each scored event is unknownCoverageSeconds; overlapping unknown spans merge and trusted intervals from another row take precedence. Unknown time is excluded from both off-peg time and the occupancy denominator, including recent 90-day statistics, and from duration severity. It is neither zero deviation nor verified at-peg time. pegPct is nullable when the known denominator is empty; pegScore is NR when fewer than seven known days remain. Public event priceCoverage and summary unknownCoverageSeconds expose this distinction, and exact Safety Score peg provenance retains it.
Producer-cutover observation: retain the pre-window Time Travel bookmark, migration ledger, and deployed Worker version; use the current migration sequence in worker/migrations/MANIFEST.md. After sole-version activation, observe two sync-stablecoins runs: a trusted open event starts an endpoint then accrues only the continuous interval; missing prices keep its lifecycle open with a persisted gap and no new observed off-peg seconds. Inspect the coverage columns with read-only SELECTs, then observe new peg-analytics and Safety Score publications for explicit unknown coverage, nullable fully blind occupancy, and admitted observed sub-floor deviations. Frozen pre-cutover captures cannot prove a producer change; compare newly produced generations. A Worker-only rollback preserves additive schema/evidence but pauses the producer; resumption after a long gap starts a new interval. Reserve D1 restore for unexpected schema/data mutation using the retained bookmark.