Skip to content

PriceAlert

Geo-located Durable Object (N per location) providing cursor-based alert evaluation and a read replica for price data. 70 global instances (7 locations × 10 per location) with random instance selection for load balancing.

Responsibilities

  1. Alert evaluation — evaluate condition-based alerts (limit orders, stop loss, take profit, liquidation) against live prices every second
  2. Price arrival notification — notify UserPaperTradePortfolio when a pending order's price data becomes available
  3. Read replica — serve cached price data to UserPaperTradePortfolio, reducing direct load on PriceCollector

High-Level Data Flow

                          ┌──────────────────┐
                          │  PriceCollector  │
                          │  (same location) │
                          └────────┬─────────┘
                                   │ getPriceDataRange()
                                   ▼                      getPriceData   
┌──────────────┐    ┌───────────────────────────────┐     registerAlert  
│SymbolRegistry│───▶│          PriceAlert           │◀──────────┐
│ (push config)│    │                               │           │
└──────────────┘    │  ┌─────────────────────────┐  │   ┌───────────────┐
                    │  │ Alarm Loop (1s cadence) │  │   │ UserPortfolio │
                    │  │                         │  │   └───────────────┘
                    │  │ 1. Fetch price range    │  │           ▲
                    │  │ 2. Evaluate conditions  │──┼───────────┘
                    │  │ 3. Process arrivals     │  │  onAlert() /
                    │  │ 4. Advance cursor       │  │  onPriceArrival()
                    │  └─────────────────────────┘  │
                    └───────────────────────────────┘

Alert Types

TypeTrigger Condition
LIMIT_ORDERPrice crosses order target (LONG: price ≤ target, SHORT: price ≥ target)
STOP_LOSSSame direction as LIMIT_ORDER
TAKE_PROFITOpposite direction (LONG: price ≥ target, SHORT: price ≤ target)
LIQUIDATION_WATCHSame direction as STOP_LOSS
PRICE_ARRIVALA specific timestamp's price data becomes available

The first four are condition-based (stored in alerts table, evaluated against live prices). PRICE_ARRIVAL is event-driven (stored in price_arrival_alerts table, resolved when data arrives).

Managers

AlertStorageManager

CRUD for condition-based alerts with an in-memory symbol-indexed cache (Map<Symbol, Alert[]>). Loaded at initialization for O(1) lookups during evaluation. All mutations write-through to both SQLite and the cache.

AlertCursorManager

Tracks the alarm loop's progress via a cursor — the last successfully processed timestamp. Persisted to alert_metrics.last_checked_timestamp. On first run, initializes to now - 1. After DO eviction, the gap between cursor and current time becomes the catch-up window.

AlertEvaluationManager

Evaluates all cached alerts against a price snapshot synchronously, then triggers callbacks asynchronously:

  • onAlert(alert, timestamp, prices)UserPaperTradePortfolio executes the trade
  • Self-healing: auto-deregisters alerts that return POSITION_NOT_FOUND or STALE_ALERT
  • One-time alerts: auto-deregister after successful trigger

PriceDataManager

Fetches and caches price data from PriceCollector at the same geographic location. Three cache tiers:

CacheTTLMax EntriesKey Alignment
Price (per-second)5 min300Raw timestamp
1-minute candles2 hours120Floor to 60s
1-hour candles2 days48Floor to 3600s

Retry strategy: 3 attempts with exponential backoff (200ms, 400ms). Inflight deduplication prevents concurrent fetches for the same timestamp. Only caches non-empty results.

Key Concepts

Cursor-Based Catch-Up

The alarm loop processes every second between lastCheckedTimestamp + 1 and now. After DO eviction, this can span minutes or hours. The catch-up window is capped at 3600 seconds (1 hour) to prevent unbounded processing.

Normal:     cursor=T-1 → process T → advance cursor to T
Post-eviction: cursor=T-100 → process T-99..T → advance cursor to T
Capped:     cursor=T-7200 → process T-7199..T-3600 → advance cursor to T-3600

The cursor only advances to the last successfully processed timestamp, not the full range. If a fetch returns incomplete data, the loop breaks early and retries next alarm.

Read Replica Pattern

UserPaperTradePortfolio and AccountValueAggregationTracker never call PriceCollector directly. All price queries route through PriceAlert, which serves from in-memory cache or fetches on-demand. This centralizes caching and reduces PriceCollector load.

  • getPriceData(timestamp) — single timestamp with staleness fallback (tries up to 3 seconds back)
  • getPriceDataBatch(timestamps[]) / getPriceDataRange(start, end) — batch queries for AccountValueAggregationTracker
  • get1mCandles() / get1hCandles() — candle data for account value calculations
  • registerLimitOrderWithPriceCheck() — atomic price check + limit order registration in a single RPC call

Price Arrival Alert Optimization

registerPriceArrivalAlert() validates first, then uses a three-level strategy to minimize alert creation:

Pre-validation:

  • If timestamp ≤ current cursor (already processed): try cache/backfill, return TIMESTAMP_PAST if unavailable
  • If timestamp > now + 300s: reject with TIMESTAMP_FUTURE

Optimization levels:

  1. Cache hit — return immediately, no alert created
  2. Proactive fetch — for timestamps ≥1s old, fetch from PriceCollector with 1 retry. Skipped for very recent timestamps (<1s) to avoid blocking on data still being collected
  3. Create alert — store in price_arrival_alerts for async delivery via alarm loop

Finalized Empty Timestamps

When PriceCollector has no data for a timestamp and marks it as finalized (via getFinalizedUpTo()), PriceAlert skips the timestamp for condition alerts but still processes price arrival alerts — so pending orders fail gracefully rather than staying orphaned.

Time Compensation

The alarm reschedules to Math.floor(Date.now() / 1000) * 1000 + 1000 to maintain a 1-second cadence despite execution time variance.

Interactions

DODirectionPurpose
PriceCollectorPriceAlert →Fetch price data, candles, finalization status (same location via geographic routing)
UserPaperTradePortfolio← PriceAlertonAlert() for condition triggers, onPriceArrival() for pending orders
UserPaperTradePortfolio→ PriceAlertregisterAlert(), registerPriceArrivalAlert(), registerLimitOrderWithPriceCheck(), getPriceData()
AccountValueAggregationTracker→ PriceAlertgetPriceDataBatch(), getPriceDataRange(), get1mCandles(), get1hCandles()
SymbolRegistry↔ PriceAlertPull on init (getEnabledSymbolsWithMetadata), push updates (onSymbolConfigUpdate); fail-open if unavailable