Alerting
A persisted rule set, a pure evaluator and a delivery seam that run strictly off the hot fold — with a default build that links no trading crate at all, because the watchdog it exists for must be able to page without the core.
vike-alerting exists because of a loss. prod1 crashed and roughly six hours of recorded Polymarket
L2 tape were lost unnoticed; the wanted fix was a watchdog that pages when a recorder stops writing,
with no business linking the trading core. Inside vike-ops it would have had to link vike-core,
vike-exec and vike-model just to post a message, so the crate was split on the seam that was
already there. A default build has zero vike-* dependencies, and the gate is cargo tree,
not the feature list: the alerting-standalone lane builds the default configuration alone and
fails if any vike- line appears. Only the two inputs that name a vike type — a CoreSnapshot and
a typed Event — sit behind the core feature. The whole thing is an off-fold consumer of
published state: the headless daemon's summary thread hands the lossy snapshot to the engine once
per tick, and nothing here back-pressures or blocks the core.
Rules
rule.rs is pure data and serde. An AlertRule has a stable id (the evaluation-state key, the
cooldown key and the delivery correlation id), a name, an enabled flag, a trigger, delivery
targets, a cooldown_ms and a once flag. RuleTrigger is internally tagged on kind; an
unknown kind from a newer build fails the whole parse, and the loader then ignores the file. The
kinds, by the input that evaluates them:
| input | triggers |
|---|---|
| a snapshot | Price — the published mark crosses a level; Drawdown — the daemon's own equity curve draws down from its session peak, latched; ReconAlert — a held reconcile divergence is present, latched |
| an indicator sample | Indicator — a streaming-indicator output crosses a threshold; the rule stores the registry key, output index and params, and the consumer computes the value |
| a typed event | Fill, optionally scoped; OrderRejected — a venue rejection or a RiskGate denial |
| a status signal | Feed (degraded or recovered), FillRateBreaker, PolymarketResolution, SeriesStale |
Crossings are edge-triggered: Above fires on the transition from at-or-below to strictly above,
never every tick the value stays there. Drawdown deliberately tracks Portfolio::drawdown_curve
— configured capital plus the daemon's own P&L — and not the cross-venue equity total, because the
total includes each live venue's wallet, which a third party can move; it is the quantity the core's
own liquidate-only latch uses, so the two cannot disagree. SeriesStale is the trigger the crate was
split for, and it is deliberately not Feed: feed health is venue-scoped and reports a transport
transition, while this one is series-scoped and reports an absence — the socket is established, the
subscription returned Ok, and there are no rows. Its scope is a prefix match on the recorder's
kind/venue/symbol key, because a Polymarket up/down family mints new token ids every five minutes
and the series an operator wants to watch does not exist yet when the rule is written.
Evaluation
eval.rs is the pure evaluator: (rule + per-rule state + one input) -> Option<FiredAlert>, with
no I/O, no clock (the caller passes now_ms) and no delivery, so every trigger's fire-or-not is
unit-tested with plain values. Each of the four entry points returns None for a rule whose trigger
belongs to a different input, so the engine hands every enabled rule to every entry point without
partitioning. RuleState holds the previous scalar sample — None until the first, so a rule can
never fire on its very first observation — the running drawdown peak, the latch, the last fire time
and the once flag; maybe_fire applies cooldown and once before building the alert. State is keyed
by rule id in an IndexMap so fire order is deterministic, and set_rules clears all of it: a rule
edit is a config change, and every latch re-arms.
Delivery
An AlertSink is fire-and-forget: deliver must never block the caller for long and must never
panic, because the engine calls it inline while draining rules. Each sink self-filters on the
alert's AlertTargets, so routing lives in the alert rather than the dispatcher. InProcessSink is
an in-memory buffer for the toast surface a desktop app would drain on repaint, and no shipped
mount registers one. WebhookSink posts to Telegram or a generic JSON webhook over the workspace's
one ureq and rustls stack through an injected transport with a 10 s timeout, so the seam is
testable with a fake sender and never touches the network in CI. Webhook names in a rule resolve
against configs built from a caller-supplied credential map by webhook_configs_from_env; a
name with no config delivers nowhere, and the crate never opens the credential store itself.
A Telegram URL embeds the token
WebhookKind holds the bot token or the webhook URL, so its Debug is manual and redacting and it
is never serialized beside the rules. A failed delivery logs the target name and a coarse error
category only — never the endpoint URL and never the raw transport error, whose display can echo
the request target. A bare HTTP status carries no secret and is surfaced; everything else is
"transport error".
Persistence, and where alerts.json lives
persist.rs reads no environment variable and resolves no path. load_path and save_path take a
caller-supplied &Path, and ALERTS_FILE — alerts.json — is the one thing the module states, so
the binary composing the directory does not re-spell the basename. An absent or unparseable file is
None, the off state, never an error. Every field but id and trigger is #[serde(default)],
so an older file still loads, and AlertRuleSet carries a schema version, currently 1. The daemon
resolves $VIKE_ALERTS, falling back to <project>/settings/state/alerts.json — the
settings page carries the file's shape.
In the headless daemon, maybe_mount is the gate: no
file, a file with zero rules, or a file whose every rule is disabled all return None, and then
nothing is constructed — no engine, no sink, and no webhook token loaded into memory, because the
webhook configs are passed as a thunk the off path never calls. When it mounts, a LogSink is
always registered (a tracing warn record on the log file, never stdout, which stays the daemon's
protocol), and the engine is driven from the existing summary thread, one on_snapshot per tick.
That is the one input the daemon can feed today, so Price, Drawdown and ReconAlert fire
there; event, indicator and signal rules load, are counted, and never fire in the daemon —
SeriesStale's producer lives in vike-recorder's own alerts module. The window that edits rules
in the desktop app is an explicit follow-up.
Options
The venue-free options core — a normalized chain model, Black–Scholes price and greeks with every transcendental from one portable library, implied vol by bisection, second-order greeks, realized-vol estimators and multi-leg basket analytics.
The command journal
An opt-in write-ahead journal of every exec-lane message the execution core folds, appended before the fold on pre-allocated memory-mapped segments — so a session can be replayed through the real core, restored after a crash, and proven bit-identical.