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.
@@ -0,0 +1,129 @@
1
+ """Discover installed data sources and retain their initialization outcomes."""
2
+
3
+ from importlib.metadata import entry_points, version
4
+ from importlib import resources
5
+ import json
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+
10
+ from nlalpha.core.description import markdown_sections
11
+
12
+ from nlalpha_data.loader import DataLoader, DataProviderRecord
13
+ from nlalpha_data.local_provider import LocalFileDataProvider
14
+
15
+ logger = logging.getLogger("nlalpha_data.discovery")
16
+
17
+
18
+ def _read_guidance(path: str | None) -> str | None:
19
+ """Read and validate one provider's explicitly declared Markdown resource."""
20
+ if path is None:
21
+ return None
22
+ if not isinstance(path, str) or not path or path != path.strip() or not Path(path).is_absolute():
23
+ raise TypeError("get_prompt_path must return an absolute trimmed file path or None")
24
+ source = Path(path)
25
+ if not source.is_file():
26
+ raise FileNotFoundError(f"Declared data provider guidance is not a file: {path}")
27
+ guidance = source.read_text(encoding="utf-8")
28
+ if not markdown_sections(guidance):
29
+ raise ValueError("Declared data provider guidance must contain Markdown text")
30
+ return guidance
31
+
32
+
33
+ def discover_data_providers(include_local: bool = True, local_data_dir: str = "workspace/data") -> list[DataProviderRecord]:
34
+ """Record every installed source while isolating plugin-specific failures."""
35
+ points = entry_points().select(group="nlalpha.data")
36
+ records = []
37
+ if include_local:
38
+ record = DataProviderRecord("local", "nlalpha-data", version("nlalpha-data"))
39
+ records.append(record)
40
+ try:
41
+ record.guidance = _read_guidance(str(resources.files("nlalpha_data").joinpath("knowledge", "local-provider.md")))
42
+ raw = os.environ.get("NLALPHA_LOCAL_PRICE_ADJUSTMENTS")
43
+ adjustments = json.loads(raw) if raw is not None else None
44
+ if raw is not None and not isinstance(adjustments, dict):
45
+ raise ValueError("NLALPHA_LOCAL_PRICE_ADJUSTMENTS must be a JSON object.")
46
+ provider = LocalFileDataProvider(local_data_dir, price_adjustments=adjustments)
47
+ available = provider.is_available()
48
+ record.manifest = {"provider_id": "local", "provider_name": "Local files", "available": available}
49
+ if available:
50
+ record.provider = provider
51
+ else:
52
+ record.status, record.reason = "unavailable", f"Local data directory is unavailable: {local_data_dir}"
53
+ except Exception as exc:
54
+ record.status, record.reason = "load_failed", str(exc)
55
+ for point in points:
56
+ distribution = point.dist
57
+ record = DataProviderRecord(None, distribution.metadata["Name"] if distribution else None,
58
+ distribution.version if distribution else None,
59
+ entry_point=point.name, entry_point_value=point.value)
60
+ if distribution is not None:
61
+ for name, value in (("Name", record.package), ("version", record.version)):
62
+ if not isinstance(value, str) or not value or value != value.strip():
63
+ raise ValueError(f"distribution {name} must be a nonempty trimmed string")
64
+ records.append(record)
65
+ stage = "load"
66
+ try:
67
+ plugin = point.load()()
68
+ for method in ("get_manifest", "get_provider", "is_available", "get_prompt_path"):
69
+ if not callable(getattr(plugin, method, None)):
70
+ raise TypeError(f"Data plugin requires {method}().")
71
+ stage = "get_manifest"
72
+ manifest = plugin.get_manifest()
73
+ if not isinstance(manifest, dict):
74
+ raise TypeError("Data manifest must be an object.")
75
+ for name in ("provider_id", "provider_name"):
76
+ value = manifest.get(name)
77
+ if not isinstance(value, str) or not value or value != value.strip():
78
+ raise ValueError(f"Data manifest {name} must be a nonempty trimmed string.")
79
+ if type(manifest.get("available")) is not bool:
80
+ raise ValueError("Data manifest available must be a boolean.")
81
+ record.manifest = dict(manifest)
82
+ record.provider_id = manifest["provider_id"]
83
+ stage = "get_prompt_path"
84
+ record.guidance = _read_guidance(plugin.get_prompt_path())
85
+ stage = "is_available"
86
+ available = plugin.is_available()
87
+ if type(available) is not bool:
88
+ raise TypeError("Data plugin is_available() must return a boolean.")
89
+ if available != manifest["available"]:
90
+ raise ValueError("Data plugin availability contradicts its manifest.")
91
+ if not available:
92
+ record.status = "unavailable"
93
+ record.reason = manifest.get("unavailable_reason") or "Installed data source is unavailable or missing configuration."
94
+ continue
95
+ stage = "get_provider"
96
+ provider = plugin.get_provider()
97
+ for method in ("load_bars", "describe_request", "is_available", "cache_identity"):
98
+ if not callable(getattr(provider, method, None)):
99
+ raise TypeError(f"Data provider requires {method}().")
100
+ stage = "provider.is_available"
101
+ provider_available = provider.is_available()
102
+ if type(provider_available) is not bool:
103
+ raise TypeError("Data provider is_available() must return a boolean.")
104
+ if not provider_available:
105
+ record.status = "unavailable"
106
+ record.reason = "Provider reports unavailable despite the plugin and manifest declaring availability."
107
+ continue
108
+ record.provider = provider
109
+ except Exception as exc:
110
+ record.status, record.reason = "load_failed", f"{stage}: {exc}"
111
+ identities = {}
112
+ for record in records:
113
+ if record.provider_id is not None:
114
+ identities.setdefault(record.provider_id, []).append(record)
115
+ for identity, duplicates in identities.items():
116
+ if len(duplicates) > 1:
117
+ for record in duplicates:
118
+ record.provider = None
119
+ record.status, record.reason = "load_failed", f"Duplicate provider_id: {identity}"
120
+ for record in records:
121
+ if record.status != "available":
122
+ logger.warning("Data source %s %s: %s", record.entry_point or record.provider_id, record.status, record.reason)
123
+ return records
124
+
125
+
126
+ def get_default_data_provider(cache_dir: str | None = None, local_data_dir: str = "workspace/data") -> DataLoader:
127
+ """Assemble an application-owned loader with the host's source selection."""
128
+ return DataLoader(discover_data_providers(local_data_dir=local_data_dir), cache_dir=cache_dir,
129
+ default_provider_id=os.environ.get("NLALPHA_DATA_PROVIDER"))
@@ -0,0 +1,17 @@
1
+ ## Data loading and coverage guide
2
+
3
+ Choose `provider_id` explicitly when multiple sources are installed, or configure `NLALPHA_DATA_PROVIDER`. A requested `price_adjustment` must be established before loading; unknown evidence cannot satisfy forward/backward/unadjusted requirements. Responses bind `source` to the loaded artifact, whose `nlalpha.market.source.v1` metadata records per-symbol evidence and cache use.
4
+
5
+ `check_data_coverage` summarizes the requested symbol, interval, and bounds with returned bar count and observed endpoints. Current provider availability does not guarantee that a specific instrument, range, or interval is supported or complete. `get_data_quality` checks OHLC range consistency and zero-volume bars; it is a limited diagnostic, not a full market-data audit. Read each response's `error`, available fields, and scope; absent evidence remains unknown.
6
+
7
+ Keep data work within the user's request. `load_bars` returns a reference, bar-count summary, and time/unit contract metadata; it does not return bars or per-day observed session dates. Do not load unrelated ranges, create extra `data_ref` values, or start a real backtest when the user only requested a data check. Keep caller IDs and artifact ownership details internal in user-facing responses.
8
+
9
+ `load_bars` returns a caller-scoped `data_ref` plus `time_contract`, `volume_unit`, and `bar_contract` when loading succeeds. Treat a reference as immutable evidence for that loaded data and its declared contracts. The canonical time contract `nlalpha.market.time.v1` records UTC bounds and precision plus per-symbol source timezone, original timestamp semantics, calendar IDs, session-date availability, and time/calendar library provenance where available. Read this metadata with the bars when a question depends on source time or session labels.
10
+
11
+ Provider conversion happens before storage and is declared by `volume_unit` and `bar_contract`. The current bar contract labels volume as `instrument_quantity`; do not convert those values a second time. If an older reference has a different or absent contract, reload the bars to obtain a current reference rather than rewriting or reusing the old reference as if it had new units. A null or absent unit is unknown, not zero.
12
+
13
+ Keep source time, analysis time, and display time distinct. Source timezone and timestamp semantics describe the origin; canonical bars preserve resolved `open_time` and `close_time` bounds in UTC and original source metadata. An explicit `open_time`/`close_time` pair represents actual endpoints whose width may differ from the interval label. For raw timestamps, verify whether the source timestamp is aware or naive and establish its endpoint/session meaning from source evidence; naive values need an explicitly verified source timezone. Do not infer meaning from the timestamp's appearance, local date, or interval. Conflicting declarations against a canonical time contract are invalid.
14
+
15
+ Backtest `execution.analysis_timezone` controls local Gregorian month/year analysis and defaults to UTC; it does not change source instants. A requested `display_timezone` only adds formatted presentation values. Neither changes the stored UTC timestamps, source timezone, session date, or bar bounds. Missing source timezone, endpoint semantics, calendar, or session bounds leaves the affected conclusion unknown.
16
+
17
+ Calendar tools report calendar sessions, not data coverage. For an exact missing-session count, retrieve actual observed session dates only through an authorized artifact-reading capability that is callable in the current tool catalog, then compare those dates with sessions from the applicable calendar and session schedule. `bar_count` alone cannot establish which dates are absent, and intraday rows cannot be compared directly to a daily session count. If that observation-reading capability, the required per-day observations, or an instrument-appropriate session definition is unavailable, state that the exact missing count is unknown.
@@ -0,0 +1,17 @@
1
+ ## File lookup and row contract
2
+
3
+ The local provider resolves its configured data directory and searches for one symbol file in this order: `<symbol>.csv`, `<symbol>.xlsx`, then `<symbol>.xls`. Forward slashes and backslashes in the symbol are replaced with underscores for the filename. CSV rows are read with the standard library; Excel requires pandas. The first matching file is used, and a missing file raises an error. A directory being available says nothing about whether it contains a file for the requested symbol.
4
+
5
+ OHLCV columns accept the provider's declared English and Chinese aliases. Rows may describe bounds with both `open_time` and `close_time`, one `datetime` value, or a date label. A single timestamp requires explicit `open` or `close` semantics from the source contract and a fixed interval. Date-only rows require `trading_date` semantics and an explicit calendar. Source time declarations may come from the provider's `time_contracts` constructor mapping or a matching `<symbol>.time.json` sidecar; conflicting declarations fail. The sidecar accepts `timezone`, `timestamp_semantics`, and `calendar_id`.
6
+
7
+ The returned Bars retain UTC bounds and source timezone, offset, timestamp semantics, session date, and calendar identity when established. Explicit bounds are preserved; one timestamp is expanded using the declared interval, and trading-date rows use calendar session bounds. Invalid timestamps, missing OHLCV values, non-finite numbers, and unsupported calendar assumptions fail rather than being guessed.
8
+
9
+ ## Price adjustment and host declarations
10
+
11
+ The local provider reports price adjustment as `unknown` unless the host supplies a symbol-specific declaration when constructing it. `nlalpha-data` reads `NLALPHA_LOCAL_PRICE_ADJUSTMENTS` as a JSON object for this declaration. The value is recorded as host evidence; it is not inferred from prices. If a request explicitly requires a price adjustment that the declaration cannot establish, the data loader fails the request.
12
+
13
+ ## Availability, cache identity, and source selection
14
+
15
+ The provider is available when its configured data path exists and is a directory. This does not prove a requested symbol file exists or that its rows cover the requested interval. Cache identity includes the resolved data directory, time-contract configuration, and price-adjustment declarations, so cached bars remain bound to the source configuration that produced them.
16
+
17
+ `DataLoader` selects one provider using an explicit provider ID, the configured default, or the single-available-provider rule. Ambiguous selection and source errors fail explicitly. The local provider does not fetch or substitute data from another source.
nlalpha_data/loader.py ADDED
@@ -0,0 +1,210 @@
1
+ """Load bars from one explicitly selected provider with request-bound provenance."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from hashlib import sha256
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from nlalpha.core.contracts.market import Bar
10
+ from nlalpha.core.errors import DataError, InvalidRequestError, NlAlphaError, INVALID_REQUEST
11
+ from nlalpha_data.base import DataProvider, validate_request_description
12
+ from nlalpha_data.parquet_store import ParquetBarStore
13
+
14
+
15
+ class DataSelectionError(NlAlphaError):
16
+ """Report an invalid provider or price-adjustment selection."""
17
+
18
+ error_type = INVALID_REQUEST
19
+
20
+
21
+ @dataclass
22
+ class DataProviderRecord:
23
+ """Retain one installed provider's identity and discovery outcome."""
24
+
25
+ provider_id: str | None
26
+ package: str | None
27
+ version: str | None
28
+ provider: DataProvider | None = None
29
+ manifest: dict = field(default_factory=dict)
30
+ status: str = "available"
31
+ reason: str | None = None
32
+ entry_point: str | None = None
33
+ entry_point_value: str | None = None
34
+ guidance: str | None = None
35
+
36
+ def summary(self) -> dict:
37
+ """Return the serializable discovery record."""
38
+ return {name: getattr(self, name) for name in (
39
+ "provider_id", "package", "version", "manifest", "status", "reason", "entry_point", "entry_point_value",
40
+ )}
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class LoadedBars:
45
+ """Bind returned bars to the source evidence used for this request."""
46
+
47
+ bars: list[Bar]
48
+ provider_id: str
49
+ package: str | None
50
+ version: str | None
51
+ price_adjustment: str
52
+ evidence: dict[str, str]
53
+ cache_hit: bool
54
+
55
+ @property
56
+ def source(self) -> dict[str, Any]:
57
+ """Return a fresh provenance snapshot for serialization."""
58
+ return {"provider_id": self.provider_id, "package": self.package, "version": self.version,
59
+ "price_adjustment": self.price_adjustment, "evidence": dict(self.evidence), "cache_hit": self.cache_hit}
60
+
61
+
62
+ class DataLoader:
63
+ """Select one provider and isolate caches by its declared request source."""
64
+
65
+ def __init__(self, records: list[DataProviderRecord], *, cache_dir: str | Path | None = None,
66
+ default_provider_id: str | None = None):
67
+ self.records = list(records)
68
+ self.cache_dir = Path(cache_dir) if cache_dir is not None else None
69
+ self.default_provider_id = default_provider_id
70
+ self._stores: dict[str, ParquetBarStore] = {}
71
+
72
+ def list_providers(self) -> list[dict]:
73
+ """List only providers usable by this loader."""
74
+ return [record.summary() for record in self.records if record.status == "available"]
75
+
76
+ def diagnostics(self) -> list[dict]:
77
+ """List installed providers with configuration or loading failures."""
78
+ return [record.summary() for record in self.records if record.status != "available"]
79
+
80
+ def get_record(self, provider_id: str) -> DataProviderRecord:
81
+ """Resolve one cached source identity and expose failed installations precisely."""
82
+ if not isinstance(provider_id, str) or not provider_id or provider_id != provider_id.strip():
83
+ raise InvalidRequestError("provider_id must be a nonempty trimmed string", stage="request")
84
+ matches = [record for record in self.records if record.provider_id == provider_id]
85
+ if len(matches) == 1 and matches[0].status == "available":
86
+ return matches[0]
87
+ faults = matches or [record for record in self.records if record.entry_point == provider_id]
88
+ if faults:
89
+ raise NlAlphaError(
90
+ f"Installed data provider is unavailable: {provider_id}",
91
+ details={"diagnostics": [record.summary() for record in faults]}, stage="load",
92
+ )
93
+ raise InvalidRequestError(
94
+ f"Unknown data provider: {provider_id}",
95
+ details={"available_providers": [record.provider_id for record in self.records if record.status == "available"]},
96
+ stage="request",
97
+ )
98
+
99
+ def _select(self, provider_id: str | None) -> DataProviderRecord:
100
+ available = {record.provider_id: record for record in self.records if record.status == "available"}
101
+ selected = provider_id if provider_id is not None else self.default_provider_id
102
+ if selected is not None and (not isinstance(selected, str) or not selected or selected != selected.strip()):
103
+ raise DataSelectionError("provider_id must be a nonempty trimmed string.")
104
+ if not available:
105
+ raise DataError("No data providers are available.", details={"diagnostics": self.diagnostics()})
106
+ if selected is None:
107
+ if len(available) != 1:
108
+ raise DataSelectionError("Select provider_id when multiple data providers are available.",
109
+ details={"available_provider_ids": sorted(available)})
110
+ return next(iter(available.values()))
111
+ if selected not in available:
112
+ raise DataSelectionError(f"Data provider {selected!r} is not available.",
113
+ details={"available_provider_ids": sorted(available)})
114
+ return available[selected]
115
+
116
+ async def load_bars(self, symbol: str, interval: str, start: str, end: str, *,
117
+ provider_id: str | None = None, price_adjustment: str | None = None) -> LoadedBars:
118
+ """Load one source after validating its request-specific price evidence."""
119
+ if price_adjustment is not None and (not isinstance(price_adjustment, str) or price_adjustment not in {"unadjusted", "forward", "backward"}):
120
+ raise DataSelectionError("price_adjustment must be unadjusted, forward, or backward.")
121
+ record = self._select(provider_id)
122
+ declaration = validate_request_description(
123
+ await record.provider.describe_request(symbol, interval, price_adjustment)
124
+ )
125
+ if price_adjustment is not None and declaration["price_adjustment"] != price_adjustment:
126
+ raise DataError("Selected source cannot establish the requested price adjustment.",
127
+ details={"requested_price_adjustment": price_adjustment, "source": declaration,
128
+ "provider_id": record.provider_id})
129
+ source = {"provider_id": record.provider_id, "package": record.package, "version": record.version, **declaration}
130
+ store = None
131
+ if self.cache_dir is not None:
132
+ identity = record.provider.cache_identity()
133
+ if not isinstance(identity, dict) or not identity:
134
+ raise DataError("Data provider cache_identity() must return a nonempty JSON object.")
135
+ try:
136
+ cache_key = json.dumps({"source": source, "configuration": identity}, sort_keys=True,
137
+ separators=(",", ":"), allow_nan=False)
138
+ except (ValueError, TypeError) as exc:
139
+ raise DataError("Data provider cache_identity() must contain canonical JSON values.") from exc
140
+ fingerprint = sha256(cache_key.encode()).hexdigest()
141
+ store = self._stores.get(fingerprint)
142
+ if store is None:
143
+ store = ParquetBarStore(self.cache_dir / fingerprint, source=source)
144
+ self._stores[fingerprint] = store
145
+ try:
146
+ bars = await store.load(symbol, interval, start, end)
147
+ except FileNotFoundError:
148
+ pass
149
+ else:
150
+ return LoadedBars(bars=bars, cache_hit=True, **source)
151
+ bars = await record.provider.load_bars(symbol, interval, start, end, price_adjustment=price_adjustment)
152
+ if store is not None and bars:
153
+ await store.save(bars, symbol=symbol, interval=interval, start=start, end=end)
154
+ return LoadedBars(bars=bars, cache_hit=False, **source)
155
+
156
+ @staticmethod
157
+ def _source(record: DataProviderRecord) -> dict[str, Any]:
158
+ """Return the provider identity attached to pass-through reference data."""
159
+ return {"provider_id": record.provider_id, "package": record.package, "version": record.version}
160
+
161
+ def _provider_call(self, method_name: str) -> tuple[DataProviderRecord, Any]:
162
+ """Resolve one required provider method or expose its absence."""
163
+ record = self._select(None)
164
+ method = getattr(record.provider, method_name, None)
165
+ if not callable(method):
166
+ raise DataError(f"Selected data provider does not expose {method_name}.")
167
+ return record, method
168
+
169
+ async def list_instruments(self, asset: str, *, market: str | None = None,
170
+ status: str | None = None) -> tuple[list[dict], dict[str, Any]]:
171
+ """List one asset class's instrument master with its source identity."""
172
+ record, method = self._provider_call("list_instruments")
173
+ rows = await method(asset, market=market, status=status)
174
+ return rows, self._source(record)
175
+
176
+ async def get_trading_calendar(self, exchange: str, start: str, end: str) -> tuple[list[dict], dict[str, Any]]:
177
+ """Return one exchange calendar with its source identity."""
178
+ record, method = self._provider_call("get_trading_calendar")
179
+ rows = await method(exchange, start, end)
180
+ return rows, self._source(record)
181
+
182
+ async def get_daily_basic(self, symbol: str, start: str, end: str) -> tuple[list[dict], dict[str, Any]]:
183
+ """Return daily fundamental metrics with their source identity."""
184
+ record, method = self._provider_call("get_daily_basic")
185
+ rows = await method(symbol, start, end)
186
+ return rows, self._source(record)
187
+
188
+ async def get_price_limits(self, symbol: str, start: str, end: str) -> tuple[list[dict], dict[str, Any]]:
189
+ """Return daily price limits with their source identity."""
190
+ record, method = self._provider_call("get_price_limits")
191
+ rows = await method(symbol, start, end)
192
+ return rows, self._source(record)
193
+
194
+ async def get_suspensions(self, symbol: str, start: str, end: str) -> tuple[list[dict], dict[str, Any]]:
195
+ """Return daily suspension rows with their source identity."""
196
+ record, method = self._provider_call("get_suspensions")
197
+ rows = await method(symbol, start, end)
198
+ return rows, self._source(record)
199
+
200
+ async def get_index_weights(self, index_code: str, start: str, end: str) -> tuple[list[dict], dict[str, Any]]:
201
+ """Return index constituent weights with their source identity."""
202
+ record, method = self._provider_call("get_index_weights")
203
+ rows = await method(index_code, start, end)
204
+ return rows, self._source(record)
205
+
206
+ async def aclose(self) -> None:
207
+ """Close all cache resources owned by this loader."""
208
+ for store in self._stores.values():
209
+ await store.aclose()
210
+ self._stores.clear()