mif-dal 0.1.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.
dal/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """MIF-DAL — Data Abstraction Layer."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _pkg_version
5
+
6
+ try:
7
+ __version__ = _pkg_version("mif-dal")
8
+ except PackageNotFoundError:
9
+ __version__ = "0.1.0"
10
+
11
+ from dal.core.config import DALConfig
12
+ from dal.core.handoff import DALHandoff
13
+ from dal.dal import DAL
14
+ from dal.exceptions import (
15
+ DALConfigError,
16
+ DALError,
17
+ DALHandoffError,
18
+ DALVersionError,
19
+ )
20
+
21
+ __all__ = [
22
+ "DAL",
23
+ "DALConfig",
24
+ "DALConfigError",
25
+ "DALError",
26
+ "DALHandoff",
27
+ "DALHandoffError",
28
+ "DALVersionError",
29
+ ]
@@ -0,0 +1,7 @@
1
+ """DAL adapters package."""
2
+
3
+ from .dukascopy import DukascopyAdapter
4
+ from .kraken import KrakenAdapter
5
+ from .yahoo import YahooAdapter
6
+
7
+ __all__ = ["KrakenAdapter", "DukascopyAdapter", "YahooAdapter"]
@@ -0,0 +1,349 @@
1
+ """
2
+ dal/adapters/dukascopy.py
3
+ ===========================
4
+ DukascopyAdapter — Source Protocol implementation via dukascopy-node (CLI).
5
+
6
+ Responsibility:
7
+ Download OHLCV data via dukascopy-node CLI (Node.js),
8
+ parse the resulting CSV, compute SHA-256 hash of raw bytes,
9
+ and return a FetchResult.
10
+
11
+ System requirements:
12
+ - Node.js ≥ 16
13
+ - dukascopy-node installed globally: npm install -g dukascopy-node
14
+ - Verify with: npx dukascopy-node --help
15
+
16
+ Documented limitations:
17
+ - PAXG not available → supports("PAXG-USD") returns False
18
+ - Max 12 months history per asset
19
+ - Depends on Node.js subprocess → mandatory timeout
20
+ - Bid price data only (no ask)
21
+
22
+ KB-008 pattern applied:
23
+ subprocess with timeout=300 + stdin=subprocess.DEVNULL
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import subprocess
30
+ import tempfile
31
+ from datetime import UTC, datetime
32
+ from pathlib import Path
33
+ from typing import ClassVar
34
+
35
+ import pandas as pd
36
+
37
+ from dal.interfaces.source import FetchRequest, FetchResult
38
+
39
+
40
+ class DukascopyAdapter:
41
+ """
42
+ Dukascopy adapter for MIF-DAL.
43
+
44
+ Uses dukascopy-node CLI to download OHLCV data.
45
+ Each download produces a temporary CSV whose raw bytes
46
+ are hashed before parsing.
47
+
48
+ Note: PAXG is not available on Dukascopy. This is not a bug.
49
+ For PAXG, use KrakenAdapter or YahooAdapter.
50
+ """
51
+
52
+ # DAL asset_id → dukascopy-node symbol mapping
53
+ SYMBOL_MAP: ClassVar[dict[str, str]] = {
54
+ "BTC-USD": "btcusd",
55
+ "ETH-USD": "ethusd",
56
+ "XRP-USD": "xrpusd",
57
+ "SOL-USD": "solusd",
58
+ "ADA-USD": "adausd",
59
+ "DOT-USD": "dotusd",
60
+ "AVAX-USD": "avaxusd",
61
+ "LINK-USD": "linkusd",
62
+ # PAXG-USD: not available on Dukascopy
63
+ }
64
+
65
+ # DAL timeframe → dukascopy-node code mapping
66
+ TIMEFRAME_MAP: ClassVar[dict[str, str]] = {
67
+ "1D": "d1",
68
+ "4H": "h4",
69
+ "1H": "h1",
70
+ "30M": "m30",
71
+ "15M": "m15",
72
+ "5M": "m5",
73
+ "1M": "m1",
74
+ }
75
+
76
+ def __init__(
77
+ self,
78
+ node_timeout: int = 300,
79
+ max_history_days: int = 365,
80
+ ) -> None:
81
+ """
82
+ Args:
83
+ node_timeout: Seconds before Node.js subprocess timeout.
84
+ max_history_days: Dukascopy limit (12 months = 365 days).
85
+ """
86
+ self._timeout = node_timeout
87
+ self._max_days = max_history_days
88
+ self._node_available: bool | None = None
89
+
90
+ # ── Source Protocol ────────────────────────────────────────────────────
91
+
92
+ @property
93
+ def source_id(self) -> str:
94
+ return "dukascopy"
95
+
96
+ def supports(self, asset_id: str) -> bool:
97
+ """Return True if the asset is available on Dukascopy."""
98
+ return asset_id in self.SYMBOL_MAP
99
+
100
+ def fetch(self, request: FetchRequest) -> FetchResult:
101
+ """
102
+ Download OHLCV data via dukascopy-node.
103
+
104
+ Sequence:
105
+ 1. Check Node.js and dukascopy-node availability
106
+ 2. Launch subprocess with timeout + stdin=DEVNULL
107
+ 3. Read raw bytes of produced CSV
108
+ 4. Compute SHA-256 on raw bytes
109
+ 5. Parse CSV to normalized OHLCV DataFrame
110
+ 6. Return FetchResult
111
+
112
+ Returns:
113
+ FetchResult with status "success" | "partial" | "failed".
114
+ """
115
+ fetched_at = datetime.now(tz=UTC)
116
+
117
+ if not self.supports(request.asset_id):
118
+ return FetchResult(
119
+ data=pd.DataFrame(),
120
+ hash="",
121
+ status="failed",
122
+ source_id=self.source_id,
123
+ fetched_at=fetched_at,
124
+ rows=0,
125
+ timeframe=request.timeframe,
126
+ fallback=False,
127
+ )
128
+
129
+ if request.timeframe not in self.TIMEFRAME_MAP:
130
+ return FetchResult(
131
+ data=pd.DataFrame(),
132
+ hash="",
133
+ status="failed",
134
+ source_id=self.source_id,
135
+ fetched_at=fetched_at,
136
+ rows=0,
137
+ timeframe=request.timeframe,
138
+ fallback=False,
139
+ )
140
+
141
+ if not self._is_node_available():
142
+ return FetchResult(
143
+ data=pd.DataFrame(),
144
+ hash="",
145
+ status="failed",
146
+ source_id=self.source_id,
147
+ fetched_at=fetched_at,
148
+ rows=0,
149
+ timeframe=request.timeframe,
150
+ fallback=False,
151
+ )
152
+
153
+ duka_symbol = self.SYMBOL_MAP[request.asset_id]
154
+ duka_timeframe = self.TIMEFRAME_MAP[request.timeframe]
155
+
156
+ # Dukascopy limited to 12 months — document truncation in FetchResult
157
+ start_ts = pd.Timestamp(request.start, tz="UTC")
158
+ end_ts = pd.Timestamp(request.end, tz="UTC")
159
+ actual_start = start_ts
160
+ truncated = False
161
+ if (end_ts - start_ts).days > self._max_days:
162
+ actual_start = end_ts - pd.Timedelta(days=self._max_days)
163
+ truncated = True
164
+
165
+ with tempfile.TemporaryDirectory() as tmpdir:
166
+ cmd = [
167
+ "npx",
168
+ "dukascopy-node",
169
+ "-i",
170
+ duka_symbol,
171
+ "-from",
172
+ actual_start.strftime("%Y-%m-%d"),
173
+ "-to",
174
+ end_ts.strftime("%Y-%m-%d"),
175
+ "-t",
176
+ duka_timeframe,
177
+ "-f",
178
+ "csv",
179
+ "-p",
180
+ "bid",
181
+ "-dir",
182
+ tmpdir,
183
+ ]
184
+
185
+ try:
186
+ proc = subprocess.run(
187
+ cmd,
188
+ capture_output=True,
189
+ text=False, # bytes, not str
190
+ timeout=self._timeout,
191
+ stdin=subprocess.DEVNULL, # KB-008: avoid stdin blocking
192
+ )
193
+ except subprocess.TimeoutExpired:
194
+ return FetchResult(
195
+ data=pd.DataFrame(),
196
+ hash="",
197
+ status="failed",
198
+ source_id=self.source_id,
199
+ fetched_at=fetched_at,
200
+ rows=0,
201
+ timeframe=request.timeframe,
202
+ fallback=False,
203
+ )
204
+
205
+ if proc.returncode != 0:
206
+ return FetchResult(
207
+ data=pd.DataFrame(),
208
+ hash="",
209
+ status="failed",
210
+ source_id=self.source_id,
211
+ fetched_at=fetched_at,
212
+ rows=0,
213
+ timeframe=request.timeframe,
214
+ fallback=False,
215
+ )
216
+
217
+ # Find CSV produced in tmpdir
218
+ csv_files = list(Path(tmpdir).glob("*.csv"))
219
+ if not csv_files:
220
+ return FetchResult(
221
+ data=pd.DataFrame(),
222
+ hash="",
223
+ status="failed",
224
+ source_id=self.source_id,
225
+ fetched_at=fetched_at,
226
+ rows=0,
227
+ timeframe=request.timeframe,
228
+ fallback=False,
229
+ )
230
+
231
+ csv_path = csv_files[0]
232
+ raw_bytes = csv_path.read_bytes()
233
+
234
+ if len(raw_bytes) < 50:
235
+ return FetchResult(
236
+ data=pd.DataFrame(),
237
+ hash="",
238
+ status="failed",
239
+ source_id=self.source_id,
240
+ fetched_at=fetched_at,
241
+ rows=0,
242
+ timeframe=request.timeframe,
243
+ fallback=False,
244
+ )
245
+
246
+ # Hash on raw bytes before any parsing
247
+ file_hash = hashlib.sha256(raw_bytes).hexdigest()
248
+
249
+ df = self._parse_csv(raw_bytes)
250
+
251
+ if df.empty:
252
+ return FetchResult(
253
+ data=pd.DataFrame(),
254
+ hash=file_hash,
255
+ status="failed",
256
+ source_id=self.source_id,
257
+ fetched_at=fetched_at,
258
+ rows=0,
259
+ timeframe=request.timeframe,
260
+ fallback=False,
261
+ )
262
+
263
+ status = "partial" if truncated else "success"
264
+ return FetchResult(
265
+ data=df,
266
+ hash=file_hash,
267
+ status=status,
268
+ source_id=self.source_id,
269
+ fetched_at=fetched_at,
270
+ rows=len(df),
271
+ timeframe=request.timeframe,
272
+ fallback=False,
273
+ )
274
+
275
+ # ── Internal methods ─────────────────────────────────────────────────
276
+
277
+ def _is_node_available(self) -> bool:
278
+ """
279
+ Check Node.js + dukascopy-node availability once
280
+ (result cached).
281
+ """
282
+ if self._node_available is not None:
283
+ return self._node_available
284
+ try:
285
+ r = subprocess.run(
286
+ ["npx", "dukascopy-node", "--help"],
287
+ capture_output=True,
288
+ timeout=15,
289
+ stdin=subprocess.DEVNULL,
290
+ )
291
+ self._node_available = r.returncode == 0
292
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
293
+ self._node_available = False
294
+ return self._node_available
295
+
296
+ def _parse_csv(self, raw_bytes: bytes) -> pd.DataFrame:
297
+ """
298
+ Parse Dukascopy CSV to normalized OHLCV DataFrame.
299
+
300
+ Dukascopy CSV: timestamp (ms Unix), open, high, low, close, volume
301
+ Header may or may not be present depending on dukascopy-node version.
302
+ """
303
+ import io
304
+
305
+ text = raw_bytes.decode("utf-8", errors="replace")
306
+ lines = text.strip().splitlines()
307
+
308
+ if not lines:
309
+ return pd.DataFrame()
310
+
311
+ # Detect if CSV has header
312
+ has_header = "timestamp" in lines[0].lower() or "time" in lines[0].lower()
313
+
314
+ if has_header:
315
+ df = pd.read_csv(io.StringIO(text))
316
+ df.columns = [c.lower().strip() for c in df.columns]
317
+ else:
318
+ df = pd.read_csv(
319
+ io.StringIO(text),
320
+ header=None,
321
+ names=["timestamp", "open", "high", "low", "close", "volume"],
322
+ )
323
+
324
+ if df.empty:
325
+ return df
326
+
327
+ # Normalize timestamp (milliseconds → DatetimeIndex UTC)
328
+ time_col = "timestamp" if "timestamp" in df.columns else "time"
329
+ if pd.api.types.is_numeric_dtype(df[time_col]):
330
+ df["timestamp"] = pd.to_datetime(df[time_col], unit="ms", utc=True)
331
+ else:
332
+ df["timestamp"] = pd.to_datetime(df[time_col], utc=True)
333
+
334
+ df = df.set_index("timestamp")
335
+ df.index.name = None
336
+
337
+ # Keep only OHLCV columns
338
+ cols = ["open", "high", "low", "close", "volume"]
339
+ available = [c for c in cols if c in df.columns]
340
+ if "volume" not in available:
341
+ df["volume"] = 0.0
342
+ available.append("volume")
343
+
344
+ df = df[cols].astype(float)
345
+ df = df.sort_index()
346
+ df = df[~df.index.duplicated(keep="first")]
347
+ df = df.dropna()
348
+
349
+ return df
@@ -0,0 +1,93 @@
1
+ """Deterministic in-memory source adapter for tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from datetime import UTC, datetime
7
+
8
+ import pandas as pd
9
+
10
+ from dal.interfaces.source import FetchRequest, FetchResult, SourceFetchError
11
+
12
+
13
+ class InMemorySource:
14
+ """Test adapter: pre-loaded data, optional failure simulation.
15
+
16
+ Args:
17
+ source_id: identifier exposed in source_manifest entries.
18
+ data: mapping asset_id -> raw OHLCV DataFrame.
19
+ fail_attempts: first N fetch() calls raise SourceFetchError;
20
+ subsequent calls succeed. Used to test retry behavior.
21
+ permanent_failure: every fetch() raises (used to test fallback).
22
+ truncate_start_days: drop N rows from the start of returned data
23
+ (simulates a source whose history starts after the request).
24
+ truncate_end_days: drop N rows from the end (simulates source
25
+ ending before the request).
26
+ """
27
+
28
+ source_id: str
29
+
30
+ def __init__(
31
+ self,
32
+ source_id: str,
33
+ data: dict[str, pd.DataFrame],
34
+ *,
35
+ fail_attempts: int = 0,
36
+ permanent_failure: bool = False,
37
+ truncate_start_days: int = 0,
38
+ truncate_end_days: int = 0,
39
+ ) -> None:
40
+ self.source_id = source_id
41
+ self._data = data
42
+ self._fail_attempts = fail_attempts
43
+ self._permanent_failure = permanent_failure
44
+ self._truncate_start_days = truncate_start_days
45
+ self._truncate_end_days = truncate_end_days
46
+ self._call_count = 0
47
+
48
+ def supports(self, asset_id: str) -> bool:
49
+ return asset_id in self._data
50
+
51
+ def fetch(self, request: FetchRequest) -> FetchResult:
52
+ self._call_count += 1
53
+
54
+ if self._permanent_failure:
55
+ raise SourceFetchError(
56
+ f"{self.source_id} permanent failure for {request.asset_id}",
57
+ source_id=self.source_id,
58
+ )
59
+
60
+ if self._call_count <= self._fail_attempts:
61
+ raise SourceFetchError(
62
+ f"{self.source_id} transient failure (attempt " f"{self._call_count})",
63
+ source_id=self.source_id,
64
+ )
65
+
66
+ if request.asset_id not in self._data:
67
+ raise SourceFetchError(
68
+ f"{self.source_id} does not have {request.asset_id}",
69
+ source_id=self.source_id,
70
+ )
71
+
72
+ df = self._data[request.asset_id]
73
+ start_ts = pd.Timestamp(request.start, tz="UTC")
74
+ end_ts = pd.Timestamp(request.end, tz="UTC")
75
+ df = df.loc[(df.index >= start_ts) & (df.index <= end_ts)]
76
+
77
+ if self._truncate_start_days:
78
+ df = df.iloc[self._truncate_start_days :]
79
+ if self._truncate_end_days:
80
+ df = df.iloc[: -self._truncate_end_days]
81
+
82
+ raw_bytes = df.to_parquet()
83
+ digest = hashlib.sha256(raw_bytes).hexdigest()
84
+
85
+ return FetchResult(
86
+ data=df,
87
+ source_id=self.source_id,
88
+ status="success",
89
+ rows=len(df),
90
+ fetched_at=datetime.now(UTC),
91
+ hash=digest,
92
+ timeframe=request.timeframe,
93
+ )