Dmitry Beresnev commited on
Commit ·
e4df689
1
Parent(s): debae55
refactor: composition root, per-cycle regime, tests, docs
Browse filesAggregate regime decisions once per cycle via system.cycle.completed;
move envelope limits to a policy table and alert building to a pub/sub
bridge; add Runtime dataclass, universe diff/refresh, logging, event
schemas, pytest suite, and updated ASCII architecture docs.
- .gitignore +3 -0
- configs/topics.yaml +1 -0
- docs/CURRENT_VERSION.md +208 -79
- pyproject.toml +5 -0
- src/core/alerting.py +23 -15
- src/core/config.py +47 -0
- src/core/event_detector.py +20 -1
- src/core/main.py +85 -58
- src/core/risk_engine.py +36 -97
- src/core/risk_policy.py +33 -0
- src/core/schemas.py +80 -0
- src/core/telemetry.py +5 -1
- src/core/topics.py +1 -0
- src/core/universe_loader.py +29 -13
- uv.lock +101 -0
.gitignore
CHANGED
|
@@ -168,3 +168,6 @@ poetry.lock
|
|
| 168 |
|
| 169 |
*.md
|
| 170 |
*.python-version
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
*.md
|
| 170 |
*.python-version
|
| 171 |
+
|
| 172 |
+
#
|
| 173 |
+
tests/
|
configs/topics.yaml
CHANGED
|
@@ -8,3 +8,4 @@ topics:
|
|
| 8 |
risk_envelope: risk.envelope.updated
|
| 9 |
data_gap: data.gap.detected
|
| 10 |
alert_ready: system.alert.ready
|
|
|
|
|
|
| 8 |
risk_envelope: risk.envelope.updated
|
| 9 |
data_gap: data.gap.detected
|
| 10 |
alert_ready: system.alert.ready
|
| 11 |
+
cycle_completed: system.cycle.completed
|
docs/CURRENT_VERSION.md
CHANGED
|
@@ -5,60 +5,139 @@ This document describes how the current `src/core` implementation works.
|
|
| 5 |
## 1. Scope
|
| 6 |
|
| 7 |
The current version implements a single-process POC service with:
|
| 8 |
-
- universe loading from no-auth sources
|
| 9 |
-
- price ingestion via `yfinance` with process pool
|
| 10 |
-
- in-memory pub/sub
|
| 11 |
-
- multi-timeframe delta and risk event detection
|
| 12 |
-
- market regime state machine
|
| 13 |
-
- `risk.regime.change` and `risk.envelope.updated` events
|
| 14 |
- data gap detection
|
| 15 |
- alert queue with retries
|
| 16 |
- basic telemetry counters
|
|
|
|
| 17 |
|
| 18 |
Entry point:
|
| 19 |
- `src/core/main.py`
|
| 20 |
|
| 21 |
-
## 2.
|
| 22 |
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
1. Build shared components (`PubSub`, `RiskEngine`, `DataGapDetector`, `AlertQueue`, `Telemetry`, `RiskEnvelopeStore`).
|
| 25 |
-
2. Register topic subscriptions.
|
| 26 |
-
3. Load universe
|
| 27 |
4. Enter infinite loop with `POLL_INTERVAL_SEC`.
|
| 28 |
|
| 29 |
Per polling iteration (`run_once`):
|
| 30 |
-
1.
|
| 31 |
-
2.
|
| 32 |
-
3.
|
| 33 |
-
4.
|
|
|
|
| 34 |
|
| 35 |
-
##
|
| 36 |
|
| 37 |
-
###
|
| 38 |
File: `src/core/universe_loader.py`
|
| 39 |
|
| 40 |
Responsibilities:
|
| 41 |
- load symbol universe for US equities, EU equities, crypto, commodities
|
| 42 |
- normalize to `yfinance`-compatible symbols
|
| 43 |
- build universe snapshot event
|
|
|
|
| 44 |
|
| 45 |
Produced events:
|
| 46 |
- `market.universe.snapshot`
|
| 47 |
-
- `market.universe.updated`
|
| 48 |
|
| 49 |
-
###
|
| 50 |
File: `src/core/price_ingestor.py`
|
| 51 |
|
| 52 |
Responsibilities:
|
| 53 |
- split ticker list into chunks (`BATCH_SIZE`)
|
| 54 |
- fetch each chunk (parallel via `ProcessPoolExecutor`)
|
| 55 |
- apply retry/backoff wrapper around `yfinance.download`
|
| 56 |
-
- emit snapshot with monotonic `sequence_id`
|
| 57 |
|
| 58 |
Produced event:
|
| 59 |
- `market.prices.snapshot`
|
| 60 |
|
| 61 |
-
###
|
| 62 |
File: `src/core/retry_utils.py`
|
| 63 |
|
| 64 |
Responsibilities:
|
|
@@ -69,25 +148,28 @@ Used by:
|
|
| 69 |
- `PriceIngestor` fetch retries
|
| 70 |
- `AlertQueue` retry scheduling
|
| 71 |
|
| 72 |
-
###
|
| 73 |
File: `src/core/risk_engine.py`
|
| 74 |
|
| 75 |
Responsibilities:
|
| 76 |
- maintain rolling price windows (`PriceBuffer`)
|
| 77 |
-
- compute per-timeframe deltas (`
|
| 78 |
-
- detect threshold breaches and severity
|
| 79 |
-
- emit per-ticker risk events
|
| 80 |
-
-
|
| 81 |
-
-
|
|
|
|
| 82 |
|
| 83 |
Produced events:
|
| 84 |
- `market.price.delta`
|
| 85 |
- `risk.event.detected`
|
| 86 |
- `risk.regime.change`
|
| 87 |
- `risk.envelope.updated`
|
| 88 |
-
- `system.alert.ready` (risk alerts)
|
| 89 |
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
| 91 |
File: `src/core/risk_regime.py`
|
| 92 |
|
| 93 |
States:
|
|
@@ -96,15 +178,16 @@ States:
|
|
| 96 |
- `stress`
|
| 97 |
- `panic`
|
| 98 |
|
| 99 |
-
Inputs per
|
| 100 |
- `worst_drop_pct`
|
| 101 |
- `high_severity_count`
|
| 102 |
|
| 103 |
Logic:
|
| 104 |
- escalates immediately on stronger stress conditions
|
| 105 |
-
- downgrades only after `calm_snapshots_to_downgrade` to avoid oscillation
|
|
|
|
| 106 |
|
| 107 |
-
###
|
| 108 |
File: `src/core/data_gap.py`
|
| 109 |
|
| 110 |
Responsibilities:
|
|
@@ -112,27 +195,29 @@ Responsibilities:
|
|
| 112 |
- detect jumps (`current - last > 1`)
|
| 113 |
- emit `data.gap.detected`
|
| 114 |
|
| 115 |
-
###
|
| 116 |
File: `src/core/alerting.py`
|
| 117 |
|
| 118 |
Responsibilities:
|
| 119 |
-
- build risk alerts (`build_alert`)
|
| 120 |
- build data gap alerts (`build_data_gap_alert`)
|
| 121 |
- send alerts via Telegram notifier
|
| 122 |
- queue alerts and retry failed sends
|
| 123 |
|
|
|
|
|
|
|
| 124 |
Queue behavior:
|
| 125 |
- enqueue on `system.alert.ready`
|
| 126 |
- process each loop
|
| 127 |
- retry with exponential backoff
|
| 128 |
- drop after `ALERT_MAX_RETRIES`
|
| 129 |
|
| 130 |
-
###
|
| 131 |
File: `src/core/telemetry.py`
|
| 132 |
|
| 133 |
Responsibilities:
|
| 134 |
- count events by class and key dimensions
|
| 135 |
-
- periodic
|
| 136 |
|
| 137 |
Current counters include:
|
| 138 |
- `alerts_enqueued`
|
|
@@ -143,7 +228,7 @@ Current counters include:
|
|
| 143 |
- `data_gap_events`
|
| 144 |
- `data_gap_ticker:*`
|
| 145 |
|
| 146 |
-
##
|
| 147 |
|
| 148 |
Configured in:
|
| 149 |
- `configs/topics.yaml`
|
|
@@ -158,6 +243,7 @@ Current keys:
|
|
| 158 |
- `risk_envelope`: `risk.envelope.updated`
|
| 159 |
- `data_gap`: `data.gap.detected`
|
| 160 |
- `alert_ready`: `system.alert.ready`
|
|
|
|
| 161 |
|
| 162 |
Loaded by:
|
| 163 |
- `src/core/topics.py`
|
|
@@ -165,7 +251,7 @@ Loaded by:
|
|
| 165 |
Behavior:
|
| 166 |
- fallback to hardcoded defaults if YAML is missing or malformed
|
| 167 |
|
| 168 |
-
##
|
| 169 |
|
| 170 |
Schemas live in:
|
| 171 |
- `src/core/schemas.py`
|
|
@@ -173,29 +259,39 @@ Schemas live in:
|
|
| 173 |
Defined typed contracts:
|
| 174 |
- `Instrument`
|
| 175 |
- `UniverseSnapshot`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
- `RiskEnvelope`
|
| 177 |
- `RiskRegimeChangeEvent`
|
| 178 |
- `DataGapEvent`
|
|
|
|
| 179 |
|
| 180 |
Notes:
|
| 181 |
- runtime payloads are plain dicts; TypedDict provides development-time contract guidance
|
| 182 |
|
| 183 |
-
##
|
| 184 |
|
| 185 |
Set in `src/core/main.py`:
|
| 186 |
- `system.alert.ready -> AlertQueue.enqueue`
|
| 187 |
- `system.alert.ready -> Telemetry.on_alert_enqueued`
|
| 188 |
- `market.prices.snapshot -> RiskEngine.on_price_snapshot`
|
| 189 |
- `market.prices.snapshot -> DataGapDetector.on_price_snapshot`
|
|
|
|
| 190 |
- `risk.event.detected -> Telemetry.on_risk_event`
|
|
|
|
| 191 |
- `risk.regime.change -> Telemetry.on_regime_change`
|
| 192 |
- `data.gap.detected -> Telemetry.on_data_gap`
|
| 193 |
- `data.gap.detected -> publish(system.alert.ready, build_data_gap_alert(event))`
|
| 194 |
- `market.universe.snapshot -> RiskEngine.on_universe_snapshot`
|
| 195 |
|
| 196 |
-
##
|
| 197 |
|
| 198 |
Envelope is generated on regime transitions and stored in `RiskEnvelopeStore`.
|
|
|
|
|
|
|
| 199 |
|
| 200 |
Current regime mapping:
|
| 201 |
- `normal`: permissive limits
|
|
@@ -207,19 +303,19 @@ Envelope TTL:
|
|
| 207 |
- represented by `valid_from_ms` / `valid_until_ms`
|
| 208 |
- store returns `None` when expired
|
| 209 |
|
| 210 |
-
##
|
| 211 |
|
| 212 |
Data gaps are inferred from `sequence_id` discontinuity in snapshots.
|
| 213 |
|
| 214 |
Current assumptions:
|
| 215 |
-
- one
|
| 216 |
- gap is tracked per ticker
|
| 217 |
|
| 218 |
Limitations:
|
| 219 |
- no persistent sequence state across restarts
|
| 220 |
- no exchange-native sequence reconciliation
|
| 221 |
|
| 222 |
-
##
|
| 223 |
|
| 224 |
### Price fetch
|
| 225 |
- retries around `yfinance.download` via `call_with_backoff`
|
|
@@ -230,32 +326,37 @@ Limitations:
|
|
| 230 |
- re-attempted with exponential backoff
|
| 231 |
- dropped after max retries
|
| 232 |
|
| 233 |
-
##
|
|
|
|
|
|
|
|
|
|
| 234 |
|
| 235 |
Main runtime config:
|
| 236 |
- `src/core/config.py`
|
| 237 |
|
| 238 |
Important values:
|
| 239 |
- polling: `POLL_INTERVAL_SEC`
|
|
|
|
|
|
|
| 240 |
- ingestion parallelism: `PROCESS_POOL_WORKERS`, `BATCH_SIZE`
|
| 241 |
- yfinance retry: `YF_MAX_RETRIES`, `YF_BACKOFF_BASE_SEC`
|
| 242 |
- alert retry: `ALERT_MAX_RETRIES`, `ALERT_BACKOFF_BASE_SEC`
|
| 243 |
- telemetry reporting: `TELEMETRY_REPORT_INTERVAL_SEC`
|
| 244 |
- topic config path: `TOPICS_CONFIG_PATH`
|
| 245 |
|
| 246 |
-
##
|
| 247 |
|
| 248 |
- In-memory pub/sub and stores are process-local only.
|
| 249 |
-
-
|
| 250 |
-
- Telemetry outputs to stdout only (no external sink).
|
| 251 |
- No durable queue for alerts.
|
| 252 |
- No persistent event/audit storage.
|
| 253 |
|
| 254 |
-
##
|
| 255 |
|
| 256 |
-
Install dependencies and run:
|
| 257 |
```bash
|
| 258 |
uv sync
|
|
|
|
| 259 |
uv run python -m src.core.main
|
| 260 |
```
|
| 261 |
|
|
@@ -264,9 +365,9 @@ Recommended quick checks:
|
|
| 264 |
2. Confirm `risk.regime.change` appears when volatility increases.
|
| 265 |
3. Confirm `risk.envelope.updated` appears on same transitions.
|
| 266 |
4. Simulate send failures and verify alert queue retries.
|
| 267 |
-
5. Observe telemetry counters
|
| 268 |
|
| 269 |
-
##
|
| 270 |
|
| 271 |
```text
|
| 272 |
+====================================================================================================+
|
|
@@ -294,35 +395,37 @@ Actors:
|
|
| 294 |
| |--on_universe_snapshot---------->| | |
|
| 295 |
| | | | |
|
| 296 |
|== loop ================================================================================================> |
|
|
|
|
| 297 |
|--for each venue--------------------------------------------------------------------------------------------|
|
| 298 |
-
|--build_event-
|
| 299 |
-
|------------------------------
|
| 300 |
|--fetch_prices------------------>| | | | | | |
|
| 301 |
| |--download+retry/backoff--> yfinance |
|
| 302 |
-
| |<--prices + sequence_id
|
| 303 |
|<-------------------------------| | | | | | |
|
| 304 |
|------------------------------publish market.prices.snapshot------------------------------------------------->|
|
| 305 |
| |--on_price_snapshot-------------------------------------->|
|
| 306 |
-
| | calc deltas/events
|
| 307 |
| |--publish market.price.delta----------------------------->|
|
| 308 |
| |--publish risk.event.detected---------------------------->|
|
| 309 |
| | |--on_risk_event
|
| 310 |
-
| |-
|
| 311 |
| | |--enqueue---->|
|
| 312 |
| | |--on_alert_enqueued
|
| 313 |
-
| |--if regime changed: |
|
| 314 |
-
| | publish risk.regime.change----------------------------->|
|
| 315 |
-
| | |--on_regime_change
|
| 316 |
-
| | build envelope |
|
| 317 |
-
| |----------------------------------------------set()------->|
|
| 318 |
-
| | publish risk.envelope.updated--------------------------->|
|
| 319 |
-
| |
|
| 320 |
| |--DG.on_price_snapshot------------------------------------>|
|
| 321 |
| | if seq gap:
|
| 322 |
| |--publish data.gap.detected------------------------------->|
|
| 323 |
| | |--on_data_gap
|
| 324 |
-
| |-
|
| 325 |
| | |--enqueue---->|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
|--AQ.process(TN.send)---------------------------------------------------------------------------------------->|
|
| 327 |
| |--send alert------------->|
|
| 328 |
| |<--ok/fail---------------|
|
|
@@ -330,14 +433,14 @@ Actors:
|
|
| 330 |
|== sleep POLL_INTERVAL_SEC =================================================================================>|
|
| 331 |
```
|
| 332 |
|
| 333 |
-
##
|
| 334 |
|
| 335 |
```text
|
| 336 |
+====================================================================================================+
|
| 337 |
| RISK REGIME TRANSITION LOGIC |
|
| 338 |
+====================================================================================================+
|
| 339 |
|
| 340 |
-
Inputs per
|
| 341 |
worst_drop_pct
|
| 342 |
high_severity_count
|
| 343 |
|
|
@@ -349,7 +452,9 @@ Threshold rules:
|
|
| 349 |
|
| 350 |
Transition behavior:
|
| 351 |
- Escalation (to stronger regime): immediate
|
| 352 |
-
- Downgrade (to weaker regime): requires calm_snapshots_to_downgrade
|
|
|
|
|
|
|
| 353 |
|
| 354 |
State graph:
|
| 355 |
|
|
@@ -370,11 +475,11 @@ State graph:
|
|
| 370 |
|
| 371 |
On each regime change:
|
| 372 |
1) publish risk.regime.change
|
| 373 |
-
2) build and store RiskEnvelope
|
| 374 |
3) publish risk.envelope.updated
|
| 375 |
```
|
| 376 |
|
| 377 |
-
##
|
| 378 |
|
| 379 |
```text
|
| 380 |
+====================================================================================================+
|
|
@@ -394,16 +499,21 @@ On each regime change:
|
|
| 394 |
-> dropped after ALERT_MAX_RETRIES
|
| 395 |
|
| 396 |
3) Data gaps
|
| 397 |
-
DG compares sequence_id per ticker
|
| 398 |
-> if jump detected: emit data.gap.detected
|
| 399 |
-> converted into system alert + telemetry counter
|
| 400 |
|
| 401 |
4) Topic config failure
|
| 402 |
topics.py load_topics()
|
| 403 |
-> if YAML missing/malformed: fallback to hardcoded defaults
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
```
|
| 405 |
|
| 406 |
-
##
|
| 407 |
|
| 408 |
```text
|
| 409 |
+====================================================================================================+
|
|
@@ -423,9 +533,10 @@ On each regime change:
|
|
| 423 |
+---------------------+ +---------------------+ +-----------------------+
|
| 424 |
|
| 425 |
+---------------------+ +---------------------+ +-----------------------+
|
| 426 |
-
|
|
| 427 |
-
| (
|
| 428 |
-
+---------------------+ +---------------------+
|
|
|
|
| 429 |
|
| 430 |
|
| 431 |
RUNTIME LOOP
|
|
@@ -435,16 +546,24 @@ On each regime change:
|
|
| 435 |
|
|
| 436 |
v
|
| 437 |
+---------------------------+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
| for each venue universe |
|
| 439 |
| (us.equities, eu..., etc) |
|
|
|
|
|
|
|
| 440 |
+---------------------------+
|
| 441 |
|
|
| 442 |
v
|
| 443 |
+---------------------------+ +--------------------------------------------+
|
| 444 |
-
| PriceIngestor
|
| 445 |
| - chunk tickers | <-----> | exponential backoff for yfinance download |
|
| 446 |
| - process pool workers | +--------------------------------------------+
|
| 447 |
-
| - sequence_id++
|
| 448 |
+---------------------------+
|
| 449 |
|
|
| 450 |
v
|
|
@@ -459,8 +578,8 @@ On each regime change:
|
|
| 459 |
| - calc delta per TF | | - detect seq jumps |
|
| 460 |
| - adaptive threshold | +---------------------------+
|
| 461 |
| - detect risk events | |
|
| 462 |
-
|
| 463 |
-
|
| 464 |
| |
|
| 465 |
| +--------------------------+
|
| 466 |
| | Telemetry.on_data_gap |
|
|
@@ -476,7 +595,16 @@ On each regime change:
|
|
| 476 |
|
|
| 477 |
+--> topic: risk.event.detected -------> Telemetry.on_risk_event
|
| 478 |
|
|
| 479 |
-
+--> topic:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
|
|
| 481 |
+--> Regime State Machine (normal/tension/stress/panic)
|
| 482 |
|
|
|
@@ -485,7 +613,7 @@ On each regime change:
|
|
| 485 |
topic: risk.regime.change -------> Telemetry.on_regime_change
|
| 486 |
|
|
| 487 |
v
|
| 488 |
-
build RiskEnvelope (
|
| 489 |
|
|
| 490 |
v
|
| 491 |
RiskEnvelopeStore.set(...)
|
|
@@ -534,6 +662,7 @@ On each regime change:
|
|
| 534 |
risk.envelope.updated
|
| 535 |
data.gap.detected
|
| 536 |
system.alert.ready
|
|
|
|
| 537 |
|
| 538 |
|
| 539 |
TELEMETRY OUTPUT
|
|
|
|
| 5 |
## 1. Scope
|
| 6 |
|
| 7 |
The current version implements a single-process POC service with:
|
| 8 |
+
- universe loading from no-auth sources, with periodic refresh and diff-aware update events
|
| 9 |
+
- price ingestion via `yfinance` with process pool (one ingestor per venue)
|
| 10 |
+
- in-memory pub/sub with a single composition root (`main.py::build_runtime`)
|
| 11 |
+
- multi-timeframe delta and risk event detection (robust MAD-based adaptive thresholds)
|
| 12 |
+
- market regime state machine advanced once per poll cycle on venue-aggregated stats
|
| 13 |
+
- `risk.regime.change` and `risk.envelope.updated` events (limits from a policy table)
|
| 14 |
- data gap detection
|
| 15 |
- alert queue with retries
|
| 16 |
- basic telemetry counters
|
| 17 |
+
- pytest test suite (`tests/`)
|
| 18 |
|
| 19 |
Entry point:
|
| 20 |
- `src/core/main.py`
|
| 21 |
|
| 22 |
+
## 2. Architecture at a Glance (ASCII)
|
| 23 |
|
| 24 |
+
```text
|
| 25 |
+
+====================================================================================================+
|
| 26 |
+
| COMPONENT LAYERS AND THE COMPOSITION ROOT |
|
| 27 |
+
+====================================================================================================+
|
| 28 |
+
|
| 29 |
+
main.py::build_runtime() is the single composition root: it creates every
|
| 30 |
+
component, registers every subscription, and returns the Runtime dataclass
|
| 31 |
+
that owns all long-lived state (pubsub, notifier, queue, loader, universe,
|
| 32 |
+
per-venue ingestors, cycle counter).
|
| 33 |
+
|
| 34 |
+
+--------------------------------- Runtime (main.py) ----------------------------------+
|
| 35 |
+
| |
|
| 36 |
+
| INGESTION DETECTION / RISK DELIVERY |
|
| 37 |
+
| +--------------------+ +------------------------+ +------------------+ |
|
| 38 |
+
| | UniverseLoader | | RiskEngine | | AlertQueue | |
|
| 39 |
+
| | - load / refresh | | - PriceBuffer | | - retry/backoff | |
|
| 40 |
+
| | - diff updates | | - price_delta | +------------------+ |
|
| 41 |
+
| +--------------------+ | - event_detector | +------------------+ |
|
| 42 |
+
| +--------------------+ | (adaptive threshold,| | TelegramNotifier | |
|
| 43 |
+
| | PriceIngestor | | dedup, severity) | +------------------+ |
|
| 44 |
+
| | (one per venue, | | - risk_regime (FSM) | |
|
| 45 |
+
| | own sequence_id) | | - risk_policy | OBSERVABILITY |
|
| 46 |
+
| +--------------------+ | (envelope table) | +------------------+ |
|
| 47 |
+
| +------------------------+ | Telemetry | |
|
| 48 |
+
| INTEGRITY +------------------------+ | (counters, log) | |
|
| 49 |
+
| +--------------------+ | RiskEnvelopeStore | +------------------+ |
|
| 50 |
+
| | DataGapDetector | | (TTL-checked get) | |
|
| 51 |
+
| +--------------------+ +------------------------+ |
|
| 52 |
+
| |
|
| 53 |
+
| all cross-component communication goes through PubSub topics |
|
| 54 |
+
| (no component imports or calls another component directly) |
|
| 55 |
+
+---------------------------------------------------------------------------------------+
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
+====================================================================================================+
|
| 59 |
+
| TOPIC FLOW MATRIX (WHO PUBLISHES / WHO CONSUMES) |
|
| 60 |
+
+====================================================================================================+
|
| 61 |
+
|
| 62 |
+
PRODUCER TOPIC CONSUMERS
|
| 63 |
+
----------------------- -------------------------- -------------------------------------------
|
| 64 |
+
main (startup + refresh) market.universe.snapshot RiskEngine.on_universe_snapshot
|
| 65 |
+
main (per venue, only market.universe.updated (informational, no consumer yet)
|
| 66 |
+
when membership changed)
|
| 67 |
+
PriceIngestor (via main) market.prices.snapshot RiskEngine.on_price_snapshot
|
| 68 |
+
DataGapDetector.on_price_snapshot
|
| 69 |
+
RiskEngine market.price.delta (informational, no consumer yet)
|
| 70 |
+
RiskEngine risk.event.detected Telemetry.on_risk_event
|
| 71 |
+
bridge -> system.alert.ready
|
| 72 |
+
main (end of cycle) system.cycle.completed RiskEngine.on_cycle_completed
|
| 73 |
+
RiskEngine risk.regime.change Telemetry.on_regime_change
|
| 74 |
+
RiskEngine risk.envelope.updated (read via RiskEnvelopeStore)
|
| 75 |
+
DataGapDetector data.gap.detected Telemetry.on_data_gap
|
| 76 |
+
bridge -> system.alert.ready
|
| 77 |
+
bridges (main.py) system.alert.ready AlertQueue.enqueue
|
| 78 |
+
Telemetry.on_alert_enqueued
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
+====================================================================================================+
|
| 82 |
+
| ONE POLL CYCLE TIMELINE |
|
| 83 |
+
+====================================================================================================+
|
| 84 |
+
|
| 85 |
+
t0 t0 + POLL_INTERVAL_SEC
|
| 86 |
+
| |
|
| 87 |
+
|--[maybe refresh universe]--[venue 1: diff? -> fetch -> snapshot]--[venue 2: ...]--[venue N] |
|
| 88 |
+
| | |
|
| 89 |
+
| publish system.cycle.completed |
|
| 90 |
+
| | |
|
| 91 |
+
| RiskEngine: regime decision on aggregated stats |
|
| 92 |
+
| | |
|
| 93 |
+
| AlertQueue.process(TelegramNotifier.send) |
|
| 94 |
+
| | |
|
| 95 |
+
| sleep --------------------------------|
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
## 3. Main Runtime Loop
|
| 99 |
+
|
| 100 |
+
At startup (`main.py::build_runtime`, the composition root — returns a `Runtime` dataclass holding all long-lived state):
|
| 101 |
1. Build shared components (`PubSub`, `RiskEngine`, `DataGapDetector`, `AlertQueue`, `Telemetry`, `RiskEnvelopeStore`).
|
| 102 |
+
2. Register topic subscriptions (including bridges from `risk.event.detected` and `data.gap.detected` to `system.alert.ready`).
|
| 103 |
+
3. Load universe, publish `market.universe.snapshot`, and create one long-lived `PriceIngestor` per venue.
|
| 104 |
4. Enter infinite loop with `POLL_INTERVAL_SEC`.
|
| 105 |
|
| 106 |
Per polling iteration (`run_once`):
|
| 107 |
+
1. Every `UNIVERSE_REFRESH_CYCLES` cycles: reload the universe and republish `market.universe.snapshot`.
|
| 108 |
+
2. Publish `market.universe.updated` for a venue only when its membership changed (diff of added/removed).
|
| 109 |
+
3. Fetch prices for each venue through its `PriceIngestor` and publish `market.prices.snapshot`.
|
| 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:
|
|
|
|
| 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:
|
|
|
|
| 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:
|
|
|
|
| 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`
|
|
|
|
| 228 |
- `data_gap_events`
|
| 229 |
- `data_gap_ticker:*`
|
| 230 |
|
| 231 |
+
## 5. Topic Taxonomy
|
| 232 |
|
| 233 |
Configured in:
|
| 234 |
- `configs/topics.yaml`
|
|
|
|
| 243 |
- `risk_envelope`: `risk.envelope.updated`
|
| 244 |
- `data_gap`: `data.gap.detected`
|
| 245 |
- `alert_ready`: `system.alert.ready`
|
| 246 |
+
- `cycle_completed`: `system.cycle.completed`
|
| 247 |
|
| 248 |
Loaded by:
|
| 249 |
- `src/core/topics.py`
|
|
|
|
| 251 |
Behavior:
|
| 252 |
- fallback to hardcoded defaults if YAML is missing or malformed
|
| 253 |
|
| 254 |
+
## 6. Event Contracts
|
| 255 |
|
| 256 |
Schemas live in:
|
| 257 |
- `src/core/schemas.py`
|
|
|
|
| 259 |
Defined typed contracts:
|
| 260 |
- `Instrument`
|
| 261 |
- `UniverseSnapshot`
|
| 262 |
+
- `UniverseUpdatedEvent` / `UniverseChanges`
|
| 263 |
+
- `PricePoint` / `PriceSnapshotEvent`
|
| 264 |
+
- `PriceDeltaEvent`
|
| 265 |
+
- `RiskEvent`
|
| 266 |
+
- `AlertEvent`
|
| 267 |
- `RiskEnvelope`
|
| 268 |
- `RiskRegimeChangeEvent`
|
| 269 |
- `DataGapEvent`
|
| 270 |
+
- `CycleCompletedEvent`
|
| 271 |
|
| 272 |
Notes:
|
| 273 |
- runtime payloads are plain dicts; TypedDict provides development-time contract guidance
|
| 274 |
|
| 275 |
+
## 7. Subscription Graph
|
| 276 |
|
| 277 |
Set in `src/core/main.py`:
|
| 278 |
- `system.alert.ready -> AlertQueue.enqueue`
|
| 279 |
- `system.alert.ready -> Telemetry.on_alert_enqueued`
|
| 280 |
- `market.prices.snapshot -> RiskEngine.on_price_snapshot`
|
| 281 |
- `market.prices.snapshot -> DataGapDetector.on_price_snapshot`
|
| 282 |
+
- `system.cycle.completed -> RiskEngine.on_cycle_completed`
|
| 283 |
- `risk.event.detected -> Telemetry.on_risk_event`
|
| 284 |
+
- `risk.event.detected -> publish(system.alert.ready, build_alert_from_risk_event(event))`
|
| 285 |
- `risk.regime.change -> Telemetry.on_regime_change`
|
| 286 |
- `data.gap.detected -> Telemetry.on_data_gap`
|
| 287 |
- `data.gap.detected -> publish(system.alert.ready, build_data_gap_alert(event))`
|
| 288 |
- `market.universe.snapshot -> RiskEngine.on_universe_snapshot`
|
| 289 |
|
| 290 |
+
## 8. Risk Envelope Semantics
|
| 291 |
|
| 292 |
Envelope is generated on regime transitions and stored in `RiskEnvelopeStore`.
|
| 293 |
+
Limits per regime come from the `RISK_ENVELOPE_POLICY` table in `config.py`,
|
| 294 |
+
applied by `risk_policy.build_envelope`.
|
| 295 |
|
| 296 |
Current regime mapping:
|
| 297 |
- `normal`: permissive limits
|
|
|
|
| 303 |
- represented by `valid_from_ms` / `valid_until_ms`
|
| 304 |
- store returns `None` when expired
|
| 305 |
|
| 306 |
+
## 9. Data Gap Handling
|
| 307 |
|
| 308 |
Data gaps are inferred from `sequence_id` discontinuity in snapshots.
|
| 309 |
|
| 310 |
Current assumptions:
|
| 311 |
+
- one independent sequence per venue (`PriceIngestor` instance)
|
| 312 |
- gap is tracked per ticker
|
| 313 |
|
| 314 |
Limitations:
|
| 315 |
- no persistent sequence state across restarts
|
| 316 |
- no exchange-native sequence reconciliation
|
| 317 |
|
| 318 |
+
## 10. Reliability and Retries
|
| 319 |
|
| 320 |
### Price fetch
|
| 321 |
- retries around `yfinance.download` via `call_with_backoff`
|
|
|
|
| 326 |
- re-attempted with exponential backoff
|
| 327 |
- dropped after max retries
|
| 328 |
|
| 329 |
+
### Universe refresh
|
| 330 |
+
- a refresh returning zero tickers is discarded; the previous universe stays active
|
| 331 |
+
|
| 332 |
+
## 11. Configuration
|
| 333 |
|
| 334 |
Main runtime config:
|
| 335 |
- `src/core/config.py`
|
| 336 |
|
| 337 |
Important values:
|
| 338 |
- polling: `POLL_INTERVAL_SEC`
|
| 339 |
+
- universe refresh: `UNIVERSE_REFRESH_CYCLES`
|
| 340 |
+
- risk limits per regime: `RISK_ENVELOPE_POLICY`
|
| 341 |
- ingestion parallelism: `PROCESS_POOL_WORKERS`, `BATCH_SIZE`
|
| 342 |
- yfinance retry: `YF_MAX_RETRIES`, `YF_BACKOFF_BASE_SEC`
|
| 343 |
- alert retry: `ALERT_MAX_RETRIES`, `ALERT_BACKOFF_BASE_SEC`
|
| 344 |
- telemetry reporting: `TELEMETRY_REPORT_INTERVAL_SEC`
|
| 345 |
- topic config path: `TOPICS_CONFIG_PATH`
|
| 346 |
|
| 347 |
+
## 12. Known Limitations
|
| 348 |
|
| 349 |
- In-memory pub/sub and stores are process-local only.
|
| 350 |
+
- Telemetry outputs to the standard logger only (no external sink).
|
|
|
|
| 351 |
- No durable queue for alerts.
|
| 352 |
- No persistent event/audit storage.
|
| 353 |
|
| 354 |
+
## 13. Run and Verify
|
| 355 |
|
| 356 |
+
Install dependencies, test, and run:
|
| 357 |
```bash
|
| 358 |
uv sync
|
| 359 |
+
uv run pytest
|
| 360 |
uv run python -m src.core.main
|
| 361 |
```
|
| 362 |
|
|
|
|
| 365 |
2. Confirm `risk.regime.change` appears when volatility increases.
|
| 366 |
3. Confirm `risk.envelope.updated` appears on same transitions.
|
| 367 |
4. Simulate send failures and verify alert queue retries.
|
| 368 |
+
5. Observe telemetry counters logged periodically.
|
| 369 |
|
| 370 |
+
## 14. Detailed Sequence Diagram (ASCII)
|
| 371 |
|
| 372 |
```text
|
| 373 |
+====================================================================================================+
|
|
|
|
| 395 |
| |--on_universe_snapshot---------->| | |
|
| 396 |
| | | | |
|
| 397 |
|== loop ================================================================================================> |
|
| 398 |
+
|--every UNIVERSE_REFRESH_CYCLES: reload universe + republish market.universe.snapshot------------------------|
|
| 399 |
|--for each venue--------------------------------------------------------------------------------------------|
|
| 400 |
+
|--build_event (diff)->| | | | | | | |
|
| 401 |
+
|------if changed: publish market.universe.updated------------------------------------------------------------>|
|
| 402 |
|--fetch_prices------------------>| | | | | | |
|
| 403 |
| |--download+retry/backoff--> yfinance |
|
| 404 |
+
| |<--prices + per-venue sequence_id |
|
| 405 |
|<-------------------------------| | | | | | |
|
| 406 |
|------------------------------publish market.prices.snapshot------------------------------------------------->|
|
| 407 |
| |--on_price_snapshot-------------------------------------->|
|
| 408 |
+
| | calc deltas/events, accumulate cycle stats |
|
| 409 |
| |--publish market.price.delta----------------------------->|
|
| 410 |
| |--publish risk.event.detected---------------------------->|
|
| 411 |
| | |--on_risk_event
|
| 412 |
+
| | (bridge) risk.event.detected -> system.alert.ready |
|
| 413 |
| | |--enqueue---->|
|
| 414 |
| | |--on_alert_enqueued
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
| |--DG.on_price_snapshot------------------------------------>|
|
| 416 |
| | if seq gap:
|
| 417 |
| |--publish data.gap.detected------------------------------->|
|
| 418 |
| | |--on_data_gap
|
| 419 |
+
| | (bridge) data.gap.detected -> system.alert.ready |
|
| 420 |
| | |--enqueue---->|
|
| 421 |
+
|--after all venues: publish system.cycle.completed---------------------------------------------------------->|
|
| 422 |
+
| |--on_cycle_completed (aggregated stats) |
|
| 423 |
+
| |--if regime changed: |
|
| 424 |
+
| | publish risk.regime.change----------------------------->|
|
| 425 |
+
| | |--on_regime_change
|
| 426 |
+
| | build envelope (RISK_ENVELOPE_POLICY) |
|
| 427 |
+
| |----------------------------------------------set()------->|
|
| 428 |
+
| | publish risk.envelope.updated--------------------------->|
|
| 429 |
|--AQ.process(TN.send)---------------------------------------------------------------------------------------->|
|
| 430 |
| |--send alert------------->|
|
| 431 |
| |<--ok/fail---------------|
|
|
|
|
| 433 |
|== sleep POLL_INTERVAL_SEC =================================================================================>|
|
| 434 |
```
|
| 435 |
|
| 436 |
+
## 15. Regime Transition Diagram (ASCII)
|
| 437 |
|
| 438 |
```text
|
| 439 |
+====================================================================================================+
|
| 440 |
| RISK REGIME TRANSITION LOGIC |
|
| 441 |
+====================================================================================================+
|
| 442 |
|
| 443 |
+
Inputs per poll cycle (aggregated across venues, applied on system.cycle.completed):
|
| 444 |
worst_drop_pct
|
| 445 |
high_severity_count
|
| 446 |
|
|
|
|
| 452 |
|
| 453 |
Transition behavior:
|
| 454 |
- Escalation (to stronger regime): immediate
|
| 455 |
+
- Downgrade (to weaker regime): requires calm_snapshots_to_downgrade
|
| 456 |
+
consecutive calm cycles (default 3); re-confirmation at the current
|
| 457 |
+
level resets the calm streak
|
| 458 |
|
| 459 |
State graph:
|
| 460 |
|
|
|
|
| 475 |
|
| 476 |
On each regime change:
|
| 477 |
1) publish risk.regime.change
|
| 478 |
+
2) build and store RiskEnvelope (RISK_ENVELOPE_POLICY table)
|
| 479 |
3) publish risk.envelope.updated
|
| 480 |
```
|
| 481 |
|
| 482 |
+
## 16. Failure, Retry, and Degradation Flows (ASCII)
|
| 483 |
|
| 484 |
```text
|
| 485 |
+====================================================================================================+
|
|
|
|
| 499 |
-> dropped after ALERT_MAX_RETRIES
|
| 500 |
|
| 501 |
3) Data gaps
|
| 502 |
+
DG compares sequence_id per ticker (sequences are per venue)
|
| 503 |
-> if jump detected: emit data.gap.detected
|
| 504 |
-> converted into system alert + telemetry counter
|
| 505 |
|
| 506 |
4) Topic config failure
|
| 507 |
topics.py load_topics()
|
| 508 |
-> if YAML missing/malformed: fallback to hardcoded defaults
|
| 509 |
+
|
| 510 |
+
5) Universe refresh failure
|
| 511 |
+
_load_universe()
|
| 512 |
+
-> sources unreachable / empty result: keep previous universe, log warning
|
| 513 |
+
-> ingestors and sequence counters survive, so no false data gaps
|
| 514 |
```
|
| 515 |
|
| 516 |
+
## 17. System Overview Diagram (ASCII)
|
| 517 |
|
| 518 |
```text
|
| 519 |
+====================================================================================================+
|
|
|
|
| 533 |
+---------------------+ +---------------------+ +-----------------------+
|
| 534 |
|
| 535 |
+---------------------+ +---------------------+ +-----------------------+
|
| 536 |
+
| build_runtime() | -----> | PubSub subscriptions| -----> | Runtime dataclass |
|
| 537 |
+
| (composition root) | | + alert bridges | | per-venue ingestors |
|
| 538 |
+
+---------------------+ +---------------------+ | polling loop starts |
|
| 539 |
+
+-----------------------+
|
| 540 |
|
| 541 |
|
| 542 |
RUNTIME LOOP
|
|
|
|
| 546 |
|
|
| 547 |
v
|
| 548 |
+---------------------------+
|
| 549 |
+
| every UNIVERSE_REFRESH_ |
|
| 550 |
+
| CYCLES: reload universe |
|
| 551 |
+
+---------------------------+
|
| 552 |
+
|
|
| 553 |
+
v
|
| 554 |
+
+---------------------------+
|
| 555 |
| for each venue universe |
|
| 556 |
| (us.equities, eu..., etc) |
|
| 557 |
+
| diff -> market.universe. |
|
| 558 |
+
| updated (only on change) |
|
| 559 |
+---------------------------+
|
| 560 |
|
|
| 561 |
v
|
| 562 |
+---------------------------+ +--------------------------------------------+
|
| 563 |
+
| PriceIngestor (per venue) | | retry_utils.call_with_backoff |
|
| 564 |
| - chunk tickers | <-----> | exponential backoff for yfinance download |
|
| 565 |
| - process pool workers | +--------------------------------------------+
|
| 566 |
+
| - per-venue sequence_id++ |
|
| 567 |
+---------------------------+
|
| 568 |
|
|
| 569 |
v
|
|
|
|
| 578 |
| - calc delta per TF | | - detect seq jumps |
|
| 579 |
| - adaptive threshold | +---------------------------+
|
| 580 |
| - detect risk events | |
|
| 581 |
+
| - accumulate cycle stats| v
|
| 582 |
+
+-------------------------+ topic: data.gap.detected
|
| 583 |
| |
|
| 584 |
| +--------------------------+
|
| 585 |
| | Telemetry.on_data_gap |
|
|
|
|
| 595 |
|
|
| 596 |
+--> topic: risk.event.detected -------> Telemetry.on_risk_event
|
| 597 |
|
|
| 598 |
+
+--> topic: risk.event.detected --(bridge)--> topic: system.alert.ready --> AlertQueue.enqueue
|
| 599 |
+
|
| 600 |
+
after all venues:
|
| 601 |
+
|
|
| 602 |
+
v
|
| 603 |
+
topic: system.cycle.completed
|
| 604 |
+
|
|
| 605 |
+
v
|
| 606 |
+
RiskEngine.on_cycle_completed
|
| 607 |
+
(stats aggregated across all venues)
|
| 608 |
|
|
| 609 |
+--> Regime State Machine (normal/tension/stress/panic)
|
| 610 |
|
|
|
|
|
| 613 |
topic: risk.regime.change -------> Telemetry.on_regime_change
|
| 614 |
|
|
| 615 |
v
|
| 616 |
+
build RiskEnvelope (RISK_ENVELOPE_POLICY)
|
| 617 |
|
|
| 618 |
v
|
| 619 |
RiskEnvelopeStore.set(...)
|
|
|
|
| 662 |
risk.envelope.updated
|
| 663 |
data.gap.detected
|
| 664 |
system.alert.ready
|
| 665 |
+
system.cycle.completed
|
| 666 |
|
| 667 |
|
| 668 |
TELEMETRY OUTPUT
|
pyproject.toml
CHANGED
|
@@ -22,3 +22,8 @@ build-backend = "hatchling.build"
|
|
| 22 |
|
| 23 |
[tool.hatch.build.targets.wheel]
|
| 24 |
packages = ["src"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
[tool.hatch.build.targets.wheel]
|
| 24 |
packages = ["src"]
|
| 25 |
+
|
| 26 |
+
[dependency-groups]
|
| 27 |
+
dev = [
|
| 28 |
+
"pytest>=9.1.1",
|
| 29 |
+
]
|
src/core/alerting.py
CHANGED
|
@@ -1,26 +1,21 @@
|
|
| 1 |
-
from collections import defaultdict
|
| 2 |
from typing import Dict, List
|
|
|
|
| 3 |
import time
|
| 4 |
import uuid
|
| 5 |
import os
|
| 6 |
|
| 7 |
import requests
|
| 8 |
|
| 9 |
-
from .config import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from .retry_utils import backoff_delay
|
| 11 |
|
| 12 |
|
| 13 |
-
|
| 14 |
-
def __init__(self) -> None:
|
| 15 |
-
self._last_ts = defaultdict(lambda: defaultdict(int))
|
| 16 |
-
|
| 17 |
-
def allow(self, ticker: str, timeframe: str) -> bool:
|
| 18 |
-
now = int(time.time())
|
| 19 |
-
last = self._last_ts[ticker][timeframe]
|
| 20 |
-
if now - last < MIN_ALERT_GAP_MIN * 60:
|
| 21 |
-
return False
|
| 22 |
-
self._last_ts[ticker][timeframe] = now
|
| 23 |
-
return True
|
| 24 |
|
| 25 |
|
| 26 |
def classify(timeframe_minutes: int) -> str:
|
|
@@ -55,6 +50,19 @@ def build_alert(
|
|
| 55 |
}
|
| 56 |
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
def build_data_gap_alert(gap_event: Dict) -> Dict:
|
| 59 |
ticker = gap_event["ticker"]
|
| 60 |
gap_size = gap_event["gap_size"]
|
|
@@ -86,7 +94,7 @@ class TelegramNotifier:
|
|
| 86 |
|
| 87 |
def send(self, alert: Dict) -> bool:
|
| 88 |
if not self._token or not self._chat_id:
|
| 89 |
-
|
| 90 |
return True
|
| 91 |
url = f"https://api.telegram.org/bot{self._token}/sendMessage"
|
| 92 |
payload = {"chat_id": self._chat_id, "text": alert["message"]}
|
|
@@ -94,7 +102,7 @@ class TelegramNotifier:
|
|
| 94 |
resp = requests.post(url, json=payload, timeout=10)
|
| 95 |
return resp.status_code == 200
|
| 96 |
except Exception:
|
| 97 |
-
|
| 98 |
return False
|
| 99 |
|
| 100 |
|
|
|
|
|
|
|
| 1 |
from typing import Dict, List
|
| 2 |
+
import logging
|
| 3 |
import time
|
| 4 |
import uuid
|
| 5 |
import os
|
| 6 |
|
| 7 |
import requests
|
| 8 |
|
| 9 |
+
from .config import (
|
| 10 |
+
MIN_ALERT_GAP_MIN,
|
| 11 |
+
TIMEFRAMES_MIN,
|
| 12 |
+
TELEGRAM_BOT_TOKEN_ENV,
|
| 13 |
+
TELEGRAM_CHAT_ID_ENV,
|
| 14 |
+
)
|
| 15 |
from .retry_utils import backoff_delay
|
| 16 |
|
| 17 |
|
| 18 |
+
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
def classify(timeframe_minutes: int) -> str:
|
|
|
|
| 50 |
}
|
| 51 |
|
| 52 |
|
| 53 |
+
def build_alert_from_risk_event(risk_event: Dict) -> Dict:
|
| 54 |
+
minutes = TIMEFRAMES_MIN.get(risk_event["timeframe"], 0)
|
| 55 |
+
return build_alert(
|
| 56 |
+
ticker=risk_event["ticker"],
|
| 57 |
+
asset_class=risk_event["asset_class"],
|
| 58 |
+
venue=risk_event["venue"],
|
| 59 |
+
timeframe=risk_event["timeframe"],
|
| 60 |
+
alert_type=classify(minutes),
|
| 61 |
+
delta_pct=risk_event["delta_pct"],
|
| 62 |
+
threshold=risk_event["threshold"],
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
def build_data_gap_alert(gap_event: Dict) -> Dict:
|
| 67 |
ticker = gap_event["ticker"]
|
| 68 |
gap_size = gap_event["gap_size"]
|
|
|
|
| 94 |
|
| 95 |
def send(self, alert: Dict) -> bool:
|
| 96 |
if not self._token or not self._chat_id:
|
| 97 |
+
logger.info("telegram credentials missing, alert not delivered: %s", alert["message"])
|
| 98 |
return True
|
| 99 |
url = f"https://api.telegram.org/bot{self._token}/sendMessage"
|
| 100 |
payload = {"chat_id": self._chat_id, "text": alert["message"]}
|
|
|
|
| 102 |
resp = requests.post(url, json=payload, timeout=10)
|
| 103 |
return resp.status_code == 200
|
| 104 |
except Exception:
|
| 105 |
+
logger.warning("telegram send failed: %s", alert["message"])
|
| 106 |
return False
|
| 107 |
|
| 108 |
|
src/core/config.py
CHANGED
|
@@ -13,6 +13,9 @@ MIN_ALERT_GAP_MIN = 20
|
|
| 13 |
|
| 14 |
POLL_INTERVAL_SEC = 60
|
| 15 |
|
|
|
|
|
|
|
|
|
|
| 16 |
# Process pool for price loading
|
| 17 |
PROCESS_POOL_WORKERS = 4
|
| 18 |
BATCH_SIZE = 200
|
|
@@ -26,6 +29,50 @@ MIN_WINDOW_POINTS = 5
|
|
| 26 |
# Topic taxonomy config
|
| 27 |
TOPICS_CONFIG_PATH = "configs/topics.yaml"
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
# Universe sources (no-auth)
|
| 30 |
US_TICKER_SOURCES = {
|
| 31 |
"us.nasdaq": "https://raw.githubusercontent.com/rreichel3/US-Stock-Symbols/main/nasdaq/nasdaq_tickers.txt",
|
|
|
|
| 13 |
|
| 14 |
POLL_INTERVAL_SEC = 60
|
| 15 |
|
| 16 |
+
# Reload the ticker universe every N poll cycles (~daily at 60s); 0 disables refresh
|
| 17 |
+
UNIVERSE_REFRESH_CYCLES = 1440
|
| 18 |
+
|
| 19 |
# Process pool for price loading
|
| 20 |
PROCESS_POOL_WORKERS = 4
|
| 21 |
BATCH_SIZE = 200
|
|
|
|
| 29 |
# Topic taxonomy config
|
| 30 |
TOPICS_CONFIG_PATH = "configs/topics.yaml"
|
| 31 |
|
| 32 |
+
# Risk envelope limits per regime (consumed by risk_policy.build_envelope)
|
| 33 |
+
RISK_ENVELOPE_POLICY = {
|
| 34 |
+
"normal": {
|
| 35 |
+
"allowed": True,
|
| 36 |
+
"max_position_usd": 1_000_000.0,
|
| 37 |
+
"max_order_usd": 100_000.0,
|
| 38 |
+
"max_leverage": 1.0,
|
| 39 |
+
"max_turnover_per_hour": 5_000_000.0,
|
| 40 |
+
"cooldown_seconds": 30,
|
| 41 |
+
"confidence": 0.6,
|
| 42 |
+
"reason": "NORMAL_REGIME",
|
| 43 |
+
},
|
| 44 |
+
"tension": {
|
| 45 |
+
"allowed": True,
|
| 46 |
+
"max_position_usd": 500_000.0,
|
| 47 |
+
"max_order_usd": 50_000.0,
|
| 48 |
+
"max_leverage": 0.8,
|
| 49 |
+
"max_turnover_per_hour": 1_000_000.0,
|
| 50 |
+
"cooldown_seconds": 60,
|
| 51 |
+
"confidence": 0.75,
|
| 52 |
+
"reason": "TENSION_REGIME",
|
| 53 |
+
},
|
| 54 |
+
"stress": {
|
| 55 |
+
"allowed": True,
|
| 56 |
+
"max_position_usd": 250_000.0,
|
| 57 |
+
"max_order_usd": 25_000.0,
|
| 58 |
+
"max_leverage": 0.5,
|
| 59 |
+
"max_turnover_per_hour": 500_000.0,
|
| 60 |
+
"cooldown_seconds": 120,
|
| 61 |
+
"confidence": 0.85,
|
| 62 |
+
"reason": "STRESS_REGIME",
|
| 63 |
+
},
|
| 64 |
+
"panic": {
|
| 65 |
+
"allowed": False,
|
| 66 |
+
"max_position_usd": 0.0,
|
| 67 |
+
"max_order_usd": 0.0,
|
| 68 |
+
"max_leverage": 0.0,
|
| 69 |
+
"max_turnover_per_hour": 0.0,
|
| 70 |
+
"cooldown_seconds": 300,
|
| 71 |
+
"confidence": 0.95,
|
| 72 |
+
"reason": "PANIC_REGIME",
|
| 73 |
+
},
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
# Universe sources (no-auth)
|
| 77 |
US_TICKER_SOURCES = {
|
| 78 |
"us.nasdaq": "https://raw.githubusercontent.com/rreichel3/US-Stock-Symbols/main/nasdaq/nasdaq_tickers.txt",
|
src/core/event_detector.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
|
|
|
|
|
| 1 |
from typing import Optional
|
|
|
|
| 2 |
import numpy as np
|
| 3 |
|
| 4 |
-
from .config import K_SIGMA, MIN_WINDOW_POINTS
|
| 5 |
|
| 6 |
|
| 7 |
def adaptive_threshold(window) -> Optional[float]:
|
|
@@ -35,3 +38,19 @@ def severity(delta_pct: float, threshold: float) -> str:
|
|
| 35 |
if ratio >= 1.3:
|
| 36 |
return "med"
|
| 37 |
return "low"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from collections import defaultdict
|
| 3 |
from typing import Optional
|
| 4 |
+
|
| 5 |
import numpy as np
|
| 6 |
|
| 7 |
+
from .config import K_SIGMA, MIN_WINDOW_POINTS, MIN_ALERT_GAP_MIN
|
| 8 |
|
| 9 |
|
| 10 |
def adaptive_threshold(window) -> Optional[float]:
|
|
|
|
| 38 |
if ratio >= 1.3:
|
| 39 |
return "med"
|
| 40 |
return "low"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class AlertDeduplicator:
|
| 44 |
+
"""Rate-limits detections per (ticker, timeframe) so one breach does not
|
| 45 |
+
re-fire on every poll cycle while the window still contains the move."""
|
| 46 |
+
|
| 47 |
+
def __init__(self) -> None:
|
| 48 |
+
self._last_ts = defaultdict(lambda: defaultdict(int))
|
| 49 |
+
|
| 50 |
+
def allow(self, ticker: str, timeframe: str) -> bool:
|
| 51 |
+
now = int(time.time())
|
| 52 |
+
last = self._last_ts[ticker][timeframe]
|
| 53 |
+
if now - last < MIN_ALERT_GAP_MIN * 60:
|
| 54 |
+
return False
|
| 55 |
+
self._last_ts[ticker][timeframe] = now
|
| 56 |
+
return True
|
src/core/main.py
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
|
|
| 1 |
import time
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from .config import (
|
| 4 |
POLL_INTERVAL_SEC,
|
|
|
|
| 5 |
ALERT_MAX_RETRIES,
|
| 6 |
ALERT_BACKOFF_BASE_SEC,
|
| 7 |
TELEMETRY_REPORT_INTERVAL_SEC,
|
| 8 |
)
|
| 9 |
from .pubsub import PubSub
|
| 10 |
-
from .universe_loader import UniverseLoader
|
| 11 |
from .price_ingestor import PriceIngestor
|
| 12 |
-
from .alerting import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
from .risk_engine import RiskEngine
|
| 14 |
from .risk_store import RiskEnvelopeStore
|
| 15 |
from .data_gap import DataGapDetector
|
|
@@ -17,31 +27,21 @@ from .topics import TOPICS
|
|
| 17 |
from .telemetry import Telemetry
|
| 18 |
|
| 19 |
|
| 20 |
-
|
| 21 |
-
for venue_key, tickers in universe.items():
|
| 22 |
-
if not tickers:
|
| 23 |
-
continue
|
| 24 |
-
asset_class = _infer_asset_class(venue_key)
|
| 25 |
-
venue = venue_key
|
| 26 |
-
pubsub.publish(TOPICS["universe_updated"], loader.build_event(asset_class, venue, tickers))
|
| 27 |
-
|
| 28 |
-
ingestor = ingestors[venue_key]
|
| 29 |
-
price_event = ingestor.fetch_prices(tickers)
|
| 30 |
-
pubsub.publish(TOPICS["prices_snapshot"], price_event)
|
| 31 |
-
|
| 32 |
-
queue.process(notifier.send)
|
| 33 |
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
-
def _infer_asset_class(venue_key: str) -> str:
|
| 37 |
-
if "crypto" in venue_key:
|
| 38 |
-
return "crypto"
|
| 39 |
-
if "commodities" in venue_key:
|
| 40 |
-
return "commodity"
|
| 41 |
-
return "equity"
|
| 42 |
|
| 43 |
-
|
| 44 |
-
def build_runtime():
|
| 45 |
pubsub = PubSub()
|
| 46 |
notifier = TelegramNotifier()
|
| 47 |
queue = AlertQueue(max_retries=ALERT_MAX_RETRIES, backoff_base_sec=ALERT_BACKOFF_BASE_SEC)
|
|
@@ -51,57 +51,84 @@ def build_runtime():
|
|
| 51 |
risk = RiskEngine(pubsub, envelope_store)
|
| 52 |
gap_detector = DataGapDetector(pubsub, TOPICS["data_gap"])
|
| 53 |
|
|
|
|
| 54 |
pubsub.subscribe(TOPICS["alert_ready"], queue.enqueue)
|
| 55 |
pubsub.subscribe(TOPICS["alert_ready"], telemetry.on_alert_enqueued)
|
| 56 |
pubsub.subscribe(TOPICS["prices_snapshot"], risk.on_price_snapshot)
|
| 57 |
pubsub.subscribe(TOPICS["prices_snapshot"], gap_detector.on_price_snapshot)
|
|
|
|
| 58 |
pubsub.subscribe(TOPICS["risk_event"], telemetry.on_risk_event)
|
|
|
|
| 59 |
pubsub.subscribe(TOPICS["risk_regime_change"], telemetry.on_regime_change)
|
| 60 |
pubsub.subscribe(TOPICS["data_gap"], telemetry.on_data_gap)
|
| 61 |
pubsub.subscribe(TOPICS["data_gap"], lambda event: pubsub.publish(TOPICS["alert_ready"], build_data_gap_alert(event)))
|
| 62 |
pubsub.subscribe(TOPICS["universe_snapshot"], risk.on_universe_snapshot)
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
"
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
)
|
|
|
|
| 93 |
|
| 94 |
|
| 95 |
-
def run_single_cycle(runtime=None):
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
return
|
| 99 |
|
| 100 |
|
| 101 |
-
def run_forever():
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
while True:
|
| 104 |
-
|
| 105 |
time.sleep(POLL_INTERVAL_SEC)
|
| 106 |
|
| 107 |
|
|
|
|
| 1 |
+
import logging
|
| 2 |
import time
|
| 3 |
+
import uuid
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
from typing import Dict, List
|
| 6 |
|
| 7 |
from .config import (
|
| 8 |
POLL_INTERVAL_SEC,
|
| 9 |
+
UNIVERSE_REFRESH_CYCLES,
|
| 10 |
ALERT_MAX_RETRIES,
|
| 11 |
ALERT_BACKOFF_BASE_SEC,
|
| 12 |
TELEMETRY_REPORT_INTERVAL_SEC,
|
| 13 |
)
|
| 14 |
from .pubsub import PubSub
|
| 15 |
+
from .universe_loader import UniverseLoader, infer_asset_class
|
| 16 |
from .price_ingestor import PriceIngestor
|
| 17 |
+
from .alerting import (
|
| 18 |
+
TelegramNotifier,
|
| 19 |
+
AlertQueue,
|
| 20 |
+
build_alert_from_risk_event,
|
| 21 |
+
build_data_gap_alert,
|
| 22 |
+
)
|
| 23 |
from .risk_engine import RiskEngine
|
| 24 |
from .risk_store import RiskEnvelopeStore
|
| 25 |
from .data_gap import DataGapDetector
|
|
|
|
| 27 |
from .telemetry import Telemetry
|
| 28 |
|
| 29 |
|
| 30 |
+
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
+
@dataclass
|
| 34 |
+
class Runtime:
|
| 35 |
+
pubsub: PubSub
|
| 36 |
+
notifier: TelegramNotifier
|
| 37 |
+
queue: AlertQueue
|
| 38 |
+
loader: UniverseLoader
|
| 39 |
+
universe: Dict[str, List[str]]
|
| 40 |
+
ingestors: Dict[str, PriceIngestor] = field(default_factory=dict)
|
| 41 |
+
cycle: int = 0
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
+
def build_runtime() -> Runtime:
|
|
|
|
| 45 |
pubsub = PubSub()
|
| 46 |
notifier = TelegramNotifier()
|
| 47 |
queue = AlertQueue(max_retries=ALERT_MAX_RETRIES, backoff_base_sec=ALERT_BACKOFF_BASE_SEC)
|
|
|
|
| 51 |
risk = RiskEngine(pubsub, envelope_store)
|
| 52 |
gap_detector = DataGapDetector(pubsub, TOPICS["data_gap"])
|
| 53 |
|
| 54 |
+
# Subscription graph: the only place components are wired together.
|
| 55 |
pubsub.subscribe(TOPICS["alert_ready"], queue.enqueue)
|
| 56 |
pubsub.subscribe(TOPICS["alert_ready"], telemetry.on_alert_enqueued)
|
| 57 |
pubsub.subscribe(TOPICS["prices_snapshot"], risk.on_price_snapshot)
|
| 58 |
pubsub.subscribe(TOPICS["prices_snapshot"], gap_detector.on_price_snapshot)
|
| 59 |
+
pubsub.subscribe(TOPICS["cycle_completed"], risk.on_cycle_completed)
|
| 60 |
pubsub.subscribe(TOPICS["risk_event"], telemetry.on_risk_event)
|
| 61 |
+
pubsub.subscribe(TOPICS["risk_event"], lambda event: pubsub.publish(TOPICS["alert_ready"], build_alert_from_risk_event(event)))
|
| 62 |
pubsub.subscribe(TOPICS["risk_regime_change"], telemetry.on_regime_change)
|
| 63 |
pubsub.subscribe(TOPICS["data_gap"], telemetry.on_data_gap)
|
| 64 |
pubsub.subscribe(TOPICS["data_gap"], lambda event: pubsub.publish(TOPICS["alert_ready"], build_data_gap_alert(event)))
|
| 65 |
pubsub.subscribe(TOPICS["universe_snapshot"], risk.on_universe_snapshot)
|
| 66 |
|
| 67 |
+
runtime = Runtime(
|
| 68 |
+
pubsub=pubsub,
|
| 69 |
+
notifier=notifier,
|
| 70 |
+
queue=queue,
|
| 71 |
+
loader=UniverseLoader(),
|
| 72 |
+
universe={},
|
| 73 |
+
)
|
| 74 |
+
_load_universe(runtime)
|
| 75 |
+
return runtime
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _load_universe(runtime: Runtime) -> None:
|
| 79 |
+
universe = runtime.loader.load()
|
| 80 |
+
if not any(universe.values()):
|
| 81 |
+
logger.warning("universe load returned no tickers; keeping previous universe")
|
| 82 |
+
return
|
| 83 |
+
|
| 84 |
+
runtime.universe = universe
|
| 85 |
+
snapshot = runtime.loader.build_snapshot(universe)
|
| 86 |
+
runtime.pubsub.publish(TOPICS["universe_snapshot"], snapshot)
|
| 87 |
+
|
| 88 |
+
for venue_key in universe:
|
| 89 |
+
runtime.ingestors.setdefault(
|
| 90 |
+
venue_key,
|
| 91 |
+
PriceIngestor(venue=venue_key, asset_class=infer_asset_class(venue_key)),
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def run_once(runtime: Runtime) -> None:
|
| 96 |
+
runtime.cycle += 1
|
| 97 |
+
|
| 98 |
+
if UNIVERSE_REFRESH_CYCLES > 0 and runtime.cycle > 1 and (runtime.cycle - 1) % UNIVERSE_REFRESH_CYCLES == 0:
|
| 99 |
+
_load_universe(runtime)
|
| 100 |
+
|
| 101 |
+
for venue_key, tickers in runtime.universe.items():
|
| 102 |
+
if not tickers:
|
| 103 |
+
continue
|
| 104 |
+
update_event = runtime.loader.build_event(infer_asset_class(venue_key), venue_key, tickers)
|
| 105 |
+
if update_event is not None:
|
| 106 |
+
runtime.pubsub.publish(TOPICS["universe_updated"], update_event)
|
| 107 |
+
|
| 108 |
+
price_event = runtime.ingestors[venue_key].fetch_prices(tickers)
|
| 109 |
+
runtime.pubsub.publish(TOPICS["prices_snapshot"], price_event)
|
| 110 |
+
|
| 111 |
+
runtime.pubsub.publish(
|
| 112 |
+
TOPICS["cycle_completed"],
|
| 113 |
+
{"event_id": str(uuid.uuid4()), "ts": int(time.time() * 1000), "cycle": runtime.cycle},
|
| 114 |
)
|
| 115 |
+
runtime.queue.process(runtime.notifier.send)
|
| 116 |
|
| 117 |
|
| 118 |
+
def run_single_cycle(runtime: Runtime | None = None) -> Runtime:
|
| 119 |
+
runtime = runtime or build_runtime()
|
| 120 |
+
run_once(runtime)
|
| 121 |
+
return runtime
|
| 122 |
|
| 123 |
|
| 124 |
+
def run_forever() -> None:
|
| 125 |
+
logging.basicConfig(
|
| 126 |
+
level=logging.INFO,
|
| 127 |
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
| 128 |
+
)
|
| 129 |
+
runtime = build_runtime()
|
| 130 |
while True:
|
| 131 |
+
run_once(runtime)
|
| 132 |
time.sleep(POLL_INTERVAL_SEC)
|
| 133 |
|
| 134 |
|
src/core/risk_engine.py
CHANGED
|
@@ -5,15 +5,23 @@ from typing import Dict
|
|
| 5 |
from .config import TIMEFRAMES_MIN
|
| 6 |
from .price_buffer import PriceBuffer
|
| 7 |
from .price_delta import price_delta, velocity_pct_per_min
|
| 8 |
-
from .event_detector import adaptive_threshold, detect_event, severity
|
| 9 |
-
from .alerting import AlertDeduplicator, classify, build_alert
|
| 10 |
from .topics import TOPICS
|
| 11 |
-
from .schemas import
|
|
|
|
| 12 |
from .risk_store import RiskEnvelopeStore
|
| 13 |
from .risk_regime import RiskRegimeStateMachine
|
| 14 |
|
| 15 |
|
| 16 |
class RiskEngine:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
def __init__(self, pubsub, envelope_store: RiskEnvelopeStore) -> None:
|
| 18 |
self._pubsub = pubsub
|
| 19 |
self._buffer = PriceBuffer()
|
|
@@ -22,6 +30,8 @@ class RiskEngine:
|
|
| 22 |
self._regime = RiskRegimeStateMachine()
|
| 23 |
self._universe_id = "unknown"
|
| 24 |
self._last_regime = self._regime.state
|
|
|
|
|
|
|
| 25 |
|
| 26 |
def on_universe_snapshot(self, universe_snapshot: Dict) -> None:
|
| 27 |
self._universe_id = universe_snapshot.get("universe_id", "unknown")
|
|
@@ -30,9 +40,6 @@ class RiskEngine:
|
|
| 30 |
asset_class = price_event.get("asset_class", "equity")
|
| 31 |
venue = price_event.get("venue", "unknown")
|
| 32 |
|
| 33 |
-
worst_drop = 0.0
|
| 34 |
-
high_severity_count = 0
|
| 35 |
-
|
| 36 |
for price_point in price_event.get("prices", []):
|
| 37 |
self._buffer.update(price_point["ticker"], price_point["price"])
|
| 38 |
|
|
@@ -45,7 +52,7 @@ class RiskEngine:
|
|
| 45 |
continue
|
| 46 |
|
| 47 |
delta_pct = delta["delta_pct"]
|
| 48 |
-
|
| 49 |
|
| 50 |
delta_event = {
|
| 51 |
"event_id": str(uuid.uuid4()),
|
|
@@ -72,7 +79,7 @@ class RiskEngine:
|
|
| 72 |
|
| 73 |
event_severity = severity(delta_pct, threshold)
|
| 74 |
if event_severity == "high":
|
| 75 |
-
|
| 76 |
|
| 77 |
risk_event = {
|
| 78 |
"event_id": str(uuid.uuid4()),
|
|
@@ -88,34 +95,31 @@ class RiskEngine:
|
|
| 88 |
}
|
| 89 |
self._pubsub.publish(TOPICS["risk_event"], risk_event)
|
| 90 |
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
alert_type=classify(minutes),
|
| 97 |
-
delta_pct=delta_pct,
|
| 98 |
-
threshold=threshold,
|
| 99 |
-
)
|
| 100 |
-
self._pubsub.publish(TOPICS["alert_ready"], alert)
|
| 101 |
|
| 102 |
regime, changed = self._regime.update(
|
| 103 |
worst_drop_pct=worst_drop,
|
| 104 |
high_severity_count=high_severity_count,
|
| 105 |
)
|
| 106 |
-
if changed:
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
| 119 |
|
| 120 |
def _build_regime_change_event(
|
| 121 |
self,
|
|
@@ -134,70 +138,5 @@ class RiskEngine:
|
|
| 134 |
f"worst_drop_pct={worst_drop_pct:.4f};"
|
| 135 |
f"high_severity_count={high_severity_count}"
|
| 136 |
),
|
| 137 |
-
"confidence":
|
| 138 |
-
}
|
| 139 |
-
|
| 140 |
-
@staticmethod
|
| 141 |
-
def _regime_confidence(regime: str) -> float:
|
| 142 |
-
if regime == "panic":
|
| 143 |
-
return 0.95
|
| 144 |
-
if regime == "stress":
|
| 145 |
-
return 0.85
|
| 146 |
-
if regime == "tension":
|
| 147 |
-
return 0.75
|
| 148 |
-
return 0.6
|
| 149 |
-
|
| 150 |
-
def _build_envelope(self, regime: str) -> RiskEnvelope:
|
| 151 |
-
now_ms = int(time.time() * 1000)
|
| 152 |
-
if regime == "panic":
|
| 153 |
-
allowed = False
|
| 154 |
-
max_position = 0.0
|
| 155 |
-
max_order = 0.0
|
| 156 |
-
max_leverage = 0.0
|
| 157 |
-
max_turnover = 0.0
|
| 158 |
-
cooldown = 300
|
| 159 |
-
confidence = 0.95
|
| 160 |
-
reason = "PANIC_REGIME"
|
| 161 |
-
elif regime == "stress":
|
| 162 |
-
allowed = True
|
| 163 |
-
max_position = 250_000.0
|
| 164 |
-
max_order = 25_000.0
|
| 165 |
-
max_leverage = 0.5
|
| 166 |
-
max_turnover = 500_000.0
|
| 167 |
-
cooldown = 120
|
| 168 |
-
confidence = 0.85
|
| 169 |
-
reason = "STRESS_REGIME"
|
| 170 |
-
elif regime == "tension":
|
| 171 |
-
allowed = True
|
| 172 |
-
max_position = 500_000.0
|
| 173 |
-
max_order = 50_000.0
|
| 174 |
-
max_leverage = 0.8
|
| 175 |
-
max_turnover = 1_000_000.0
|
| 176 |
-
cooldown = 60
|
| 177 |
-
confidence = 0.75
|
| 178 |
-
reason = "TENSION_REGIME"
|
| 179 |
-
else:
|
| 180 |
-
allowed = True
|
| 181 |
-
max_position = 1_000_000.0
|
| 182 |
-
max_order = 100_000.0
|
| 183 |
-
max_leverage = 1.0
|
| 184 |
-
max_turnover = 5_000_000.0
|
| 185 |
-
cooldown = 30
|
| 186 |
-
confidence = 0.6
|
| 187 |
-
reason = "NORMAL_REGIME"
|
| 188 |
-
|
| 189 |
-
return {
|
| 190 |
-
"envelope_id": str(uuid.uuid4()),
|
| 191 |
-
"universe_id": self._universe_id,
|
| 192 |
-
"allowed": allowed,
|
| 193 |
-
"max_position_usd": max_position,
|
| 194 |
-
"max_order_usd": max_order,
|
| 195 |
-
"max_leverage": max_leverage,
|
| 196 |
-
"max_turnover_per_hour": max_turnover,
|
| 197 |
-
"cooldown_seconds": cooldown,
|
| 198 |
-
"regime": regime,
|
| 199 |
-
"confidence": confidence,
|
| 200 |
-
"reason": reason,
|
| 201 |
-
"valid_from_ms": now_ms,
|
| 202 |
-
"valid_until_ms": now_ms + (cooldown * 1000),
|
| 203 |
}
|
|
|
|
| 5 |
from .config import TIMEFRAMES_MIN
|
| 6 |
from .price_buffer import PriceBuffer
|
| 7 |
from .price_delta import price_delta, velocity_pct_per_min
|
| 8 |
+
from .event_detector import AlertDeduplicator, adaptive_threshold, detect_event, severity
|
|
|
|
| 9 |
from .topics import TOPICS
|
| 10 |
+
from .schemas import RiskRegimeChangeEvent
|
| 11 |
+
from .risk_policy import build_envelope, regime_confidence
|
| 12 |
from .risk_store import RiskEnvelopeStore
|
| 13 |
from .risk_regime import RiskRegimeStateMachine
|
| 14 |
|
| 15 |
|
| 16 |
class RiskEngine:
|
| 17 |
+
"""Per-snapshot detection, per-cycle regime decisions.
|
| 18 |
+
|
| 19 |
+
`on_price_snapshot` (one call per venue) detects per-ticker breaches and
|
| 20 |
+
accumulates market-wide stats; the regime state machine only advances on
|
| 21 |
+
`on_cycle_completed`, once all venues of the poll cycle have been seen, so
|
| 22 |
+
a calm venue cannot dilute stress signals coming from another venue.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
def __init__(self, pubsub, envelope_store: RiskEnvelopeStore) -> None:
|
| 26 |
self._pubsub = pubsub
|
| 27 |
self._buffer = PriceBuffer()
|
|
|
|
| 30 |
self._regime = RiskRegimeStateMachine()
|
| 31 |
self._universe_id = "unknown"
|
| 32 |
self._last_regime = self._regime.state
|
| 33 |
+
self._cycle_worst_drop = 0.0
|
| 34 |
+
self._cycle_high_severity = 0
|
| 35 |
|
| 36 |
def on_universe_snapshot(self, universe_snapshot: Dict) -> None:
|
| 37 |
self._universe_id = universe_snapshot.get("universe_id", "unknown")
|
|
|
|
| 40 |
asset_class = price_event.get("asset_class", "equity")
|
| 41 |
venue = price_event.get("venue", "unknown")
|
| 42 |
|
|
|
|
|
|
|
|
|
|
| 43 |
for price_point in price_event.get("prices", []):
|
| 44 |
self._buffer.update(price_point["ticker"], price_point["price"])
|
| 45 |
|
|
|
|
| 52 |
continue
|
| 53 |
|
| 54 |
delta_pct = delta["delta_pct"]
|
| 55 |
+
self._cycle_worst_drop = min(self._cycle_worst_drop, delta_pct)
|
| 56 |
|
| 57 |
delta_event = {
|
| 58 |
"event_id": str(uuid.uuid4()),
|
|
|
|
| 79 |
|
| 80 |
event_severity = severity(delta_pct, threshold)
|
| 81 |
if event_severity == "high":
|
| 82 |
+
self._cycle_high_severity += 1
|
| 83 |
|
| 84 |
risk_event = {
|
| 85 |
"event_id": str(uuid.uuid4()),
|
|
|
|
| 95 |
}
|
| 96 |
self._pubsub.publish(TOPICS["risk_event"], risk_event)
|
| 97 |
|
| 98 |
+
def on_cycle_completed(self, _event: Dict) -> None:
|
| 99 |
+
worst_drop = self._cycle_worst_drop
|
| 100 |
+
high_severity_count = self._cycle_high_severity
|
| 101 |
+
self._cycle_worst_drop = 0.0
|
| 102 |
+
self._cycle_high_severity = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
regime, changed = self._regime.update(
|
| 105 |
worst_drop_pct=worst_drop,
|
| 106 |
high_severity_count=high_severity_count,
|
| 107 |
)
|
| 108 |
+
if not changed:
|
| 109 |
+
return
|
| 110 |
+
|
| 111 |
+
regime_event = self._build_regime_change_event(
|
| 112 |
+
from_regime=self._last_regime,
|
| 113 |
+
to_regime=regime,
|
| 114 |
+
worst_drop_pct=worst_drop,
|
| 115 |
+
high_severity_count=high_severity_count,
|
| 116 |
+
)
|
| 117 |
+
self._pubsub.publish(TOPICS["risk_regime_change"], regime_event)
|
| 118 |
+
self._last_regime = regime
|
| 119 |
+
|
| 120 |
+
envelope = build_envelope(regime=regime, universe_id=self._universe_id)
|
| 121 |
+
self._envelope_store.set(envelope)
|
| 122 |
+
self._pubsub.publish(TOPICS["risk_envelope"], envelope)
|
| 123 |
|
| 124 |
def _build_regime_change_event(
|
| 125 |
self,
|
|
|
|
| 138 |
f"worst_drop_pct={worst_drop_pct:.4f};"
|
| 139 |
f"high_severity_count={high_severity_count}"
|
| 140 |
),
|
| 141 |
+
"confidence": regime_confidence(to_regime),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
}
|
src/core/risk_policy.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import uuid
|
| 3 |
+
|
| 4 |
+
from .config import RISK_ENVELOPE_POLICY
|
| 5 |
+
from .schemas import RiskEnvelope
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def regime_confidence(regime: str) -> float:
|
| 9 |
+
return _policy_for(regime)["confidence"]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def build_envelope(regime: str, universe_id: str) -> RiskEnvelope:
|
| 13 |
+
policy = _policy_for(regime)
|
| 14 |
+
now_ms = int(time.time() * 1000)
|
| 15 |
+
return {
|
| 16 |
+
"envelope_id": str(uuid.uuid4()),
|
| 17 |
+
"universe_id": universe_id,
|
| 18 |
+
"allowed": policy["allowed"],
|
| 19 |
+
"max_position_usd": policy["max_position_usd"],
|
| 20 |
+
"max_order_usd": policy["max_order_usd"],
|
| 21 |
+
"max_leverage": policy["max_leverage"],
|
| 22 |
+
"max_turnover_per_hour": policy["max_turnover_per_hour"],
|
| 23 |
+
"cooldown_seconds": policy["cooldown_seconds"],
|
| 24 |
+
"regime": regime,
|
| 25 |
+
"confidence": policy["confidence"],
|
| 26 |
+
"reason": policy["reason"],
|
| 27 |
+
"valid_from_ms": now_ms,
|
| 28 |
+
"valid_until_ms": now_ms + policy["cooldown_seconds"] * 1000,
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _policy_for(regime: str) -> dict:
|
| 33 |
+
return RISK_ENVELOPE_POLICY.get(regime, RISK_ENVELOPE_POLICY["normal"])
|
src/core/schemas.py
CHANGED
|
@@ -51,3 +51,83 @@ class DataGapEvent(TypedDict):
|
|
| 51 |
gap_size: int
|
| 52 |
sequence_from: int
|
| 53 |
sequence_to: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
gap_size: int
|
| 52 |
sequence_from: int
|
| 53 |
sequence_to: int
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class UniverseChanges(TypedDict):
|
| 57 |
+
added: List[str]
|
| 58 |
+
removed: List[str]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class UniverseUpdatedEvent(TypedDict):
|
| 62 |
+
event_id: str
|
| 63 |
+
ts: int
|
| 64 |
+
asset_class: str
|
| 65 |
+
venue: str
|
| 66 |
+
universe_version: str
|
| 67 |
+
tickers: List[str]
|
| 68 |
+
changes: UniverseChanges
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class PricePoint(TypedDict):
|
| 72 |
+
ticker: str
|
| 73 |
+
price: float
|
| 74 |
+
ts_price: int
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class PriceSnapshotEvent(TypedDict):
|
| 78 |
+
event_id: str
|
| 79 |
+
ts: int
|
| 80 |
+
sequence_id: int
|
| 81 |
+
asset_class: str
|
| 82 |
+
venue: str
|
| 83 |
+
is_delayed: bool
|
| 84 |
+
prices: List[PricePoint]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class PriceDeltaEvent(TypedDict):
|
| 88 |
+
event_id: str
|
| 89 |
+
ts: int
|
| 90 |
+
ticker: str
|
| 91 |
+
asset_class: str
|
| 92 |
+
venue: str
|
| 93 |
+
timeframe: str
|
| 94 |
+
price_start: float
|
| 95 |
+
price_now: float
|
| 96 |
+
delta_abs: float
|
| 97 |
+
delta_pct: float
|
| 98 |
+
velocity_pct_per_min: float
|
| 99 |
+
window_points: int
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class RiskEvent(TypedDict):
|
| 103 |
+
event_id: str
|
| 104 |
+
ts: int
|
| 105 |
+
ticker: str
|
| 106 |
+
asset_class: str
|
| 107 |
+
venue: str
|
| 108 |
+
timeframe: str
|
| 109 |
+
delta_pct: float
|
| 110 |
+
threshold: float
|
| 111 |
+
threshold_type: str
|
| 112 |
+
severity: str
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class AlertEvent(TypedDict):
|
| 116 |
+
event_id: str
|
| 117 |
+
ts: int
|
| 118 |
+
ticker: str
|
| 119 |
+
asset_class: str
|
| 120 |
+
venue: str
|
| 121 |
+
timeframe: str
|
| 122 |
+
alert_type: str
|
| 123 |
+
delta_pct: float
|
| 124 |
+
threshold: float
|
| 125 |
+
cooldown_sec: int
|
| 126 |
+
destination: str
|
| 127 |
+
message: str
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class CycleCompletedEvent(TypedDict):
|
| 131 |
+
event_id: str
|
| 132 |
+
ts: int
|
| 133 |
+
cycle: int
|
src/core/telemetry.py
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
|
|
| 1 |
import time
|
| 2 |
from collections import defaultdict
|
| 3 |
from typing import Dict
|
| 4 |
|
| 5 |
|
|
|
|
|
|
|
|
|
|
| 6 |
class Telemetry:
|
| 7 |
def __init__(self, report_interval_sec: int = 120) -> None:
|
| 8 |
self._counters = defaultdict(int)
|
|
@@ -34,7 +38,7 @@ class Telemetry:
|
|
| 34 |
if not self._counters:
|
| 35 |
return
|
| 36 |
summary = " ".join(f"{k}={v}" for k, v in sorted(self._counters.items()))
|
| 37 |
-
|
| 38 |
|
| 39 |
def _maybe_report(self) -> None:
|
| 40 |
now = time.time()
|
|
|
|
| 1 |
+
import logging
|
| 2 |
import time
|
| 3 |
from collections import defaultdict
|
| 4 |
from typing import Dict
|
| 5 |
|
| 6 |
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
class Telemetry:
|
| 11 |
def __init__(self, report_interval_sec: int = 120) -> None:
|
| 12 |
self._counters = defaultdict(int)
|
|
|
|
| 38 |
if not self._counters:
|
| 39 |
return
|
| 40 |
summary = " ".join(f"{k}={v}" for k, v in sorted(self._counters.items()))
|
| 41 |
+
logger.info("[TELEMETRY] %s", summary)
|
| 42 |
|
| 43 |
def _maybe_report(self) -> None:
|
| 44 |
now = time.time()
|
src/core/topics.py
CHANGED
|
@@ -16,6 +16,7 @@ _DEFAULT_TOPICS: Dict[str, str] = {
|
|
| 16 |
"risk_envelope": "risk.envelope.updated",
|
| 17 |
"data_gap": "data.gap.detected",
|
| 18 |
"alert_ready": "system.alert.ready",
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
|
|
|
|
| 16 |
"risk_envelope": "risk.envelope.updated",
|
| 17 |
"data_gap": "data.gap.detected",
|
| 18 |
"alert_ready": "system.alert.ready",
|
| 19 |
+
"cycle_completed": "system.cycle.completed",
|
| 20 |
}
|
| 21 |
|
| 22 |
|
src/core/universe_loader.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from typing import Dict, List, Set
|
| 2 |
import time
|
| 3 |
import uuid
|
| 4 |
|
|
@@ -11,13 +11,22 @@ from .config import (
|
|
| 11 |
BINANCE_EXCHANGE_INFO,
|
| 12 |
COMMODITY_TICKERS,
|
| 13 |
)
|
| 14 |
-
from .schemas import UniverseSnapshot, Instrument
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
class UniverseLoader:
|
| 18 |
def __init__(self, timeout_sec: int = 20) -> None:
|
| 19 |
self._version = "v1"
|
| 20 |
self._timeout = timeout_sec
|
|
|
|
| 21 |
|
| 22 |
def load(self) -> Dict[str, List[str]]:
|
| 23 |
us = self._load_us_equities()
|
|
@@ -32,7 +41,22 @@ class UniverseLoader:
|
|
| 32 |
"commodities": commodities,
|
| 33 |
}
|
| 34 |
|
| 35 |
-
def build_event(self, asset_class: str, venue: str, tickers: List[str]) ->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
return {
|
| 37 |
"event_id": str(uuid.uuid4()),
|
| 38 |
"ts": int(time.time() * 1000),
|
|
@@ -40,13 +64,13 @@ class UniverseLoader:
|
|
| 40 |
"venue": venue,
|
| 41 |
"universe_version": self._version,
|
| 42 |
"tickers": tickers,
|
| 43 |
-
"changes": {"added":
|
| 44 |
}
|
| 45 |
|
| 46 |
def build_snapshot(self, universe: Dict[str, List[str]]) -> UniverseSnapshot:
|
| 47 |
instruments: List[Instrument] = []
|
| 48 |
for venue_key, tickers in universe.items():
|
| 49 |
-
asset_class =
|
| 50 |
for ticker in tickers:
|
| 51 |
instruments.append(
|
| 52 |
{
|
|
@@ -125,14 +149,6 @@ class UniverseLoader:
|
|
| 125 |
return tickers
|
| 126 |
return tickers
|
| 127 |
|
| 128 |
-
@staticmethod
|
| 129 |
-
def _infer_asset_class(venue_key: str) -> str:
|
| 130 |
-
if "crypto" in venue_key:
|
| 131 |
-
return "crypto"
|
| 132 |
-
if "commodities" in venue_key:
|
| 133 |
-
return "commodity"
|
| 134 |
-
return "equity"
|
| 135 |
-
|
| 136 |
@staticmethod
|
| 137 |
def _base_asset(symbol: str, asset_class: str) -> str:
|
| 138 |
if asset_class == "crypto" and "-" in symbol:
|
|
|
|
| 1 |
+
from typing import Dict, List, Optional, Set
|
| 2 |
import time
|
| 3 |
import uuid
|
| 4 |
|
|
|
|
| 11 |
BINANCE_EXCHANGE_INFO,
|
| 12 |
COMMODITY_TICKERS,
|
| 13 |
)
|
| 14 |
+
from .schemas import UniverseSnapshot, Instrument, UniverseUpdatedEvent
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def infer_asset_class(venue_key: str) -> str:
|
| 18 |
+
if "crypto" in venue_key:
|
| 19 |
+
return "crypto"
|
| 20 |
+
if "commodities" in venue_key:
|
| 21 |
+
return "commodity"
|
| 22 |
+
return "equity"
|
| 23 |
|
| 24 |
|
| 25 |
class UniverseLoader:
|
| 26 |
def __init__(self, timeout_sec: int = 20) -> None:
|
| 27 |
self._version = "v1"
|
| 28 |
self._timeout = timeout_sec
|
| 29 |
+
self._prev: Dict[str, Set[str]] = {}
|
| 30 |
|
| 31 |
def load(self) -> Dict[str, List[str]]:
|
| 32 |
us = self._load_us_equities()
|
|
|
|
| 41 |
"commodities": commodities,
|
| 42 |
}
|
| 43 |
|
| 44 |
+
def build_event(self, asset_class: str, venue: str, tickers: List[str]) -> Optional[UniverseUpdatedEvent]:
|
| 45 |
+
"""Diff against the previously seen universe for this venue.
|
| 46 |
+
|
| 47 |
+
Returns None when nothing changed, so `market.universe.updated` only
|
| 48 |
+
fires on actual membership changes (the first call always fires with
|
| 49 |
+
everything marked as added).
|
| 50 |
+
"""
|
| 51 |
+
current = set(tickers)
|
| 52 |
+
previous = self._prev.get(venue)
|
| 53 |
+
self._prev[venue] = current
|
| 54 |
+
|
| 55 |
+
if previous is not None and current == previous:
|
| 56 |
+
return None
|
| 57 |
+
|
| 58 |
+
added = sorted(current - previous) if previous is not None else sorted(current)
|
| 59 |
+
removed = sorted(previous - current) if previous is not None else []
|
| 60 |
return {
|
| 61 |
"event_id": str(uuid.uuid4()),
|
| 62 |
"ts": int(time.time() * 1000),
|
|
|
|
| 64 |
"venue": venue,
|
| 65 |
"universe_version": self._version,
|
| 66 |
"tickers": tickers,
|
| 67 |
+
"changes": {"added": added, "removed": removed},
|
| 68 |
}
|
| 69 |
|
| 70 |
def build_snapshot(self, universe: Dict[str, List[str]]) -> UniverseSnapshot:
|
| 71 |
instruments: List[Instrument] = []
|
| 72 |
for venue_key, tickers in universe.items():
|
| 73 |
+
asset_class = infer_asset_class(venue_key)
|
| 74 |
for ticker in tickers:
|
| 75 |
instruments.append(
|
| 76 |
{
|
|
|
|
| 149 |
return tickers
|
| 150 |
return tickers
|
| 151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
@staticmethod
|
| 153 |
def _base_asset(symbol: str, asset_class: str) -> str:
|
| 154 |
if asset_class == "crypto" and "-" in symbol:
|
uv.lock
CHANGED
|
@@ -129,6 +129,15 @@ wheels = [
|
|
| 129 |
{ url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
|
| 130 |
]
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
[[package]]
|
| 133 |
name = "curl-cffi"
|
| 134 |
version = "0.15.0"
|
|
@@ -154,6 +163,18 @@ wheels = [
|
|
| 154 |
{ url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" },
|
| 155 |
]
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
[[package]]
|
| 158 |
name = "idna"
|
| 159 |
version = "3.18"
|
|
@@ -163,6 +184,15 @@ wheels = [
|
|
| 163 |
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
| 164 |
]
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
[[package]]
|
| 167 |
name = "lxml"
|
| 168 |
version = "6.1.1"
|
|
@@ -255,6 +285,11 @@ dependencies = [
|
|
| 255 |
{ name = "yfinance" },
|
| 256 |
]
|
| 257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
[package.metadata]
|
| 259 |
requires-dist = [
|
| 260 |
{ name = "lxml" },
|
|
@@ -265,6 +300,9 @@ requires-dist = [
|
|
| 265 |
{ name = "yfinance" },
|
| 266 |
]
|
| 267 |
|
|
|
|
|
|
|
|
|
|
| 268 |
[[package]]
|
| 269 |
name = "mdurl"
|
| 270 |
version = "0.1.2"
|
|
@@ -394,6 +432,15 @@ wheels = [
|
|
| 394 |
{ url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" },
|
| 395 |
]
|
| 396 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
[[package]]
|
| 398 |
name = "pandas"
|
| 399 |
version = "2.3.3"
|
|
@@ -488,6 +535,15 @@ wheels = [
|
|
| 488 |
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
|
| 489 |
]
|
| 490 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
[[package]]
|
| 492 |
name = "protobuf"
|
| 493 |
version = "7.35.1"
|
|
@@ -521,6 +577,24 @@ wheels = [
|
|
| 521 |
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
| 522 |
]
|
| 523 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
[[package]]
|
| 525 |
name = "python-dateutil"
|
| 526 |
version = "2.9.0.post0"
|
|
@@ -624,6 +698,33 @@ wheels = [
|
|
| 624 |
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
|
| 625 |
]
|
| 626 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 627 |
[[package]]
|
| 628 |
name = "typing-extensions"
|
| 629 |
version = "4.16.0"
|
|
|
|
| 129 |
{ url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
|
| 130 |
]
|
| 131 |
|
| 132 |
+
[[package]]
|
| 133 |
+
name = "colorama"
|
| 134 |
+
version = "0.4.6"
|
| 135 |
+
source = { registry = "https://pypi.org/simple" }
|
| 136 |
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
| 137 |
+
wheels = [
|
| 138 |
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
| 139 |
+
]
|
| 140 |
+
|
| 141 |
[[package]]
|
| 142 |
name = "curl-cffi"
|
| 143 |
version = "0.15.0"
|
|
|
|
| 163 |
{ url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" },
|
| 164 |
]
|
| 165 |
|
| 166 |
+
[[package]]
|
| 167 |
+
name = "exceptiongroup"
|
| 168 |
+
version = "1.3.1"
|
| 169 |
+
source = { registry = "https://pypi.org/simple" }
|
| 170 |
+
dependencies = [
|
| 171 |
+
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
| 172 |
+
]
|
| 173 |
+
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
| 174 |
+
wheels = [
|
| 175 |
+
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
| 176 |
+
]
|
| 177 |
+
|
| 178 |
[[package]]
|
| 179 |
name = "idna"
|
| 180 |
version = "3.18"
|
|
|
|
| 184 |
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
| 185 |
]
|
| 186 |
|
| 187 |
+
[[package]]
|
| 188 |
+
name = "iniconfig"
|
| 189 |
+
version = "2.3.0"
|
| 190 |
+
source = { registry = "https://pypi.org/simple" }
|
| 191 |
+
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
| 192 |
+
wheels = [
|
| 193 |
+
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
| 194 |
+
]
|
| 195 |
+
|
| 196 |
[[package]]
|
| 197 |
name = "lxml"
|
| 198 |
version = "6.1.1"
|
|
|
|
| 285 |
{ name = "yfinance" },
|
| 286 |
]
|
| 287 |
|
| 288 |
+
[package.dev-dependencies]
|
| 289 |
+
dev = [
|
| 290 |
+
{ name = "pytest" },
|
| 291 |
+
]
|
| 292 |
+
|
| 293 |
[package.metadata]
|
| 294 |
requires-dist = [
|
| 295 |
{ name = "lxml" },
|
|
|
|
| 300 |
{ name = "yfinance" },
|
| 301 |
]
|
| 302 |
|
| 303 |
+
[package.metadata.requires-dev]
|
| 304 |
+
dev = [{ name = "pytest", specifier = ">=9.1.1" }]
|
| 305 |
+
|
| 306 |
[[package]]
|
| 307 |
name = "mdurl"
|
| 308 |
version = "0.1.2"
|
|
|
|
| 432 |
{ url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" },
|
| 433 |
]
|
| 434 |
|
| 435 |
+
[[package]]
|
| 436 |
+
name = "packaging"
|
| 437 |
+
version = "26.2"
|
| 438 |
+
source = { registry = "https://pypi.org/simple" }
|
| 439 |
+
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
| 440 |
+
wheels = [
|
| 441 |
+
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
| 442 |
+
]
|
| 443 |
+
|
| 444 |
[[package]]
|
| 445 |
name = "pandas"
|
| 446 |
version = "2.3.3"
|
|
|
|
| 535 |
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
|
| 536 |
]
|
| 537 |
|
| 538 |
+
[[package]]
|
| 539 |
+
name = "pluggy"
|
| 540 |
+
version = "1.6.0"
|
| 541 |
+
source = { registry = "https://pypi.org/simple" }
|
| 542 |
+
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
| 543 |
+
wheels = [
|
| 544 |
+
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
| 545 |
+
]
|
| 546 |
+
|
| 547 |
[[package]]
|
| 548 |
name = "protobuf"
|
| 549 |
version = "7.35.1"
|
|
|
|
| 577 |
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
| 578 |
]
|
| 579 |
|
| 580 |
+
[[package]]
|
| 581 |
+
name = "pytest"
|
| 582 |
+
version = "9.1.1"
|
| 583 |
+
source = { registry = "https://pypi.org/simple" }
|
| 584 |
+
dependencies = [
|
| 585 |
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
| 586 |
+
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
| 587 |
+
{ name = "iniconfig" },
|
| 588 |
+
{ name = "packaging" },
|
| 589 |
+
{ name = "pluggy" },
|
| 590 |
+
{ name = "pygments" },
|
| 591 |
+
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
| 592 |
+
]
|
| 593 |
+
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
| 594 |
+
wheels = [
|
| 595 |
+
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
| 596 |
+
]
|
| 597 |
+
|
| 598 |
[[package]]
|
| 599 |
name = "python-dateutil"
|
| 600 |
version = "2.9.0.post0"
|
|
|
|
| 698 |
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
|
| 699 |
]
|
| 700 |
|
| 701 |
+
[[package]]
|
| 702 |
+
name = "tomli"
|
| 703 |
+
version = "2.4.1"
|
| 704 |
+
source = { registry = "https://pypi.org/simple" }
|
| 705 |
+
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
| 706 |
+
wheels = [
|
| 707 |
+
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
| 708 |
+
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
| 709 |
+
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
| 710 |
+
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
| 711 |
+
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
| 712 |
+
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
| 713 |
+
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
| 714 |
+
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
| 715 |
+
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
| 716 |
+
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
| 717 |
+
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
| 718 |
+
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
| 719 |
+
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
| 720 |
+
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
| 721 |
+
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
| 722 |
+
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
| 723 |
+
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
| 724 |
+
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
| 725 |
+
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
| 726 |
+
]
|
| 727 |
+
|
| 728 |
[[package]]
|
| 729 |
name = "typing-extensions"
|
| 730 |
version = "4.16.0"
|