Dmitry Beresnev commited on
Commit
e4df689
·
1 Parent(s): debae55

refactor: composition root, per-cycle regime, tests, docs

Browse files

Aggregate 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 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. Main Runtime Loop
22
 
23
- At startup (`main.py`):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  1. Build shared components (`PubSub`, `RiskEngine`, `DataGapDetector`, `AlertQueue`, `Telemetry`, `RiskEnvelopeStore`).
25
- 2. Register topic subscriptions.
26
- 3. Load universe once and publish `market.universe.snapshot`.
27
  4. Enter infinite loop with `POLL_INTERVAL_SEC`.
28
 
29
  Per polling iteration (`run_once`):
30
- 1. Publish `market.universe.updated` for each venue bucket.
31
- 2. Fetch prices for that venue through `PriceIngestor`.
32
- 3. Publish `market.prices.snapshot`.
33
- 4. Process pending alert queue retries.
 
34
 
35
- ## 3. Components and Responsibilities
36
 
37
- ### 3.1 Universe Loader
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
- ### 3.2 Price Ingestor
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
- ### 3.3 Retry Utility
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
- ### 3.4 Risk Engine
73
  File: `src/core/risk_engine.py`
74
 
75
  Responsibilities:
76
  - maintain rolling price windows (`PriceBuffer`)
77
- - compute per-timeframe deltas (`Price Delta Calculator`)
78
- - detect threshold breaches and severity
79
- - emit per-ticker risk events and alerts
80
- - maintain and update regime state machine
81
- - publish regime transitions separately from envelope updates
 
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
- ### 3.5 Regime State Machine
 
 
 
91
  File: `src/core/risk_regime.py`
92
 
93
  States:
@@ -96,15 +178,16 @@ States:
96
  - `stress`
97
  - `panic`
98
 
99
- Inputs per snapshot:
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
- ### 3.6 Data Gap Detector
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
- ### 3.7 Alerting
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
- ### 3.8 Telemetry
131
  File: `src/core/telemetry.py`
132
 
133
  Responsibilities:
134
  - count events by class and key dimensions
135
- - periodic stdout report every `TELEMETRY_REPORT_INTERVAL_SEC`
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
- ## 4. Topic Taxonomy
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
- ## 5. Event Contracts
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
- ## 6. Subscription Graph
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
- ## 7. Risk Envelope Semantics
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
- ## 8. Data Gap Handling
211
 
212
  Data gaps are inferred from `sequence_id` discontinuity in snapshots.
213
 
214
  Current assumptions:
215
- - one global sequence per `PriceIngestor` process
216
  - gap is tracked per ticker
217
 
218
  Limitations:
219
  - no persistent sequence state across restarts
220
  - no exchange-native sequence reconciliation
221
 
222
- ## 9. Reliability and Retries
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
- ## 10. Configuration
 
 
 
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
- ## 11. Known Limitations
247
 
248
  - In-memory pub/sub and stores are process-local only.
249
- - Universe is loaded once at startup (no periodic refresh yet).
250
- - Telemetry outputs to stdout only (no external sink).
251
  - No durable queue for alerts.
252
  - No persistent event/audit storage.
253
 
254
- ## 12. Run and Verify
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 printed periodically.
268
 
269
- ## 13. Detailed Sequence Diagram (ASCII)
270
 
271
  ```text
272
  +====================================================================================================+
@@ -294,35 +395,37 @@ Actors:
294
  | |--on_universe_snapshot---------->| | |
295
  | | | | |
296
  |== loop ================================================================================================> |
 
297
  |--for each venue--------------------------------------------------------------------------------------------|
298
- |--build_event------->| | | | | | | |
299
- |------------------------------publish market.universe.updated---------------------------------------------->|
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/regime |
307
  | |--publish market.price.delta----------------------------->|
308
  | |--publish risk.event.detected---------------------------->|
309
  | | |--on_risk_event
310
- | |--publish system.alert.ready----------------------------->|
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
- | |--publish system.alert.ready(data_gap)-------------------->|
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
- ## 14. Regime Transition Diagram (ASCII)
334
 
335
  ```text
