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.
- {dccd-3.3.1 → dccd-3.4.0}/CHANGELOG.md +53 -0
- {dccd-3.3.1 → dccd-3.4.0}/PKG-INFO +1 -1
- {dccd-3.3.1 → dccd-3.4.0}/dccd/application/monitor.py +30 -2
- {dccd-3.3.1 → dccd-3.4.0}/dccd/application/operations.py +40 -15
- {dccd-3.3.1 → dccd-3.4.0}/dccd/application/scheduler.py +30 -6
- {dccd-3.3.1 → dccd-3.4.0}/dccd/domain/capability.py +7 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/api/app.py +24 -5
- {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/templates/dashboard.html +8 -6
- {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/templates/live.html +12 -7
- {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/templates/storage.html +2 -1
- {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/base.py +21 -2
- {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/binance.py +28 -3
- {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/bitfinex.py +7 -1
- {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/bitmex.py +46 -3
- {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/bybit.py +49 -10
- {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/coinbase.py +7 -1
- {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/kraken.py +58 -10
- {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/okx.py +44 -3
- {dccd-3.3.1 → dccd-3.4.0}/dccd/storage/parquet.py +127 -35
- {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_api.py +28 -0
- dccd-3.4.0/dccd/tests/v3/test_orderbook_throttle.py +406 -0
- dccd-3.4.0/dccd/tests/v3/test_scheduler_hygiene.py +305 -0
- dccd-3.4.0/dccd/tests/v3/test_storage_extended.py +407 -0
- dccd-3.4.0/dccd/tests/v3/test_stream_end_state.py +86 -0
- dccd-3.4.0/dccd/tests/v3/test_ws_subscription_honesty.py +200 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd.egg-info/PKG-INFO +1 -1
- {dccd-3.3.1 → dccd-3.4.0}/dccd.egg-info/SOURCES.txt +4 -0
- {dccd-3.3.1 → dccd-3.4.0}/pyproject.toml +1 -1
- dccd-3.3.1/dccd/tests/v3/test_storage_extended.py +0 -151
- {dccd-3.3.1 → dccd-3.4.0}/CLAUDE.md +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/CONTRIBUTING.md +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/LICENSE.txt +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/MANIFEST.in +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/README.md +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/__init__.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/application/__init__.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/application/config.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/application/events.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/application/jobs.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/application/registry.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/application/service_factory.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/domain/__init__.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/domain/dataset.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/domain/errors.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/domain/records.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/domain/symbol.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/domain/timeutils.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/domain/transforms.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/domain/types.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/__init__.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/api/__init__.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/cli/__init__.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/cli/main.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/__init__.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/static/favicon.svg +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/static/logo.svg +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/templates/base.html +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/templates/config.html +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/templates/data.html +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/templates/historical.html +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/templates/login.html +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/interfaces/ui/templates/logs.html +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/__init__.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/sources/registry.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/storage/__init__.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/storage/coverage_sqlite.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/storage/purge.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/storage/remote.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/storage/runs_sqlite.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/__init__.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/__init__.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_application.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_backfill_lookback.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_client.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_coverage.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_domain.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_domain_extended.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_network.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_purge.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_remote_sync.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_restart.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_restore.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_sources.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_storage.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/tests/v3/test_transport.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/transport/__init__.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/transport/http.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/transport/paginate.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/transport/ratelimit.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd/transport/ws.py +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd.egg-info/dependency_links.txt +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd.egg-info/entry_points.txt +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd.egg-info/requires.txt +0 -0
- {dccd-3.3.1 → dccd-3.4.0}/dccd.egg-info/top_level.txt +0 -0
- {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
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
#
|
|
453
|
-
#
|
|
454
|
-
#
|
|
455
|
-
#
|
|
456
|
-
#
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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) ->
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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,
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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(
|
|
134
|
-
|
|
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(
|
|
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(
|
|
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
|
|