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.
Files changed (106) hide show
  1. {dccd-3.6.0 → dccd-3.6.2}/CHANGELOG.md +21 -0
  2. {dccd-3.6.0 → dccd-3.6.2}/PKG-INFO +1 -1
  3. {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/kraken.py +16 -13
  4. {dccd-3.6.0 → dccd-3.6.2}/dccd/storage/parquet.py +14 -0
  5. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_sources.py +43 -3
  6. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_storage.py +50 -6
  7. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_storage_extended.py +22 -17
  8. {dccd-3.6.0 → dccd-3.6.2}/dccd.egg-info/PKG-INFO +1 -1
  9. {dccd-3.6.0 → dccd-3.6.2}/pyproject.toml +1 -1
  10. {dccd-3.6.0 → dccd-3.6.2}/CLAUDE.md +0 -0
  11. {dccd-3.6.0 → dccd-3.6.2}/CONTRIBUTING.md +0 -0
  12. {dccd-3.6.0 → dccd-3.6.2}/LICENSE.txt +0 -0
  13. {dccd-3.6.0 → dccd-3.6.2}/MANIFEST.in +0 -0
  14. {dccd-3.6.0 → dccd-3.6.2}/README.md +0 -0
  15. {dccd-3.6.0 → dccd-3.6.2}/dccd/__init__.py +0 -0
  16. {dccd-3.6.0 → dccd-3.6.2}/dccd/application/__init__.py +0 -0
  17. {dccd-3.6.0 → dccd-3.6.2}/dccd/application/config.py +0 -0
  18. {dccd-3.6.0 → dccd-3.6.2}/dccd/application/events.py +0 -0
  19. {dccd-3.6.0 → dccd-3.6.2}/dccd/application/jobs.py +0 -0
  20. {dccd-3.6.0 → dccd-3.6.2}/dccd/application/monitor.py +0 -0
  21. {dccd-3.6.0 → dccd-3.6.2}/dccd/application/operations.py +0 -0
  22. {dccd-3.6.0 → dccd-3.6.2}/dccd/application/registry.py +0 -0
  23. {dccd-3.6.0 → dccd-3.6.2}/dccd/application/scheduler.py +0 -0
  24. {dccd-3.6.0 → dccd-3.6.2}/dccd/application/service_factory.py +0 -0
  25. {dccd-3.6.0 → dccd-3.6.2}/dccd/domain/__init__.py +0 -0
  26. {dccd-3.6.0 → dccd-3.6.2}/dccd/domain/capability.py +0 -0
  27. {dccd-3.6.0 → dccd-3.6.2}/dccd/domain/dataset.py +0 -0
  28. {dccd-3.6.0 → dccd-3.6.2}/dccd/domain/errors.py +0 -0
  29. {dccd-3.6.0 → dccd-3.6.2}/dccd/domain/records.py +0 -0
  30. {dccd-3.6.0 → dccd-3.6.2}/dccd/domain/symbol.py +0 -0
  31. {dccd-3.6.0 → dccd-3.6.2}/dccd/domain/timeutils.py +0 -0
  32. {dccd-3.6.0 → dccd-3.6.2}/dccd/domain/transforms.py +0 -0
  33. {dccd-3.6.0 → dccd-3.6.2}/dccd/domain/types.py +0 -0
  34. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/__init__.py +0 -0
  35. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/api/__init__.py +0 -0
  36. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/api/app.py +0 -0
  37. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/cli/__init__.py +0 -0
  38. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/cli/main.py +0 -0
  39. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/__init__.py +0 -0
  40. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/static/favicon.svg +0 -0
  41. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/static/fonts/martian-mono-600.woff2 +0 -0
  42. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/static/fonts/martian-mono-700.woff2 +0 -0
  43. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/static/fonts/spline-sans-400.woff2 +0 -0
  44. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/static/fonts/spline-sans-500.woff2 +0 -0
  45. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/static/fonts/spline-sans-600.woff2 +0 -0
  46. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/static/fonts/spline-sans-700.woff2 +0 -0
  47. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/static/logo.svg +0 -0
  48. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/templates/base.html +0 -0
  49. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/templates/config.html +0 -0
  50. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/templates/dashboard.html +0 -0
  51. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/templates/data.html +0 -0
  52. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/templates/historical.html +0 -0
  53. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/templates/live.html +0 -0
  54. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/templates/login.html +0 -0
  55. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/templates/logs.html +0 -0
  56. {dccd-3.6.0 → dccd-3.6.2}/dccd/interfaces/ui/templates/storage.html +0 -0
  57. {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/__init__.py +0 -0
  58. {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/base.py +0 -0
  59. {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/binance.py +0 -0
  60. {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/bitfinex.py +0 -0
  61. {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/bitmex.py +0 -0
  62. {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/bybit.py +0 -0
  63. {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/coinbase.py +0 -0
  64. {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/okx.py +0 -0
  65. {dccd-3.6.0 → dccd-3.6.2}/dccd/sources/registry.py +0 -0
  66. {dccd-3.6.0 → dccd-3.6.2}/dccd/storage/__init__.py +0 -0
  67. {dccd-3.6.0 → dccd-3.6.2}/dccd/storage/coverage_sqlite.py +0 -0
  68. {dccd-3.6.0 → dccd-3.6.2}/dccd/storage/purge.py +0 -0
  69. {dccd-3.6.0 → dccd-3.6.2}/dccd/storage/remote.py +0 -0
  70. {dccd-3.6.0 → dccd-3.6.2}/dccd/storage/runs_sqlite.py +0 -0
  71. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/__init__.py +0 -0
  72. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/__init__.py +0 -0
  73. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_adapter_parsing.py +0 -0
  74. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_api.py +0 -0
  75. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_application.py +0 -0
  76. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_backfill_lookback.py +0 -0
  77. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_cli.py +0 -0
  78. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_client.py +0 -0
  79. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_coverage.py +0 -0
  80. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_domain.py +0 -0
  81. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_domain_extended.py +0 -0
  82. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_monitor_webhook.py +0 -0
  83. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_network.py +0 -0
  84. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_orderbook_throttle.py +0 -0
  85. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_purge.py +0 -0
  86. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_ratelimit.py +0 -0
  87. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_remote_sync.py +0 -0
  88. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_restart.py +0 -0
  89. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_restore.py +0 -0
  90. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_scheduler_hygiene.py +0 -0
  91. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_stream_end_state.py +0 -0
  92. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_stream_flush.py +0 -0
  93. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_stream_nocapability.py +0 -0
  94. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_transport.py +0 -0
  95. {dccd-3.6.0 → dccd-3.6.2}/dccd/tests/v3/test_ws_subscription_honesty.py +0 -0
  96. {dccd-3.6.0 → dccd-3.6.2}/dccd/transport/__init__.py +0 -0
  97. {dccd-3.6.0 → dccd-3.6.2}/dccd/transport/http.py +0 -0
  98. {dccd-3.6.0 → dccd-3.6.2}/dccd/transport/paginate.py +0 -0
  99. {dccd-3.6.0 → dccd-3.6.2}/dccd/transport/ratelimit.py +0 -0
  100. {dccd-3.6.0 → dccd-3.6.2}/dccd/transport/ws.py +0 -0
  101. {dccd-3.6.0 → dccd-3.6.2}/dccd.egg-info/SOURCES.txt +0 -0
  102. {dccd-3.6.0 → dccd-3.6.2}/dccd.egg-info/dependency_links.txt +0 -0
  103. {dccd-3.6.0 → dccd-3.6.2}/dccd.egg-info/entry_points.txt +0 -0
  104. {dccd-3.6.0 → dccd-3.6.2}/dccd.egg-info/requires.txt +0 -0
  105. {dccd-3.6.0 → dccd-3.6.2}/dccd.egg-info/top_level.txt +0 -0
  106. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dccd
3
- Version: 3.6.0
3
+ Version: 3.6.2
4
4
  Summary: Download Crypto Currency Data — hexagonal architecture, async-first.
5
5
  Author-email: Arthur Bernard <arthur.bernard.92@gmail.com>
6
6
  License: MIT
@@ -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
- 'XXBTZUSD'
58
+ 'XBTUSD'
49
59
  >>> _kraken_pair(Symbol(base='ETH', quote='BTC'))
50
- 'XETHXXBT'
60
+ 'ETHXBT'
51
61
  """
52
- # Kraken names BTC as XBT — for the quote too, not just the base, so a
53
- # crypto/crypto pair like ETH/BTC must become XETHXXBT (not XETHXBTC,
54
- # which Kraken rejects with "Unknown asset pair").
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) == "XXBTZUSD"
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 for the *quote* too, else Kraken rejects "XETHXBTC".
121
- assert self.src.render_symbol(Symbol(base="ETH", quote="BTC")) == "XETHXXBT"
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(10)
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(5)
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 5) → 9 stored, 10 expected.
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(10) if i != 5
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(10)
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(3)
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 [0, 4]; request [0, 10] → trailing gap after last bar."""
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(5)
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(5)
72
+ for i in range(1, 6)
71
73
  ]
72
74
  store.save(ohlc_ds, bars)
73
- end_ns = 4 * 3600 * NS
74
- gaps = store.missing_intervals(ohlc_ds, 0, end_ns)
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 after the last bar within the stored range
77
- assert all(g[0] >= 4 * 3600 * NS for g in gaps), f"Unexpected gap: {gaps}"
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(5)
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(5, 10)
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 5 missing → 9 rows, expected=10, missing=1
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(10) if i != 5
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"] == 0
356
- assert entry["max_ts"] == 9 * 3600 * NS
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(5)
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"] == 0
374
- assert entry["max_ts"] == 4 * 1000 * NS
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dccd
3
- Version: 3.6.0
3
+ Version: 3.6.2
4
4
  Summary: Download Crypto Currency Data — hexagonal architecture, async-first.
5
5
  Author-email: Arthur Bernard <arthur.bernard.92@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dccd"
7
- version = "3.6.0"
7
+ version = "3.6.2"
8
8
  description = "Download Crypto Currency Data — hexagonal architecture, async-first."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
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