|
1 | | -# Custom Price & Yield Alert Rules |
| 1 | +# Alert Rules, Acknowledgement, Snooze & Escalation (#366) |
2 | 2 |
|
3 | | -User-defined rules that proactively notify a user when a market or portfolio |
4 | | -condition they care about is met — e.g. "tell me if Blend's APY drops below 5%" |
5 | | -or "alert me if my portfolio value falls under $1,000". |
| 3 | +NeuroWealth provides user-defined alert rules watching portfolio metrics (`PROTOCOL_APY`, `PORTFOLIO_VALUE`, `POSITION_DRAWDOWN`, `DRIFT`, `VOLATILITY_REGIME`, `ANOMALY`). |
6 | 4 |
|
7 | | -This is **end-user** alerting and is deliberately distinct from the operator- |
8 | | -facing Prometheus/Grafana alerting in [`OBSERVABILITY.md`](./OBSERVABILITY.md) |
9 | | -(`agent_loop_status`, `cursor_lag_ledgers`, `dlq_size`, …), which watches system |
10 | | -health for on-call engineers rather than portfolio conditions for users. |
| 5 | +## Concepts & Control Flow |
11 | 6 |
|
12 | | -- Data model: `AlertRule` in [`prisma/schema.prisma`](../prisma/schema.prisma) |
13 | | -- Evaluation core (pure, unit-tested): [`src/services/alertEvaluator.ts`](../src/services/alertEvaluator.ts) |
14 | | -- Scheduled job: [`src/jobs/alertRules.ts`](../src/jobs/alertRules.ts) |
15 | | -- CRUD API: [`src/routes/alerts.ts`](../src/routes/alerts.ts) |
16 | | -- Delivery: reuses [`src/services/webhookDispatcher.ts`](../src/services/webhookDispatcher.ts) |
17 | | - (webhook) and [`src/whatsapp/formatters.ts`](../src/whatsapp/formatters.ts) (WhatsApp) |
| 7 | +- **Cooldown (`cooldownMinutes`)**: Restricts re-firing frequency while a condition remains true across consecutive ticks. |
| 8 | +- **Snooze (`POST /api/v1/alerts/:id/snooze`)**: Temporarily mutes an alert rule for a duration (clamped to max 30 days). Evaluation is skipped while snoozed, but would-be fires are recorded in `AlertFire` history with `suppressedBySnooze: true` for auditing. Auto-expires emitting `alert.snooze_expired`. |
| 9 | +- **Acknowledge (`POST /api/v1/alerts/:id/ack`)**: Marks an alert episode as "seen" by linking an `AlertAck` to `AlertFire` records. Resets the escalation counter. Can be invoked via API or via signed single-use `ackToken` in WhatsApp/Telegram/Email notifications. |
| 10 | +- **Escalation**: Triggers when the count of **un-acknowledged** fires (`AlertFire` where `ackId IS NULL`) within a rule's window reaches `escalationThreshold`. Escalated fires set `escalated = true` and deliver to `escalationChannel`. |
18 | 11 |
|
19 | | -## Rule model |
| 12 | +--- |
20 | 13 |
|
21 | | -A rule is a single condition (compound/multi-condition rules are out of scope |
22 | | -for v1): |
| 14 | +## API Endpoints |
23 | 15 |
|
24 | | -| Field | Meaning | |
25 | | -| ----------------- | ------------------------------------------------------------------- | |
26 | | -| `metric` | `PROTOCOL_APY`, `PORTFOLIO_VALUE`, or `POSITION_DRAWDOWN` | |
27 | | -| `protocolName` | required for `PROTOCOL_APY`, rejected for the other metrics | |
28 | | -| `comparator` | `LT`, `LTE`, `GT`, `GTE` | |
29 | | -| `threshold` | compared against the observed value (units below) | |
30 | | -| `deliveryChannel` | `WEBHOOK`, `WHATSAPP`, or `BOTH` | |
31 | | -| `cooldownMinutes` | minimum gap between notifications for this rule (default 60) | |
32 | | -| `lastFiredAt` | when the rule last fired; drives cooldown | |
33 | | -| `isActive` | inactive rules are never evaluated | |
34 | | - |
35 | | -### Units per metric |
36 | | - |
37 | | -- **`PROTOCOL_APY`** — threshold and observed value are **percentages** |
38 | | - (`5` == 5%). `ProtocolRate.supplyApy` is stored as a fraction (`0.05`), so the |
39 | | - evaluator scales it by 100 before comparing. |
40 | | -- **`PORTFOLIO_VALUE`** — threshold and observed value are the **USD sum of the |
41 | | - user's ACTIVE positions' `currentValue`**. |
42 | | -- **`POSITION_DRAWDOWN`** — threshold and observed value are a **percentage |
43 | | - decline from a reference peak** (see below). |
44 | | - |
45 | | -## POSITION_DRAWDOWN reference window |
46 | | - |
47 | | -"Drawdown" is meaningless without a reference point, so we fix one explicitly: |
48 | | - |
49 | | -> Drawdown is measured against the **rolling 30-day peak of the user's total |
50 | | -> portfolio value**. |
51 | | -
|
52 | | -The peak is the maximum of: |
53 | | - |
54 | | -1. every historical whole-portfolio value reconstructed from `YieldSnapshot` |
55 | | - rows (`principalAmount + yieldAmount`, summed across the user's positions per |
56 | | - snapshot instant) within the trailing 30 days, and |
57 | | -2. the current total portfolio value. |
58 | | - |
59 | | -Including the current value as a candidate means a fresh all-time high reports |
60 | | -**0% drawdown** rather than a spurious decline against a stale sample. |
61 | | - |
62 | | -``` |
63 | | -drawdown% = max(0, (peak - current) / peak * 100) |
64 | | -``` |
65 | | - |
66 | | -The window length is `WINDOW_DAYS` in [`src/jobs/alertRules.ts`](../src/jobs/alertRules.ts). |
67 | | - |
68 | | -## Evaluation & cooldown |
69 | | - |
70 | | -The job runs on a fixed interval (`ALERT_RULES_INTERVAL_MS`, default 60s). On |
71 | | -each tick it loads all `isActive` rules and, for each: |
72 | | - |
73 | | -1. Observes the current value for the rule's metric. |
74 | | -2. Checks the comparator against the threshold. |
75 | | -3. If the condition holds, **atomically claims a fire** with an `updateMany` |
76 | | - guarded on `{ id, isActive, lastFiredAt outside cooldown }`, setting |
77 | | - `lastFiredAt = now`. Only if that update matches exactly one row does it |
78 | | - deliver. |
79 | | - |
80 | | -The cooldown is essential: a rule sitting right at its threshold would otherwise |
81 | | -fire on every tick. With it, a rule notifies **at most once per |
82 | | -`cooldownMinutes`**. The condition does **not** have to flip false→true again — |
83 | | -if it is still true once the cooldown elapses, the rule re-fires. |
84 | | - |
85 | | -### Edge cases |
86 | | - |
87 | | -- **Condition true across many ticks** — cooldown suppresses repeats; the rule |
88 | | - stays active and re-fires after the cooldown if still true. |
89 | | -- **Rule deleted/deactivated mid-tick** — the atomic fire-claim matches 0 rows, |
90 | | - so delivery is skipped silently (no error, no send to a gone rule). |
91 | | -- **Protocol delisted** — a `PROTOCOL_APY` rule whose protocol has no |
92 | | - `ProtocolRate` row is **auto-deactivated** (`isActive = false`) with a logged |
93 | | - reason, rather than evaluated against missing/stale data. |
94 | | - |
95 | | -## Delivery & failed-delivery retry policy |
96 | | - |
97 | | -Delivery reuses the existing HMAC-signed webhook dispatcher |
98 | | -(`dispatchWebhookEvent('alert_rule.triggered', …)`) and/or the Twilio WhatsApp |
99 | | -sender. No new unsigned delivery path is introduced. |
100 | | - |
101 | | -**Decision (per issue #289): alert deliveries reuse `dispatchWebhookEvent` |
102 | | -as-is and get no additional retry sweep beyond its synchronous 3-attempt |
103 | | -exponential backoff (1s/2s/4s).** |
104 | | - |
105 | | -Rationale: alerts are about a *live* condition. A separate sweep that later |
106 | | -replays a `FAILED` delivery could fire a stale alert for a condition that has |
107 | | -since reversed. Instead: |
108 | | - |
109 | | -- If **all** requested channels hard-fail during a fire, the job **rolls back |
110 | | - `lastFiredAt`** to its prior value, so the next tick re-evaluates the *current* |
111 | | - condition and retries if it still holds (bounded by cooldown). A transient |
112 | | - failure therefore self-heals on the following tick without replaying stale |
113 | | - data. |
114 | | -- The webhook dispatcher still persists a `WebhookDelivery` row with |
115 | | - `status = FAILED` for observability, exactly as for every other event. |
116 | | - |
117 | | -If durable, at-least-once alert delivery is required later, the follow-up is a |
118 | | -dedicated retry sweep over `FAILED` `WebhookDelivery` rows — explicitly out of |
119 | | -scope here. |
120 | | - |
121 | | -## Configuration |
122 | | - |
123 | | -| Env var | Default | Meaning | |
124 | | -| ------------------------ | ------- | ------------------------------------ | |
125 | | -| `ALERT_RULES_INTERVAL_MS`| `60000` | Evaluation tick interval (ms) | |
126 | | - |
127 | | -## Conversational management (WhatsApp) |
128 | | - |
129 | | -Alert rules can be managed over WhatsApp via the NLP intents in |
130 | | -[`src/nlp/parser.ts`](../src/nlp/parser.ts) (`alert_create`, `alert_list`, |
131 | | -`alert_delete`), handled in [`src/whatsapp/handler.ts`](../src/whatsapp/handler.ts). |
132 | | -As with the other intents (#281/#282), the intent union, the `KNOWN_ACTIONS` |
133 | | -allowlist, and the handler switch are kept in sync **manually**. WhatsApp-created |
134 | | -rules default to `WHATSAPP` delivery. |
| 16 | +| Method | Endpoint | Description | |
| 17 | +| :--- | :--- | :--- | |
| 18 | +| `POST` | `/api/v1/alerts/:id/snooze` | Mute rule for `durationMinutes` or `until` date | |
| 19 | +| `POST` | `/api/v1/alerts/:id/ack` | Acknowledge alert fire(s) using `fireId` or `ackToken` | |
| 20 | +| `GET` | `/api/v1/alerts/:id/fires` | View paginated fire history with ack & snooze status | |
0 commit comments