Dmitry Beresnev commited on
Commit
47b314d
Β·
1 Parent(s): e4df689

update current version doc

Browse files
Files changed (1) hide show
  1. docs/CURRENT_VERSION.md +325 -117
docs/CURRENT_VERSION.md CHANGED
@@ -110,123 +110,331 @@ Per polling iteration (`run_once`):
110
  4. Publish `system.cycle.completed` (triggers the per-cycle regime decision in `RiskEngine`).
111
  5. Process pending alert queue retries.
112
 
113
- ## 4. Components and Responsibilities
114
-
115
- ### 4.1 Universe Loader
116
- File: `src/core/universe_loader.py`
117
-
118
- Responsibilities:
119
- - load symbol universe for US equities, EU equities, crypto, commodities
120
- - normalize to `yfinance`-compatible symbols
121
- - build universe snapshot event
122
- - diff venue membership against the previous cycle (`build_event` returns `None` when unchanged)
123
-
124
- Produced events:
125
- - `market.universe.snapshot`
126
- - `market.universe.updated` (only on membership changes)
127
-
128
- ### 4.2 Price Ingestor
129
- File: `src/core/price_ingestor.py`
130
-
131
- Responsibilities:
132
- - split ticker list into chunks (`BATCH_SIZE`)
133
- - fetch each chunk (parallel via `ProcessPoolExecutor`)
134
- - apply retry/backoff wrapper around `yfinance.download`
135
- - emit snapshot with a per-venue monotonic `sequence_id` (one long-lived instance per venue)
136
-
137
- Produced event:
138
- - `market.prices.snapshot`
139
-
140
- ### 4.3 Retry Utility
141
- File: `src/core/retry_utils.py`
142
-
143
- Responsibilities:
144
- - `backoff_delay(base_delay_sec, attempt)` exponential delay
145
- - `call_with_backoff(fn, max_attempts, base_delay_sec)` common retry wrapper
146
-
147
- Used by:
148
- - `PriceIngestor` fetch retries
149
- - `AlertQueue` retry scheduling
150
-
151
- ### 4.4 Risk Engine
152
- File: `src/core/risk_engine.py`
153
-
154
- Responsibilities:
155
- - maintain rolling price windows (`PriceBuffer`)
156
- - compute per-timeframe deltas (`price_delta`)
157
- - detect threshold breaches and severity (adaptive MAD-based sigma scaled to the timeframe horizon)
158
- - emit per-ticker risk events
159
- - accumulate cycle-wide stats (worst drop, high-severity count) across venues
160
- - on `system.cycle.completed`: advance the regime state machine once per cycle
161
- - on regime change: publish the transition and a new `RiskEnvelope` (built from the `RISK_ENVELOPE_POLICY` table in `risk_policy.py`)
162
-
163
- Produced events:
164
- - `market.price.delta`
165
- - `risk.event.detected`
166
- - `risk.regime.change`
167
- - `risk.envelope.updated`
168
-
169
- Note: alerts are no longer built inside the engine; `risk.event.detected` is
170
- converted to `system.alert.ready` by a bridge subscription in `main.py`.
171
-
172
- ### 4.5 Regime State Machine
173
- File: `src/core/risk_regime.py`
174
-
175
- States:
176
- - `normal`
177
- - `tension`
178
- - `stress`
179
- - `panic`
180
-
181
- Inputs per poll cycle (aggregated across all venues by `RiskEngine`):
182
- - `worst_drop_pct`
183
- - `high_severity_count`
184
-
185
- Logic:
186
- - escalates immediately on stronger stress conditions
187
- - downgrades only after `calm_snapshots_to_downgrade` consecutive calm cycles to avoid oscillation
188
- - re-confirmation at the current level resets the calm streak
189
-
190
- ### 4.6 Data Gap Detector
191
- File: `src/core/data_gap.py`
192
-
193
- Responsibilities:
194
- - track last seen `sequence_id` per ticker
195
- - detect jumps (`current - last > 1`)
196
- - emit `data.gap.detected`
197
-
198
- ### 4.7 Alerting
199
- File: `src/core/alerting.py`
200
-
201
- Responsibilities:
202
- - build risk alerts (`build_alert`, `build_alert_from_risk_event`)
203
- - build data gap alerts (`build_data_gap_alert`)
204
- - send alerts via Telegram notifier
205
- - queue alerts and retry failed sends
206
-
207
- Detection-side rate limiting (`AlertDeduplicator`) lives in `event_detector.py`.
208
-
209
- Queue behavior:
210
- - enqueue on `system.alert.ready`
211
- - process each loop
212
- - retry with exponential backoff
213
- - drop after `ALERT_MAX_RETRIES`
214
-
215
- ### 4.8 Telemetry
216
- File: `src/core/telemetry.py`
217
-
218
- Responsibilities:
219
- - count events by class and key dimensions
220
- - periodic report via the standard logger every `TELEMETRY_REPORT_INTERVAL_SEC`
221
-
222
- Current counters include:
223
- - `alerts_enqueued`
224
- - `risk_events`
225
- - `risk_severity:*`
226
- - `risk_regime_changes`
227
- - `risk_regime_to:*`
228
- - `data_gap_events`
229
- - `data_gap_ticker:*`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
 
