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/__init__.py +30 -0
- nlalpha_data/bars_aligner.py +82 -0
- nlalpha_data/base.py +90 -0
- nlalpha_data/calendar/__init__.py +15 -0
- nlalpha_data/calendar/cn_equity.py +27 -0
- nlalpha_data/calendar/futures.py +54 -0
- nlalpha_data/calendar/us_equity.py +24 -0
- nlalpha_data/calendar_base.py +201 -0
- nlalpha_data/calendar_tools.py +133 -0
- nlalpha_data/discovery.py +129 -0
- nlalpha_data/knowledge/loading.md +17 -0
- nlalpha_data/knowledge/local-provider.md +17 -0
- nlalpha_data/loader.py +210 -0
- nlalpha_data/local_provider.py +407 -0
- nlalpha_data/mock_provider.py +123 -0
- nlalpha_data/parquet_store.py +134 -0
- nlalpha_data/server.py +392 -0
- nlalpha_data/sqlite_store.py +174 -0
- nlalpha_data/store.py +316 -0
- nlalpha_data-0.3.0.dist-info/METADATA +149 -0
- nlalpha_data-0.3.0.dist-info/RECORD +24 -0
- nlalpha_data-0.3.0.dist-info/WHEEL +4 -0
- nlalpha_data-0.3.0.dist-info/entry_points.txt +2 -0
- nlalpha_data-0.3.0.dist-info/licenses/LICENSE +661 -0
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
|