Add HTML templates for dashboard, metrics, overview, and backtesting
CI / lint-test-build (push) Failing after 1m7s
CI / lint-test-build (push) Failing after 1m7s
- Introduced new HTML templates for the dashboard, metrics, overview, and backtesting functionalities. - Implemented partial templates for metrics, overview, audit, controls, and charts to enhance modularity. - Updated the Jinja2 template resolution logic to support different deployment environments. - Added a health check template to display the service status. - Included a test suite to verify the template resolution logic. - Updated `pyproject.toml` to include new HTML templates in the package data.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Kraken exchange integration package."""
|
||||
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from arbitrade.config.settings import Settings
|
||||
from arbitrade.exchange.models import KrakenApiResult, LatencySample
|
||||
from arbitrade.exchange.signing import sign_kraken_private_path
|
||||
|
||||
_LOG = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _result_dict(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
result = payload.get("result", {})
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
return {}
|
||||
|
||||
|
||||
class KrakenRestClient:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=settings.kraken_rest_url,
|
||||
timeout=settings.kraken_http_timeout_seconds,
|
||||
limits=httpx.Limits(max_keepalive_connections=10, max_connections=50),
|
||||
headers={"User-Agent": "arbitrade/0.1.0"},
|
||||
)
|
||||
self._private_lock = asyncio.Lock()
|
||||
|
||||
issues = self.validate_compliance()
|
||||
if issues:
|
||||
_LOG.warning("kraken_compliance_issues", issues=issues)
|
||||
else:
|
||||
_LOG.info("kraken_compliance_ok")
|
||||
|
||||
def validate_compliance(self) -> list[str]:
|
||||
issues: list[str] = []
|
||||
|
||||
if not self._settings.kraken_rest_url.startswith("https://"):
|
||||
issues.append("KRAKEN_REST_URL should use https://")
|
||||
|
||||
if self._settings.kraken_private_rate_limit_seconds < 1.0:
|
||||
issues.append("KRAKEN_PRIVATE_RATE_LIMIT_SECONDS below 1.0 may violate Kraken limits")
|
||||
|
||||
if self._settings.kraken_retry_attempts < 1:
|
||||
issues.append("KRAKEN_RETRY_ATTEMPTS must be >= 1")
|
||||
|
||||
if self._settings.kraken_retry_base_delay_seconds < 0:
|
||||
issues.append("KRAKEN_RETRY_BASE_DELAY_SECONDS must be >= 0")
|
||||
|
||||
return issues
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
async def warm_connection_pool(self) -> None:
|
||||
await self.server_time()
|
||||
|
||||
async def _request_with_retry(
|
||||
self,
|
||||
endpoint: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> KrakenApiResult:
|
||||
attempts = self._settings.kraken_retry_attempts
|
||||
delay = self._settings.kraken_retry_base_delay_seconds
|
||||
params = params or {}
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
response = await self._client.get(endpoint, params=params)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if payload.get("error"):
|
||||
raise RuntimeError(f"Kraken error: {payload['error']}")
|
||||
|
||||
latency = (time.perf_counter() - t0) * 1000
|
||||
_LOG.info(
|
||||
"kraken_rest_request_ok",
|
||||
endpoint=endpoint,
|
||||
attempt=attempt,
|
||||
latency_ms=latency,
|
||||
sample=LatencySample.now("rest_request", latency_ms=latency).latency_ms,
|
||||
)
|
||||
return KrakenApiResult(endpoint=endpoint, payload=payload)
|
||||
except Exception as exc:
|
||||
latency = (time.perf_counter() - t0) * 1000
|
||||
_LOG.warning(
|
||||
"kraken_rest_request_failed",
|
||||
endpoint=endpoint,
|
||||
attempt=attempt,
|
||||
latency_ms=latency,
|
||||
error=str(exc),
|
||||
)
|
||||
if attempt >= attempts:
|
||||
raise
|
||||
await asyncio.sleep(delay * (2 ** (attempt - 1)))
|
||||
|
||||
raise RuntimeError("unreachable retry loop")
|
||||
|
||||
async def _private_post_with_retry(
|
||||
self,
|
||||
endpoint: str,
|
||||
data: dict[str, str] | None = None,
|
||||
) -> KrakenApiResult:
|
||||
api_key = self._settings.kraken_api_key
|
||||
api_secret = self._settings.kraken_api_secret
|
||||
if not api_key or not api_secret:
|
||||
raise RuntimeError("Missing Kraken API credentials for private endpoint")
|
||||
|
||||
attempts = self._settings.kraken_retry_attempts
|
||||
delay = self._settings.kraken_retry_base_delay_seconds
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
nonce = str(int(time.time() * 1000))
|
||||
payload = {"nonce": nonce}
|
||||
if data is not None:
|
||||
payload.update(data)
|
||||
|
||||
encoded = urlencode(payload)
|
||||
signature = sign_kraken_private_path(endpoint, nonce, encoded, api_secret)
|
||||
|
||||
response = await self._client.post(
|
||||
endpoint,
|
||||
data=payload,
|
||||
headers={
|
||||
"API-Key": api_key,
|
||||
"API-Sign": signature,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
if body.get("error"):
|
||||
raise RuntimeError(f"Kraken error: {body['error']}")
|
||||
|
||||
latency = (time.perf_counter() - t0) * 1000
|
||||
_LOG.info(
|
||||
"kraken_private_rest_request_ok",
|
||||
endpoint=endpoint,
|
||||
attempt=attempt,
|
||||
latency_ms=latency,
|
||||
sample=LatencySample.now("private_rest_request", latency_ms=latency).latency_ms,
|
||||
)
|
||||
return KrakenApiResult(endpoint=endpoint, payload=body)
|
||||
except Exception as exc:
|
||||
latency = (time.perf_counter() - t0) * 1000
|
||||
_LOG.warning(
|
||||
"kraken_private_rest_request_failed",
|
||||
endpoint=endpoint,
|
||||
attempt=attempt,
|
||||
latency_ms=latency,
|
||||
error=str(exc),
|
||||
)
|
||||
if attempt >= attempts:
|
||||
raise
|
||||
await asyncio.sleep(delay * (2 ** (attempt - 1)))
|
||||
|
||||
raise RuntimeError("unreachable retry loop")
|
||||
|
||||
async def server_time(self) -> dict[str, Any]:
|
||||
result = await self._request_with_retry("/0/public/Time")
|
||||
return _result_dict(result.payload)
|
||||
|
||||
async def assets(self) -> dict[str, Any]:
|
||||
result = await self._request_with_retry("/0/public/Assets")
|
||||
return _result_dict(result.payload)
|
||||
|
||||
async def asset_pairs(self) -> dict[str, Any]:
|
||||
result = await self._request_with_retry("/0/public/AssetPairs")
|
||||
return _result_dict(result.payload)
|
||||
|
||||
async def _throttled_private_call(
|
||||
self,
|
||||
endpoint: str,
|
||||
data: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
async with self._private_lock:
|
||||
result = await self._private_post_with_retry(endpoint, data=data)
|
||||
await asyncio.sleep(self._settings.kraken_private_rate_limit_seconds)
|
||||
return _result_dict(result.payload)
|
||||
|
||||
async def balances(self) -> dict[str, Any]:
|
||||
return await self._throttled_private_call("/0/private/Balance")
|
||||
|
||||
async def place_market_order(
|
||||
self,
|
||||
*,
|
||||
pair: str,
|
||||
side: str,
|
||||
volume: float,
|
||||
user_ref: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized_side = side.lower()
|
||||
if normalized_side not in {"buy", "sell"}:
|
||||
raise ValueError("side must be 'buy' or 'sell'")
|
||||
if volume <= 0.0:
|
||||
raise ValueError("volume must be > 0.0")
|
||||
if user_ref is not None and user_ref < 0:
|
||||
raise ValueError("user_ref must be >= 0")
|
||||
|
||||
data = {
|
||||
"pair": pair,
|
||||
"type": normalized_side,
|
||||
"ordertype": "market",
|
||||
"volume": str(volume),
|
||||
}
|
||||
if user_ref is not None:
|
||||
data["userref"] = str(user_ref)
|
||||
|
||||
return await self._throttled_private_call(
|
||||
"/0/private/AddOrder",
|
||||
data=data,
|
||||
)
|
||||
|
||||
async def place_limit_order(
|
||||
self,
|
||||
*,
|
||||
pair: str,
|
||||
side: str,
|
||||
volume: float,
|
||||
price: float,
|
||||
user_ref: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized_side = side.lower()
|
||||
if normalized_side not in {"buy", "sell"}:
|
||||
raise ValueError("side must be 'buy' or 'sell'")
|
||||
if volume <= 0.0:
|
||||
raise ValueError("volume must be > 0.0")
|
||||
if price <= 0.0:
|
||||
raise ValueError("price must be > 0.0")
|
||||
if user_ref is not None and user_ref < 0:
|
||||
raise ValueError("user_ref must be >= 0")
|
||||
|
||||
data = {
|
||||
"pair": pair,
|
||||
"type": normalized_side,
|
||||
"ordertype": "limit",
|
||||
"price": str(price),
|
||||
"volume": str(volume),
|
||||
}
|
||||
if user_ref is not None:
|
||||
data["userref"] = str(user_ref)
|
||||
|
||||
return await self._throttled_private_call(
|
||||
"/0/private/AddOrder",
|
||||
data=data,
|
||||
)
|
||||
|
||||
async def query_order(
|
||||
self,
|
||||
*,
|
||||
order_id: str,
|
||||
include_trades: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if not order_id.strip():
|
||||
raise ValueError("order_id must be non-empty")
|
||||
|
||||
return await self._throttled_private_call(
|
||||
"/0/private/QueryOrders",
|
||||
data={
|
||||
"txid": order_id,
|
||||
"trades": "true" if include_trades else "false",
|
||||
},
|
||||
)
|
||||
|
||||
async def cancel_order(self, *, order_id: str) -> dict[str, Any]:
|
||||
if not order_id.strip():
|
||||
raise ValueError("order_id must be non-empty")
|
||||
|
||||
return await self._throttled_private_call(
|
||||
"/0/private/CancelOrder",
|
||||
data={"txid": order_id},
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import orjson
|
||||
import structlog
|
||||
import websockets
|
||||
|
||||
from arbitrade.alerting.notifier import AlertSeverity, SupportsAlerts
|
||||
from arbitrade.config.settings import Settings
|
||||
from arbitrade.exchange.models import BookDelta, BookLevel
|
||||
|
||||
_LOG = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WsMessage:
|
||||
received_at: datetime
|
||||
payload: dict[str, Any]
|
||||
|
||||
|
||||
class KrakenWsClient:
|
||||
def __init__(self, settings: Settings, *, alert_notifier: SupportsAlerts | None = None) -> None:
|
||||
self._settings = settings
|
||||
self._last_message_at: datetime | None = None
|
||||
self._stop = asyncio.Event()
|
||||
self._alert_notifier = alert_notifier
|
||||
self._has_connected_once = False
|
||||
self._was_disconnected = False
|
||||
|
||||
@property
|
||||
def is_stale(self) -> bool:
|
||||
if self._last_message_at is None:
|
||||
return True
|
||||
return (
|
||||
datetime.now(UTC) - self._last_message_at
|
||||
).total_seconds() > self._settings.ws_max_staleness_seconds
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop.set()
|
||||
|
||||
async def connect_stream(self) -> AsyncIterator[WsMessage]:
|
||||
delay = 1.0
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
async with websockets.connect(
|
||||
self._settings.kraken_ws_url, max_size=2_000_000
|
||||
) as ws:
|
||||
_LOG.info("kraken_ws_connected", url=self._settings.kraken_ws_url)
|
||||
if self._has_connected_once and self._was_disconnected:
|
||||
await self._notify(
|
||||
category="system",
|
||||
severity="info",
|
||||
title="WebSocket reconnected",
|
||||
message="Kraken WebSocket connection restored.",
|
||||
details={"url": self._settings.kraken_ws_url},
|
||||
)
|
||||
self._has_connected_once = True
|
||||
self._was_disconnected = False
|
||||
delay = 1.0
|
||||
async for raw in self._recv_loop(ws):
|
||||
yield raw
|
||||
except Exception as exc:
|
||||
_LOG.warning("kraken_ws_disconnected", error=str(exc), reconnect_in=delay)
|
||||
self._was_disconnected = True
|
||||
await self._notify(
|
||||
category="system",
|
||||
severity="warning",
|
||||
title="WebSocket disconnected",
|
||||
message="Kraken WebSocket disconnected, reconnect scheduled.",
|
||||
details={
|
||||
"error": str(exc),
|
||||
"reconnect_in_seconds": f"{delay}",
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, 30.0)
|
||||
|
||||
async def _recv_loop(self, ws: Any) -> AsyncIterator[WsMessage]:
|
||||
while not self._stop.is_set():
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
raw = await asyncio.wait_for(
|
||||
ws.recv(), timeout=self._settings.ws_heartbeat_timeout_seconds
|
||||
)
|
||||
except TimeoutError:
|
||||
await self._notify(
|
||||
category="system",
|
||||
severity="critical",
|
||||
title="WebSocket staleness abort",
|
||||
message="No WebSocket heartbeat within configured timeout; reconnecting.",
|
||||
details={
|
||||
"heartbeat_timeout_seconds": (
|
||||
f"{self._settings.ws_heartbeat_timeout_seconds}"
|
||||
),
|
||||
},
|
||||
)
|
||||
raise
|
||||
parse_start = time.perf_counter()
|
||||
payload = orjson.loads(raw)
|
||||
self._last_message_at = datetime.now(UTC)
|
||||
|
||||
_LOG.debug(
|
||||
"kraken_ws_message",
|
||||
recv_latency_ms=(parse_start - t0) * 1000,
|
||||
parse_latency_ms=(time.perf_counter() - parse_start) * 1000,
|
||||
)
|
||||
if isinstance(payload, dict):
|
||||
yield WsMessage(received_at=self._last_message_at, payload=payload)
|
||||
|
||||
async def _notify(
|
||||
self,
|
||||
*,
|
||||
category: str,
|
||||
severity: AlertSeverity,
|
||||
title: str,
|
||||
message: str,
|
||||
details: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
if self._alert_notifier is None:
|
||||
return
|
||||
await self._alert_notifier.notify(
|
||||
category=category,
|
||||
severity=severity,
|
||||
title=title,
|
||||
message=message,
|
||||
details=details,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_book_delta(message: dict[str, Any]) -> BookDelta | None:
|
||||
# Kraken v2 book update shape can vary by channel; keep parser defensive.
|
||||
channel = str(message.get("channel", ""))
|
||||
if "book" not in channel:
|
||||
return None
|
||||
|
||||
symbol = str(message.get("symbol", ""))
|
||||
data = message.get("data")
|
||||
if not isinstance(data, list) or not data:
|
||||
return None
|
||||
|
||||
first = data[0]
|
||||
if not isinstance(first, dict):
|
||||
return None
|
||||
|
||||
bids = [
|
||||
BookLevel(price=float(level["price"]), volume=float(level["qty"]))
|
||||
for level in first.get("bids", [])
|
||||
if isinstance(level, dict) and "price" in level and "qty" in level
|
||||
]
|
||||
asks = [
|
||||
BookLevel(price=float(level["price"]), volume=float(level["qty"]))
|
||||
for level in first.get("asks", [])
|
||||
if isinstance(level, dict) and "price" in level and "qty" in level
|
||||
]
|
||||
|
||||
checksum: int | None = None
|
||||
raw_checksum = first.get("checksum")
|
||||
if isinstance(raw_checksum, int):
|
||||
checksum = raw_checksum
|
||||
|
||||
source_timestamp_ms: int | None = None
|
||||
if isinstance(first.get("timestamp"), int):
|
||||
source_timestamp_ms = first["timestamp"]
|
||||
|
||||
return BookDelta(
|
||||
symbol=symbol,
|
||||
bids=bids,
|
||||
asks=asks,
|
||||
checksum=checksum,
|
||||
source_timestamp_ms=source_timestamp_ms,
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KrakenApiResult:
|
||||
endpoint: str
|
||||
payload: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LatencySample:
|
||||
stage: str
|
||||
at: datetime
|
||||
latency_ms: float
|
||||
|
||||
@classmethod
|
||||
def now(cls, stage: str, latency_ms: float) -> LatencySample:
|
||||
return cls(stage=stage, at=datetime.now(UTC), latency_ms=latency_ms)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BookLevel:
|
||||
price: float
|
||||
volume: float
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BookDelta:
|
||||
symbol: str
|
||||
bids: list[BookLevel]
|
||||
asks: list[BookLevel]
|
||||
checksum: int | None = None
|
||||
source_timestamp_ms: int | None = None
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
@lru_cache(maxsize=2048)
|
||||
def sign_kraken_private_path(path: str, nonce: str, post_data: str, api_secret: str) -> str:
|
||||
message = nonce.encode("utf-8") + post_data.encode("utf-8")
|
||||
sha256 = hashlib.sha256(message).digest()
|
||||
mac = hmac.new(base64.b64decode(api_secret), path.encode("utf-8") + sha256, hashlib.sha512)
|
||||
return base64.b64encode(mac.digest()).decode("utf-8")
|
||||
Reference in New Issue
Block a user