dccd 2.3.0__tar.gz → 2.3.1__tar.gz
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.
- {dccd-2.3.0 → dccd-2.3.1}/CHANGELOG.md +12 -0
- {dccd-2.3.0 → dccd-2.3.1}/PKG-INFO +1 -1
- {dccd-2.3.0 → dccd-2.3.1}/dccd/histo_dl/coinbase.py +6 -3
- {dccd-2.3.0 → dccd-2.3.1}/dccd/histo_dl/exchange.py +16 -3
- {dccd-2.3.0 → dccd-2.3.1}/dccd/histo_dl/okx.py +5 -2
- {dccd-2.3.0 → dccd-2.3.1}/dccd/storage.py +14 -9
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_coinbase.py +1 -1
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_storage.py +46 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd.egg-info/PKG-INFO +1 -1
- {dccd-2.3.0 → dccd-2.3.1}/pyproject.toml +1 -1
- {dccd-2.3.0 → dccd-2.3.1}/CONTRIBUTING.md +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/LICENSE.txt +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/MANIFEST.in +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/README.rst +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/__init__.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/continuous_dl/__init__.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/continuous_dl/binance.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/continuous_dl/bitfinex.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/continuous_dl/bitmex.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/continuous_dl/bybit.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/continuous_dl/exchange.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/continuous_dl/kraken.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/continuous_dl/okx.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/daemon/__init__.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/daemon/backfill.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/daemon/cli.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/daemon/config.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/daemon/health.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/daemon/scheduler.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/daemon/storage.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/daemon/stream_manager.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/histo_dl/__init__.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/histo_dl/binance.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/histo_dl/bybit.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/histo_dl/kraken.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/models.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/process_data.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/__init__.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/conftest.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_backfill.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_binance.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_binance_ws.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_bitfinex.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_bitmex.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_bybit.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_bybit_ws.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_daemon_cli.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_daemon_config.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_daemon_health.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_daemon_scheduler.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_daemon_storage.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_daemon_stream_manager.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_date_time.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_histo_dl.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_io.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_kraken.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_kraken_ws.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_models.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_okx.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_okx_ws.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_process_data.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tests/test_websocket.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tools/__init__.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tools/date_time.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tools/io.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd/tools/websocket.py +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd.egg-info/SOURCES.txt +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd.egg-info/dependency_links.txt +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd.egg-info/entry_points.txt +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd.egg-info/requires.txt +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/dccd.egg-info/top_level.txt +0 -0
- {dccd-2.3.0 → dccd-2.3.1}/setup.cfg +0 -0
|
@@ -6,6 +6,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.3.1] - 2026-05-24
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- `dccd/storage.py` — `DataStore.missing_intervals` now detects the gap **before** the first saved row when the requested `start` predates `file_min`; previously only the trailing gap (after `file_max`) was returned, causing `dccd backfill --start <early-date>` to silently skip all historical data before the first existing candle (#46)
|
|
14
|
+
- `dccd/histo_dl/coinbase.py` — raise `RuntimeError` when Coinbase returns HTTP 200 with a JSON dict (e.g. `{"message": "..."}` for near-future windows) instead of silently iterating dict keys and crashing with `ValueError` (#45)
|
|
15
|
+
- `dccd/histo_dl/coinbase.py` — additional guard: raise `RuntimeError` when Coinbase returns a JSON list whose first element is not itself a list/tuple (e.g. `["message"]`); previously caused `float("m")` `ValueError` (#45)
|
|
16
|
+
- `dccd/histo_dl/exchange.py` — `_sort_data` no longer raises `KeyError: 'TS'` when the API returns empty data; returns early with an empty `self.df` so the backfill skips the window cleanly (#45)
|
|
17
|
+
- `dccd/histo_dl/exchange.py` — `_sort_data` strips any candle at or beyond `self.end` before merging; exchanges with inclusive endpoint semantics (Coinbase) no longer cause `_advance` to overshoot by one span per window, preventing drift that accumulated into near-future requests (#45)
|
|
18
|
+
- `dccd/histo_dl/okx.py` — raise `RuntimeError` when OKX response code is not `"0"`, letting the backfill retry/skip logic handle API-level errors (#45)
|
|
19
|
+
- `dccd/histo_dl/okx.py` — switch `_import_data` from `/market/candles` to `/market/history-candles`; the former only serves the last ~24 h of 1-minute bars and silently returns empty data for older windows (#45)
|
|
20
|
+
|
|
9
21
|
## [2.3.0] - 2026-05-22
|
|
10
22
|
|
|
11
23
|
### Added
|
|
@@ -26,7 +26,6 @@ from typing import Any
|
|
|
26
26
|
from dccd.histo_dl.exchange import ImportDataCryptoCurrencies
|
|
27
27
|
|
|
28
28
|
# Import local packages
|
|
29
|
-
from dccd.tools.date_time import TS_to_date
|
|
30
29
|
|
|
31
30
|
__all__ = ['FromCoinbase']
|
|
32
31
|
|
|
@@ -116,8 +115,8 @@ class FromCoinbase(ImportDataCryptoCurrencies):
|
|
|
116
115
|
def _import_data(self, start: int | str = 'last', end: int | str = 'now') -> list[dict[str, Any]]:
|
|
117
116
|
self.start, self.end = self._set_time(start, end)
|
|
118
117
|
param = {
|
|
119
|
-
'start':
|
|
120
|
-
'end':
|
|
118
|
+
'start': datetime.fromtimestamp(self.start, tz=timezone.utc).isoformat(),
|
|
119
|
+
'end': datetime.fromtimestamp(self.end, tz=timezone.utc).isoformat(),
|
|
121
120
|
'granularity': self.span,
|
|
122
121
|
}
|
|
123
122
|
r = self._fetch(
|
|
@@ -127,6 +126,10 @@ class FromCoinbase(ImportDataCryptoCurrencies):
|
|
|
127
126
|
param,
|
|
128
127
|
)
|
|
129
128
|
text = r.json()
|
|
129
|
+
if not isinstance(text, list):
|
|
130
|
+
raise RuntimeError(f"Coinbase API error: {text!r:.200}")
|
|
131
|
+
if text and not isinstance(text[0], (list, tuple)):
|
|
132
|
+
raise RuntimeError(f"Coinbase unexpected response: {text[:3]!r:.200}")
|
|
130
133
|
data = [{
|
|
131
134
|
'date': float(e[0]),
|
|
132
135
|
'open': float(e[3]),
|
|
@@ -128,10 +128,9 @@ class ImportDataCryptoCurrencies(ABC):
|
|
|
128
128
|
wait=wait_exponential(multiplier=1, min=1, max=60),
|
|
129
129
|
stop=stop_after_attempt(5))
|
|
130
130
|
def _fetch(self, url: str, params: dict[str, Any]) -> requests.Response:
|
|
131
|
-
""" Fetch URL
|
|
131
|
+
""" Fetch URL and raise on any HTTP error. """
|
|
132
132
|
r = requests.get(url, params)
|
|
133
|
-
|
|
134
|
-
r.raise_for_status()
|
|
133
|
+
r.raise_for_status()
|
|
135
134
|
return r
|
|
136
135
|
|
|
137
136
|
def _get_last_date(self) -> int:
|
|
@@ -228,6 +227,17 @@ class ImportDataCryptoCurrencies(ABC):
|
|
|
228
227
|
"""
|
|
229
228
|
data = [OHLCBar(**d).model_dump(exclude_none=False) for d in data]
|
|
230
229
|
df = pd.DataFrame(data).rename(columns={'date': 'TS'})
|
|
230
|
+
if df.empty or 'TS' not in df.columns:
|
|
231
|
+
self.df = df
|
|
232
|
+
return self
|
|
233
|
+
# Discard any candle at or beyond self.end: those belong to the next
|
|
234
|
+
# window. Without this filter, exchanges that return the endpoint
|
|
235
|
+
# candle (inclusive API boundary) would cause _advance to overshoot
|
|
236
|
+
# by one span, and the drift compounds over many windows.
|
|
237
|
+
# Guard: only filter when self.end is set (> 0); direct callers that
|
|
238
|
+
# bypass _set_time leave self.end = 0, so we skip the filter.
|
|
239
|
+
if self.end > 0:
|
|
240
|
+
df = df[df['TS'] < self.end]
|
|
231
241
|
# Use self.end as the exclusive grid boundary so the full window is
|
|
232
242
|
# covered even when the last trade arrives >span seconds before the
|
|
233
243
|
# window end. Callers must set self.end to the correct window
|
|
@@ -236,6 +246,9 @@ class ImportDataCryptoCurrencies(ABC):
|
|
|
236
246
|
list(range(self.start, self.end, self.span)),
|
|
237
247
|
columns=['TS']
|
|
238
248
|
)
|
|
249
|
+
if df.empty or 'TS' not in df.columns:
|
|
250
|
+
self.df = df
|
|
251
|
+
return self
|
|
239
252
|
df = (df.merge(TS, on='TS', how='outer', sort=False)
|
|
240
253
|
.sort_values('TS')
|
|
241
254
|
.reset_index(drop=True)
|
|
@@ -145,8 +145,11 @@ class FromOKX(ImportDataCryptoCurrencies):
|
|
|
145
145
|
'limit': 300,
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
r = self._fetch('https://www.okx.com/api/v5/market/candles', param)
|
|
149
|
-
|
|
148
|
+
r = self._fetch('https://www.okx.com/api/v5/market/history-candles', param)
|
|
149
|
+
body = r.json()
|
|
150
|
+
if body.get('code', '0') != '0':
|
|
151
|
+
raise RuntimeError(f"OKX API error {body['code']}: {body.get('msg', body)}")
|
|
152
|
+
text = body['data']
|
|
150
153
|
text.reverse()
|
|
151
154
|
|
|
152
155
|
data = [{
|
|
@@ -319,18 +319,23 @@ class DataStore:
|
|
|
319
319
|
|
|
320
320
|
file_path = self.directory / f'{year}.parquet'
|
|
321
321
|
|
|
322
|
-
if
|
|
323
|
-
if self.is_period_complete(year):
|
|
322
|
+
if file_path.exists():
|
|
323
|
+
if year < current_year and self.is_period_complete(year):
|
|
324
324
|
continue # full year already on disk — skip
|
|
325
|
-
# incomplete past year: resume from last saved row
|
|
326
|
-
df = pd.read_parquet(file_path, columns=['TS'])
|
|
327
|
-
if not df.empty:
|
|
328
|
-
ivl_start = int(df['TS'].max()) + self.span
|
|
329
|
-
elif file_path.exists():
|
|
330
|
-
# current year: always extend from the last saved row
|
|
331
325
|
df = pd.read_parquet(file_path, columns=['TS'])
|
|
332
326
|
if not df.empty:
|
|
333
|
-
|
|
327
|
+
file_min = int(df['TS'].min())
|
|
328
|
+
file_max = int(df['TS'].max())
|
|
329
|
+
# Gap before the first saved row (e.g. backfill requested
|
|
330
|
+
# from an earlier date than the file's first candle)
|
|
331
|
+
if ivl_start < file_min:
|
|
332
|
+
intervals.append((ivl_start, file_min))
|
|
333
|
+
# Trailing gap after the last saved row
|
|
334
|
+
trailing = file_max + self.span
|
|
335
|
+
if trailing < ivl_end:
|
|
336
|
+
intervals.append((trailing, ivl_end))
|
|
337
|
+
continue
|
|
338
|
+
# file exists but is empty: fall through to full-interval append
|
|
334
339
|
|
|
335
340
|
if ivl_start < ivl_end:
|
|
336
341
|
intervals.append((ivl_start, ivl_end))
|
|
@@ -47,7 +47,7 @@ def test_malformed_response_raises(loader, monkeypatch):
|
|
|
47
47
|
m.status_code = 200
|
|
48
48
|
m.json.return_value = {"error": "bad"}
|
|
49
49
|
monkeypatch.setattr("requests.get", lambda *a, **kw: m)
|
|
50
|
-
with pytest.raises((TypeError, ValueError)):
|
|
50
|
+
with pytest.raises((TypeError, ValueError, RuntimeError)):
|
|
51
51
|
loader._import_data(start=0)
|
|
52
52
|
|
|
53
53
|
|
|
@@ -336,6 +336,52 @@ def test_missing_intervals_already_up_to_date(tmp_path):
|
|
|
336
336
|
assert intervals == []
|
|
337
337
|
|
|
338
338
|
|
|
339
|
+
def test_missing_intervals_start_before_file_min_current_year(tmp_path):
|
|
340
|
+
"""File starts mid-year; requested start is before file_min → beginning gap + trailing gap."""
|
|
341
|
+
from datetime import datetime, timezone
|
|
342
|
+
span = 3600
|
|
343
|
+
store = DataStore(str(tmp_path), 'binance', 'BTC/USDT', span, 'ohlc')
|
|
344
|
+
current_year = datetime.now(tz=timezone.utc).year
|
|
345
|
+
year_start = int(datetime(current_year, 1, 1, tzinfo=timezone.utc).timestamp())
|
|
346
|
+
# File covers mid-May onwards (simulates the user's real situation)
|
|
347
|
+
file_start = year_start + 100 * span
|
|
348
|
+
file_end = year_start + 110 * span
|
|
349
|
+
store.save(_ohlc_df(list(range(file_start, file_end + span, span))))
|
|
350
|
+
end_ts = file_end + 50 * span
|
|
351
|
+
|
|
352
|
+
intervals = store.missing_intervals(year_start, end_ts)
|
|
353
|
+
|
|
354
|
+
# Expect two intervals: [year_start, file_start) and [file_end+span, end_ts)
|
|
355
|
+
assert len(intervals) == 2
|
|
356
|
+
assert intervals[0] == (year_start, file_start)
|
|
357
|
+
assert intervals[1] == (file_end + span, end_ts)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_missing_intervals_start_before_file_min_past_year(tmp_path):
|
|
361
|
+
"""Past incomplete year where file starts after requested start → beginning gap returned."""
|
|
362
|
+
span = 3600
|
|
363
|
+
store = DataStore(str(tmp_path), 'binance', 'BTC/USDT', span, 'ohlc')
|
|
364
|
+
file_start = _Y2023 + 100 * span
|
|
365
|
+
file_end = _Y2023 + 200 * span
|
|
366
|
+
store.save(_ohlc_df(list(range(file_start, file_end + span, span))))
|
|
367
|
+
|
|
368
|
+
intervals = store.missing_intervals(_Y2023, _Y2024)
|
|
369
|
+
|
|
370
|
+
assert len(intervals) == 2
|
|
371
|
+
assert intervals[0] == (_Y2023, file_start)
|
|
372
|
+
assert intervals[1] == (file_end + span, _Y2024)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_missing_intervals_file_already_covers_full_request(tmp_path):
|
|
376
|
+
"""File already covers [start, end] exactly → no intervals."""
|
|
377
|
+
span = 3600
|
|
378
|
+
store = DataStore(str(tmp_path), 'binance', 'BTC/USDT', span, 'ohlc')
|
|
379
|
+
store.save(_ohlc_df(list(range(_Y2023, _Y2023 + 10 * span + span, span))))
|
|
380
|
+
end_ts = _Y2023 + 5 * span
|
|
381
|
+
intervals = store.missing_intervals(_Y2023, end_ts)
|
|
382
|
+
assert intervals == []
|
|
383
|
+
|
|
384
|
+
|
|
339
385
|
def test_missing_intervals_non_ohlc_simple_resume(tmp_path):
|
|
340
386
|
store = DataStore(str(tmp_path), 'binance', 'BTC/USDT', None, 'trades')
|
|
341
387
|
store.save(_trades_df([1672531200]))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|