dccd 3.6.0__tar.gz → 3.6.2__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-3.6.0 → dccd-3.6.2}/CHANGELOG.md +21 -0
- {dccd-3.6.0 → dccd-3.6.2}/PKG-INFO +1 -1
- {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/kraken.py +16 -13
- {dccd-3.6.0 → dccd-3.6.2}/dccd/storage/parquet.py +14 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_sources.py +43 -3
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_storage.py +50 -6
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_storage_extended.py +22 -17
- {dccd-3.6.0 → dccd-3.6.2}/dccd.egg-info/PKG-INFO +1 -1
- {dccd-3.6.0 → dccd-3.6.2}/pyproject.toml +1 -1
- {dccd-3.6.0 → dccd-3.6.2}/CLAUDE.md +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/CONTRIBUTING.md +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/LICENSE.txt +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/MANIFEST.in +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/README.md +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/__init__.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/application/__init__.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/application/config.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/application/events.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/application/jobs.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/application/monitor.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/application/operations.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/application/registry.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/application/scheduler.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/application/service_factory.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/domain/__init__.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/domain/capability.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/domain/dataset.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/domain/errors.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/domain/records.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/domain/symbol.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/domain/timeutils.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/domain/transforms.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/domain/types.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/__init__.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/api/__init__.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/api/app.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/cli/__init__.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/cli/main.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/__init__.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/static/favicon.svg +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/static/fonts/martian-mono-600.woff2 +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/static/fonts/martian-mono-700.woff2 +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/static/fonts/spline-sans-400.woff2 +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/static/fonts/spline-sans-500.woff2 +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/static/fonts/spline-sans-600.woff2 +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/static/fonts/spline-sans-700.woff2 +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/static/logo.svg +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/templates/base.html +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/templates/config.html +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/templates/dashboard.html +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/templates/data.html +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/templates/historical.html +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/templates/live.html +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/templates/login.html +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/templates/logs.html +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/templates/storage.html +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/__init__.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/base.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/binance.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/bitfinex.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/bitmex.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/bybit.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/coinbase.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/okx.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/registry.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/storage/__init__.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/storage/coverage_sqlite.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/storage/purge.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/storage/remote.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/storage/runs_sqlite.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/__init__.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/__init__.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_adapter_parsing.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_api.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_application.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_backfill_lookback.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_cli.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_client.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_coverage.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_domain.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_domain_extended.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_monitor_webhook.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_network.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_orderbook_throttle.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_purge.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_ratelimit.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_remote_sync.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_restart.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_restore.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_scheduler_hygiene.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_stream_end_state.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_stream_flush.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_stream_nocapability.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_transport.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_ws_subscription_honesty.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/transport/__init__.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/transport/http.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/transport/paginate.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/transport/ratelimit.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd/transport/ws.py +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd.egg-info/SOURCES.txt +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd.egg-info/dependency_links.txt +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd.egg-info/entry_points.txt +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd.egg-info/requires.txt +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/dccd.egg-info/top_level.txt +0 -0
- {dccd-3.6.0 → dccd-3.6.2}/setup.cfg +0 -0
|
@@ -16,6 +16,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
16
16
|
|
|
17
17
|
### Removed
|
|
18
18
|
|
|
19
|
+
## [3.6.2] - 2026-06-20
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Kraken adapter maps pairs by **altname** (`{base}{quote}`, with `BTC→XBT` and
|
|
24
|
+
`DOGE→XDG`) instead of legacy X/Z-prefixed codes, so OHLC and trades for modern
|
|
25
|
+
Kraken assets (TRX, DOT, BNB, …) and Dogecoin no longer fail with `Unknown asset
|
|
26
|
+
pair`. Legacy pairs are unaffected — Kraken accepts altnames universally and the
|
|
27
|
+
response is parsed by its code-key fallback. (#169)
|
|
28
|
+
|
|
29
|
+
## [3.6.1] - 2026-06-19
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- `ParquetStore.save()` now rejects rows whose timestamp is null or `<= 0` at the
|
|
34
|
+
storage write boundary (one central guard for every adapter and data type), so a
|
|
35
|
+
lost/epoch-0 timestamp can no longer be persisted or poison gap detection — a
|
|
36
|
+
single `TS=0` bar otherwise dragged `inventory()` `min_ts` to `1970` and inflated
|
|
37
|
+
`expected_rows`/`missing_rows` to a bogus ~89 %. Dropped rows are logged, not
|
|
38
|
+
raised, so one bad bar can't abort a good page. (#165)
|
|
39
|
+
|
|
19
40
|
## [3.6.0] - 2026-06-13
|
|
20
41
|
|
|
21
42
|
### Added
|
|
@@ -37,28 +37,31 @@ logger = logging.getLogger(__name__)
|
|
|
37
37
|
|
|
38
38
|
_BASE = "https://api.kraken.com/0/public"
|
|
39
39
|
|
|
40
|
+
# Canonical symbol name → Kraken altname for assets whose ticker differs.
|
|
41
|
+
_KRAKEN_ALIASES: dict[str, str] = {
|
|
42
|
+
"BTC": "XBT",
|
|
43
|
+
"DOGE": "XDG",
|
|
44
|
+
}
|
|
45
|
+
|
|
40
46
|
|
|
41
47
|
def _kraken_pair(symbol: Symbol) -> str:
|
|
42
|
-
"""Convert a canonical Symbol to a Kraken REST pair string.
|
|
48
|
+
"""Convert a canonical Symbol to a Kraken REST altname pair string.
|
|
49
|
+
|
|
50
|
+
Kraken accepts altnames (e.g. ``XBTUSD``, ``TRXUSD``) for all assets,
|
|
51
|
+
including modern ones that lack legacy X/Z-prefixed codes. ``BTC`` is
|
|
52
|
+
aliased to ``XBT`` and ``DOGE`` to ``XDG`` on both base and quote.
|
|
43
53
|
|
|
44
54
|
Examples
|
|
45
55
|
--------
|
|
46
56
|
>>> from dccd.domain.symbol import Symbol
|
|
47
57
|
>>> _kraken_pair(Symbol(base='BTC', quote='USD'))
|
|
48
|
-
'
|
|
58
|
+
'XBTUSD'
|
|
49
59
|
>>> _kraken_pair(Symbol(base='ETH', quote='BTC'))
|
|
50
|
-
'
|
|
60
|
+
'ETHXBT'
|
|
51
61
|
"""
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
base = "XBT" if symbol.base == "BTC" else symbol.base
|
|
56
|
-
quote = "XBT" if symbol.quote == "BTC" else symbol.quote
|
|
57
|
-
if base in ("BCH", "DASH"):
|
|
58
|
-
return f"{base}{quote}"
|
|
59
|
-
if quote in ("EUR", "USD", "CAD", "JPY", "GBP"):
|
|
60
|
-
return f"X{base}Z{quote}"
|
|
61
|
-
return f"X{base}X{quote}"
|
|
62
|
+
base = _KRAKEN_ALIASES.get(symbol.base, symbol.base)
|
|
63
|
+
quote = _KRAKEN_ALIASES.get(symbol.quote, symbol.quote)
|
|
64
|
+
return f"{base}{quote}"
|
|
62
65
|
|
|
63
66
|
|
|
64
67
|
def _ws_pair(symbol: Symbol) -> str:
|
|
@@ -288,6 +288,20 @@ class ParquetStore:
|
|
|
288
288
|
if len(df) == 0:
|
|
289
289
|
return 0
|
|
290
290
|
|
|
291
|
+
# Reject bars with an invalid timestamp (null or <= 0). TS is ns UTC
|
|
292
|
+
# int64; 0 is the Unix epoch (1970) — always corrupt for crypto market
|
|
293
|
+
# data (real history starts ~2009). One such row poisons gap detection
|
|
294
|
+
# (inventory min_ts → 1970, expected_rows balloons). Drop, don't raise,
|
|
295
|
+
# so one bad bar can't abort a good page. Seen in prod: a Kraken OHLC bar
|
|
296
|
+
# with a null time parsed to 0 (audit 2026-06-19).
|
|
297
|
+
n_before = len(df)
|
|
298
|
+
df = df.filter(pl.col("TS").is_not_null() & (pl.col("TS") > 0))
|
|
299
|
+
dropped = n_before - len(df)
|
|
300
|
+
if dropped:
|
|
301
|
+
logger.warning("save(%s): dropped %d row(s) with invalid TS<=0", ds, dropped)
|
|
302
|
+
if len(df) == 0:
|
|
303
|
+
return 0
|
|
304
|
+
|
|
291
305
|
fmt = self._period_fmt(ds)
|
|
292
306
|
df_with_period = df.with_columns(
|
|
293
307
|
pl.from_epoch("TS", time_unit="ns").dt.strftime(fmt).alias("_period")
|
|
@@ -114,11 +114,11 @@ class TestKrakenCapabilities:
|
|
|
114
114
|
self.src = KrakenSource()
|
|
115
115
|
|
|
116
116
|
def test_render_symbol(self):
|
|
117
|
-
assert self.src.render_symbol(BTC_USD) == "
|
|
117
|
+
assert self.src.render_symbol(BTC_USD) == "XBTUSD"
|
|
118
118
|
|
|
119
119
|
def test_render_symbol_crypto_quote_btc(self):
|
|
120
|
-
# ETH/BTC: BTC→XBT
|
|
121
|
-
assert self.src.render_symbol(Symbol(base="ETH", quote="BTC")) == "
|
|
120
|
+
# ETH/BTC: BTC→XBT via _KRAKEN_ALIASES on the quote side → ETHXBT.
|
|
121
|
+
assert self.src.render_symbol(Symbol(base="ETH", quote="BTC")) == "ETHXBT"
|
|
122
122
|
|
|
123
123
|
def test_ohlc_cap_history_recent(self):
|
|
124
124
|
cap = self.src.capability_for(DataType.OHLC, "rest", "historical")
|
|
@@ -132,6 +132,46 @@ class TestKrakenCapabilities:
|
|
|
132
132
|
assert cap.history == "full"
|
|
133
133
|
|
|
134
134
|
|
|
135
|
+
class TestKrakenPairMapping:
|
|
136
|
+
"""Unit tests for _kraken_pair altname construction."""
|
|
137
|
+
|
|
138
|
+
def test_legacy_btc_usd(self):
|
|
139
|
+
from dccd.sources.kraken import _kraken_pair
|
|
140
|
+
assert _kraken_pair(Symbol(base="BTC", quote="USD")) == "XBTUSD"
|
|
141
|
+
|
|
142
|
+
def test_legacy_eth_btc(self):
|
|
143
|
+
from dccd.sources.kraken import _kraken_pair
|
|
144
|
+
assert _kraken_pair(Symbol(base="ETH", quote="BTC")) == "ETHXBT"
|
|
145
|
+
|
|
146
|
+
def test_legacy_xrp_usd(self):
|
|
147
|
+
from dccd.sources.kraken import _kraken_pair
|
|
148
|
+
assert _kraken_pair(Symbol(base="XRP", quote="USD")) == "XRPUSD"
|
|
149
|
+
|
|
150
|
+
def test_legacy_xrp_btc(self):
|
|
151
|
+
from dccd.sources.kraken import _kraken_pair
|
|
152
|
+
assert _kraken_pair(Symbol(base="XRP", quote="BTC")) == "XRPXBT"
|
|
153
|
+
|
|
154
|
+
def test_modern_trx_usd(self):
|
|
155
|
+
from dccd.sources.kraken import _kraken_pair
|
|
156
|
+
assert _kraken_pair(Symbol(base="TRX", quote="USD")) == "TRXUSD"
|
|
157
|
+
|
|
158
|
+
def test_modern_dot_btc(self):
|
|
159
|
+
from dccd.sources.kraken import _kraken_pair
|
|
160
|
+
assert _kraken_pair(Symbol(base="DOT", quote="BTC")) == "DOTXBT"
|
|
161
|
+
|
|
162
|
+
def test_modern_bnb_usd(self):
|
|
163
|
+
from dccd.sources.kraken import _kraken_pair
|
|
164
|
+
assert _kraken_pair(Symbol(base="BNB", quote="USD")) == "BNBUSD"
|
|
165
|
+
|
|
166
|
+
def test_doge_usd(self):
|
|
167
|
+
from dccd.sources.kraken import _kraken_pair
|
|
168
|
+
assert _kraken_pair(Symbol(base="DOGE", quote="USD")) == "XDGUSD"
|
|
169
|
+
|
|
170
|
+
def test_doge_btc(self):
|
|
171
|
+
from dccd.sources.kraken import _kraken_pair
|
|
172
|
+
assert _kraken_pair(Symbol(base="DOGE", quote="BTC")) == "XDGXBT"
|
|
173
|
+
|
|
174
|
+
|
|
135
175
|
class TestBybitCapabilities:
|
|
136
176
|
def setup_method(self):
|
|
137
177
|
self.src = BybitSource()
|
|
@@ -41,7 +41,7 @@ class TestParquetStore:
|
|
|
41
41
|
def test_save_and_load_ohlc(self, tmp_store, ohlc_ds):
|
|
42
42
|
bars = [
|
|
43
43
|
OHLCBar(ts=i * 3600 * NS, open=1.0, high=2.0, low=0.5, close=1.5, volume=10.0)
|
|
44
|
-
for i in range(
|
|
44
|
+
for i in range(1, 11) # start at 1 — TS=0 is the Unix epoch and is rejected
|
|
45
45
|
]
|
|
46
46
|
n = tmp_store.save(ohlc_ds, bars)
|
|
47
47
|
assert n > 0
|
|
@@ -53,7 +53,7 @@ class TestParquetStore:
|
|
|
53
53
|
def test_save_and_load_trades(self, tmp_store, trades_ds):
|
|
54
54
|
trades = [
|
|
55
55
|
Trade(ts=i * 1000 * NS, price=50000.0 + i, amount=0.1, side="buy")
|
|
56
|
-
for i in range(
|
|
56
|
+
for i in range(1, 6) # start at 1 — TS=0 is the Unix epoch and is rejected
|
|
57
57
|
]
|
|
58
58
|
n = tmp_store.save(trades_ds, trades)
|
|
59
59
|
assert n > 0
|
|
@@ -92,10 +92,11 @@ class TestParquetStore:
|
|
|
92
92
|
assert any(d["exchange"] == "binance" for d in inv)
|
|
93
93
|
|
|
94
94
|
def test_inventory_bytes_and_gaps(self, tmp_store, ohlc_ds):
|
|
95
|
-
# 10 hourly bars but with one hole (skip index
|
|
95
|
+
# 10 hourly bars but with one hole (skip index 6) → 9 stored, 10 expected.
|
|
96
|
+
# Start at 1 — TS=0 is the Unix epoch and is rejected by the store guard.
|
|
96
97
|
bars = [
|
|
97
98
|
OHLCBar(ts=i * 3600 * NS, open=1.0, high=2.0, low=0.5, close=1.5, volume=10.0)
|
|
98
|
-
for i in range(
|
|
99
|
+
for i in range(1, 11) if i != 6
|
|
99
100
|
]
|
|
100
101
|
tmp_store.save(ohlc_ds, bars)
|
|
101
102
|
entry = next(d for d in tmp_store.inventory() if d["data_type"] == "ohlc")
|
|
@@ -114,7 +115,7 @@ class TestParquetStore:
|
|
|
114
115
|
def test_load_with_range(self, tmp_store, ohlc_ds):
|
|
115
116
|
bars = [
|
|
116
117
|
OHLCBar(ts=i * 3600 * NS, open=1.0, high=2.0, low=0.5, close=1.5, volume=10.0)
|
|
117
|
-
for i in range(
|
|
118
|
+
for i in range(1, 11) # start at 1 — TS=0 is the Unix epoch and is rejected
|
|
118
119
|
]
|
|
119
120
|
tmp_store.save(ohlc_ds, bars)
|
|
120
121
|
df = tmp_store.load(ohlc_ds, start_ns=3 * 3600 * NS, end_ns=6 * 3600 * NS)
|
|
@@ -132,11 +133,54 @@ class TestParquetStore:
|
|
|
132
133
|
bids=[OrderBookLevel(price=100.0, amount=1.0)],
|
|
133
134
|
asks=[OrderBookLevel(price=100.1, amount=0.5)],
|
|
134
135
|
)
|
|
135
|
-
for i in range(
|
|
136
|
+
for i in range(1, 4) # start at 1 — TS=0 is the Unix epoch and is rejected
|
|
136
137
|
]
|
|
137
138
|
n = tmp_store.save(book_ds, snaps)
|
|
138
139
|
assert n > 0
|
|
139
140
|
|
|
141
|
+
def test_save_rejects_nonpositive_ts(self, tmp_store, ohlc_ds, trades_ds):
|
|
142
|
+
"""Bars with TS=0 or TS<0 are silently dropped; valid bars land on disk.
|
|
143
|
+
|
|
144
|
+
Regression guard for the 2026-06-19 prod incident: a Kraken OHLC bar
|
|
145
|
+
with a null-parsed timestamp (TS=0) inflated inventory expected_rows
|
|
146
|
+
to ~89% missing by dragging min_ts to 1970-01-01.
|
|
147
|
+
"""
|
|
148
|
+
# Use timestamps well into BTC history (2017-ish, in ns).
|
|
149
|
+
base_ts = 1_500_000_000 * NS # 2017-07-14 in ns
|
|
150
|
+
|
|
151
|
+
# OHLC: mix 3 valid bars with one TS=0 and one TS=-1.
|
|
152
|
+
bars = [
|
|
153
|
+
OHLCBar(ts=base_ts + i * 3600 * NS, open=1.0, high=2.0, low=0.5, close=1.5, volume=10.0)
|
|
154
|
+
for i in range(3)
|
|
155
|
+
] + [
|
|
156
|
+
OHLCBar(ts=0, open=60882.4, high=60900.0, low=60800.0, close=60867.9, volume=5.0),
|
|
157
|
+
OHLCBar(ts=-1, open=1.0, high=2.0, low=0.5, close=1.5, volume=1.0),
|
|
158
|
+
]
|
|
159
|
+
n = tmp_store.save(ohlc_ds, bars)
|
|
160
|
+
|
|
161
|
+
# Only the 3 valid rows should be counted.
|
|
162
|
+
assert n == 3
|
|
163
|
+
|
|
164
|
+
df = tmp_store.load(ohlc_ds)
|
|
165
|
+
assert len(df) == 3
|
|
166
|
+
assert df["TS"].min() > 0, "no TS<=0 row must reach disk"
|
|
167
|
+
|
|
168
|
+
# No 1970.parquet partition should exist.
|
|
169
|
+
ohlc_dir = tmp_store.directory(ohlc_ds)
|
|
170
|
+
assert not (ohlc_dir / "1970.parquet").exists()
|
|
171
|
+
|
|
172
|
+
# Trades: same guard applies — one valid trade + one with TS=0.
|
|
173
|
+
trades = [
|
|
174
|
+
Trade(ts=base_ts, price=50000.0, amount=0.1, side="buy"),
|
|
175
|
+
Trade(ts=0, price=60000.0, amount=0.2, side="sell"),
|
|
176
|
+
]
|
|
177
|
+
n_trades = tmp_store.save(trades_ds, trades)
|
|
178
|
+
assert n_trades == 1
|
|
179
|
+
|
|
180
|
+
df_trades = tmp_store.load(trades_ds)
|
|
181
|
+
assert len(df_trades) == 1
|
|
182
|
+
assert df_trades["TS"].min() > 0
|
|
183
|
+
|
|
140
184
|
|
|
141
185
|
class TestRunsStore:
|
|
142
186
|
@pytest.fixture
|
|
@@ -50,10 +50,11 @@ class TestMissingIntervalsPartialYear:
|
|
|
50
50
|
assert leading, f"Expected leading gap starting at 0, got: {gaps}"
|
|
51
51
|
|
|
52
52
|
def test_trailing_gap_detected(self, store: ParquetStore, ohlc_ds: DatasetId) -> None:
|
|
53
|
-
"""Save bars [
|
|
53
|
+
"""Save bars [1, 5]; request [0, 10] → trailing gap after last bar."""
|
|
54
|
+
# Start at 1 — TS=0 is the Unix epoch and is rejected by the store guard.
|
|
54
55
|
bars = [
|
|
55
56
|
OHLCBar(ts=i * 3600 * NS, open=1.0, high=1.0, low=1.0, close=1.0, volume=1.0)
|
|
56
|
-
for i in range(
|
|
57
|
+
for i in range(1, 6)
|
|
57
58
|
]
|
|
58
59
|
store.save(ohlc_ds, bars)
|
|
59
60
|
|
|
@@ -64,17 +65,18 @@ class TestMissingIntervalsPartialYear:
|
|
|
64
65
|
assert trailing, f"Expected trailing gap ending at {end_ns}, got: {gaps}"
|
|
65
66
|
|
|
66
67
|
def test_no_gap_when_complete(self, store: ParquetStore, ohlc_ds: DatasetId) -> None:
|
|
67
|
-
"""Exact coverage → no gaps."""
|
|
68
|
+
"""Exact coverage → no gaps within stored range."""
|
|
69
|
+
# Start at 1 — TS=0 is the Unix epoch and is rejected by the store guard.
|
|
68
70
|
bars = [
|
|
69
71
|
OHLCBar(ts=i * 3600 * NS, open=1.0, high=1.0, low=1.0, close=1.0, volume=1.0)
|
|
70
|
-
for i in range(
|
|
72
|
+
for i in range(1, 6)
|
|
71
73
|
]
|
|
72
74
|
store.save(ohlc_ds, bars)
|
|
73
|
-
end_ns =
|
|
74
|
-
gaps = store.missing_intervals(ohlc_ds,
|
|
75
|
+
end_ns = 5 * 3600 * NS
|
|
76
|
+
gaps = store.missing_intervals(ohlc_ds, 1 * 3600 * NS, end_ns)
|
|
75
77
|
# May still have a trailing gap if end_ns > last bar
|
|
76
|
-
# No gap should start
|
|
77
|
-
assert all(g[0] >=
|
|
78
|
+
# No gap should start before the last stored bar
|
|
79
|
+
assert all(g[0] >= 5 * 3600 * NS for g in gaps), f"Unexpected gap: {gaps}"
|
|
78
80
|
|
|
79
81
|
|
|
80
82
|
# ---------------------------------------------------------------------------
|
|
@@ -206,9 +208,10 @@ class TestFileStatsCache:
|
|
|
206
208
|
self, store: ParquetStore, ohlc_ds: DatasetId, monkeypatch: pytest.MonkeyPatch
|
|
207
209
|
) -> None:
|
|
208
210
|
"""After save() (mtime changes) the cache is invalidated and new rows appear."""
|
|
211
|
+
# Start at 1 — TS=0 is the Unix epoch and is rejected by the store guard.
|
|
209
212
|
bars_first = [
|
|
210
213
|
OHLCBar(ts=i * 3600 * NS, open=1.0, high=1.0, low=1.0, close=1.0, volume=1.0)
|
|
211
|
-
for i in range(
|
|
214
|
+
for i in range(1, 6)
|
|
212
215
|
]
|
|
213
216
|
store.save(ohlc_ds, bars_first)
|
|
214
217
|
|
|
@@ -228,7 +231,7 @@ class TestFileStatsCache:
|
|
|
228
231
|
monkeypatch.undo()
|
|
229
232
|
bars_second = [
|
|
230
233
|
OHLCBar(ts=i * 3600 * NS, open=1.0, high=1.0, low=1.0, close=1.0, volume=1.0)
|
|
231
|
-
for i in range(
|
|
234
|
+
for i in range(6, 11) # non-overlapping with first batch (1..5)
|
|
232
235
|
]
|
|
233
236
|
store.save(ohlc_ds, bars_second)
|
|
234
237
|
|
|
@@ -343,17 +346,18 @@ class TestFileStatsValueIdentity:
|
|
|
343
346
|
self, store: ParquetStore, ohlc_ds: DatasetId
|
|
344
347
|
) -> None:
|
|
345
348
|
"""inventory() rows/min_ts/max_ts/expected_rows/missing_rows match stored data."""
|
|
346
|
-
# 10 hourly bars with index
|
|
349
|
+
# 10 hourly bars (indices 1..10) with index 6 missing → 9 rows, expected=10, missing=1.
|
|
350
|
+
# Start at 1 — TS=0 is the Unix epoch and is rejected by the store guard.
|
|
347
351
|
bars = [
|
|
348
352
|
OHLCBar(ts=i * 3600 * NS, open=1.0, high=2.0, low=0.5, close=1.5, volume=10.0)
|
|
349
|
-
for i in range(
|
|
353
|
+
for i in range(1, 11) if i != 6
|
|
350
354
|
]
|
|
351
355
|
store.save(ohlc_ds, bars)
|
|
352
356
|
entry = next(d for d in store.inventory() if d["data_type"] == "ohlc")
|
|
353
357
|
|
|
354
358
|
assert entry["rows"] == 9
|
|
355
|
-
assert entry["min_ts"] ==
|
|
356
|
-
assert entry["max_ts"] ==
|
|
359
|
+
assert entry["min_ts"] == 1 * 3600 * NS
|
|
360
|
+
assert entry["max_ts"] == 10 * 3600 * NS
|
|
357
361
|
assert entry["expected_rows"] == 10
|
|
358
362
|
assert entry["missing_rows"] == 1
|
|
359
363
|
assert entry["bytes"] > 0
|
|
@@ -362,16 +366,17 @@ class TestFileStatsValueIdentity:
|
|
|
362
366
|
self, store: ParquetStore, trades_ds: DatasetId
|
|
363
367
|
) -> None:
|
|
364
368
|
"""Trades inventory has no gap fields, correct rows/min_ts/max_ts."""
|
|
369
|
+
# Start at 1 — TS=0 is the Unix epoch and is rejected by the store guard.
|
|
365
370
|
trades = [
|
|
366
371
|
Trade(ts=i * 1000 * NS, price=50000.0, amount=0.1, side="buy")
|
|
367
|
-
for i in range(
|
|
372
|
+
for i in range(1, 6)
|
|
368
373
|
]
|
|
369
374
|
store.save(trades_ds, trades)
|
|
370
375
|
entry = next(d for d in store.inventory() if d["data_type"] == "trades")
|
|
371
376
|
|
|
372
377
|
assert entry["rows"] == 5
|
|
373
|
-
assert entry["min_ts"] ==
|
|
374
|
-
assert entry["max_ts"] ==
|
|
378
|
+
assert entry["min_ts"] == 1 * 1000 * NS
|
|
379
|
+
assert entry["max_ts"] == 5 * 1000 * NS
|
|
375
380
|
assert entry["missing_rows"] is None
|
|
376
381
|
assert entry["expected_rows"] is None
|
|
377
382
|
assert entry["bytes"] > 0
|
|
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
|
|
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
|