vike
Concepts

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:

inputtriggers
a snapshotPrice — 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 sampleIndicator — 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 eventFill, optionally scoped; OrderRejected — a venue rejection or a RiskGate denial
a status signalFeed (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.

On this page