Appearance
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-7Registration 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:
| Context | Strategy | Why |
|---|---|---|
| Cancel/update SL/TP, cancel limit order | await deregisterAlert() | User-facing RPC — must complete before response |
| Position closed, account reset | Fire-and-forget via TaskScheduler | Cleanup shouldn't block the critical path |
| Challenge reset/deactivation | Fire-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:
- Validate position exists and at least one field is provided
- Compute instance number (reuse existing or select random)
- 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
- Broadcast
POSITION_ALERTS_UPDATEDWebSocket 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
| Type | oneTime | Use Case |
|---|---|---|
| LIQUIDATION_WATCH | false | Continuous — re-evaluated every price tick until position closes |
| STOP_LOSS | false | Continuous — survives price fluctuations near target |
| TAKE_PROFIT | false | Continuous — same as stop loss |
| LIMIT_ORDER | true | One-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
idas the alert ID in PriceAlert (not a separatealertIdfield like positions)
See Also
- PriceAlertManager - Handles inbound alert callbacks (the reverse direction)
- PriceFetchManager - Handles price data fetching
- UserPaperTradePortfolio - Parent DO