231
  ## 5. Topic Taxonomy
232
 
 
110
  4. Publish `system.cycle.completed` (triggers the per-cycle regime decision in `RiskEngine`).
111
  5. Process pending alert queue retries.
112
 
113
+ ## 4. Module Reference (Detailed)
114
+
115
+ ### 4.0 Design Principles and Process Model
116
+
117
+ Principles the codebase follows:
118
+ - **Single composition root.** Components never construct or import each
119
+ other; `main.py::build_runtime` creates everything and wires every
120
+ subscription. To understand "who talks to whom", read that one function.
121
+ - **Events over calls.** All cross-component communication is a published
122
+ event on a named topic. Components depend on event *shapes* (`schemas.py`),
123
+ not on each other.
124
+ - **Detection is separated from delivery.** The risk engine only states facts
125
+ (`risk.event.detected`); turning facts into notifications is a bridge
126
+ subscription plus `alerting.py`.
127
+ - **Decisions per cycle, facts per snapshot.** Per-ticker detection happens on
128
+ every venue snapshot; market-wide regime decisions happen exactly once per
129
+ poll cycle, on stats aggregated across venues (`system.cycle.completed`).
130
+ - **Config as data.** Tunables and the regime→limits mapping are plain data in
131
+ `config.py`, not logic.
132
+ - **Degrade, don't crash.** Every external dependency (GitHub, Wikipedia,
133
+ Binance, yfinance, Telegram) is wrapped so failure shrinks the data or
134
+ delays an alert, never kills the loop.
135
+
136
+ Process/threading model:
137
+ - The main loop is **single-threaded and synchronous**. `PubSub.publish` calls
138
+ handlers inline, in subscription order; a publish returns only after every
139
+ handler (and any events they published transitively) has finished. Event
140
+ ordering is therefore deterministic.
141
+ - The only parallelism is the `ProcessPoolExecutor` inside
142
+ `PriceIngestor.fetch_prices`, used purely for network-bound yfinance
143
+ downloads. Worker processes return plain dicts; no shared state.
144
+
145
+ Module dependency graph (imports, arrows point at the dependency):
146
+
147
+ ```text
148
+ app.py
149
+ └── main.py ──────────────┬──────────────────────────────────────────────┐
150
+ β”‚ β”‚ β”‚
151
+ β”œβ”€β”€ pubsub.py β”œβ”€β”€ universe_loader.py ── schemas.py β”‚
152
+ β”œβ”€β”€ telemetry.py β”œβ”€β”€ price_ingestor.py ─── retry_utils.py β”‚
153
+ β”œβ”€β”€ data_gap.py β”œβ”€β”€ alerting.py ───────── retry_utils.py β”‚
154
+ β”œβ”€β”€ risk_store.py β”‚ β”‚
155
+ └── risk_engine.py ─┼── price_buffer.py β”‚
156
+ β”œβ”€β”€ price_delta.py β”‚
157
+ β”œβ”€β”€ event_detector.py β”‚
158
+ β”œβ”€β”€ risk_regime.py β”‚
159
+ β”œβ”€β”€ risk_policy.py ── config.py, schemas.py β”‚
160
+ └── risk_store.py ─── schemas.py β”‚
161
+ β”‚
162
+ config.py (leaf: constants only, imported by almost everything) <─────────
163
+ topics.py (loads configs/topics.yaml; imported by main, risk_engine) <────
164
+ schemas.py (leaf: TypedDicts only) <β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
165
+ ```
166
+
167
+ ### 4.1 `app.py`
168
+ Thin launcher: imports `run_forever` from `src.core.main` and calls it under
169
+ `if __name__ == "__main__"`. Exists so `uv run python app.py` works alongside
170
+ `python -m src.core.main` and the `market-analyzing-platform` console script.
171
+
172
+ ### 4.2 `src/core/main.py` β€” Composition Root and Loop
173
+ Public API:
174
+ - `Runtime` (dataclass) β€” all long-lived state: `pubsub`, `notifier`, `queue`,
175
+ `loader`, `universe` (dict venue→tickers), `ingestors` (dict venue→
176
+ `PriceIngestor`), `cycle` (int counter).
177
+ - `build_runtime() -> Runtime` β€” constructs every component, registers the
178
+ full subscription graph (see section 7), loads the universe, publishes the
179
+ first `market.universe.snapshot`, creates one `PriceIngestor` per venue.
180
+ - `run_once(runtime)` β€” one poll cycle (see section 3 for the exact steps).
181
+ - `run_single_cycle(runtime=None) -> Runtime` β€” convenience for manual testing.
182
+ - `run_forever()` β€” configures `logging.basicConfig(INFO)` and loops
183
+ `run_once` + `sleep(POLL_INTERVAL_SEC)`.
184
+
185
+ Behavior details:
186
+ - `_load_universe` discards an empty reload (all venues empty) with a warning,
187
+ keeping the previous universe β€” external source outages cannot wipe state.
188
+ - Ingestors are created with `setdefault`, so a universe refresh never
189
+ replaces an existing ingestor; per-venue `sequence_id` continuity is
190
+ preserved and gap detection stays valid across refreshes.
191
+ - Refresh trigger: `UNIVERSE_REFRESH_CYCLES > 0 and cycle > 1 and
192
+ (cycle - 1) % UNIVERSE_REFRESH_CYCLES == 0`.
193
+ - The two alert **bridges** live here as lambda subscriptions:
194
+ `risk.event.detected -> system.alert.ready` and
195
+ `data.gap.detected -> system.alert.ready`.
196
+
197
+ ### 4.3 `src/core/pubsub.py` β€” Event Hub
198
+ `PubSub` with two methods: `subscribe(topic, handler)` appends to a
199
+ `defaultdict(list)`; `publish(topic, message)` invokes each handler
200
+ synchronously in registration order. Publishing to a topic with no
201
+ subscribers is a no-op. There is intentionally no unsubscribe, no async, no
202
+ error isolation: a raising handler propagates to the publisher β€” acceptable
203
+ for a single-process POC, and it keeps failures loud.
204
+
205
+ ### 4.4 `src/core/topics.py` β€” Topic Registry
206
+ Loads `configs/topics.yaml` at import time into the module-level `TOPICS`
207
+ dict. `_resolve_path` resolves the relative config path against the project
208
+ root (two levels above `src/core`), so the service can be launched from any
209
+ CWD. Merge semantics: start from `_DEFAULT_TOPICS` (all 10 topics hardcoded),
210
+ overlay YAML values that are strings; missing file or malformed YAML falls
211
+ back to the defaults silently. Code always refers to topics by short key
212
+ (`TOPICS["risk_event"]`), never by wire name.
213
+
214
+ ### 4.5 `src/core/config.py` β€” Constants
215
+ Pure data, no logic. Key values:
216
+ - `TIMEFRAMES_MIN = {10m:10, 30m:30, 1h:60, 2h:120, 3h:180, 6h:360, 10h:600}`
217
+ - `K_SIGMA = 3.0`, `MIN_WINDOW_POINTS = 5`, `MIN_ALERT_GAP_MIN = 20`
218
+ - `POLL_INTERVAL_SEC = 60`, `UNIVERSE_REFRESH_CYCLES = 1440`
219
+ - `PROCESS_POOL_WORKERS = 4`, `BATCH_SIZE = 200`
220
+ - `RISK_ENVELOPE_POLICY` β€” per-regime dict: `allowed`, `max_position_usd`,
221
+ `max_order_usd`, `max_leverage`, `max_turnover_per_hour`,
222
+ `cooldown_seconds`, `confidence`, `reason`
223
+ - universe sources: `US_TICKER_SOURCES` (3 GitHub raw text lists),
224
+ `EU_WIKI_INDEX_SOURCES` (FTSE 100 / DAX / CAC 40 Wikipedia pages with
225
+ per-index column name and yfinance suffix), `BINANCE_EXCHANGE_INFO`,
226
+ `COMMODITY_TICKERS` (9 hardcoded futures)
227
+ - retry: `YF_MAX_RETRIES=3`, `YF_BACKOFF_BASE_SEC=2`, `ALERT_MAX_RETRIES=3`,
228
+ `ALERT_BACKOFF_BASE_SEC=5`
229
+ - `TELEGRAM_BOT_TOKEN_ENV` / `TELEGRAM_CHAT_ID_ENV` β€” env var *names*
230
+
231
+ ### 4.6 `src/core/schemas.py` β€” Event Contracts
232
+ `TypedDict` definitions for every payload on the bus: `Instrument`,
233
+ `UniverseSnapshot`, `UniverseUpdatedEvent`/`UniverseChanges`, `PricePoint`,
234
+ `PriceSnapshotEvent`, `PriceDeltaEvent`, `RiskEvent`, `AlertEvent`,
235
+ `RiskEnvelope`, `RiskRegimeChangeEvent`, `DataGapEvent`,
236
+ `CycleCompletedEvent`. Runtime payloads are plain dicts; the TypedDicts are
237
+ development-time contracts. Rule: adding a new event type means defining its
238
+ schema here and registering its topic in `configs/topics.yaml`.
239
+
240
+ ### 4.7 `src/core/universe_loader.py` β€” Universe
241
+ Module-level `infer_asset_class(venue_key)` β€” the single source of truth for
242
+ venue→asset-class mapping (`crypto`, `commodity`, else `equity`).
243
+
244
+ `UniverseLoader`:
245
+ - `load() -> dict` β€” returns `{us.equities, eu.equities, crypto, commodities}`
246
+ β†’ sorted ticker lists.
247
+ - US: three GitHub raw `.txt` files; lines uppercased, blanks and
248
+ `#`-comments skipped; each source failure is swallowed (partial universe).
249
+ - EU: `pandas.read_html` on Wikipedia index pages; first table containing
250
+ the configured column wins; symbol is the first whitespace token; the
251
+ exchange suffix (`.L`, `.DE`, `.PA`) is appended when missing.
252
+ - Crypto: Binance `exchangeInfo`; keeps symbols with `status == TRADING`
253
+ and quote asset in {USD, USDT, USDC, BUSD}; emitted as yfinance-style
254
+ `BASE-USD` (so `BTCUSDT` and `BTCUSD` collapse to one `BTC-USD`).
255
+ - Commodities: the hardcoded futures list.
256
+ - `build_event(asset_class, venue, tickers) -> Optional[UniverseUpdatedEvent]`
257
+ β€” diffs against the previously seen membership for that venue (kept in
258
+ `self._prev`); returns `None` when unchanged, else an event with sorted
259
+ `changes.added` / `changes.removed`. The first call for a venue reports
260
+ everything as added.
261
+ - `build_snapshot(universe) -> UniverseSnapshot` β€” flattens all venues to
262
+ `Instrument` records with a fresh `universe_id` (uuid4); crypto symbols are
263
+ split into base/quote around `-`, everything else quotes in USD.
264
+
265
+ ### 4.8 `src/core/price_ingestor.py` β€” Price Ingestion
266
+ Module-level `_fetch_chunk(tickers)` runs **in worker processes**: it imports
267
+ `yfinance`/`pandas` inside the function (spawn-safe on macOS), calls
268
+ `yf.download(period="1d", interval="1m", group_by="ticker", threads=False)`
269
+ wrapped in `call_with_backoff`, and returns `{ticker: {price, ts_price}}`
270
+ using the last non-NaN close per ticker. It handles both the MultiIndex
271
+ (multi-ticker) and flat (single-ticker) column layouts and returns `{}` on
272
+ exhausted retries β€” a failed chunk shrinks the snapshot, never raises.
273
+
274
+ `PriceIngestor` (one long-lived instance per venue):
275
+ - holds `self._seq = itertools.count(1)` β€” a **per-venue** monotonic sequence.
276
+ Venues must not share a counter: `DataGapDetector` interprets any
277
+ per-ticker jump > 1 as data loss.
278
+ - `fetch_prices(tickers) -> PriceSnapshotEvent` β€” chunks into `BATCH_SIZE`
279
+ lists; runs serially when there is 1 chunk or `PROCESS_POOL_WORKERS <= 1`,
280
+ otherwise fans out over a `ProcessPoolExecutor`; a failed future is skipped.
281
+ Emits the snapshot with `sequence_id = next(self._seq)`.
282
+
283
+ ### 4.9 `src/core/price_buffer.py` β€” Rolling Windows
284
+ `PriceBuffer` keeps `ticker -> timeframe -> deque(maxlen=minutes)`. One price
285
+ is appended to all seven deques per snapshot; at the 60s poll cadence one
286
+ sample β‰ˆ one minute, so `maxlen` in minutes doubles as the window length.
287
+ `window(ticker, tf)` returns a list copy (safe to iterate while updating);
288
+ unknown ticker/timeframe returns `[]`.
289
+
290
+ ### 4.10 `src/core/price_delta.py` β€” Delta Math
291
+ - `price_delta(window)` β€” compares `window[0]` vs `window[-1]`; returns
292
+ `{price_start, price_now, delta_abs, delta_pct}` or `None` when the window
293
+ has < 2 points or starts at 0 (division guard).
294
+ - `velocity_pct_per_min(delta_pct, minutes)` β€” normalizes a move by its
295
+ timeframe; 0.0 for non-positive minutes.
296
+
297
+ ### 4.11 `src/core/event_detector.py` β€” Detection Statistics
298
+ - `adaptive_threshold(window) -> Optional[float]` β€” the core statistic.
299
+ Requires `MIN_WINDOW_POINTS` (5) prices. Computes one-step returns, then a
300
+ **robust sigma** via MAD (`1.4826 Γ— median(|r βˆ’ median(r)|)`) β€” a plain std
301
+ would be contaminated by the very crash being tested, inflating the
302
+ threshold past the crash itself. Returns `None` for zero sigma (flat
303
+ window: volatility unknowable). Scales by `sqrt(n_returns)` because
304
+ `delta_pct` spans the whole window while sigma is per-step. Final value:
305
+ `-K_SIGMA Γ— sigma Γ— sqrt(n) Γ— 100` (negative percent).
306
+ - `detect_event(delta_pct, threshold)` β€” `delta_pct < threshold`; only drops
307
+ can trigger (both values negative).
308
+ - `severity(delta_pct, threshold)` β€” ratio of breach: β‰₯2.0Γ— β†’ `high`,
309
+ β‰₯1.3Γ— β†’ `med`, else `low`.
310
+ - `AlertDeduplicator.allow(ticker, timeframe)` β€” per (ticker, timeframe)
311
+ wall-clock gate of `MIN_ALERT_GAP_MIN` (20 min). Lives here, not in
312
+ alerting, because it rate-limits *detections* (a breach stays inside the
313
+ rolling window for many cycles and would re-fire every 60s otherwise).
314
+
315
+ ### 4.12 `src/core/risk_engine.py` β€” Risk Engine
316
+ State: `PriceBuffer`, `AlertDeduplicator`, `RiskRegimeStateMachine`,
317
+ `RiskEnvelopeStore` (injected), current `universe_id`, `_last_regime`, and
318
+ two per-cycle accumulators (`_cycle_worst_drop`, `_cycle_high_severity`).
319
+
320
+ Handlers (all invoked via pub/sub, never directly):
321
+ - `on_universe_snapshot(event)` β€” caches `universe_id` for stamping envelopes
322
+ and regime events.
323
+ - `on_price_snapshot(event)` β€” two passes over the snapshot:
324
+ 1. append every price to the buffer (so same-snapshot windows are complete);
325
+ 2. per ticker Γ— per timeframe: compute delta β†’ publish
326
+ `market.price.delta` β†’ adaptive threshold β†’ `detect_event` β†’ dedupe β†’
327
+ `severity` β†’ publish `risk.event.detected`. Along the way it updates
328
+ `_cycle_worst_drop` (min of all deltas) and `_cycle_high_severity`
329
+ (count of deduped high-severity detections). **No regime logic here.**
330
+ - `on_cycle_completed(event)` β€” snapshots and resets both accumulators, calls
331
+ `RiskRegimeStateMachine.update(...)` once; on a change publishes
332
+ `risk.regime.change` (with a `reason` string embedding the stats and a
333
+ policy-table confidence), builds the envelope via
334
+ `risk_policy.build_envelope`, stores it, publishes `risk.envelope.updated`.
335
+
336
+ Invariants: dedupe runs *before* severity counting, so a re-detection inside
337
+ the 20-minute window neither alerts nor re-escalates the regime; accumulator
338
+ reset happens even when the regime does not change.
339
+
340
+ ### 4.13 `src/core/risk_regime.py` β€” Regime State Machine
341
+ `RegimeConfig` (frozen dataclass): `tension_drop_pct=-3.0`,
342
+ `stress_drop_pct=-5.0`, `panic_drop_pct=-8.0`,
343
+ `calm_snapshots_to_downgrade=3`.
344
+
345
+ `RiskRegimeStateMachine.update(worst_drop_pct, high_severity_count)
346
+ -> (state, changed)`:
347
+ 1. Classify the target regime: `panic` if drop ≀ βˆ’8 or count β‰₯ 3; `stress`
348
+ if ≀ βˆ’5 or β‰₯ 2; `tension` if ≀ βˆ’3 or β‰₯ 1; else `normal`.
349
+ 2. Target equals current state β†’ re-confirmation: calm streak resets, no
350
+ change reported.
351
+ 3. Target ranks higher β†’ **immediate escalation**, streak resets.
352
+ 4. Target ranks lower β†’ increment calm streak; only after 3 consecutive
353
+ lower-target updates does the state drop (directly to the latest target,
354
+ e.g. `panic β†’ normal` is possible).
355
+
356
+ With one update per poll cycle, downgrading takes β‰₯ 3 cycles (~3 minutes).
357
+
358
+ ### 4.14 `src/core/risk_policy.py` β€” Envelope Policy
359
+ Stateless translation of the `RISK_ENVELOPE_POLICY` config table:
360
+ - `build_envelope(regime, universe_id) -> RiskEnvelope` β€” copies the regime's
361
+ limits, stamps `envelope_id` (uuid4) and validity
362
+ `[now, now + cooldown_seconds]`. Unknown regime falls back to `normal`.
363
+ - `regime_confidence(regime) -> float` β€” confidence from the same table.
364
+ Changing risk limits is a config edit, not a code change.
365
+
366
+ ### 4.15 `src/core/risk_store.py` β€” Envelope Store
367
+ `RiskEnvelopeStore` holds the single latest envelope. `get()` returns `None`
368
+ when nothing is stored **or the envelope's `valid_until_ms` has passed** β€” a
369
+ consumer can never act on stale permissions; absence of a valid envelope must
370
+ be treated as "no opinion". (No consumer exists in-process yet.)
371
+
372
+ ### 4.16 `src/core/data_gap.py` β€” Gap Detection
373
+ `DataGapDetector` keeps `ticker -> last sequence_id`. On each snapshot, for
374
+ each ticker present: if `seq - last > 1`, publish `data.gap.detected` with
375
+ `gap_size = seq - last - 1` and the from/to sequence numbers; then update the
376
+ ticker's last-seen. A ticker absent from a snapshot is *not* flagged
377
+ immediately β€” the gap surfaces on its next appearance. Correctness rests on
378
+ per-venue sequences (section 4.8) since tickers never move between venues.
379
+
380
+ ### 4.17 `src/core/alerting.py` β€” Alert Construction and Delivery
381
+ Builders (pure functions):
382
+ - `classify(minutes)` β€” `flash` for ≀ 30 min timeframes, else `slow`.
383
+ - `build_alert(...)` β€” canonical `AlertEvent` with rounded values, cooldown
384
+ metadata, `destination: telegram`, and the human message
385
+ `[FLASH|SLOW] TICKER tf drop X% (thr Y%)`.
386
+ - `build_alert_from_risk_event(risk_event)` β€” the bridge adapter; maps the
387
+ event's timeframe through `TIMEFRAMES_MIN` for classification.
388
+ - `build_data_gap_alert(gap_event)` β€” `[DATA_GAP] ticker missing=N seq:a->b`.
389
+
390
+ `TelegramNotifier.send(alert) -> bool` β€” reads token/chat-id env vars at
391
+ construction. Missing credentials: logs the message and returns `True`
392
+ (deliberate drop β€” no retry storm in dev). POST to `sendMessage`, 10s
393
+ timeout; `True` iff HTTP 200. Network exception: logs, returns `False`
394
+ (queue will retry).
395
+
396
+ `AlertQueue`:
397
+ - `enqueue(alert)` β€” copies the dict, seeds `_attempts=0`,
398
+ `_next_attempt_ts=0.0`.
399
+ - `process(sender)` β€” called once per cycle: skips items whose backoff hasn't
400
+ elapsed; sends the rest; on failure increments attempts and reschedules at
401
+ `backoff_delay(ALERT_BACKOFF_BASE_SEC, attempts)`; drops after the initial
402
+ attempt + `ALERT_MAX_RETRIES` retries.
403
+
404
+ ### 4.18 `src/core/telemetry.py` β€” Counters
405
+ `Telemetry` holds a `defaultdict(int)`. Handlers: `on_risk_event` (total +
406
+ per severity), `on_regime_change` (total + per target regime), `on_data_gap`
407
+ (total + per ticker), `on_alert_enqueued`. Each handler calls
408
+ `_maybe_report`, which logs the sorted `key=value` summary at most once per
409
+ `TELEMETRY_REPORT_INTERVAL_SEC` β€” reporting is piggybacked on event arrival,
410
+ so a fully silent system logs nothing.
411
+
412
+ ### 4.19 `src/core/retry_utils.py` β€” Retry Primitives
413
+ - `backoff_delay(base, attempt)` β€” `base Γ— 2^(attemptβˆ’1)`, 0 for attempt ≀ 0.
414
+ - `call_with_backoff(fn, max_attempts, base_delay_sec)` β€” retries `fn` with
415
+ that schedule (sleeping between attempts) and raises
416
+ `RuntimeError("retry attempts exhausted")` chained to the last error.
417
+ Shared by price fetching (blocking retry in worker processes) and the alert
418
+ queue (non-blocking: the delay is stored as a timestamp, not slept on).
419
+
420
+ ### 4.20 `tests/` β€” Test Suite (pytest)
421
+ - `conftest.py` β€” `make_snapshot` payload factory; `pubsub` and `captured`
422
+ fixtures (the latter subscribes a collector list to any topic).
423
+ - `test_risk_regime.py` β€” escalation, 3-calm-cycle downgrade, streak reset on
424
+ re-confirmation.
425
+ - `test_event_detector.py` β€” window-size and flat-window guards, random walk
426
+ non-breach, crash breach despite window contamination, severity buckets.
427
+ - `test_data_gap.py` β€” no false gaps on consecutive/per-venue sequences,
428
+ real gap detection.
429
+ - `test_alerting.py` β€” queue success/retry/drop accounting, bridge
430
+ classification.
431
+ - `test_universe_loader.py` β€” asset-class inference, first-event/diff/no-op
432
+ semantics, snapshot base/quote splitting.
433
+ - `test_risk_engine.py` β€” end-to-end via real pub/sub: crash β†’ risk events β†’
434
+ bridged alerts β†’ panic regime β†’ blocking envelope; calm venue does not
435
+ dilute a crashing venue; accumulators reset between cycles.
436
+
437
+ Run with `uv run pytest` (pytest is in the `dev` dependency group).
438
 
439
  ## 5. Topic Taxonomy
440