Dmitry Beresnev commited on
Commit Β·
47b314d
1
Parent(s): e4df689
update current version doc
Browse files- 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.
|
| 114 |
-
|
| 115 |
-
### 4.
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
### 4.
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
- `
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
- `
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
-
|
| 187 |
-
|
| 188 |
-
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
-
|
| 220 |
-
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
- `
|
| 224 |
-
|
| 225 |
-
- `
|
| 226 |
-
|
| 227 |
-
-
|
| 228 |
-
|
| 229 |
-
- `
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|