mdrap 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
analytics.py ADDED
@@ -0,0 +1,200 @@
1
+ """
2
+ Analytical Storage & Query Engine module for MDRAP V3.
3
+ Implements Spec §14 — Analytical query and aggregation engine for market data.
4
+ """
5
+
6
+ import math
7
+ from typing import Dict, List, Tuple
8
+
9
+ from models import CanonicalEvent, EventType, QualityStatus
10
+
11
+
12
+ class OHLCVAggregator:
13
+ def __init__(self, interval_s: float = 5.0):
14
+ self.interval_s = interval_s
15
+ # Dict key: (instrument_id, bucket_start)
16
+ # Value: dict holding the raw OHLCV calculations
17
+ self._buckets: Dict[Tuple[str, float], dict] = {}
18
+
19
+ def observe(self, event: CanonicalEvent) -> None:
20
+ if event.event_type != EventType.TRADE or event.price is None:
21
+ return
22
+ if event.quality_status == QualityStatus.INVALID:
23
+ return
24
+
25
+ bucket_start = float(int(event.exchange_timestamp // self.interval_s) * self.interval_s)
26
+ key = (event.instrument_id, bucket_start)
27
+
28
+ if key not in self._buckets:
29
+ self._buckets[key] = {
30
+ "instrument_id": event.instrument_id,
31
+ "bucket_start": bucket_start,
32
+ "interval_s": self.interval_s,
33
+ "open": event.price,
34
+ "high": event.price,
35
+ "low": event.price,
36
+ "close": event.price,
37
+ "volume": event.quantity if event.quantity is not None else 0.0,
38
+ "event_count": 1,
39
+ "_first_ts": event.exchange_timestamp,
40
+ "_last_ts": event.exchange_timestamp
41
+ }
42
+ else:
43
+ b = self._buckets[key]
44
+ first_ts = b.get("_first_ts", b.get("bucket_start", 0.0))
45
+ last_ts = b.get("_last_ts", b.get("bucket_start", 0.0))
46
+ if event.exchange_timestamp < first_ts:
47
+ b["open"] = event.price
48
+ b["_first_ts"] = event.exchange_timestamp
49
+ if event.exchange_timestamp >= last_ts:
50
+ b["close"] = event.price
51
+ b["_last_ts"] = event.exchange_timestamp
52
+
53
+ b["high"] = max(b["high"], event.price)
54
+ b["low"] = min(b["low"], event.price)
55
+ if event.quantity is not None:
56
+ b["volume"] += event.quantity
57
+ b["event_count"] += 1
58
+
59
+ def _format_candle(self, b: dict) -> dict:
60
+ return {
61
+ "instrument_id": b["instrument_id"],
62
+ "bucket_start": b["bucket_start"],
63
+ "interval_s": b["interval_s"],
64
+ "open": b["open"],
65
+ "high": b["high"],
66
+ "low": b["low"],
67
+ "close": b["close"],
68
+ "volume": b["volume"],
69
+ "event_count": b["event_count"]
70
+ }
71
+
72
+ def candles(self) -> list[dict]:
73
+ sorted_keys = sorted(self._buckets.keys())
74
+ return [self._format_candle(self._buckets[k]) for k in sorted_keys]
75
+
76
+ def candles_for(self, instrument_id: str) -> list[dict]:
77
+ res = []
78
+ for k in sorted(self._buckets.keys()):
79
+ if k[0] == instrument_id:
80
+ res.append(self._format_candle(self._buckets[k]))
81
+ return res
82
+
83
+
84
+ class SpreadAnalyzer:
85
+ def __init__(self):
86
+ self._stats: Dict[str, dict] = {}
87
+
88
+ def observe(self, event: CanonicalEvent) -> None:
89
+ if event.event_type != EventType.QUOTE or event.bid_price is None or event.ask_price is None:
90
+ return
91
+ if not math.isfinite(event.bid_price) or not math.isfinite(event.ask_price):
92
+ return
93
+
94
+ spread = event.ask_price - event.bid_price
95
+ crossed = 1 if event.bid_price > event.ask_price else 0
96
+
97
+ if event.instrument_id not in self._stats:
98
+ self._stats[event.instrument_id] = {
99
+ "quote_count": 1,
100
+ "sum_spread": spread,
101
+ "min_spread": spread,
102
+ "max_spread": spread,
103
+ "crossed_count": crossed
104
+ }
105
+ else:
106
+ s = self._stats[event.instrument_id]
107
+ s["quote_count"] += 1
108
+ s["sum_spread"] += spread
109
+ s["min_spread"] = min(s["min_spread"], spread)
110
+ s["max_spread"] = max(s["max_spread"], spread)
111
+ s["crossed_count"] += crossed
112
+
113
+ def summary(self) -> list[dict]:
114
+ res = []
115
+ for instr, s in self._stats.items():
116
+ count = s["quote_count"]
117
+ mean_spread = s["sum_spread"] / count if count > 0 else 0.0
118
+ crossed_pct = (s["crossed_count"] / count * 100) if count > 0 else 0.0
119
+ res.append({
120
+ "instrument_id": instr,
121
+ "quote_count": count,
122
+ "mean_spread": mean_spread,
123
+ "min_spread": s["min_spread"],
124
+ "max_spread": s["max_spread"],
125
+ "crossed_count": s["crossed_count"],
126
+ "crossed_pct": crossed_pct
127
+ })
128
+ return res
129
+
130
+
131
+ class VolatilityTracker:
132
+ def __init__(self, window: int = 100):
133
+ self.window = window
134
+ self._stats: Dict[str, dict] = {}
135
+
136
+ def observe(self, event: CanonicalEvent) -> None:
137
+ if event.event_type != EventType.TRADE or event.price is None:
138
+ return
139
+ if not math.isfinite(event.price) or event.price <= 0:
140
+ return
141
+
142
+ p = event.price
143
+
144
+ instr = event.instrument_id
145
+
146
+ if instr not in self._stats:
147
+ self._stats[instr] = {
148
+ "count": 1,
149
+ "mean": p,
150
+ "M2": 0.0,
151
+ "min_price": p,
152
+ "max_price": p
153
+ }
154
+ else:
155
+ s = self._stats[instr]
156
+ s["count"] += 1
157
+ delta = p - s["mean"]
158
+ s["mean"] += delta / s["count"]
159
+ delta2 = p - s["mean"]
160
+ s["M2"] += delta * delta2
161
+ s["min_price"] = min(s["min_price"], p)
162
+ s["max_price"] = max(s["max_price"], p)
163
+
164
+ def summary(self) -> list[dict]:
165
+ res = []
166
+ for instr, s in self._stats.items():
167
+ count = s["count"]
168
+ mean = s["mean"]
169
+ std_dev = math.sqrt(s["M2"] / count) if count > 0 else 0.0
170
+ price_range_pct = ((s["max_price"] - s["min_price"]) / mean * 100) if mean > 0 else 0.0
171
+
172
+ res.append({
173
+ "instrument_id": instr,
174
+ "trade_count": count,
175
+ "mean_price": mean,
176
+ "std_dev": std_dev,
177
+ "min_price": s["min_price"],
178
+ "max_price": s["max_price"],
179
+ "price_range_pct": price_range_pct
180
+ })
181
+ return res
182
+
183
+
184
+ class MarketAnalytics:
185
+ def __init__(self, ohlcv_interval_s: float = 5.0, volatility_window: int = 100):
186
+ self.ohlcv = OHLCVAggregator(interval_s=ohlcv_interval_s)
187
+ self.spreads = SpreadAnalyzer()
188
+ self.volatility = VolatilityTracker(window=volatility_window)
189
+
190
+ def observe(self, event: CanonicalEvent) -> None:
191
+ self.ohlcv.observe(event)
192
+ self.spreads.observe(event)
193
+ self.volatility.observe(event)
194
+
195
+ def full_summary(self) -> dict:
196
+ return {
197
+ "ohlcv": self.ohlcv.candles(),
198
+ "spreads": self.spreads.summary(),
199
+ "volatility": self.volatility.summary()
200
+ }
archive.py ADDED
@@ -0,0 +1,154 @@
1
+ """
2
+ Immutable Raw Event Archive module.
3
+
4
+ Implements Spec §6.10 and §19 — immutable raw event persistence.
5
+ This is a write-ahead JSONL archive that captures every raw event BEFORE processing,
6
+ partitioned by date and source.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import time
12
+ from typing import Iterator, Optional, Any, Dict, List
13
+
14
+ from models import RawEvent
15
+
16
+
17
+ class RawArchive:
18
+ def __init__(self, base_dir: str = 'data/raw_archive', buffer_size: int = 500):
19
+ self.base_dir = base_dir
20
+ self.buffer_size = buffer_size
21
+ self._buffer: list[RawEvent] = []
22
+ self._file_handles: dict[str, Any] = {}
23
+
24
+ def __enter__(self):
25
+ return self
26
+
27
+ def __exit__(self, exc_type, exc_val, exc_tb):
28
+ self.close()
29
+
30
+ def write(self, raw: RawEvent) -> None:
31
+ self._buffer.append(raw)
32
+ if len(self._buffer) >= self.buffer_size:
33
+ self.flush()
34
+
35
+ def flush(self) -> None:
36
+ if not self._buffer:
37
+ return
38
+
39
+ grouped: dict[str, list[str]] = {}
40
+ for raw in self._buffer:
41
+ # Format receive_timestamp to YYYY-MM-DD for partitioning
42
+ date_str = time.strftime('%Y-%m-%d', time.gmtime(raw.receive_timestamp))
43
+ dir_path = os.path.join(self.base_dir, date_str)
44
+ file_path = os.path.join(dir_path, f"{raw.source}.jsonl")
45
+
46
+ os.makedirs(dir_path, exist_ok=True)
47
+
48
+ if file_path not in grouped:
49
+ grouped[file_path] = []
50
+
51
+ # Line is a JSON object
52
+ line = json.dumps({
53
+ "raw_id": raw.raw_id,
54
+ "source": raw.source,
55
+ "payload": raw.payload,
56
+ "receive_timestamp": raw.receive_timestamp
57
+ })
58
+ grouped[file_path].append(line)
59
+
60
+ for file_path, lines in grouped.items():
61
+ if file_path not in self._file_handles:
62
+ self._file_handles[file_path] = open(file_path, 'a', encoding='utf-8')
63
+
64
+ handle = self._file_handles[file_path]
65
+ for line in lines:
66
+ handle.write(line + '\n')
67
+ handle.flush()
68
+
69
+ self._buffer.clear()
70
+
71
+ def close(self) -> None:
72
+ self.flush()
73
+ for handle in self._file_handles.values():
74
+ handle.close()
75
+ self._file_handles.clear()
76
+
77
+ def stats(self) -> dict:
78
+ total_events = 0
79
+ dates = set()
80
+ sources = set()
81
+ size_bytes = 0
82
+
83
+ if not os.path.exists(self.base_dir):
84
+ return {
85
+ "total_events": 0,
86
+ "dates": [],
87
+ "sources": [],
88
+ "size_bytes": 0
89
+ }
90
+
91
+ for root, _, files in os.walk(self.base_dir):
92
+ for file in files:
93
+ if file.endswith('.jsonl'):
94
+ file_path = os.path.join(root, file)
95
+ date_dir = os.path.basename(root)
96
+ dates.add(date_dir)
97
+
98
+ source = file[:-6] # remove .jsonl
99
+ sources.add(source)
100
+
101
+ size_bytes += os.path.getsize(file_path)
102
+
103
+ # count lines
104
+ with open(file_path, 'r', encoding='utf-8') as f:
105
+ for _ in f:
106
+ total_events += 1
107
+
108
+ return {
109
+ "total_events": total_events,
110
+ "dates": sorted(list(dates)),
111
+ "sources": sorted(list(sources)),
112
+ "size_bytes": size_bytes
113
+ }
114
+
115
+
116
+ def replay(base_dir: str, date: Optional[str] = None, source: Optional[str] = None) -> Iterator[RawEvent]:
117
+ """
118
+ Yields RawEvent objects from archived JSONL files.
119
+ """
120
+ if not os.path.exists(base_dir):
121
+ return
122
+
123
+ # Get all date directories sorted
124
+ date_dirs = sorted([d for d in os.listdir(base_dir) if os.path.isdir(os.path.join(base_dir, d))])
125
+
126
+ if date:
127
+ date_dirs = [d for d in date_dirs if d == date]
128
+
129
+ for date_dir in date_dirs:
130
+ dir_path = os.path.join(base_dir, date_dir)
131
+
132
+ # Get all jsonl files
133
+ files = sorted([f for f in os.listdir(dir_path) if f.endswith('.jsonl')])
134
+
135
+ if source:
136
+ target_file = f"{source}.jsonl"
137
+ files = [f for f in files if f == target_file]
138
+
139
+ for file in files:
140
+ file_path = os.path.join(dir_path, file)
141
+ with open(file_path, 'r', encoding='utf-8') as f:
142
+ for line in f:
143
+ if line.strip():
144
+ try:
145
+ data = json.loads(line)
146
+ yield RawEvent(
147
+ raw_id=data["raw_id"],
148
+ source=data["source"],
149
+ payload=data["payload"],
150
+ receive_timestamp=data["receive_timestamp"]
151
+ )
152
+ except (json.JSONDecodeError, KeyError):
153
+ # Skip corrupted/truncated archive lines without crashing replay
154
+ continue
async_storage.py ADDED
@@ -0,0 +1,261 @@
1
+ """
2
+ Dedicated Asynchronous Storage Worker for MDRAP.
3
+
4
+ Decouples synchronous SQLite and DuckDB disk commits (fsync) from the real-time
5
+ market data processing loop. Canonical events, quarantine tuples, and lineage
6
+ records are enqueued into a lock-free SPSC ring buffer and flushed in batches
7
+ by a dedicated background worker thread.
8
+
9
+ Zero disk I/O pauses on the tick broadcasting hot path.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import threading
15
+ import time
16
+ from typing import Any, List, Optional, Tuple, Union
17
+
18
+ from models import CanonicalEvent
19
+ from spsc_ring import SPSCRingBuffer
20
+ from storage import Store
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Action tags for storage worker
25
+ OP_CANONICAL = 1
26
+ OP_QUARANTINE = 2
27
+ OP_LINEAGE = 3
28
+ OP_HEALTH = 4
29
+ OP_FLUSH_BARRIER = 5
30
+
31
+
32
+ class StorageCommand:
33
+ __slots__ = ("op_type", "payload")
34
+
35
+ def __init__(self, op_type: int, payload: Any):
36
+ self.op_type = op_type
37
+ self.payload = payload
38
+
39
+
40
+ class AsyncStorageWorker:
41
+ """
42
+ Background worker thread that drains persistence commands from an SPSC ring buffer
43
+ and commits them to SQLite in optimized batches.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ store: Store,
49
+ queue_capacity: int = 65536,
50
+ batch_size: int = 2000,
51
+ flush_interval_s: float = 0.25,
52
+ duck_path: Optional[str] = None,
53
+ ):
54
+ self.store = store
55
+ self.batch_size = batch_size
56
+ self.flush_interval_s = flush_interval_s
57
+ self.duck_path = duck_path
58
+
59
+ self._queue: SPSCRingBuffer[StorageCommand] = SPSCRingBuffer(capacity=queue_capacity)
60
+ self._running = False
61
+ self._thread: Optional[threading.Thread] = None
62
+ self._flush_lock = threading.Lock()
63
+ self._flush_complete_event = threading.Event()
64
+
65
+ # Telemetry counters
66
+ self._total_canonical = 0
67
+ self._total_quarantine = 0
68
+ self._total_lineage = 0
69
+ self._total_commits = 0
70
+ self._last_commit_ms = 0.0
71
+ self._max_commit_ms = 0.0
72
+ self._total_dropped = 0
73
+
74
+ @property
75
+ def queue(self) -> SPSCRingBuffer[StorageCommand]:
76
+ return self._queue
77
+
78
+ def start(self) -> None:
79
+ """Start the background storage thread."""
80
+ if self._running:
81
+ return
82
+ self._running = True
83
+ self._thread = threading.Thread(target=self._worker_loop, name="mdrap-async-storage", daemon=True)
84
+ self._thread.start()
85
+
86
+ def stop(self, timeout: float = 5.0) -> None:
87
+ """Gracefully stop worker and drain all pending storage writes."""
88
+ if not self._running:
89
+ return
90
+ self._running = False
91
+ if self._thread and self._thread.is_alive():
92
+ self._thread.join(timeout=timeout)
93
+
94
+ # Final synchronous flush of any residual queue items
95
+ self._drain_and_commit(force_all=True)
96
+
97
+ def write_canonical(self, event: CanonicalEvent) -> bool:
98
+ """Enqueue a validated canonical event for background SQLite storage."""
99
+ cmd = StorageCommand(OP_CANONICAL, event)
100
+ ok = self._queue.offer(cmd)
101
+ if not ok:
102
+ self._total_dropped += 1
103
+ return ok
104
+
105
+ def write_quarantine(self, row: tuple) -> bool:
106
+ """Enqueue an invalid/suspicious quarantine record."""
107
+ cmd = StorageCommand(OP_QUARANTINE, row)
108
+ ok = self._queue.offer(cmd)
109
+ if not ok:
110
+ self._total_dropped += 1
111
+ return ok
112
+
113
+ def write_lineage(self, row: tuple) -> bool:
114
+ """Enqueue an audit lineage trace."""
115
+ cmd = StorageCommand(OP_LINEAGE, row)
116
+ ok = self._queue.offer(cmd)
117
+ if not ok:
118
+ self._total_dropped += 1
119
+ return ok
120
+
121
+ def write_health(self, rows: list) -> bool:
122
+ """Enqueue source health telemetry."""
123
+ cmd = StorageCommand(OP_HEALTH, rows)
124
+ ok = self._queue.offer(cmd)
125
+ if not ok:
126
+ self._total_dropped += 1
127
+ return ok
128
+
129
+ def flush(self, timeout: float = 2.0) -> None:
130
+ """Synchronously wait until all currently queued events are committed to disk."""
131
+ if not self._running:
132
+ self._drain_and_commit(force_all=True)
133
+ return
134
+
135
+ with self._flush_lock:
136
+ self._flush_complete_event.clear()
137
+ barrier = StorageCommand(OP_FLUSH_BARRIER, self._flush_complete_event)
138
+ while not self._queue.offer(barrier):
139
+ time.sleep(0.0005)
140
+ self._flush_complete_event.wait(timeout=timeout)
141
+
142
+ def _worker_loop(self) -> None:
143
+ """Main background loop draining SPSC queue and committing in batches."""
144
+ last_flush = time.time()
145
+ canonical_batch: List[CanonicalEvent] = []
146
+ quarantine_batch: List[tuple] = []
147
+ lineage_batch: List[tuple] = []
148
+ health_batch: List[tuple] = []
149
+
150
+ while self._running:
151
+ cmd = self._queue.poll()
152
+ if cmd is not None:
153
+ if cmd.op_type == OP_CANONICAL:
154
+ canonical_batch.append(cmd.payload)
155
+ elif cmd.op_type == OP_QUARANTINE:
156
+ quarantine_batch.append(cmd.payload)
157
+ elif cmd.op_type == OP_LINEAGE:
158
+ lineage_batch.append(cmd.payload)
159
+ elif cmd.op_type == OP_HEALTH:
160
+ health_batch.extend(cmd.payload)
161
+ elif cmd.op_type == OP_FLUSH_BARRIER:
162
+ # Flush immediately on barrier
163
+ self._commit_batches(canonical_batch, quarantine_batch, lineage_batch, health_batch)
164
+ last_flush = time.time()
165
+ if isinstance(cmd.payload, threading.Event):
166
+ cmd.payload.set()
167
+ continue
168
+
169
+ now = time.time()
170
+ total_pending = len(canonical_batch) + len(quarantine_batch) + len(lineage_batch)
171
+ time_elapsed = (now - last_flush) >= self.flush_interval_s
172
+
173
+ if total_pending >= self.batch_size or (total_pending > 0 and time_elapsed):
174
+ self._commit_batches(canonical_batch, quarantine_batch, lineage_batch, health_batch)
175
+ last_flush = now
176
+ elif cmd is None:
177
+ # Yield CPU briefly if queue was empty
178
+ time.sleep(0.001)
179
+
180
+ # Drain any residual items on exit
181
+ self._commit_batches(canonical_batch, quarantine_batch, lineage_batch, health_batch)
182
+
183
+ def _commit_batches(
184
+ self,
185
+ canonical_batch: List[CanonicalEvent],
186
+ quarantine_batch: List[tuple],
187
+ lineage_batch: List[tuple],
188
+ health_batch: List[tuple],
189
+ ) -> None:
190
+ if not canonical_batch and not quarantine_batch and not lineage_batch and not health_batch:
191
+ return
192
+
193
+ t0 = time.perf_counter()
194
+ try:
195
+ if canonical_batch:
196
+ self.store.write_canonical_batch(canonical_batch)
197
+ self._total_canonical += len(canonical_batch)
198
+ canonical_batch.clear()
199
+
200
+ if quarantine_batch:
201
+ self.store.write_quarantine_batch(quarantine_batch)
202
+ self._total_quarantine += len(quarantine_batch)
203
+ quarantine_batch.clear()
204
+
205
+ if lineage_batch:
206
+ self.store.write_lineage_batch(lineage_batch)
207
+ self._total_lineage += len(lineage_batch)
208
+ lineage_batch.clear()
209
+
210
+ if health_batch:
211
+ self.store.upsert_source_health(health_batch)
212
+ health_batch.clear()
213
+
214
+ self.store.commit()
215
+ self._total_commits += 1
216
+ dur_ms = (time.perf_counter() - t0) * 1000.0
217
+ self._last_commit_ms = round(dur_ms, 2)
218
+ if dur_ms > self._max_commit_ms:
219
+ self._max_commit_ms = round(dur_ms, 2)
220
+ except Exception as e:
221
+ logger.error(f"Async storage batch commit failed: {e}")
222
+
223
+ def _drain_and_commit(self, force_all: bool = True) -> None:
224
+ """Synchronous drain of the queue for shutdown or offline testing."""
225
+ canonical_batch: List[CanonicalEvent] = []
226
+ quarantine_batch: List[tuple] = []
227
+ lineage_batch: List[tuple] = []
228
+ health_batch: List[tuple] = []
229
+
230
+ while True:
231
+ cmd = self._queue.poll()
232
+ if cmd is None:
233
+ break
234
+ if cmd.op_type == OP_CANONICAL:
235
+ canonical_batch.append(cmd.payload)
236
+ elif cmd.op_type == OP_QUARANTINE:
237
+ quarantine_batch.append(cmd.payload)
238
+ elif cmd.op_type == OP_LINEAGE:
239
+ lineage_batch.append(cmd.payload)
240
+ elif cmd.op_type == OP_HEALTH:
241
+ health_batch.extend(cmd.payload)
242
+ elif cmd.op_type == OP_FLUSH_BARRIER:
243
+ if isinstance(cmd.payload, threading.Event):
244
+ cmd.payload.set()
245
+
246
+ self._commit_batches(canonical_batch, quarantine_batch, lineage_batch, health_batch)
247
+
248
+ def stats(self) -> dict:
249
+ """Return operational telemetry."""
250
+ return {
251
+ "is_running": self._running,
252
+ "queue_size": self._queue.size(),
253
+ "queue_capacity": self._queue.capacity,
254
+ "total_canonical": self._total_canonical,
255
+ "total_quarantine": self._total_quarantine,
256
+ "total_lineage": self._total_lineage,
257
+ "total_commits": self._total_commits,
258
+ "last_commit_ms": self._last_commit_ms,
259
+ "max_commit_ms": self._max_commit_ms,
260
+ "total_dropped": self._total_dropped,
261
+ }