336
  +====================================================================================================+
337
  | RISK REGIME TRANSITION LOGIC |
338
  +====================================================================================================+
339
 
340
- Inputs per snapshot:
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 (default 3)
 
 
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
- ## 15. Failure, Retry, and Degradation Flows (ASCII)
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
- ## 16. System Overview Diagram (ASCII)
407
 
408
  ```text
409
  +====================================================================================================+
@@ -423,9 +533,10 @@ On each regime change:
423
  +---------------------+ +---------------------+ +-----------------------+
424
 
425
  +---------------------+ +---------------------+ +-----------------------+
426
- | Build runtime graph | -----> | PubSub subscriptions| -----> | Service ready |
427
- | (main.py) | | (handlers) | | polling loop starts |
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 | | retry_utils.call_with_backoff |
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
- +-------------------------+ v
463
- | topic: data.gap.detected
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: system.alert.ready --------> AlertQueue.enqueue
 
 
 
 
 
 
 
 
 
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 (limits by regime)
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 MIN_ALERT_GAP_MIN, TELEGRAM_BOT_TOKEN_ENV, TELEGRAM_CHAT_ID_ENV
 
 
 
 
 
10
  from .retry_utils import backoff_delay
11
 
12
 
13
- class AlertDeduplicator:
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
- print(alert["message"])
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
- print(alert["message"])
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 TelegramNotifier, AlertQueue, build_data_gap_alert
 
 
 
 
 
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
- def run_once(pubsub, loader, universe, queue, notifier, ingestors):
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
- loader = UniverseLoader()
65
- universe = loader.load()
66
- snapshot = loader.build_snapshot(universe)
67
- pubsub.publish(TOPICS["universe_snapshot"], snapshot)
68
-
69
- ingestors = {
70
- venue_key: PriceIngestor(venue=venue_key, asset_class=_infer_asset_class(venue_key))
71
- for venue_key in universe
72
- }
73
-
74
- return {
75
- "pubsub": pubsub,
76
- "notifier": notifier,
77
- "queue": queue,
78
- "loader": loader,
79
- "universe": universe,
80
- "ingestors": ingestors,
81
- }
82
-
83
-
84
- def _run_cycle(state):
85
- run_once(
86
- state["pubsub"],
87
- state["loader"],
88
- state["universe"],
89
- state["queue"],
90
- state["notifier"],
91
- state["ingestors"],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  )
 
93
 
94
 
95
- def run_single_cycle(runtime=None):
96
- state = runtime or build_runtime()
97
- _run_cycle(state)
98
- return state
99
 
100
 
101
- def run_forever():
102
- state = build_runtime()
 
 
 
 
103
  while True:
104
- _run_cycle(state)
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 RiskEnvelope, RiskRegimeChangeEvent
 
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
- worst_drop = min(worst_drop, delta_pct)
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
- high_severity_count += 1
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
- alert = build_alert(
92
- ticker=ticker,
93
- asset_class=asset_class,
94
- venue=venue,
95
- timeframe=timeframe,
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
- regime_event = self._build_regime_change_event(
108
- from_regime=self._last_regime,
109
- to_regime=regime,
110
- worst_drop_pct=worst_drop,
111
- high_severity_count=high_severity_count,
112
- )
113
- self._pubsub.publish(TOPICS["risk_regime_change"], regime_event)
114
- self._last_regime = regime
115
-
116
- envelope = self._build_envelope(regime=regime)
117
- self._envelope_store.set(envelope)
118
- self._pubsub.publish(TOPICS["risk_envelope"], envelope)
 
 
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": self._regime_confidence(to_regime),
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
- print(f"[TELEMETRY] {summary}")
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]) -> Dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": tickers, "removed": []},
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 = self._infer_asset_class(venue_key)
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"