nlalpha-data 0.3.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.
nlalpha_data/server.py ADDED
@@ -0,0 +1,392 @@
1
+ """Application-scoped MCP data tools publishing immutable market-data artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import io
7
+ import json
8
+ import logging
9
+ import os
10
+ import time
11
+ from contextlib import AsyncExitStack, asynccontextmanager
12
+ from importlib import resources
13
+ from importlib.metadata import version
14
+ from pathlib import Path
15
+ from typing import Any, Callable
16
+
17
+ import pyarrow.parquet as pq
18
+ import uvicorn
19
+ from mcp.server.fastmcp import Context, FastMCP
20
+ from mcp.server.transport_security import TransportSecuritySettings
21
+ from starlette.applications import Starlette
22
+ from starlette.responses import JSONResponse
23
+ from starlette.routing import Route
24
+
25
+ from nlalpha.core.artifact_config import ArtifactStoreConfig, create_data_artifact_store
26
+ from nlalpha.core.data_artifacts import DataArtifactBackendError, DataArtifactStore
27
+ from nlalpha.core.description import common_description, describe_document, markdown_sections
28
+ from nlalpha.core.contracts.market import Bar
29
+ from nlalpha.core.platform import HostIdentityConfig, resolve_allowed_hosts
30
+ from nlalpha.core.result_contract import failure_response
31
+ from nlalpha.core.telemetry import configure_telemetry, shutdown, get_meter, setup_logging, span, start_span
32
+ from nlalpha_data.calendar_tools import (
33
+ confess as _confess,
34
+ get_trading_days as _get_trading_days,
35
+ is_trading_day as _is_trading_day,
36
+ list_trading_calendars as _list_trading_calendars,
37
+ )
38
+ from nlalpha_data.loader import DataLoader, LoadedBars
39
+ from nlalpha_data.discovery import get_default_data_provider
40
+ from nlalpha_data.store import BAR_CONTRACT, BAR_CONTRACT_KEY, TIME_CONTRACT_KEY, SOURCE_KEY, read_source_contract, _bars_to_table
41
+
42
+ logger = logging.getLogger("nlalpha-data")
43
+
44
+
45
+ def _serialize_bars(loaded: LoadedBars, symbol: str, interval: str) -> tuple[bytes, dict[str, Any]]:
46
+ """Serialize canonical bars and derive their response summary from the same table."""
47
+ table = _bars_to_table(loaded.bars, require_bounds=True)
48
+ table = table.replace_schema_metadata({
49
+ **(table.schema.metadata or {}), BAR_CONTRACT_KEY: BAR_CONTRACT,
50
+ SOURCE_KEY: json.dumps({"schema": "nlalpha.market.source.v1", "assets": {symbol: loaded.source}}, sort_keys=True).encode(),
51
+ })
52
+ summary = {
53
+ "symbol": symbol, "interval": interval, "bar_count": table.num_rows,
54
+ "time_contract": json.loads(table.schema.metadata[TIME_CONTRACT_KEY]),
55
+ "volume_unit": "instrument_quantity", "bar_contract": BAR_CONTRACT.decode("ascii"),
56
+ "source": read_source_contract(table)["assets"][symbol],
57
+ }
58
+ output = io.BytesIO()
59
+ pq.write_table(table, output)
60
+ return output.getvalue(), summary
61
+
62
+
63
+ class DataService:
64
+ """Bind data tools to one provider, artifact store, and host identity policy."""
65
+
66
+ def __init__(
67
+ self, provider: DataLoader, artifact_store: DataArtifactStore,
68
+ identity: HostIdentityConfig,
69
+ ) -> None:
70
+ self.provider = provider
71
+ self.artifact_store = artifact_store
72
+ self.identity = identity
73
+ meter = get_meter()
74
+ self._load_duration = meter.create_histogram(
75
+ "nlalpha.data.load_duration", description="Data loading duration in seconds", unit="s",
76
+ )
77
+ self._load_size = meter.create_histogram(
78
+ "nlalpha.data.load_size", description="Loaded data size in bytes", unit="B",
79
+ )
80
+ self._load_counter = meter.create_counter(
81
+ "nlalpha.data.load_completed", description="Number of data load operations completed",
82
+ )
83
+
84
+ @span("data.list_providers")
85
+ async def list_data_providers(self) -> dict[str, Any]:
86
+ """List the providers assembled for this application and their availability."""
87
+ providers = self.provider.list_providers()
88
+ return {"providers": providers, "count": len(providers)}
89
+
90
+ async def diagnose_data_providers(self) -> dict[str, Any]:
91
+ """Report installed data sources that failed discovery or configuration."""
92
+ diagnostics = self.provider.diagnostics()
93
+ return {"diagnostics": diagnostics, "count": len(diagnostics)}
94
+
95
+ @span("data.check_coverage")
96
+ async def check_data_coverage(
97
+ self, symbol: str, interval: str, start: str, end: str,
98
+ provider_id: str | None = None, price_adjustment: str | None = None,
99
+ ) -> dict[str, Any]:
100
+ """Check returned bar counts and endpoints for the requested range."""
101
+ requested = {"symbol": symbol, "interval": interval, "requested_start": start, "requested_end": end}
102
+ try:
103
+ loaded = await self.provider.load_bars(symbol, interval, start, end, provider_id=provider_id, price_adjustment=price_adjustment)
104
+ bars = loaded.bars
105
+ except Exception as exc:
106
+ logger.warning("check_data_coverage failed | symbol=%s | error=%s", symbol, exc)
107
+ return {**failure_response(exc), **requested, "available": False, "bar_count": 0}
108
+ if not bars:
109
+ return {**requested, "available": False, "bar_count": 0, "source": loaded.source}
110
+ actual_start = bars[0].datetime.isoformat()
111
+ actual_end = bars[-1].datetime.isoformat()
112
+ return {
113
+ **requested, "available": True, "bar_count": len(bars),
114
+ "actual_start": actual_start, "actual_end": actual_end, "source": loaded.source,
115
+ }
116
+
117
+ @span("data.get_quality")
118
+ async def get_data_quality(
119
+ self, symbol: str, interval: str, start: str, end: str,
120
+ provider_id: str | None = None, price_adjustment: str | None = None,
121
+ ) -> dict[str, Any]:
122
+ """Check OHLC range consistency and zero-volume bars in a requested range."""
123
+ try:
124
+ loaded = await self.provider.load_bars(symbol, interval, start, end, provider_id=provider_id, price_adjustment=price_adjustment)
125
+ bars = loaded.bars
126
+ except Exception as exc:
127
+ logger.warning("get_data_quality failed | symbol=%s | error=%s", symbol, exc)
128
+ return {**failure_response(exc), "symbol": symbol, "interval": interval, "quality_score": "unknown"}
129
+ if not bars:
130
+ return {"symbol": symbol, "interval": interval, "bar_count": 0, "quality_score": "poor", "source": loaded.source}
131
+ ohlc_anomalies = 0
132
+ zero_volume = 0
133
+ for bar in bars:
134
+ if bar.high < bar.low or bar.close < bar.low or bar.close > bar.high:
135
+ ohlc_anomalies += 1
136
+ if bar.open < bar.low or bar.open > bar.high:
137
+ ohlc_anomalies += 1
138
+ if bar.volume == 0:
139
+ zero_volume += 1
140
+ return {
141
+ "symbol": symbol, "interval": interval, "bar_count": len(bars),
142
+ "ohlc_anomalies": ohlc_anomalies, "zero_volume_bars": zero_volume, "source": loaded.source,
143
+ }
144
+
145
+ async def load_bars(
146
+ self, symbol: str, interval: str, start: str, end: str, ctx: Context,
147
+ provider_id: str | None = None, price_adjustment: str | None = None,
148
+ ) -> dict[str, Any]:
149
+ """Publish canonical bars as an immutable caller-scoped artifact."""
150
+ started_at = time.monotonic()
151
+ status = "failed"
152
+ with start_span("data.load_bars", attributes={
153
+ "symbol": symbol, "interval": interval, "start": start, "end": end,
154
+ }) as trace:
155
+ try:
156
+ scope = self.identity.resolve(ctx)
157
+ loaded = await self.provider.load_bars(symbol, interval, start, end, provider_id=provider_id, price_adjustment=price_adjustment)
158
+ bars = loaded.bars
159
+ if not bars:
160
+ status = "empty"
161
+ return {"symbol": symbol, "interval": interval, "bar_count": 0, "source": loaded.source}
162
+ data, summary = await asyncio.to_thread(_serialize_bars, loaded, symbol, interval)
163
+ metadata = await self.artifact_store.publish(
164
+ data, scope=scope, media_type="application/vnd.apache.parquet",
165
+ schema=summary["bar_contract"],
166
+ )
167
+ summary["data_ref"] = metadata.data_ref
168
+ status = "completed"
169
+ trace.set_attribute("bar_count", summary["bar_count"])
170
+ trace.set_attribute("data_size", len(data))
171
+ self._load_size.record(len(data), {"status": status})
172
+ logger.info("load_bars published | symbol=%s | ref=%s | bars=%d", symbol, metadata.data_ref, summary["bar_count"])
173
+ return summary
174
+ except asyncio.CancelledError:
175
+ status = "cancelled"
176
+ raise
177
+ except Exception as exc:
178
+ trace.set_attribute("error", str(exc))
179
+ trace.record_exception(exc)
180
+ logger.warning("load_bars failed | symbol=%s | error=%s", symbol, exc)
181
+ return {**failure_response(exc), "symbol": symbol, "interval": interval}
182
+ finally:
183
+ trace.set_attribute("status", status)
184
+ self._load_duration.record(time.monotonic() - started_at, {"status": status})
185
+ self._load_counter.add(1, {"status": status})
186
+
187
+ @span("data.list_calendars")
188
+ async def list_trading_calendars(self) -> list[dict]:
189
+ """List available exchange calendars."""
190
+ return _list_trading_calendars()
191
+
192
+ @span("data.trading_days")
193
+ async def get_trading_days(self, calendar_id: str, start: str, end: str) -> dict:
194
+ """Return the trading-session count for a calendar and date range."""
195
+ return _get_trading_days(calendar_id, start, end)
196
+
197
+ @span("data.is_trading_day")
198
+ async def is_trading_day(self, calendar_id: str, date: str) -> dict:
199
+ """Report whether a date is a trading session for the selected calendar."""
200
+ return _is_trading_day(calendar_id, date)
201
+
202
+ @span("data.list_instruments")
203
+ async def list_instruments(self, asset: str, market: str | None = None,
204
+ status: str | None = None) -> dict[str, Any]:
205
+ """List one asset class's instrument master from the selected source."""
206
+ try:
207
+ instruments, source = await self.provider.list_instruments(asset, market=market, status=status)
208
+ return {"asset": asset, "instruments": instruments, "count": len(instruments), "source": source}
209
+ except Exception as exc:
210
+ logger.warning("list_instruments failed | asset=%s | error=%s", asset, exc)
211
+ return {**failure_response(exc), "asset": asset}
212
+
213
+ @span("data.get_trading_calendar")
214
+ async def get_trading_calendar(self, exchange: str, start: str, end: str) -> dict[str, Any]:
215
+ """Return normalized exchange calendar days for a date range."""
216
+ try:
217
+ days, source = await self.provider.get_trading_calendar(exchange, start, end)
218
+ return {"exchange": exchange, "days": days, "count": len(days), "source": source}
219
+ except Exception as exc:
220
+ logger.warning("get_trading_calendar failed | exchange=%s | error=%s", exchange, exc)
221
+ return {**failure_response(exc), "exchange": exchange}
222
+
223
+ @span("data.get_daily_basic")
224
+ async def get_daily_basic(self, symbol: str, start: str, end: str) -> dict[str, Any]:
225
+ """Return daily fundamental metrics for a symbol."""
226
+ try:
227
+ rows, source = await self.provider.get_daily_basic(symbol, start, end)
228
+ return {"symbol": symbol, "rows": rows, "count": len(rows), "source": source}
229
+ except Exception as exc:
230
+ logger.warning("get_daily_basic failed | symbol=%s | error=%s", symbol, exc)
231
+ return {**failure_response(exc), "symbol": symbol}
232
+
233
+ @span("data.get_price_limits")
234
+ async def get_price_limits(self, symbol: str, start: str, end: str) -> dict[str, Any]:
235
+ """Return daily price limits for a symbol."""
236
+ try:
237
+ rows, source = await self.provider.get_price_limits(symbol, start, end)
238
+ return {"symbol": symbol, "rows": rows, "count": len(rows), "source": source}
239
+ except Exception as exc:
240
+ logger.warning("get_price_limits failed | symbol=%s | error=%s", symbol, exc)
241
+ return {**failure_response(exc), "symbol": symbol}
242
+
243
+ @span("data.get_suspensions")
244
+ async def get_suspensions(self, symbol: str, start: str, end: str) -> dict[str, Any]:
245
+ """Return daily suspension rows for a symbol."""
246
+ try:
247
+ rows, source = await self.provider.get_suspensions(symbol, start, end)
248
+ return {"symbol": symbol, "rows": rows, "count": len(rows), "source": source}
249
+ except Exception as exc:
250
+ logger.warning("get_suspensions failed | symbol=%s | error=%s", symbol, exc)
251
+ return {**failure_response(exc), "symbol": symbol}
252
+
253
+ @span("data.get_index_weights")
254
+ async def get_index_weights(self, index_code: str, start: str, end: str) -> dict[str, Any]:
255
+ """Return index constituent weights for an index code."""
256
+ try:
257
+ rows, source = await self.provider.get_index_weights(index_code, start, end)
258
+ return {"index_code": index_code, "rows": rows, "count": len(rows), "source": source}
259
+ except Exception as exc:
260
+ logger.warning("get_index_weights failed | index_code=%s | error=%s", index_code, exc)
261
+ return {**failure_response(exc), "index_code": index_code}
262
+
263
+ @span("data.about")
264
+ async def about(self, sections: list[str] | None = None) -> dict[str, Any]:
265
+ """Describe this application's sources, shared data contracts, and calendar tools."""
266
+ try:
267
+ return describe_document("service", "nlalpha-data", "nlalpha-data", version("nlalpha-data"), {
268
+ "principles": common_description(),
269
+ "navigation": {
270
+ "about": "Read shared data, time, source, coverage, and calendar contracts.",
271
+ "list_data_providers": "Discover sources available in this application.",
272
+ "diagnose_data_providers": "Inspect installed sources that are unavailable or failed to load.",
273
+ "confess": "Read one installed source's manifest and provider-authored guidance.",
274
+ "load_bars": "Load canonical bars into a caller-scoped immutable artifact.",
275
+ "check_data_coverage": "Inspect returned row counts and observed endpoints for a requested range.",
276
+ "get_data_quality": "Inspect OHLC consistency and zero-volume rows.",
277
+ "list_trading_calendars": "Discover calendar IDs in the installed calendar library.",
278
+ "get_trading_days": "Count calendar sessions in a requested date range.",
279
+ "is_trading_day": "Check whether one date is a calendar session.",
280
+ "list_instruments": "List an asset class's instrument master from the selected source.",
281
+ "get_trading_calendar": "Read normalized exchange calendar days for a date range.",
282
+ "get_daily_basic": "Read daily fundamental metrics for a symbol.",
283
+ "get_price_limits": "Read daily price limits for a symbol.",
284
+ "get_suspensions": "Read daily suspension records for a symbol.",
285
+ "get_index_weights": "Read index constituent weights for a date range.",
286
+ },
287
+ "providers": [record.summary() for record in self.provider.records],
288
+ "diagnostics": self.provider.diagnostics(),
289
+ "loading": markdown_sections(resources.files("nlalpha_data").joinpath("knowledge", "loading.md").read_text(encoding="utf-8")),
290
+ "calendars": _confess(),
291
+ }, sections)
292
+ except Exception as exc:
293
+ return failure_response(exc)
294
+
295
+ @span("data.confess")
296
+ async def confess(self, provider_id: str, sections: list[str] | None = None) -> dict[str, Any]:
297
+ """Describe one source from this application's cached manifest and declared guidance."""
298
+ try:
299
+ record = self.provider.get_record(provider_id)
300
+ document_sections = {"identity": record.summary(), "manifest": record.manifest}
301
+ if record.guidance is not None:
302
+ document_sections.update({f"notes-{name}": text for name, text in markdown_sections(record.guidance).items()})
303
+ return describe_document("data_provider", provider_id, record.package, record.version, document_sections, sections)
304
+ except Exception as exc:
305
+ return failure_response(exc)
306
+
307
+
308
+ async def _health(request):
309
+ """Report application liveness."""
310
+ return JSONResponse({"status": "ok"})
311
+
312
+
313
+ async def _ready(request):
314
+ """Report startup completion and current artifact backend availability."""
315
+ ready = request.app.state.ready
316
+ if ready:
317
+ try:
318
+ await request.app.state.service.artifact_store.check_health()
319
+ except DataArtifactBackendError:
320
+ logger.error("Artifact storage readiness check failed")
321
+ ready = False
322
+ return JSONResponse({"status": "ready" if ready else "not_ready"}, status_code=200 if ready else 503)
323
+
324
+
325
+ def create_app(
326
+ *, provider: DataLoader | None = None,
327
+ artifact_store: DataArtifactStore | None = None,
328
+ artifact_config: ArtifactStoreConfig | None = None,
329
+ identity: HostIdentityConfig | None = None,
330
+ allowed_hosts: list[str] | tuple[str, ...] | None = None,
331
+ on_shutdown: Callable[[], None] | None = None,
332
+ ) -> Starlette:
333
+ """Create an isolated data application and its application-owned resources."""
334
+ if artifact_store is not None and artifact_config is not None:
335
+ raise ValueError("Provide artifact_store or artifact_config, not both")
336
+ hosts = resolve_allowed_hosts(allowed_hosts)
337
+ selected_identity = HostIdentityConfig.from_env() if identity is None else identity
338
+ if provider is None:
339
+ workspace = Path(os.getenv("NLALPHA_WORKSPACE_DIR", str(Path.cwd() / "workspace")))
340
+ provider = get_default_data_provider(
341
+ cache_dir=str(workspace / "cache"), local_data_dir=str(workspace / "data"),
342
+ )
343
+ store = create_data_artifact_store(artifact_config) if artifact_store is None else artifact_store
344
+ service = DataService(provider, store, selected_identity)
345
+ mcp = FastMCP("nlalpha-data")
346
+ mcp.settings.transport_security = TransportSecuritySettings(
347
+ enable_dns_rebinding_protection=True, allowed_hosts=hosts,
348
+ )
349
+ for tool in (
350
+ service.list_data_providers, service.diagnose_data_providers, service.check_data_coverage, service.get_data_quality,
351
+ service.load_bars, service.list_trading_calendars, service.get_trading_days,
352
+ service.is_trading_day, service.list_instruments, service.get_trading_calendar,
353
+ service.get_daily_basic, service.get_price_limits, service.get_suspensions,
354
+ service.get_index_weights, service.about, service.confess,
355
+ ):
356
+ mcp.add_tool(tool)
357
+
358
+ @asynccontextmanager
359
+ async def lifespan(app):
360
+ app.state.ready = False
361
+ async with AsyncExitStack() as resources:
362
+ if on_shutdown is not None:
363
+ resources.callback(on_shutdown)
364
+ resources.push_async_callback(service.provider.aclose)
365
+ resources.push_async_callback(store.aclose)
366
+ await store.start()
367
+ app.state.ready = True
368
+ try:
369
+ yield
370
+ finally:
371
+ app.state.ready = False
372
+
373
+ sse_app = mcp.sse_app()
374
+ app = Starlette(routes=[Route("/health", _health), Route("/ready", _ready), *sse_app.routes], lifespan=lifespan)
375
+ app.state.mcp = mcp
376
+ app.state.service = service
377
+ app.state.ready = False
378
+ return app
379
+
380
+
381
+ def main() -> None:
382
+ """Run the data application using explicit host and port settings."""
383
+ try:
384
+ configure_telemetry()
385
+ setup_logging(level="INFO", json_format=True)
386
+ uvicorn.run(create_app(on_shutdown=shutdown), host=os.getenv("NLALPHA_HOST", "127.0.0.1"), port=int(os.getenv("NLALPHA_PORT", "8000")))
387
+ finally:
388
+ shutdown()
389
+
390
+
391
+ if __name__ == "__main__":
392
+ main()
@@ -0,0 +1,174 @@
1
+ """Store canonical UTC bars with instrument quantity volume in SQLite's v2 table."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+ from pathlib import Path
7
+ import asyncio
8
+
9
+ from nlalpha.core.contracts.market import Bar
10
+ from nlalpha.core.contracts.time import datetime_to_ns, ns_to_datetime
11
+ from nlalpha.core.errors import DataError
12
+ from nlalpha_data.store import BaseBarStore, BarOverview, bar_source_date, BAR_CACHE_SCHEMA_VERSION
13
+
14
+ try:
15
+ import aiosqlite
16
+ except ImportError:
17
+ aiosqlite = None
18
+
19
+ _TABLE = f"bar_data_v{BAR_CACHE_SCHEMA_VERSION}"
20
+
21
+ _COLUMNS = (
22
+ "symbol", "interval", "timestamp_ns", "open_timestamp_ns", "open", "high", "low",
23
+ "close", "volume", "open_interest", "session_date", "source_timezone",
24
+ "source_utc_offset_seconds", "source_timestamp_semantics", "calendar_id", "source_date",
25
+ )
26
+
27
+
28
+ class SQLiteBarStore(BaseBarStore):
29
+ """Persist exact microsecond Bar values as UTC nanosecond integers."""
30
+
31
+ def __init__(self, db_path: str | None = None) -> None:
32
+ self._db_path = db_path or str(Path("workspace/bar_data.db").resolve())
33
+ self._conn = None
34
+ self._schema_ready = False
35
+ self._schema_lock = asyncio.Lock()
36
+
37
+ async def _ensure_conn(self):
38
+ """Open the async SQLite connection on demand."""
39
+ if self._conn is None:
40
+ if aiosqlite is None:
41
+ raise DataError("SQLiteBarStore requires aiosqlite.")
42
+ Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
43
+ self._conn = await aiosqlite.connect(self._db_path)
44
+ self._conn.row_factory = aiosqlite.Row
45
+ return self._conn
46
+
47
+ async def _ensure_schema(self) -> None:
48
+ """Create the current bar contract namespace and validate its columns."""
49
+ async with self._schema_lock:
50
+ if self._schema_ready:
51
+ return
52
+ conn = await self._ensure_conn()
53
+ async with conn.execute(f"PRAGMA table_info({_TABLE})") as cursor:
54
+ existing = await cursor.fetchall()
55
+ if existing and tuple(row["name"] for row in existing) != _COLUMNS:
56
+ raise DataError(
57
+ f"SQLite {_TABLE} has no exact UTC/source contract.",
58
+ details={"time_contract": True},
59
+ )
60
+ await conn.execute(f"""
61
+ CREATE TABLE IF NOT EXISTS {_TABLE} (
62
+ symbol TEXT NOT NULL,
63
+ interval TEXT NOT NULL,
64
+ timestamp_ns INTEGER NOT NULL,
65
+ open_timestamp_ns INTEGER,
66
+ open REAL NOT NULL, high REAL NOT NULL, low REAL NOT NULL,
67
+ close REAL NOT NULL, volume REAL NOT NULL, open_interest REAL,
68
+ session_date TEXT, source_timezone TEXT,
69
+ source_utc_offset_seconds INTEGER,
70
+ source_timestamp_semantics TEXT NOT NULL,
71
+ calendar_id TEXT,
72
+ source_date TEXT NOT NULL,
73
+ PRIMARY KEY (symbol, interval, timestamp_ns)
74
+ )
75
+ """)
76
+ await conn.execute(f"CREATE INDEX IF NOT EXISTS idx_{_TABLE}_range ON {_TABLE}(symbol, interval, timestamp_ns)")
77
+ await conn.execute(f"CREATE INDEX IF NOT EXISTS idx_{_TABLE}_source_date ON {_TABLE}(symbol, interval, source_date)")
78
+ await conn.commit()
79
+ self._schema_ready = True
80
+
81
+ async def save(self, bars: list[Bar], symbol: str = "", interval: str = "", start: str = "", end: str = "") -> int:
82
+ """Insert canonical bars with complete source metadata."""
83
+ if not bars:
84
+ return 0
85
+ key_symbol = symbol or bars[0].symbol
86
+ if any(bar.symbol != key_symbol for bar in bars):
87
+ raise DataError("SQLite cache key contradicts bar symbols.")
88
+ key_interval = interval or "1d"
89
+ await self._ensure_schema()
90
+ conn = await self._ensure_conn()
91
+ values = []
92
+ for bar in sorted(bars, key=lambda row: row.datetime):
93
+ close_ns = datetime_to_ns(bar.datetime)
94
+ open_ns = datetime_to_ns(bar.open_time) if bar.open_time is not None else None
95
+ if not -(1 << 63) <= close_ns < (1 << 63) or (open_ns is not None and not -(1 << 63) <= open_ns < (1 << 63)):
96
+ raise DataError("SQLite UTC nanosecond key is outside signed 64-bit range.", details={"time_contract": True})
97
+ values.append((
98
+ key_symbol, key_interval, close_ns, open_ns,
99
+ bar.open, bar.high, bar.low, bar.close, bar.volume, bar.open_interest,
100
+ bar.session_date.isoformat() if bar.session_date is not None else None,
101
+ bar.source_timezone, bar.source_utc_offset_seconds,
102
+ bar.source_timestamp_semantics, bar.calendar_id,
103
+ bar_source_date(bar).isoformat(),
104
+ ))
105
+ columns = ", ".join(_COLUMNS)
106
+ placeholders = ", ".join("?" for _ in _COLUMNS)
107
+ async with conn.executemany(f"INSERT OR IGNORE INTO {_TABLE} ({columns}) VALUES ({placeholders})", values) as cursor:
108
+ inserted = cursor.rowcount
109
+ await conn.commit()
110
+ return inserted
111
+
112
+ async def load(self, symbol: str, interval: str, start: str, end: str) -> list[Bar]:
113
+ """Load by inclusive source session dates or declared local civil dates."""
114
+ await self._ensure_schema()
115
+ conn = await self._ensure_conn()
116
+ begin, finish = date.fromisoformat(start), date.fromisoformat(end)
117
+ if begin > finish:
118
+ raise DataError("Requested start date is after end date.")
119
+ async with conn.execute(f"""
120
+ SELECT * FROM {_TABLE}
121
+ WHERE symbol = ? AND interval = ? AND source_date BETWEEN ? AND ?
122
+ ORDER BY timestamp_ns
123
+ """, (symbol, interval, begin.isoformat(), finish.isoformat())) as cursor:
124
+ rows = await cursor.fetchall()
125
+ bars = [Bar(
126
+ symbol=row["symbol"], datetime=ns_to_datetime(row["timestamp_ns"]),
127
+ open_time=ns_to_datetime(row["open_timestamp_ns"]) if row["open_timestamp_ns"] is not None else None,
128
+ open=row["open"], high=row["high"], low=row["low"], close=row["close"],
129
+ volume=row["volume"], open_interest=row["open_interest"],
130
+ session_date=date.fromisoformat(row["session_date"]) if row["session_date"] is not None else None,
131
+ source_timezone=row["source_timezone"], source_utc_offset_seconds=row["source_utc_offset_seconds"],
132
+ source_timestamp_semantics=row["source_timestamp_semantics"], calendar_id=row["calendar_id"],
133
+ ) for row in rows]
134
+ for bar, row in zip(bars, rows):
135
+ if bar_source_date(bar).isoformat() != row["source_date"]:
136
+ raise DataError("SQLite source_date contradicts its bar time contract.", details={"time_contract": True})
137
+ return bars
138
+
139
+ async def delete(self, symbol: str, interval: str) -> int:
140
+ """Delete one symbol and interval from the validated cache."""
141
+ await self._ensure_schema()
142
+ conn = await self._ensure_conn()
143
+ async with conn.execute(f"DELETE FROM {_TABLE} WHERE symbol = ? AND interval = ?", (symbol, interval)) as cursor:
144
+ deleted = cursor.rowcount
145
+ await conn.commit()
146
+ return deleted
147
+
148
+ async def get_overview(self) -> list[BarOverview]:
149
+ """Return exact UTC close-time ranges for stored series."""
150
+ await self._ensure_schema()
151
+ conn = await self._ensure_conn()
152
+ async with conn.execute(f"""
153
+ SELECT symbol, interval, COUNT(*) AS count,
154
+ MIN(timestamp_ns) AS start, MAX(timestamp_ns) AS end
155
+ FROM {_TABLE} GROUP BY symbol, interval ORDER BY symbol, interval
156
+ """) as cursor:
157
+ rows = await cursor.fetchall()
158
+ return [BarOverview(symbol=row["symbol"], interval=row["interval"], count=row["count"],
159
+ start=ns_to_datetime(row["start"]), end=ns_to_datetime(row["end"])) for row in rows]
160
+
161
+ async def close(self) -> None:
162
+ """Close the connection and reset its schema state."""
163
+ if self._conn is not None:
164
+ await self._conn.close()
165
+ self._conn = None
166
+ self._schema_ready = False
167
+
168
+ async def __aenter__(self):
169
+ await self._ensure_schema()
170
+ return self
171
+
172
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
173
+ await self.close()
174
+ return False