Appearance
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
- Alert evaluation — evaluate condition-based alerts (limit orders, stop loss, take profit, liquidation) against live prices every second
- Price arrival notification — notify
UserPaperTradePortfoliowhen a pending order's price data becomes available - Read replica — serve cached price data to
UserPaperTradePortfolio, reducing direct load onPriceCollector
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
| Type | Trigger Condition |
|---|---|
LIMIT_ORDER | Price crosses order target (LONG: price ≤ target, SHORT: price ≥ target) |
STOP_LOSS | Same direction as LIMIT_ORDER |
TAKE_PROFIT | Opposite direction (LONG: price ≥ target, SHORT: price ≤ target) |
LIQUIDATION_WATCH | Same direction as STOP_LOSS |
PRICE_ARRIVAL | A 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)→UserPaperTradePortfolioexecutes the trade- Self-healing: auto-deregisters alerts that return
POSITION_NOT_FOUNDorSTALE_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:
| Cache | TTL | Max Entries | Key Alignment |
|---|---|---|---|
| Price (per-second) | 5 min | 300 | Raw timestamp |
| 1-minute candles | 2 hours | 120 | Floor to 60s |
| 1-hour candles | 2 days | 48 | Floor 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-3600The 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 AccountValueAggregationTrackerget1mCandles()/get1hCandles()— candle data for account value calculationsregisterLimitOrderWithPriceCheck()— 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_PASTif unavailable - If timestamp > now + 300s: reject with
TIMESTAMP_FUTURE
Optimization levels:
- Cache hit — return immediately, no alert created
- 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
- Create alert — store in
price_arrival_alertsfor 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
| DO | Direction | Purpose |
|---|---|---|
PriceCollector | PriceAlert → | Fetch price data, candles, finalization status (same location via geographic routing) |
UserPaperTradePortfolio | ← PriceAlert | onAlert() for condition triggers, onPriceArrival() for pending orders |
UserPaperTradePortfolio | → PriceAlert | registerAlert(), registerPriceArrivalAlert(), registerLimitOrderWithPriceCheck(), getPriceData() |
AccountValueAggregationTracker | → PriceAlert | getPriceDataBatch(), getPriceDataRange(), get1mCandles(), get1hCandles() |
SymbolRegistry | ↔ PriceAlert | Pull on init (getEnabledSymbolsWithMetadata), push updates (onSymbolConfigUpdate); fail-open if unavailable |