Skip to content

AlertRegistrationManager

Bridge between UserPaperTradePortfolio and PriceAlert DOs. Registers and deregisters price-condition alerts (LIMIT_ORDER, STOP_LOSS, TAKE_PROFIT, LIQUIDATION_WATCH) and exposes an RPC for atomically updating SL/TP on existing positions.

Purpose

Centralizes all PriceAlert DO interactions (register/deregister) so that callers (OrderExecutionManager, LimitOrderManager, LeverageSettings, ChallengeManager) don't need to know about instance selection, network context, or the fire-and-forget cleanup pattern. Stateless — stores nothing itself; alert IDs and instance numbers live in the positions and limit_orders tables owned by other managers.

High-Level Design

Instance-Based Load Balancing

Each geo-location runs 10 PriceAlert DO instances (0-9). When a position or limit order first needs an alert, an instance is selected via Math.floor(Math.random() * 10) and persisted in the positions.price_alert_instance / limit_orders.price_alert_instance column. All subsequent alerts for that position or order reuse the same instance so registration and deregistration always target the same DO.

Position opened (no instance yet)
  → random instance selected (e.g. 7)
  → registerLiquidationWatch → PriceAlert-7
  → registerStopLoss        → PriceAlert-7
  → registerTakeProfit       → PriceAlert-7
  → instance 7 persisted in positions table

Later: position closed
  → deregisterPositionAlerts reads instance 7 from DB
  → deregisters all 3 alert IDs from PriceAlert-7

Registration vs Deregistration Patterns

Registration is synchronous (awaited) — it's on the critical path. A position or limit order cannot be committed until the alert is confirmed registered, because the alert ID must be stored alongside the record.

Deregistration uses two strategies:

ContextStrategyWhy
Cancel/update SL/TP, cancel limit orderawait deregisterAlert()User-facing RPC — must complete before response
Position closed, account resetFire-and-forget via TaskSchedulerCleanup shouldn't block the critical path
Challenge reset/deactivationFire-and-forget via TaskScheduler (batched by instance)Bulk cleanup across all positions and limit orders

Fire-and-Forget Deregistration via TaskScheduler

deregisterPositionAlerts() and deregisterAllAlerts() don't make RPC calls directly. Instead they schedule ALERT_DEREGISTER tasks:

deregisterPositionAlerts(position)
  → collects [liquidationAlertId, stopLossAlertId, takeProfitAlertId]
  → upsertTask(ALERT_DEREGISTER, { alertIds, instanceNum })
  → ensureAlarmScheduled()

AlertDeregisterHandler.execute(task)
  → calls deregisterAlerts(alertIds, instanceNum) — single batch RPC
  → on failure: shouldRetry = true (TaskScheduler retries with backoff)

deregisterAllAlerts() groups alert IDs by instance number first, then creates one task per instance to minimize RPC calls.

SL/TP Update (handleUpdatePositionAlertsRPC)

Updates stop loss and/or take profit on an existing position. Not truly atomic — SL and TP are processed sequentially without a shared transaction:

  1. Validate position exists and at least one field is provided
  2. Compute instance number (reuse existing or select random)
  3. For each field (SL/TP):
    • If value is a price string: deregister old alert → validate new price → register new alert → update position record
    • If value is null: deregister old alert → clear alert ID and price from position record
  4. Broadcast POSITION_ALERTS_UPDATED WebSocket event

Failure modes:

  • If new alert registration fails after old alert was deregistered, the position record still holds the old (now dead) alert ID — a dangling reference. The alert will never trigger.
  • If SL succeeds but TP validation/registration fails, the SL change is committed but the WebSocket broadcast is skipped (early return on error).
  • If deregisterAlert() itself throws (network/DO failure), the entire RPC throws — no partial changes committed.

Account ID for Stale Alert Rejection

Every registered alert includes the portfolio's accountId. When PriceAlert evaluates an alert, it can reject alerts from accounts that are no longer active (e.g., after challenge reset creates a new account). This prevents stale alerts from triggering on dead accounts.

Alert Type Semantics

TypeoneTimeUse Case
LIQUIDATION_WATCHfalseContinuous — re-evaluated every price tick until position closes
STOP_LOSSfalseContinuous — survives price fluctuations near target
TAKE_PROFITfalseContinuous — same as stop loss
LIMIT_ORDERtrueOne-shot — auto-deregistered after trigger

Edge Cases & Error Handling

  • No instance assigned: Falls back to Math.floor(Math.random() * 10) — can happen if a position was created before instance tracking was added
  • Test hook: getTestFailAlertRegistration() forces liquidation watch registration to fail, used in integration tests
  • Partial SL/TP update: SL and TP are processed sequentially — if SL succeeds but TP fails, only SL is committed (see failure modes above)
  • Limit order alert ID: Limit orders use their own id as the alert ID in PriceAlert (not a separate alertId field like positions)

See Also