dccd 3.3.1__tar.gz → 3.4.0__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 (95) hide show
  1. {dccd-3.3.1 → dccd-3.4.0}/CHANGELOG.md +53 -0
  2. {dccd-3.3.1 → dccd-3.4.0}/PKG-INFO +1 -1
  3. {dccd-3.3.1 → dccd-3.4.0}/dccd/application/monitor.py +30 -2
  4. {dccd-3.3.1 → dccd-3.4.0}/dccd/application/operations.py +40 -15
  5. {dccd-3.3.1 → dccd-3.4.0}/dccd/application/scheduler.py +30 -6
  6. {dccd-3.3.1 → dccd-3.4.0}/dccd/domain/capability.py +7 -0
  7. {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/api/app.py +24 -5
  8. {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/templates/dashboard.html +8 -6
  9. {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/templates/live.html +12 -7
  10. {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/templates/storage.html +2 -1
  11. {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/base.py +21 -2
  12. {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/binance.py +28 -3
  13. {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/bitfinex.py +7 -1
  14. {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/bitmex.py +46 -3
  15. {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/bybit.py +49 -10
  16. {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/coinbase.py +7 -1
  17. {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/kraken.py +58 -10
  18. {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/okx.py +44 -3
  19. {dccd-3.3.1 → dccd-3.4.0}/dccd/storage/parquet.py +127 -35
  20. {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_api.py +28 -0
  21. dccd-3.4.0/dccd/tests/v3/test_orderbook_throttle.py +406 -0
  22. dccd-3.4.0/dccd/tests/v3/test_scheduler_hygiene.py +305 -0
  23. dccd-3.4.0/dccd/tests/v3/test_storage_extended.py +407 -0
  24. dccd-3.4.0/dccd/tests/v3/test_stream_end_state.py +86 -0
  25. dccd-3.4.0/dccd/tests/v3/test_ws_subscription_honesty.py +200 -0
  26. {dccd-3.3.1 → dccd-3.4.0}/dccd.egg-info/PKG-INFO +1 -1
  27. {dccd-3.3.1 → dccd-3.4.0}/dccd.egg-info/SOURCES.txt +4 -0
  28. {dccd-3.3.1 → dccd-3.4.0}/pyproject.toml +1 -1
  29. dccd-3.3.1/dccd/tests/v3/test_storage_extended.py +0 -151
  30. {dccd-3.3.1 → dccd-3.4.0}/CLAUDE.md +0 -0
  31. {dccd-3.3.1 → dccd-3.4.0}/CONTRIBUTING.md +0 -0
  32. {dccd-3.3.1 → dccd-3.4.0}/LICENSE.txt +0 -0
  33. {dccd-3.3.1 → dccd-3.4.0}/MANIFEST.in +0 -0
  34. {dccd-3.3.1 → dccd-3.4.0}/README.md +0 -0
  35. {dccd-3.3.1 → dccd-3.4.0}/dccd/__init__.py +0 -0
  36. {dccd-3.3.1 → dccd-3.4.0}/dccd/application/__init__.py +0 -0
  37. {dccd-3.3.1 → dccd-3.4.0}/dccd/application/config.py +0 -0
  38. {dccd-3.3.1 → dccd-3.4.0}/dccd/application/events.py +0 -0
  39. {dccd-3.3.1 → dccd-3.4.0}/dccd/application/jobs.py +0 -0
  40. {dccd-3.3.1 → dccd-3.4.0}/dccd/application/registry.py +0 -0
  41. {dccd-3.3.1 → dccd-3.4.0}/dccd/application/service_factory.py +0 -0
  42. {dccd-3.3.1 → dccd-3.4.0}/dccd/domain/__init__.py +0 -0
  43. {dccd-3.3.1 → dccd-3.4.0}/dccd/domain/dataset.py +0 -0
  44. {dccd-3.3.1 → dccd-3.4.0}/dccd/domain/errors.py +0 -0
  45. {dccd-3.3.1 → dccd-3.4.0}/dccd/domain/records.py +0 -0
  46. {dccd-3.3.1 → dccd-3.4.0}/dccd/domain/symbol.py +0 -0
  47. {dccd-3.3.1 → dccd-3.4.0}/dccd/domain/timeutils.py +0 -0
  48. {dccd-3.3.1 → dccd-3.4.0}/dccd/domain/transforms.py +0 -0
  49. {dccd-3.3.1 → dccd-3.4.0}/dccd/domain/types.py +0 -0
  50. {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/__init__.py +0 -0
  51. {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/api/__init__.py +0 -0
  52. {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/cli/__init__.py +0 -0
  53. {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/cli/main.py +0 -0
  54. {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/__init__.py +0 -0
  55. {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/static/favicon.svg +0 -0
  56. {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/static/logo.svg +0 -0
  57. {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/templates/base.html +0 -0
  58. {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/templates/config.html +0 -0
  59. {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/templates/data.html +0 -0
  60. {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/templates/historical.html +0 -0
  61. {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/templates/login.html +0 -0
  62. {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/templates/logs.html +0 -0
  63. {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/__init__.py +0 -0
  64. {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/registry.py +0 -0
  65. {dccd-3.3.1 → dccd-3.4.0}/dccd/storage/__init__.py +0 -0
  66. {dccd-3.3.1 → dccd-3.4.0}/dccd/storage/coverage_sqlite.py +0 -0
  67. {dccd-3.3.1 → dccd-3.4.0}/dccd/storage/purge.py +0 -0
  68. {dccd-3.3.1 → dccd-3.4.0}/dccd/storage/remote.py +0 -0
  69. {dccd-3.3.1 → dccd-3.4.0}/dccd/storage/runs_sqlite.py +0 -0
  70. {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/__init__.py +0 -0
  71. {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/__init__.py +0 -0
  72. {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_application.py +0 -0
  73. {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_backfill_lookback.py +0 -0
  74. {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_client.py +0 -0
  75. {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_coverage.py +0 -0
  76. {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_domain.py +0 -0
  77. {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_domain_extended.py +0 -0
  78. {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_network.py +0 -0
  79. {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_purge.py +0 -0
  80. {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_remote_sync.py +0 -0
  81. {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_restart.py +0 -0
  82. {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_restore.py +0 -0
  83. {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_sources.py +0 -0
  84. {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_storage.py +0 -0
  85. {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_transport.py +0 -0
  86. {dccd-3.3.1 → dccd-3.4.0}/dccd/transport/__init__.py +0 -0
  87. {dccd-3.3.1 → dccd-3.4.0}/dccd/transport/http.py +0 -0
  88. {dccd-3.3.1 → dccd-3.4.0}/dccd/transport/paginate.py +0 -0
  89. {dccd-3.3.1 → dccd-3.4.0}/dccd/transport/ratelimit.py +0 -0
  90. {dccd-3.3.1 → dccd-3.4.0}/dccd/transport/ws.py +0 -0
  91. {dccd-3.3.1 → dccd-3.4.0}/dccd.egg-info/dependency_links.txt +0 -0
  92. {dccd-3.3.1 → dccd-3.4.0}/dccd.egg-info/entry_points.txt +0 -0
  93. {dccd-3.3.1 → dccd-3.4.0}/dccd.egg-info/requires.txt +0 -0
  94. {dccd-3.3.1 → dccd-3.4.0}/dccd.egg-info/top_level.txt +0 -0
  95. {dccd-3.3.1 → dccd-3.4.0}/setup.cfg +0 -0
@@ -16,6 +16,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ### Removed
18
18
 
19
+ ## [3.4.0] - 2026-06-10
20
+
21
+ ### Added
22
+
23
+ ### Changed
24
+
25
+ - Remote-friendly UI transport: gzip on API/page responses (`/api/inventory`
26
+ measured 12 450 B → 460 B, 27×; SSE excluded and flushed immediately so
27
+ EventSource connects without waiting for the first event), dashboard fetches
28
+ parallelised (3×RTT → 1×RTT), Live re-fetches the inventory only on load and
29
+ stream-status changes instead of every 8 s, dashboard/storage poll cadences
30
+ relaxed (15 s / 30 s), and runs-store SQLite reads moved off the event loop.
31
+ UI smoke: 27/27 steps clean. (#121)
32
+
33
+ ### Fixed
34
+
35
+ - Order-book stream jobs with a depth the exchange doesn't support (production
36
+ case: Kraken `depth: 20`/`50` — WS v2 only accepts {10, 25, 100, 500, 1000})
37
+ were silently rejected and sat "live" forever writing nothing. Valid depths
38
+ are now declared per capability (verified against the live APIs), requests
39
+ snap to the nearest valid value with a warning, and WS subscription
40
+ rejections raise — surfacing in runs as `failed` with the exchange's error —
41
+ instead of being filtered out. (#122)
42
+ - A live stream whose WS generator ended on its own (no stop requested) was
43
+ recorded `cancelled`; it is now `failed` with an explicit
44
+ "stream ended unexpectedly" error, so Logs/Runs no longer claim someone
45
+ stopped a stream nobody touched. (#121)
46
+ - Order-book WS adapters built the full book as pydantic objects on **every**
47
+ delta while the stream operation kept only one frame per `snapshot_interval` —
48
+ 97.7 % CPU on the production collector, starving the event loop and making
49
+ the remote UI unusable. Snapshots are now constructed only at capture time
50
+ (`min_interval` pushed down into the adapters; `0.0` keeps per-frame), and
51
+ Kraken/Bybit book state is truncated to the subscribed depth (WS truncation
52
+ contract). Verified live: Kraken 60 s at 10 s interval → exactly 6 snapshots,
53
+ ≤ 25 levels/side, never crossed; 3 live books for 2 min → **2.0 %** CPU. (#120)
54
+ - `ParquetStore` metadata (inventory, last timestamp, gap detection) no longer
55
+ reads the full TS column of every file in the store — it reads parquet footer
56
+ statistics with a per-file mtime cache (legacy files without statistics fall
57
+ back to the old path), and `/api/inventory` + `/api/storage/sync` now run it
58
+ off the event loop. Verified value-identical on real data; 13× faster warm on
59
+ a small store, and the gap grows with file size (production showed 100 s for
60
+ a 10 KB inventory response under load). (#119)
61
+ - Permanently failing scheduled jobs no longer hammer the exchange at full
62
+ cadence: the interval loop applies exponential backoff (reset on success) and
63
+ starts with a random jitter instead of firing every job at once on daemon
64
+ start. `HealthMonitor` alerts once when the failure threshold is crossed,
65
+ then at most hourly — instead of on every failure (a broken job used to spam
66
+ the webhook every ~20 s). (#118)
67
+
68
+ ### Deprecated
69
+
70
+ ### Removed
71
+
19
72
  ## [3.3.1] - 2026-06-10
20
73
 
21
74
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dccd
3
- Version: 3.3.1
3
+ Version: 3.4.0
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
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ import time
6
7
  from collections import defaultdict
7
8
 
8
9
  from dccd.application.events import Event, EventBus, StatusEvent
@@ -12,10 +13,19 @@ __all__ = ["HealthMonitor"]
12
13
 
13
14
  logger = logging.getLogger(__name__)
14
15
 
16
+ # Minimum seconds between repeated alerts for the same job while it keeps failing.
17
+ _ALERT_COOLDOWN_S = 3600
18
+
15
19
 
16
20
  class HealthMonitor:
17
21
  """Monitors job runs and fires webhook alerts on repeated failures.
18
22
 
23
+ An alert fires when the consecutive-failure count first **crosses**
24
+ ``max_consecutive_errors``. While the job keeps failing, a follow-up alert
25
+ fires at most once per ``_ALERT_COOLDOWN_S`` (1 hour) so a permanently-broken
26
+ job does not flood a webhook. The count (and cooldown) reset on the first
27
+ success.
28
+
19
29
  Parameters
20
30
  ----------
21
31
  runs_store : RunsStore
@@ -36,6 +46,10 @@ class HealthMonitor:
36
46
  self._webhook = webhook_url
37
47
  self._max_errors = max_consecutive_errors
38
48
  self._consecutive: dict[str, int] = defaultdict(int)
49
+ # Last monotonic timestamp at which an alert was sent per job key.
50
+ self._last_alert_ts: dict[str, float] = {}
51
+ # Last monotonic timestamp at which a webhook-send failure was logged per key.
52
+ self._last_webhook_err_ts: dict[str, float] = {}
39
53
  event_bus.subscribe(self._on_event)
40
54
 
41
55
  def _on_event(self, event: Event) -> None:
@@ -49,14 +63,24 @@ class HealthMonitor:
49
63
  if event.state == "failed":
50
64
  self._consecutive[key] += 1
51
65
  count = self._consecutive[key]
52
- if count >= self._max_errors:
66
+ if count == self._max_errors:
67
+ # First crossing: always alert.
53
68
  self._alert(key, count)
69
+ elif count > self._max_errors:
70
+ # Still failing: re-alert only once per cooldown window.
71
+ last = self._last_alert_ts.get(key, 0.0)
72
+ if time.monotonic() - last >= _ALERT_COOLDOWN_S:
73
+ self._alert(key, count)
54
74
  elif event.state == "succeeded":
55
75
  self._consecutive[key] = 0
76
+ # Reset cooldown so the next failure streak starts fresh.
77
+ self._last_alert_ts.pop(key, None)
78
+ self._last_webhook_err_ts.pop(key, None)
56
79
 
57
80
  def _alert(self, run_id: str, count: int) -> None:
58
81
  msg = f"dccd alert: {run_id} failed {count} times consecutively."
59
82
  logger.error(msg)
83
+ self._last_alert_ts[run_id] = time.monotonic()
60
84
  if self._webhook:
61
85
  try:
62
86
  import json
@@ -70,4 +94,8 @@ class HealthMonitor:
70
94
  with urllib.request.urlopen(req, timeout=5):
71
95
  pass
72
96
  except Exception as exc:
73
- logger.warning("Webhook alert failed: %s", exc)
97
+ # Log webhook-send failures at most once per cooldown window.
98
+ last_err = self._last_webhook_err_ts.get(run_id, 0.0)
99
+ if time.monotonic() - last_err >= _ALERT_COOLDOWN_S:
100
+ logger.warning("Webhook alert failed: %s", exc)
101
+ self._last_webhook_err_ts[run_id] = time.monotonic()
@@ -449,19 +449,33 @@ async def stream(
449
449
  elif target.data_type == DataType.ORDERBOOK:
450
450
  if not isinstance(adapter, OrderBookLive):
451
451
  raise NoCapability(target.exchange, "orderbook", "live")
452
- # The WS pushes the book continuously, but we only *capture* a
453
- # snapshot every ``snapshot_interval`` seconds. Emit the liveness
454
- # sample only when we actually save, so the liveness reflects the
455
- # real capture cadence (matching the Ns" shown in the UI) instead
456
- # of flickering every second on data we discard. ``last_save = 0``
457
- # captures the first frame immediately.
458
- last_save = 0.0
459
- async for snap in adapter.stream_orderbook(target.symbol, params.depth or 50):
452
+ # Snap the requested depth to the channel's declared discrete values
453
+ # (e.g. Kraken WS v2 only accepts {10,25,100,500,1000}): an undeclared
454
+ # depth is silently rejected by the exchange and the "live" stream
455
+ # would never write anything. Prefer the smallest valid depth that
456
+ # still covers the request; fall back to the largest available.
457
+ depth = params.depth or 50
458
+ ob_cap = adapter.capability_for(DataType.ORDERBOOK, "ws", "live")
459
+ if ob_cap is not None and ob_cap.depths and depth not in ob_cap.depths:
460
+ snapped = min((d for d in ob_cap.depths if d >= depth),
461
+ default=max(ob_cap.depths))
462
+ if events:
463
+ events.log(
464
+ f"{target.exchange} order-book channel does not accept "
465
+ f"depth={depth}; using {snapped} "
466
+ f"(valid: {sorted(ob_cap.depths)})",
467
+ level="warning",
468
+ )
469
+ depth = snapped
470
+ # The throttle is owned by the adapter: pass min_interval so that
471
+ # pydantic objects are only constructed for frames that will be saved.
472
+ # The adapter yields one snapshot per interval, so every yielded
473
+ # snapshot is saved immediately (no downstream throttle needed).
474
+ async for snap in adapter.stream_orderbook(
475
+ target.symbol, depth, min_interval=snapshot_interval
476
+ ):
460
477
  if stop_event and stop_event.is_set():
461
478
  break
462
- if time.time() - last_save < snapshot_interval:
463
- continue
464
- last_save = time.time()
465
479
  await asyncio.to_thread(store.save, ds, [snap], Provenance(source=prov_src))
466
480
  # Best bid = highest bid price, best ask = lowest ask price.
467
481
  # Compute rather than trust ordering so a momentarily unsorted
@@ -481,10 +495,21 @@ async def stream(
481
495
  if batch:
482
496
  await asyncio.to_thread(store.save, ds, batch, Provenance(source=prov_src))
483
497
 
484
- if events:
485
- events.status("cancelled")
486
- if runs_store:
487
- runs_store.finish_run(run_id, "cancelled")
498
+ # `cancelled` only when a stop was actually requested. A WS generator that
499
+ # ends on its own (exhausted without stop) is a failure — recording it as
500
+ # `cancelled` made Logs/Runs claim someone stopped a stream nobody touched.
501
+ if stop_event and stop_event.is_set():
502
+ if events:
503
+ events.status("cancelled")
504
+ if runs_store:
505
+ runs_store.finish_run(run_id, "cancelled")
506
+ else:
507
+ msg = "stream ended unexpectedly"
508
+ if events:
509
+ events.log(msg, level="error")
510
+ events.status("failed")
511
+ if runs_store:
512
+ runs_store.finish_run(run_id, "failed", error=msg)
488
513
 
489
514
 
490
515
  def read(
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import logging
7
+ import random
7
8
  import time
8
9
 
9
10
  from dccd.application.events import EventBus, RunEvents
@@ -197,9 +198,13 @@ class Scheduler:
197
198
  self._interval_of(spec),
198
199
  )
199
200
 
201
+ async def _run_once_void(self, spec: JobSpec) -> None:
202
+ """Thin void wrapper around :meth:`_run_once` for use with ``create_task``."""
203
+ await self._run_once(spec)
204
+
200
205
  async def run_now(self, spec: JobSpec) -> None:
201
206
  """Trigger a one-shot backfill for *spec* immediately."""
202
- self._track(asyncio.create_task(self._run_once(spec)))
207
+ self._track(asyncio.create_task(self._run_once_void(spec)))
203
208
 
204
209
  async def start(self, specs: list[JobSpec]) -> None:
205
210
  """Start all enabled specs (full daemon mode)."""
@@ -222,7 +227,7 @@ class Scheduler:
222
227
  elif spec.trigger.kind == "once":
223
228
  # Track the task so stop() can cancel it and the event loop
224
229
  # cannot silently GC it before it finishes.
225
- self._track(asyncio.create_task(self._run_once(spec)))
230
+ self._track(asyncio.create_task(self._run_once_void(spec)))
226
231
 
227
232
  async def stop(self) -> None:
228
233
  """Stop all running jobs."""
@@ -303,15 +308,32 @@ class Scheduler:
303
308
 
304
309
  async def _interval_loop(self, spec: JobSpec) -> None:
305
310
  every = spec.trigger.every or spec.target.span or 3600
311
+ # Startup jitter: spread simultaneous daemon starts over [0, min(every, 60)].
312
+ jitter = random.uniform(0, min(every, 60))
313
+ await asyncio.sleep(jitter)
314
+ consecutive_failures = 0
306
315
  while self._running:
307
- await self._run_once(spec)
308
- await asyncio.sleep(every)
316
+ success = await self._run_once(spec)
317
+ if success:
318
+ consecutive_failures = 0
319
+ delay = every
320
+ else:
321
+ # Cap the exponent at 11 to avoid overflow (2**11 = 2048).
322
+ k = min(consecutive_failures, 11)
323
+ delay = min(every * (2 ** k), max(every, 6 * 3600))
324
+ consecutive_failures += 1
325
+ logger.warning(
326
+ "Scheduled job %s failed (consecutive=%d) — backing off %ds",
327
+ spec.id, consecutive_failures, int(delay),
328
+ )
329
+ await asyncio.sleep(delay)
309
330
 
310
- async def _run_once(self, spec: JobSpec) -> None:
331
+ async def _run_once(self, spec: JobSpec) -> bool:
332
+ """Run *spec* once; return True on success, False on error."""
311
333
  from dccd.application.operations import backfill
312
334
  try:
313
335
  run_events = self._events.for_run(f"{spec.id}@{int(time.time())}")
314
- await backfill(
336
+ result = await backfill(
315
337
  spec,
316
338
  registry=self._registry,
317
339
  store=self._store,
@@ -319,8 +341,10 @@ class Scheduler:
319
341
  coverage_store=self._coverage_store,
320
342
  events=run_events,
321
343
  )
344
+ return not (isinstance(result, dict) and "error" in result)
322
345
  except Exception as exc:
323
346
  logger.error("Scheduled job %s failed: %s", spec.id, exc)
347
+ return False
324
348
 
325
349
  def start_stream(self, spec_id: str) -> bool:
326
350
  """Start one registered stream by spec id; return whether it existed."""
@@ -31,6 +31,12 @@ class Capability(BaseModel, frozen=True):
31
31
  Supported OHLC spans in seconds. ``None`` = span list not constrained.
32
32
  max_depth : int or None
33
33
  Maximum order book depth.
34
+ depths : list[int] or None
35
+ Discrete order-book depths the channel accepts (e.g. Kraken WS v2:
36
+ ``[10, 25, 100, 500, 1000]``). ``None`` = not constrained. The stream
37
+ operation snaps a requested depth to the nearest declared value — an
38
+ undeclared depth would be silently rejected by the exchange and leave
39
+ a "live" stream that never writes anything.
34
40
  auth_required : bool
35
41
  Whether this capability requires authentication.
36
42
 
@@ -50,4 +56,5 @@ class Capability(BaseModel, frozen=True):
50
56
  page_direction: Literal["forward", "backward"] | None = None
51
57
  spans: list[int] | None = None
52
58
  max_depth: int | None = None
59
+ depths: list[int] | None = None
53
60
  auth_required: bool = False
@@ -32,6 +32,7 @@ from urllib.parse import parse_qs
32
32
 
33
33
  from fastapi import FastAPI, HTTPException, Request
34
34
  from fastapi.middleware.cors import CORSMiddleware
35
+ from fastapi.middleware.gzip import GZipMiddleware
35
36
  from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
36
37
  from fastapi.staticfiles import StaticFiles
37
38
  from fastapi.templating import Jinja2Templates
@@ -301,6 +302,11 @@ def create_app(
301
302
  # all; allowing every origin let any website's JS drive the local API
302
303
  # (reachable on 127.0.0.1 from the user's browser). Cross-origin access is
303
304
  # opt-in via settings.ui_allow_origins.
305
+ # Compress API/page responses for remote (Tailscale/TLS) clients — inventory
306
+ # and runs JSON shrink ~10×. Starlette excludes `text/event-stream` from
307
+ # compression by default, so the SSE stream at /api/events is untouched.
308
+ app.add_middleware(GZipMiddleware, minimum_size=1024)
309
+
304
310
  allow_origins = list(getattr(getattr(config, "settings", None), "ui_allow_origins", []) or [])
305
311
  if allow_origins:
306
312
  app.add_middleware(
@@ -464,7 +470,9 @@ def create_app(
464
470
  @app.get("/api/inventory")
465
471
  async def get_inventory(request: Request) -> dict[str, Any]:
466
472
  """Return all stored datasets."""
467
- return {"datasets": _store(request).inventory()}
473
+ store = _store(request)
474
+ datasets = await asyncio.to_thread(store.inventory)
475
+ return {"datasets": datasets}
468
476
 
469
477
  # -----------------------------------------------------------------------
470
478
  # Remote sync
@@ -483,7 +491,9 @@ def create_app(
483
491
  remotes = [r.remote for r in cfg.storage.remotes]
484
492
  configured = bool(remotes)
485
493
  interval = cfg.storage.sync_interval
486
- runs = _runs(request).list_runs(spec_id="remote-sync", limit=1)
494
+ runs = await asyncio.to_thread(
495
+ lambda: _runs(request).list_runs(spec_id="remote-sync", limit=1)
496
+ )
487
497
  last = None
488
498
  next_eta = None
489
499
  if runs:
@@ -496,7 +506,9 @@ def create_app(
496
506
  }
497
507
  if configured and r["ended_at"]:
498
508
  next_eta = r["ended_at"] + interval * NS
499
- total_bytes = sum(d.get("bytes", 0) for d in _store(request).inventory())
509
+ store = _store(request)
510
+ inventory = await asyncio.to_thread(store.inventory)
511
+ total_bytes = sum(d.get("bytes", 0) for d in inventory)
500
512
  return {
501
513
  "configured": configured,
502
514
  "remotes": remotes,
@@ -566,7 +578,7 @@ def create_app(
566
578
  @app.get("/api/backfill/{run_id}")
567
579
  async def get_backfill_status(run_id: str, request: Request) -> dict[str, Any]:
568
580
  """Get the status of a backfill run by ``run_id``."""
569
- run = _runs(request).get_run(run_id)
581
+ run = await asyncio.to_thread(_runs(request).get_run, run_id)
570
582
  if not run:
571
583
  raise HTTPException(404, f"Run {run_id!r} not found")
572
584
  return _parse_run(run)
@@ -583,7 +595,8 @@ def create_app(
583
595
  @app.get("/api/runs")
584
596
  async def list_runs(request: Request, limit: int = 50) -> dict[str, Any]:
585
597
  """List recent job runs."""
586
- return {"runs": [_parse_run(r) for r in _runs(request).list_runs(limit=limit)]}
598
+ runs = await asyncio.to_thread(lambda: _runs(request).list_runs(limit=limit))
599
+ return {"runs": [_parse_run(r) for r in runs]}
587
600
 
588
601
  # -----------------------------------------------------------------------
589
602
  # Stream control
@@ -785,6 +798,12 @@ def create_app(
785
798
 
786
799
  async def _generator():
787
800
  try:
801
+ # Flush an immediate comment: GZipMiddleware (even though it
802
+ # excludes text/event-stream from compression) buffers the
803
+ # response *start* until the first body chunk — without this,
804
+ # EventSource sits in "connecting" until the first event or
805
+ # the 30 s heartbeat.
806
+ yield ": connected\n\n"
788
807
  while True:
789
808
  if await request.is_disconnected():
790
809
  break
@@ -45,11 +45,13 @@ function kpi(label, value) {
45
45
  }
46
46
 
47
47
  async function load() {
48
- // Fetch everything we need once.
49
- let runs = [], streams = [], datasets = [];
50
- try { runs = (await api('GET','/api/runs?limit=50')).runs || []; } catch (_) {}
51
- try { streams = (await api('GET','/api/streams')).streams || []; } catch (_) {}
52
- try { datasets = (await api('GET','/api/inventory')).datasets || []; } catch (_) {}
48
+ // Fetch everything we need once — in parallel: serial awaits cost 3×RTT,
49
+ // which is what a remote (Tailscale/TLS) client actually feels.
50
+ const [runsR, streamsR, invR] = await Promise.allSettled([
51
+ api('GET','/api/runs?limit=50'), api('GET','/api/streams'), api('GET','/api/inventory')]);
52
+ const runs = runsR.status === 'fulfilled' ? (runsR.value.runs || []) : [];
53
+ const streams = streamsR.status === 'fulfilled' ? (streamsR.value.streams || []) : [];
54
+ const datasets = invR.status === 'fulfilled' ? (invR.value.datasets || []) : [];
53
55
 
54
56
  const liveStreams = streams.filter(s => s.running);
55
57
  const activeRuns = runs.filter(r => r.state === 'running' || r.state === 'reconnecting');
@@ -118,6 +120,6 @@ async function load() {
118
120
  }
119
121
 
120
122
  load();
121
- setInterval(load, 8000);
123
+ setInterval(load, 15000);
122
124
  </script>
123
125
  {% endblock %}
@@ -40,17 +40,22 @@ function invFor(j) {
40
40
  return INV.find(d => d.exchange===j.exchange && d.pair===pair && d.data_type===j.data_type);
41
41
  }
42
42
 
43
- async function loadAll() {
43
+ async function loadAll(withInventory) {
44
44
  try {
45
- const [jr, sr, ir] = await Promise.all([
46
- api('GET','/api/jobs'), api('GET','/api/streams'), api('GET','/api/inventory')]);
45
+ // Inventory only seeds liveness from disk — fetch it on initial load and
46
+ // on SSE status changes (a stream stopped/failed wrote its last point),
47
+ // not on every 8 s poll: it is the heaviest endpoint and live samples
48
+ // arrive over SSE anyway.
49
+ const reqs = [api('GET','/api/jobs'), api('GET','/api/streams')];
50
+ if (withInventory) reqs.push(api('GET','/api/inventory'));
51
+ const [jr, sr, ir] = await Promise.all(reqs);
47
52
  JOBS = (jr.jobs||[]).filter(j => j.operation === 'stream');
48
53
  RUNNING = {};
49
54
  for (const s of (sr.streams||[])) RUNNING[s.id] = s.running;
50
55
  // Seed liveness from what's on disk so a page refresh immediately reflects
51
56
  // the last stored data point (and its freshness) instead of "waiting…".
52
57
  // Real-time SSE samples (`live`) always win and are never overwritten here.
53
- INV = ir.datasets || [];
58
+ if (ir) INV = ir.datasets || [];
54
59
  for (const j of JOBS) {
55
60
  if (samples[j.id] && samples[j.id].live) continue;
56
61
  const inv = invFor(j);
@@ -187,7 +192,7 @@ async function toggleStream(jobId) {
187
192
  await api('POST', running?'/api/streams/stop':'/api/streams/start', { spec_id: jobId });
188
193
  toast(running?'Stream stopped':'Stream started','ok');
189
194
  if (running) delete samples[jobId];
190
- setTimeout(loadAll, 400);
195
+ setTimeout(() => loadAll(true), 400);
191
196
  } catch (e) { toast(e.message,'err'); btn.disabled = false; }
192
197
  }
193
198
 
@@ -290,7 +295,7 @@ function connectSSE() {
290
295
  samples[specId] = { value: ev.value, bid: ev.bid, ask: ev.ask, ts: ev.ts, live: true };
291
296
  } else if (ev.kind === 'status') {
292
297
  // a stream stopping/failing → refresh state soon
293
- setTimeout(loadAll, 300);
298
+ setTimeout(() => loadAll(true), 300);
294
299
  }
295
300
  };
296
301
  es.onerror = () => {
@@ -300,7 +305,7 @@ function connectSSE() {
300
305
  }
301
306
 
302
307
  initTabs('#live-tabs');
303
- loadAll();
308
+ loadAll(true);
304
309
  connectSSE();
305
310
  setInterval(refreshLiveness, 1000);
306
311
  setInterval(loadAll, 8000);
@@ -103,6 +103,7 @@ async function loadInventory() {
103
103
 
104
104
  loadSync();
105
105
  loadInventory();
106
- setInterval(loadSync, 10000);
106
+ // Status view: 30 s is plenty ("Sync now" already polls itself after a trigger).
107
+ setInterval(loadSync, 30000);
107
108
  </script>
108
109
  {% endblock %}
@@ -130,6 +130,25 @@ class TradesLive(Source):
130
130
  class OrderBookLive(Source):
131
131
  """Protocol: can stream live order book snapshots/deltas via WebSocket."""
132
132
 
133
- def stream_orderbook(self, symbol: Symbol, depth: int) -> AsyncIterator[OrderBookSnapshot]:
134
- """Yield live order-book snapshots/deltas over WebSocket."""
133
+ def stream_orderbook(
134
+ self,
135
+ symbol: Symbol,
136
+ depth: int,
137
+ *,
138
+ min_interval: float = 0.0,
139
+ ) -> AsyncIterator[OrderBookSnapshot]:
140
+ """Yield live order-book snapshots over WebSocket.
141
+
142
+ Parameters
143
+ ----------
144
+ symbol : Symbol
145
+ depth : int
146
+ Maximum number of levels per side to include in each snapshot.
147
+ min_interval : float, optional
148
+ Minimum seconds between emitted snapshots. ``0.0`` (default)
149
+ preserves the legacy per-frame behaviour — every WS frame yields a
150
+ snapshot. Pass ``snapshot_interval`` from the job spec to move the
151
+ throttle *upstream* so pydantic objects are only constructed for
152
+ frames that will actually be saved.
153
+ """
135
154
  raise NotImplementedError
@@ -87,7 +87,7 @@ class BinanceSource(
87
87
  ),
88
88
  Capability(data_type=DataType.OHLC, transport="ws", mode="live"),
89
89
  Capability(data_type=DataType.TRADES, transport="ws", mode="live"),
90
- Capability(data_type=DataType.ORDERBOOK, transport="ws", mode="live", max_depth=20),
90
+ Capability(data_type=DataType.ORDERBOOK, transport="ws", mode="live", max_depth=20, depths=[5, 10, 20]),
91
91
  ]
92
92
 
93
93
  def render_symbol(self, s: Symbol) -> str:
@@ -205,7 +205,13 @@ class BinanceSource(
205
205
  ws = _BinanceTradeWS(pair)
206
206
  return ws.stream()
207
207
 
208
- def stream_orderbook(self, symbol: Symbol, depth: int) -> AsyncIterator[OrderBookSnapshot]:
208
+ def stream_orderbook(
209
+ self,
210
+ symbol: Symbol,
211
+ depth: int,
212
+ *,
213
+ min_interval: float = 0.0,
214
+ ) -> AsyncIterator[OrderBookSnapshot]:
209
215
  """Stream live order-book snapshots over WebSocket.
210
216
 
211
217
  Uses Binance's *partial book depth* stream, which pushes a fully sorted
@@ -214,7 +220,7 @@ class BinanceSource(
214
220
  pair = self.render_symbol(symbol).lower()
215
221
  levels = 5 if depth <= 5 else 10 if depth <= 10 else 20
216
222
  ws = _BinanceDepthWS(pair, levels)
217
- return ws.stream()
223
+ return ws.stream(min_interval=min_interval)
218
224
 
219
225
 
220
226
  class _BinanceKlineWS(WebSocketBase):
@@ -279,3 +285,22 @@ class _BinanceDepthWS(WebSocketBase):
279
285
  asks=asks,
280
286
  is_snapshot=True,
281
287
  )
288
+
289
+ async def stream(self, min_interval: float = 0.0) -> AsyncIterator[OrderBookSnapshot]:
290
+ """Yield parsed order-book snapshots, throttled to *min_interval* seconds.
291
+
292
+ The throttle check is applied on the raw frame **before** calling
293
+ ``parse_message`` so no pydantic objects are constructed for frames that
294
+ will be discarded. ``min_interval=0.0`` preserves the legacy per-frame
295
+ behaviour.
296
+ """
297
+ import time as _time
298
+ last_emit: float = -float("inf") # first frame always emits
299
+ async for raw in self.stream_raw():
300
+ # Fast path: do a cheap JSON sniff before committing to parse_message.
301
+ now = _time.monotonic()
302
+ if now - last_emit < min_interval:
303
+ continue
304
+ async for record in self.parse_message(raw):
305
+ last_emit = _time.monotonic()
306
+ yield record
@@ -219,7 +219,13 @@ class BitfinexSource(OHLCHistory, TradesHistory, OrderBookSnapshotREST, OHLCLive
219
219
  ws = _BitfinexWS(_bfx_symbol(symbol), "trades")
220
220
  return ws.stream()
221
221
 
222
- def stream_orderbook(self, symbol: Symbol, depth: int) -> AsyncIterator[OrderBookSnapshot]:
222
+ def stream_orderbook(
223
+ self,
224
+ symbol: Symbol,
225
+ depth: int,
226
+ *,
227
+ min_interval: float = 0.0,
228
+ ) -> AsyncIterator[OrderBookSnapshot]:
223
229
  """Stream live order-book snapshots/deltas over WebSocket."""
224
230
  raise NotImplementedError("Bitfinex live order book stream is not implemented")
225
231