Compare commits

...

3 Commits

Author SHA1 Message Date
zwitschi 1e4086a0fd feat: add logging routes and update health page to display system logs
CI / lint-test-build (push) Failing after 12s
2026-06-07 18:10:50 +02:00
zwitschi cf5ff2e2d8 feat: implement logging system with aggregation and archiving tasks 2026-06-07 18:06:35 +02:00
zwitschi db2e02c316 feat: add pairings management page and integrate with Kraken API for syncing
feat: create configuration templates for alerts, Kraken settings, risk limits, and runtime settings
refactor: streamline config form by including separate template files for better organization
2026-06-07 17:44:26 +02:00
18 changed files with 1228 additions and 685 deletions
+10
View File
@@ -17,6 +17,8 @@ from arbitrade.config.settings import Settings
from arbitrade.exchange.fee_service import run_fee_sync_loop from arbitrade.exchange.fee_service import run_fee_sync_loop
from arbitrade.exchange.kraken_rest import KrakenRestClient from arbitrade.exchange.kraken_rest import KrakenRestClient
from arbitrade.exchange.kraken_ws import KrakenWsClient from arbitrade.exchange.kraken_ws import KrakenWsClient
from arbitrade.logging.db_sink import get_db_sink
from arbitrade.logging.maintenance import run_log_aggregation_loop, run_log_archive_loop
from arbitrade.logging_setup import configure_logging from arbitrade.logging_setup import configure_logging
from arbitrade.market_data.feed import MarketDataFeed from arbitrade.market_data.feed import MarketDataFeed
from arbitrade.market_data.feed_builder import ( from arbitrade.market_data.feed_builder import (
@@ -108,6 +110,7 @@ def create_app(settings: Settings) -> FastAPI:
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await app.state.store.start() await app.state.store.start()
await app.state.store.migrate() await app.state.store.migrate()
get_db_sink().start_consumer(db)
await app.state.configuration_service.load_database_settings() await app.state.configuration_service.load_database_settings()
await restore_runtime_state(app) await restore_runtime_state(app)
fee_sync_task = asyncio.create_task( fee_sync_task = asyncio.create_task(
@@ -135,6 +138,12 @@ def create_app(settings: Settings) -> FastAPI:
app.state.fee_sync_task = fee_sync_task app.state.fee_sync_task = fee_sync_task
app.state.pairing_sync_task = pairing_sync_task app.state.pairing_sync_task = pairing_sync_task
app.state.backtest_task = backtest_task app.state.backtest_task = backtest_task
app.state.log_aggregation_task = asyncio.create_task(
run_log_aggregation_loop(db), name="log_aggregation"
)
app.state.log_archive_task = asyncio.create_task(
run_log_archive_loop(db), name="log_archive"
)
yield yield
fee_sync_stop_event.set() fee_sync_stop_event.set()
pairing_sync_stop_event.set() pairing_sync_stop_event.set()
@@ -170,6 +179,7 @@ def create_app(settings: Settings) -> FastAPI:
await kraken_client.close() await kraken_client.close()
await graceful_shutdown(app) await graceful_shutdown(app)
await app.state.store.stop() await app.state.store.stop()
await get_db_sink().stop_consumer()
app = FastAPI(title="arbitrade", version="0.1.0", lifespan=lifespan) app = FastAPI(title="arbitrade", version="0.1.0", lifespan=lifespan)
app.state.settings = settings app.state.settings = settings
+91 -15
View File
@@ -27,6 +27,7 @@ from arbitrade.storage.repositories import (
BacktestJobRepository, BacktestJobRepository,
ConfigPairingRepository, ConfigPairingRepository,
KrakenAccountSnapshotRepository, KrakenAccountSnapshotRepository,
LogRepository,
) )
router = APIRouter(dependencies=[Depends(require_dashboard_auth)]) router = APIRouter(dependencies=[Depends(require_dashboard_auth)])
@@ -899,6 +900,25 @@ async def dashboard_audit(request: Request) -> HTMLResponse:
) )
@router.get("/dashboard/config/pairings", response_class=HTMLResponse)
async def dashboard_config_pairings_page(
request: Request,
search: str | None = None,
enabled: str | None = None,
) -> HTMLResponse:
"""Standalone pairings management page."""
return templates.TemplateResponse(
request=request,
name="pairings.html",
context={
"title": "Currency Pairings",
"request": request,
"search": search or "",
"enabled": enabled or "all",
},
)
@router.get("/dashboard/config", response_class=HTMLResponse) @router.get("/dashboard/config", response_class=HTMLResponse)
async def dashboard_config_page(request: Request) -> HTMLResponse: async def dashboard_config_page(request: Request) -> HTMLResponse:
d_context = await _dashboard_config_context(request) d_context = await _dashboard_config_context(request)
@@ -969,6 +989,15 @@ async def dashboard_backtesting_run(request: Request) -> HTMLResponse:
) )
fee_rate = await _fee_rate_for_profile(defaults["fee_profile"], custom_fee_rate, request=request) fee_rate = await _fee_rate_for_profile(defaults["fee_profile"], custom_fee_rate, request=request)
# Use enabled pairings from DB when none selected
symbols_str = defaults["symbols"]
if not symbols_str.strip():
pairing_repo = ConfigPairingRepository(request.app.state.store)
enabled = await pairing_repo.list_pairings(enabled_only=True)
symbols_str = ",".join(
f"{p.base_asset}/{p.quote_asset}" for p in enabled
)
config_dict: dict[str, object] = { config_dict: dict[str, object] = {
"source": defaults["source"], "source": defaults["source"],
"starting_balances": defaults["starting_balances"], "starting_balances": defaults["starting_balances"],
@@ -980,12 +1009,12 @@ async def dashboard_backtesting_run(request: Request) -> HTMLResponse:
"execution_latency_ms": float(defaults["execution_latency_ms"]), "execution_latency_ms": float(defaults["execution_latency_ms"]),
"start_time": defaults["start_time"], "start_time": defaults["start_time"],
"end_time": defaults["end_time"], "end_time": defaults["end_time"],
"symbols": defaults["symbols"], "symbols": symbols_str,
} }
store = request.app.state.store store = request.app.state.store
repo = BacktestJobRepository(store) repo = BacktestJobRepository(store)
events_label = defaults["symbols"] if defaults["symbols"] else "DB-sourced" events_label = symbols_str if symbols_str else "DB-sourced"
job = await repo.create_job(events_label, config_dict) job = await repo.create_job(events_label, config_dict)
msg_job = job.id[:8] if job.id else "unknown" msg_job = job.id[:8] if job.id else "unknown"
@@ -1447,19 +1476,66 @@ async def dashboard_api_pairings_toggle(request: Request) -> HTMLResponse:
) )
@router.post("/dashboard/api/pairings/sync") @router.post("/dashboard/api/pairings/sync", response_class=HTMLResponse)
async def dashboard_api_pairings_sync(request: Request) -> JSONResponse: async def dashboard_api_pairings_sync(request: Request) -> HTMLResponse:
"""Trigger a re-sync of pairings from Kraken.""" """Sync pairings from Kraken and return refreshed table."""
kraken_client = request.app.state.kraken_client from arbitrade.config.pairing_sync import sync_pairings_from_kraken
store = request.app.state.store
summary = await sync_pairings_from_kraken(kraken_client, store)
await _record_audit( store = request.app.state.store
request, kraken = getattr(request.app.state, "kraken_client", None)
actor="dashboard_user", if kraken is not None:
event_type="dashboard.pairings.sync", await sync_pairings_from_kraken(kraken, store)
decision="approved", repo = _pairing_repo(request)
payload=summary, # type: ignore pairings = await repo.list_pairings()
pairings.sort(key=lambda p: (p.base_asset, p.quote_asset))
return templates.TemplateResponse(
request=request,
name="partials/pairings_table.html",
context={"request": request, "pairings": pairings},
) )
return JSONResponse(summary)
# ── Log routes ──────────────────────────────────────────────────────────────
@router.get("/dashboard/fragment/logs", response_class=HTMLResponse)
async def dashboard_logs_fragment(
request: Request,
level: str | None = None,
page: int = 1,
per_page: int = 50,
) -> HTMLResponse:
"""HTMX fragment: log table for health page."""
repo = LogRepository(request.app.state.store)
offset = (page - 1) * per_page
records = await repo.query(level=level, limit=per_page, offset=offset)
total = await repo.count_filtered(level=level)
total_pages = max(1, (total + per_page - 1) // per_page)
return templates.TemplateResponse(
request=request,
name="partials/log_table.html",
context={
"request": request,
"records": records,
"page": page,
"total_pages": total_pages,
"total": total,
"current_level": level or "all",
},
)
@router.post("/dashboard/api/logging/aggregate", response_class=JSONResponse)
async def dashboard_logging_aggregate(request: Request) -> JSONResponse:
from arbitrade.logging.maintenance import run_log_aggregation
await run_log_aggregation(request.app.state.store)
return JSONResponse({"status": "ok"})
@router.post("/dashboard/api/logging/archive", response_class=JSONResponse)
async def dashboard_logging_archive(request: Request) -> JSONResponse:
from arbitrade.logging.maintenance import run_log_archive
count = await run_log_archive(request.app.state.store)
return JSONResponse({"status": "ok", "archived": count})
+123
View File
@@ -0,0 +1,123 @@
"""DB sink — writes structlog events to app_logs table via background queue."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
from typing import Any
import structlog
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import LogRecord, LogRepository
_LOG = structlog.get_logger(__name__)
class DbSinkProcessor:
"""structlog processor that queues log events for DB writes.
Must be registered in the structlog processor chain. The consumer
task must be started on app init via ``start_consumer(store)``.
"""
def __init__(self) -> None:
self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=2000)
self._consumer_task: asyncio.Task[None] | None = None
def __call__(self, logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
"""Processor — called for every structlog event. Non-blocking."""
try:
self._queue.put_nowait(dict(event_dict))
except asyncio.QueueFull:
pass # drop event if queue full, avoid backpressure
return event_dict
def start_consumer(self, store: PgStore) -> None:
"""Start background consumer task."""
if self._consumer_task is not None and not self._consumer_task.done():
return
self._consumer_task = asyncio.create_task(
self._consume(store), name="log_db_sink"
)
async def stop_consumer(self) -> None:
"""Drain queue and cancel consumer."""
if self._consumer_task is None:
return
self._consumer_task.cancel()
try:
await self._consumer_task
except asyncio.CancelledError:
pass
self._consumer_task = None
# Flush remaining
await self._flush(store=None)
async def _consume(self, store: PgStore) -> None:
repo = LogRepository(store)
while True:
try:
event = await self._queue.get()
await self._write_one(repo, event)
except asyncio.CancelledError:
break
except Exception:
pass # swallow consumer errors, never crash
# Final flush
await self._flush(repo)
async def _write_one(self, repo: LogRepository, event: dict[str, Any]) -> None:
recorded_at = event.pop("timestamp", None)
if isinstance(recorded_at, str):
try:
recorded_at = datetime.fromisoformat(recorded_at)
except ValueError:
recorded_at = datetime.now(UTC)
elif not isinstance(recorded_at, datetime):
recorded_at = datetime.now(UTC)
level = str(event.pop("level", "info")).upper()
logger = str(event.pop("logger", "root"))
message = str(event.pop("event", event.pop("message", "")))
context = {k: v for k, v in event.items() if not k.startswith("_")} if event else None
record = LogRecord(
recorded_at=recorded_at,
level=level,
logger=logger,
message=message,
context=context if context else None,
)
try:
await repo.insert(record)
except Exception:
pass # never crash from DB write failure
async def _flush(self, repo: LogRepository | None) -> None:
drained = 0
while not self._queue.empty() and drained < 500:
try:
event = self._queue.get_nowait()
if repo is not None:
await self._write_one(repo, event)
drained += 1
except asyncio.QueueEmpty:
break
except Exception:
pass
# Module-level singleton
_db_sink = DbSinkProcessor()
def get_db_sink() -> DbSinkProcessor:
return _db_sink
def db_sink_processor(
logger: Any, method_name: str, event_dict: dict[str, Any]
) -> dict[str, Any]:
"""Standalone processor function wrapping the singleton."""
return _db_sink(logger, method_name, event_dict)
+61
View File
@@ -0,0 +1,61 @@
"""Log maintenance — aggregation and archiving tasks."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
import structlog
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import LogAggregationRepository, LogArchiveRepository
_LOG = structlog.get_logger(__name__)
_AGGREGATE_INTERVAL = 3600 # 1 hour
_ARCHIVE_INTERVAL = 86400 # 1 day
_RETENTION_DAYS = 30
async def run_log_aggregation(store: PgStore) -> None:
"""Aggregate log counts for the last 2 hours across all periods."""
repo = LogAggregationRepository(store)
since = datetime.now(UTC) - timedelta(hours=2)
periods = ["1h", "3h", "6h", "1d", "1w", "1mo"]
for period in periods:
try:
await repo.aggregate_since(since, period)
except Exception:
_LOG.exception("log_aggregation_failed", period=period)
_LOG.info("log_aggregation_complete", since=since.isoformat())
async def run_log_archive(store: PgStore, retention_days: int = _RETENTION_DAYS) -> int:
"""Archive log entries older than retention_days."""
cutoff = datetime.now(UTC) - timedelta(days=retention_days)
repo = LogArchiveRepository(store)
count = await repo.archive_before(cutoff)
if count > 0:
_LOG.info("log_archive_complete",
cutoff=cutoff.isoformat(), archived=count)
return count
async def run_log_aggregation_loop(store: PgStore) -> None:
"""Periodic aggregation loop."""
while True:
try:
await run_log_aggregation(store)
except Exception:
_LOG.exception("log_aggregation_loop_error")
await asyncio.sleep(_AGGREGATE_INTERVAL)
async def run_log_archive_loop(store: PgStore) -> None:
"""Periodic archive loop."""
while True:
try:
await run_log_archive(store)
except Exception:
_LOG.exception("log_archive_loop_error")
await asyncio.sleep(_ARCHIVE_INTERVAL)
+3
View File
@@ -6,6 +6,8 @@ from typing import Any
import structlog import structlog
from arbitrade.logging.db_sink import db_sink_processor
def configure_logging(log_level: str = "INFO", json_logs: bool = True) -> None: def configure_logging(log_level: str = "INFO", json_logs: bool = True) -> None:
level = getattr(logging, log_level.upper(), logging.INFO) level = getattr(logging, log_level.upper(), logging.INFO)
@@ -17,6 +19,7 @@ def configure_logging(log_level: str = "INFO", json_logs: bool = True) -> None:
structlog.stdlib.add_log_level, structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name, structlog.stdlib.add_logger_name,
timestamper, timestamper,
db_sink_processor,
] ]
if json_logs: if json_logs:
+2 -1
View File
@@ -86,7 +86,8 @@ class OrderBook:
BookLevel(price=price, volume=self._bids[price]) BookLevel(price=price, volume=self._bids[price])
for price in reversed(bid_keys[-depth:]) for price in reversed(bid_keys[-depth:])
] ]
asks = [BookLevel(price=price, volume=self._asks[price]) for price in ask_keys[:depth]] asks = [BookLevel(price=price, volume=self._asks[price])
for price in ask_keys[:depth]]
return bids, asks return bids, asks
def compute_checksum(self, depth: int = 10) -> int: def compute_checksum(self, depth: int = 10) -> int:
+235 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
import orjson import orjson
@@ -1009,3 +1009,237 @@ class BacktestJobRepository:
elif isinstance(result, str): elif isinstance(result, str):
return result != "DELETE 0" return result != "DELETE 0"
return False return False
@dataclass(slots=True)
class LogRecord:
recorded_at: datetime
level: str
logger: str
message: str
context: dict[str, Any] | None = None
@dataclass(slots=True)
class LogAggregateRecord:
bucket_start: datetime
period: str
level: str
count: int
class LogRepository:
def __init__(self, store: PgStore) -> None:
self._store = store
async def insert(self, record: LogRecord) -> None:
async with self._store.pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO app_logs (recorded_at, level, logger, message, context)
VALUES ($1, $2, $3, $4, $5)
""",
record.recorded_at,
record.level,
record.logger,
record.message,
orjson.dumps(record.context).decode("utf-8") if record.context else None,
)
async def query(
self,
*,
level: str | None = None,
before: datetime | None = None,
after: datetime | None = None,
limit: int = 50,
offset: int = 0,
) -> list[LogRecord]:
async with self._store.pool.acquire() as conn:
conditions: list[str] = []
params: list[Any] = []
idx = 1
if level:
conditions.append(f"level = ${idx}")
params.append(level.upper())
idx += 1
if before:
conditions.append(f"recorded_at < ${idx}")
params.append(before)
idx += 1
if after:
conditions.append(f"recorded_at >= ${idx}")
params.append(after)
idx += 1
where = ""
if conditions:
where = "WHERE " + " AND ".join(conditions)
rows = await conn.fetch(
f"""
SELECT recorded_at, level, logger, message, context
FROM app_logs
{where}
ORDER BY recorded_at DESC
LIMIT ${idx} OFFSET ${idx + 1}
""",
*params, limit, offset,
)
return [
LogRecord(
recorded_at=r["recorded_at"],
level=r["level"],
logger=r["logger"],
message=r["message"],
context=r["context"],
)
for r in rows
]
async def count(self, level: str | None = None) -> int:
async with self._store.pool.acquire() as conn:
if level:
row = await conn.fetchrow(
"SELECT COUNT(*) as cnt FROM app_logs WHERE level = $1", level.upper()
)
else:
row = await conn.fetchrow("SELECT COUNT(*) as cnt FROM app_logs")
return row["cnt"] if row else 0
async def count_filtered(
self,
*,
level: str | None = None,
before: datetime | None = None,
after: datetime | None = None,
) -> int:
async with self._store.pool.acquire() as conn:
conditions: list[str] = []
params: list[Any] = []
idx = 1
if level:
conditions.append(f"level = ${idx}")
params.append(level.upper())
idx += 1
if before:
conditions.append(f"recorded_at < ${idx}")
params.append(before)
idx += 1
if after:
conditions.append(f"recorded_at >= ${idx}")
params.append(after)
idx += 1
where = ""
if conditions:
where = "WHERE " + " AND ".join(conditions)
row = await conn.fetchrow(f"SELECT COUNT(*) as cnt FROM app_logs {where}", *params)
return row["cnt"] if row else 0
class LogArchiveRepository:
def __init__(self, store: PgStore) -> None:
self._store = store
async def archive_before(self, cutoff: datetime) -> int:
"""Move rows older than cutoff from app_logs to app_log_archives."""
async with self._store.pool.acquire() as conn:
# Insert into archive
result = await conn.execute(
"""
INSERT INTO app_log_archives (id, recorded_at, level, logger, message, context)
SELECT id, recorded_at, level, logger, message, context
FROM app_logs
WHERE recorded_at < $1
""",
cutoff,
)
# Delete originals
await conn.execute(
"DELETE FROM app_logs WHERE recorded_at < $1", cutoff
)
if isinstance(result, str):
parts = result.split()
if len(parts) == 2 and parts[0] == "INSERT":
return int(parts[1])
return 0
class LogAggregationRepository:
def __init__(self, store: PgStore) -> None:
self._store = store
async def aggregate_since(self, since: datetime, period: str) -> None:
"""Aggregate log counts per level for entries >= since, grouped by period."""
period_map = {
"1h": "date_trunc('hour', recorded_at)",
"3h": "date_trunc('hour', recorded_at) - interval '1 hour' * (extract(hour from recorded_at)::int %% 3)",
"6h": "date_trunc('hour', recorded_at) - interval '1 hour' * (extract(hour from recorded_at)::int %% 6)",
"1d": "date_trunc('day', recorded_at)",
"1w": "date_trunc('week', recorded_at)",
"1mo": "date_trunc('month', recorded_at)",
}
bucket_expr = period_map.get(period)
if bucket_expr is None:
raise ValueError(f"Unknown period: {period}")
async with self._store.pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT {bucket_expr} AS bucket_start, level, COUNT(*) AS cnt
FROM app_logs
WHERE recorded_at >= $1
GROUP BY bucket_start, level
""",
since,
)
for row in rows:
await conn.execute(
"""
INSERT INTO app_log_aggregates (bucket_start, period, level, count)
VALUES ($1, $2, $3, $4)
ON CONFLICT (bucket_start, period, level)
DO UPDATE SET count = EXCLUDED.count
""",
row["bucket_start"],
period,
str(row["level"]),
row["cnt"],
)
async def query_aggregates(
self,
period: str,
level: str | None = None,
limit: int = 50,
) -> list[LogAggregateRecord]:
async with self._store.pool.acquire() as conn:
if level:
rows = await conn.fetch(
"""
SELECT bucket_start, period, level, count
FROM app_log_aggregates
WHERE period = $1 AND level = $2
ORDER BY bucket_start DESC
LIMIT $3
""",
period, level.upper(), limit,
)
else:
rows = await conn.fetch(
"""
SELECT bucket_start, period, level, count
FROM app_log_aggregates
WHERE period = $1
ORDER BY bucket_start DESC
LIMIT $2
""",
period, limit,
)
return [
LogAggregateRecord(
bucket_start=r["bucket_start"],
period=r["period"],
level=r["level"],
count=r["count"],
)
for r in rows
]
+35
View File
@@ -189,3 +189,38 @@ ALTER TABLE kraken_account_snapshots ALTER COLUMN snapshot_at TYPE TIMESTAMPT
ALTER TABLE backtest_jobs ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'; ALTER TABLE backtest_jobs ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
ALTER TABLE backtest_jobs ALTER COLUMN started_at TYPE TIMESTAMPTZ USING started_at AT TIME ZONE 'UTC'; ALTER TABLE backtest_jobs ALTER COLUMN started_at TYPE TIMESTAMPTZ USING started_at AT TIME ZONE 'UTC';
ALTER TABLE backtest_jobs ALTER COLUMN finished_at TYPE TIMESTAMPTZ USING finished_at AT TIME ZONE 'UTC'; ALTER TABLE backtest_jobs ALTER COLUMN finished_at TYPE TIMESTAMPTZ USING finished_at AT TIME ZONE 'UTC';
-- ========================================
-- Logging tables
-- ========================================
CREATE TABLE IF NOT EXISTS app_logs (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
recorded_at TIMESTAMPTZ NOT NULL,
level VARCHAR NOT NULL,
logger VARCHAR NOT NULL,
message TEXT NOT NULL,
context JSONB
);
CREATE INDEX IF NOT EXISTS idx_app_logs_recorded_at ON app_logs (recorded_at DESC);
CREATE INDEX IF NOT EXISTS idx_app_logs_level ON app_logs (level);
CREATE TABLE IF NOT EXISTS app_log_archives (
id UUID PRIMARY KEY,
recorded_at TIMESTAMPTZ NOT NULL,
level VARCHAR NOT NULL,
logger VARCHAR NOT NULL,
message TEXT NOT NULL,
context JSONB,
archived_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_app_log_archives_recorded_at ON app_log_archives (recorded_at DESC);
CREATE TABLE IF NOT EXISTS app_log_aggregates (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
bucket_start TIMESTAMPTZ NOT NULL,
period VARCHAR NOT NULL,
level VARCHAR NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
UNIQUE (bucket_start, period, level)
);
CREATE INDEX IF NOT EXISTS idx_app_log_aggregates_bucket ON app_log_aggregates (bucket_start DESC, period);
+4 -3
View File
@@ -5,9 +5,10 @@
</div> </div>
{% set nav_links = [ {"url": "/dashboard", "label": "Dashboard", "class": {% set nav_links = [ {"url": "/dashboard", "label": "Dashboard", "class":
"secondary"}, {"url": "/dashboard/config", "label": "Config", "class": "secondary"}, {"url": "/dashboard/config", "label": "Config", "class":
"secondary"}, {"url": "/dashboard/backtesting", "label": "Backtesting", "secondary"}, {"url": "/dashboard/config/pairings", "label": "Pairings",
"class": "secondary"}, {"url": "/dashboard/health", "label": "Health", "class": "secondary"}, {"url": "/dashboard/backtesting", "label":
"class": "secondary"}, ] %} "Backtesting", "class": "secondary"}, {"url": "/dashboard/health", "label":
"Health", "class": "secondary"}, ] %}
<div class="toolbar"> <div class="toolbar">
{% for link in nav_links %} {% for link in nav_links %}
<a <a
@@ -0,0 +1,192 @@
<div class="card">
<div class="label">Alerting</div>
<label class="field checkbox">
<input name="alerts_enabled" type="checkbox" {{ alerts_enabled }} />
<span>Alerts enabled</span>
</label>
<label class="field">
<span>Min severity</span>
<select name="alert_min_severity">
{% for sev in ["info", "warning", "error", "critical"] %} {% set sel =
"selected" if alert_min_severity == sev else "" %}
<option value="{{ sev }}" {{ sel }}>{{ sev }}</option>
{% endfor %}
</select>
</label>
<label class="field">
<span>Dedup seconds</span>
<input
name="alert_dedup_seconds"
type="number"
min="0"
step="1"
value="{{ alert_dedup_seconds }}"
/>
</label>
<label class="field checkbox">
<input
name="alert_on_trade_events"
type="checkbox"
{{
alert_on_trade_events
}}
/>
<span>Trade events</span>
</label>
<label class="field checkbox">
<input
name="alert_on_error_events"
type="checkbox"
{{
alert_on_error_events
}}
/>
<span>Error events</span>
</label>
<label class="field checkbox">
<input
name="alert_on_threshold_events"
type="checkbox"
{{
alert_on_threshold_events
}}
/>
<span>Threshold events</span>
</label>
<label class="field checkbox">
<input
name="alert_on_system_events"
type="checkbox"
{{
alert_on_system_events
}}
/>
<span>System events</span>
</label>
<hr
style="
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 12px 0;
"
/>
<label class="field checkbox">
<input
name="telegram_alerts_enabled"
type="checkbox"
{{
telegram_alerts_enabled
}}
/>
<span>Telegram</span>
</label>
<label class="field">
<span>Telegram bot token</span>
<input
name="telegram_bot_token"
type="password"
value="{{ telegram_bot_token }}"
placeholder="Bot token"
/>
</label>
<label class="field">
<span>Telegram chat ID</span>
<input
name="telegram_chat_id"
type="text"
value="{{ telegram_chat_id }}"
placeholder="Chat ID"
/>
</label>
<hr
style="
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 12px 0;
"
/>
<label class="field checkbox">
<input
name="discord_alerts_enabled"
type="checkbox"
{{
discord_alerts_enabled
}}
/>
<span>Discord</span>
</label>
<label class="field">
<span>Discord webhook URL</span>
<input
name="discord_webhook_url"
type="password"
value="{{ discord_webhook_url }}"
placeholder="Webhook URL"
/>
</label>
<hr
style="
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 12px 0;
"
/>
<label class="field checkbox">
<input
name="email_alerts_enabled"
type="checkbox"
{{
email_alerts_enabled
}}
/>
<span>Email</span>
</label>
<label class="field">
<span>SMTP host</span>
<input
name="email_smtp_host"
type="text"
value="{{ email_smtp_host }}"
placeholder="smtp.example.com"
/>
</label>
<label class="field">
<span>SMTP port</span>
<input
name="email_smtp_port"
type="number"
min="1"
max="65535"
value="{{ email_smtp_port }}"
/>
</label>
<label class="field">
<span>SMTP username</span>
<input
name="email_smtp_username"
type="text"
value="{{ email_smtp_username }}"
/>
</label>
<label class="field">
<span>SMTP password</span>
<input
name="email_smtp_password"
type="password"
value=""
placeholder="Leave blank to keep existing"
/>
</label>
<label class="field">
<span>From address</span>
<input name="email_alert_from" type="text" value="{{ email_alert_from }}" />
</label>
<label class="field">
<span>To address</span>
<input name="email_alert_to" type="text" value="{{ email_alert_to }}" />
</label>
<label class="field checkbox">
<input name="email_smtp_use_tls" type="checkbox" {{ email_smtp_use_tls }} />
<span>Use TLS</span>
</label>
</div>
@@ -0,0 +1,93 @@
<div class="card">
<div class="label">Kraken Exchange</div>
<label class="field">
<span>REST URL</span>
<input name="kraken_rest_url" type="text" value="{{ kraken_rest_url }}" />
</label>
<label class="field">
<span>WebSocket URL</span>
<input name="kraken_ws_url" type="text" value="{{ kraken_ws_url }}" />
</label>
<label class="field">
<span>Private rate limit (s)</span>
<input
name="kraken_private_rate_limit_seconds"
type="number"
min="0"
step="0.01"
value="{{ kraken_private_rate_limit_seconds }}"
/>
</label>
<label class="field">
<span>HTTP timeout (s)</span>
<input
name="kraken_http_timeout_seconds"
type="number"
min="1"
step="0.5"
value="{{ kraken_http_timeout_seconds }}"
/>
</label>
<label class="field">
<span>Retry attempts</span>
<input
name="kraken_retry_attempts"
type="number"
min="0"
step="1"
value="{{ kraken_retry_attempts }}"
/>
</label>
<label class="field">
<span>Retry base delay (s)</span>
<input
name="kraken_retry_base_delay_seconds"
type="number"
min="0"
step="0.01"
value="{{ kraken_retry_base_delay_seconds }}"
/>
</label>
<label class="field">
<span>API key</span>
<input name="kraken_api_key" type="text" value="{{ kraken_api_key }}" />
</label>
<label class="field">
<span>API secret</span>
<input
name="kraken_api_secret"
type="password"
value=""
placeholder="Leave blank to keep existing"
/>
</label>
<label class="field">
<span>API key permissions</span>
<input
name="kraken_api_key_permissions"
type="text"
value="{{ kraken_api_key_permissions }}"
disabled
/>
</label>
<label class="field">
<span>WS heartbeat timeout (s)</span>
<input
name="ws_heartbeat_timeout_seconds"
type="number"
min="1"
step="1"
value="{{ ws_heartbeat_timeout_seconds }}"
/>
</label>
<label class="field">
<span>WS max staleness (s)</span>
<input
name="ws_max_staleness_seconds"
type="number"
min="1"
step="1"
value="{{ ws_max_staleness_seconds }}"
/>
</label>
</div>
@@ -0,0 +1,57 @@
<div class="card">
<div class="label">Risk & Guardrails</div>
<label class="field">
<span>Daily loss limit USD</span>
<input
name="daily_loss_limit_usd"
type="number"
min="0"
step="0.01"
value="{{ daily_loss_limit_value }}"
/>
</label>
<label class="field">
<span>Cumulative loss limit USD</span>
<input
name="cumulative_loss_limit_usd"
type="number"
min="0"
step="0.01"
value="{{ cumulative_loss_limit_value }}"
/>
</label>
<label class="field">
<span>Max source latency (ms)</span>
<input
name="max_source_latency_ms"
type="number"
min="0"
step="1"
value="{{ max_source_latency_value }}"
/>
</label>
<label class="field">
<span>Max apply latency (ms)</span>
<input
name="max_apply_latency_ms"
type="number"
min="0"
step="1"
value="{{ max_apply_latency_value }}"
/>
</label>
<label class="field">
<span>Max consecutive failures</span>
<input
name="max_consecutive_failures"
type="number"
min="0"
step="1"
value="{{ max_consecutive_failures_value }}"
/>
</label>
<label class="field checkbox">
<input name="kill_switch_active" type="checkbox" {{ kill_switch_active }} />
<span>Kill switch active</span>
</label>
</div>
@@ -0,0 +1,140 @@
<div class="card">
<div class="label">Runtime</div>
<label class="field">
<span>App env</span>
<input type="text" value="{{ app_env }}" disabled />
</label>
<label class="field">
<span>App host</span>
<input name="app_host" type="text" value="{{ app_host }}" />
</label>
<label class="field">
<span>App port</span>
<input
name="app_port"
type="number"
min="1"
max="65535"
value="{{ app_port }}"
/>
</label>
<label class="field">
<span>Log level</span>
<select name="log_level">
{% for lvl in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] %} {% set
sel = "selected" if log_level == lvl else "" %}
<option value="{{ lvl }}" {{ sel }}>{{ lvl }}</option>
{% endfor %}
</select>
</label>
<label class="field checkbox">
<input name="log_json" type="checkbox" {{ log_json }} />
<span>JSON logs</span>
</label>
<label class="field checkbox">
<input name="paper_trading_mode" type="checkbox" {{ paper_trading_mode }} />
<span>Paper trading mode</span>
</label>
<label class="field">
<span>Trade capital USD</span>
<input
name="trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max trade capital USD</span>
<input
name="max_trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ max_trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max concurrent trades</span>
<input
name="max_concurrent_trades"
type="number"
min="1"
step="1"
value="{{ max_concurrent_trades_value }}"
/>
</label>
<label class="field">
<span>Max exposure per asset USD</span>
<input
name="max_exposure_per_asset_usd"
type="number"
min="0"
step="0.01"
value="{{ max_exposure_per_asset_value }}"
/>
</label>
<label class="field">
<span>Quote balance asset</span>
<input
name="quote_balance_asset"
type="text"
value="{{ quote_balance_asset }}"
/>
</label>
<label class="field">
<span>Min order size USD</span>
<input
name="min_order_size_usd"
type="number"
min="0"
step="0.01"
value="{{ min_order_size_usd_value }}"
/>
</label>
<label class="field">
<span>Tradable pairs (comma-separated)</span>
<input
name="tradable_pairs"
type="text"
placeholder="BTC/USD, ETH/BTC"
value="{{ tradable_pairs_value }}"
/>
</label>
<label class="field">
<span>Strategy mode</span>
<select name="strategy_mode">
{% set sel = "selected" if strategy_mode == "incremental" else "" %}
<option value="incremental" {{ sel }}>incremental</option>
{% set sel = "selected" if strategy_mode == "paper" else "" %}
<option value="paper" {{ sel }}>paper</option>
{% set sel = "selected" if strategy_mode == "live" else "" %}
<option value="live" {{ sel }}>live</option>
{% if strategy_stat_arb_enabled %} {% set sel = "selected" if
strategy_mode == "stat_arb_experiment" else "" %}
<option value="stat_arb_experiment" {{ sel }}>stat_arb_experiment</option>
{% endif %}
</select>
</label>
<label class="field">
<span>Strategy profit threshold</span>
<input
name="strategy_profit_threshold"
type="number"
min="0"
step="0.0001"
value="{{ strategy_profit_threshold }}"
/>
</label>
<label class="field">
<span>Max depth levels</span>
<input
name="strategy_max_depth_levels"
type="number"
min="1"
step="1"
value="{{ strategy_max_depth_levels }}"
/>
</label>
</div>
+44 -3
View File
@@ -1,8 +1,10 @@
{% extends "_base.html" %} {% block title %}Arbitrade Health Check{% endblock %} {% extends "_base.html" %} {% block title %}Arbitrade Health Check{% endblock %}
{% block header %} {% with page_title="Arbitrade Health Check", {% block header %} {% with page_title="Arbitrade Health Check",
page_subtitle="Live system state." %} {% include "_header.html" %} {% endwith %} page_subtitle="Live system state and logs." %} {% include "_header.html" %} {%
{% endblock %} {% block main_class %}shell{% endblock %} {% block content %} endwith %} {% endblock %} {% block main_class %}shell{% endblock %} {% block
<section class="card"> content %}
<section class="card" style="margin-bottom: 24px">
<h1>Arbitrade Bootstrap Complete</h1> <h1>Arbitrade Bootstrap Complete</h1>
<p><span class="badge">Status: {{ status }}</span></p> <p><span class="badge">Status: {{ status }}</span></p>
<p>UTC: {{ time }}</p> <p>UTC: {{ time }}</p>
@@ -18,4 +20,43 @@ page_subtitle="Live system state." %} {% include "_header.html" %} {% endwith %}
</p> </p>
<pre id="health-json">{"status":"ok","service":"arbitrade"}</pre> <pre id="health-json">{"status":"ok","service":"arbitrade"}</pre>
</section> </section>
<section class="card">
<h2>System Logs</h2>
<div class="toolbar" style="display: flex; gap: 8px; margin-bottom: 12px">
<form
hx-post="/dashboard/api/logging/aggregate"
hx-target="#aggregate-result"
hx-swap="innerHTML"
style="display: inline"
>
<button type="submit" class="button secondary" style="font-size: 0.85rem">
Aggregate Now
</button>
</form>
<form
hx-post="/dashboard/api/logging/archive"
hx-target="#archive-result"
hx-swap="innerHTML"
style="display: inline"
>
<button type="submit" class="button secondary" style="font-size: 0.85rem">
Archive Old Logs
</button>
</form>
<span id="aggregate-result" style="font-size: 0.85rem; opacity: 0.6"></span>
<span id="archive-result" style="font-size: 0.85rem; opacity: 0.6"></span>
</div>
<div
id="log-table-container"
hx-get="/dashboard/fragment/logs"
hx-trigger="load, every 30s"
hx-swap="outerHTML"
>
<div style="text-align: center; padding: 20px; opacity: 0.5">
Loading logs...
</div>
</div>
</section>
{% endblock %} {% block scripts %}{% endblock %} {% endblock %} {% block scripts %}{% endblock %}
+52
View File
@@ -0,0 +1,52 @@
{% extends "_base.html" %} {% block title %}{{ title }}{% endblock %} {% block
main_class %}shell{% endblock %} {% block header %} {% with
page_title="Currency Pairings",
page_subtitle="Enable/disable pairings, search, and sync from Kraken." %}
{% include "_header.html" %} {% endwith %} {% endblock %} {% block content %}
<div class="toolbar" style="margin-bottom: 16px; display: flex; gap: 8px">
<input
id="pairing-search"
type="text"
placeholder="Search pairings…"
value="{{ search or '' }}"
style="flex: 1; max-width: 300px"
hx-get="/dashboard/fragment/pairings"
hx-target="#pairings-table-container"
hx-trigger="keyup changed delay:300ms"
hx-include="#pairing-enabled-filter"
name="search"
/>
<select
id="pairing-enabled-filter"
name="enabled"
hx-get="/dashboard/fragment/pairings"
hx-target="#pairings-table-container"
hx-trigger="change"
hx-include="#pairing-search"
>
<option value="all">All</option>
<option value="true" {{ 'selected' if enabled == 'true' else '' }}>
Enabled
</option>
<option value="false" {{ 'selected' if enabled == 'false' else '' }}>
Disabled
</option>
</select>
<button
class="button"
hx-post="/dashboard/api/pairings/sync"
hx-target="#pairings-table-container"
hx-swap="innerHTML"
>
Sync from Kraken
</button>
</div>
<div id="pairings-table-container">
{% include "partials/pairings_table.html" %}
</div>
{% endblock %}
@@ -52,17 +52,12 @@
hx-swap="outerHTML" hx-swap="outerHTML"
> >
<input type="hidden" name="source" value="db" /> <input type="hidden" name="source" value="db" />
<label class="field"> <input type="hidden" name="symbols" value="" />
<span>Pairings</span> <div class="meta" style="margin-bottom: 12px">
<div Pairings managed in
id="pairing-checkboxes" <a href="/dashboard/config/pairings">Configuration → Pairings</a>. Only
hx-get="/dashboard/fragment/backtesting-pairings" enabled pairings are backtested.
hx-trigger="load" </div>
style="display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px"
>
<span style="opacity: 0.5">Loading pairings...</span>
</div>
</label>
<label class="field"> <label class="field">
<span>Start time (ISO datetime, optional)</span> <span>Start time (ISO datetime, optional)</span>
<input <input
@@ -4,657 +4,14 @@
hx-post="{{ config_endpoint }}" hx-post="{{ config_endpoint }}"
hx-target="#config-panel" hx-target="#config-panel"
hx-swap="outerHTML" hx-swap="outerHTML"
style=" style="grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 20px"
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
"
> >
<!-- Runtime --> {% include "config/runtime.html" %}
<div class="card"> {% include "config/alerts.html" %}
<div class="label">Runtime</div> {% include "config/kraken.html" %}
<label class="field"> {% include "config/risk.html" %}
<span>App env</span> <div style="grid-column: 1 / -1">
<input type="text" value="{{ app_env }}" disabled /> <button type="submit" class="button">Save Settings</button>
</label>
<label class="field">
<span>App host</span>
<input name="app_host" type="text" value="{{ app_host }}" />
</label>
<label class="field">
<span>App port</span>
<input
name="app_port"
type="number"
min="1"
max="65535"
value="{{ app_port }}"
/>
</label>
<label class="field">
<span>Log level</span>
<select name="log_level">
{% for lvl in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] %} {%
set sel = "selected" if log_level == lvl else "" %}
<option value="{{ lvl }}" {{ sel }}>{{ lvl }}</option>
{% endfor %}
</select>
</label>
<label class="field checkbox">
<input name="log_json" type="checkbox" {{ log_json }} />
<span>JSON logs</span>
</label>
<label class="field checkbox">
<input
name="paper_trading_mode"
type="checkbox"
{{
paper_trading_mode
}}
/>
<span>Paper trading mode</span>
</label>
<label class="field">
<span>Trade capital USD</span>
<input
name="trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max trade capital USD</span>
<input
name="max_trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ max_trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max concurrent trades</span>
<input
name="max_concurrent_trades"
type="number"
min="1"
step="1"
value="{{ max_concurrent_trades_value }}"
/>
</label>
<label class="field">
<span>Max exposure per asset USD</span>
<input
name="max_exposure_per_asset_usd"
type="number"
min="0"
step="0.01"
value="{{ max_exposure_per_asset_value }}"
/>
</label>
<label class="field">
<span>Quote balance asset</span>
<input
name="quote_balance_asset"
type="text"
value="{{ quote_balance_asset }}"
/>
</label>
<label class="field">
<span>Min order size USD</span>
<input
name="min_order_size_usd"
type="number"
min="0"
step="0.01"
value="{{ min_order_size_usd_value }}"
/>
</label>
<label class="field">
<span>Tradable pairs (comma-separated)</span>
<input
name="tradable_pairs"
type="text"
placeholder="BTC/USD, ETH/BTC"
value="{{ tradable_pairs_value }}"
/>
</label>
<label class="field">
<span>Strategy mode</span>
<select name="strategy_mode">
{% set sel = "selected" if strategy_mode == "incremental" else "" %}
<option value="incremental" {{ sel }}>incremental</option>
{% set sel = "selected" if strategy_mode == "paper" else "" %}
<option value="paper" {{ sel }}>paper</option>
{% set sel = "selected" if strategy_mode == "live" else "" %}
<option value="live" {{ sel }}>live</option>
{% if strategy_stat_arb_enabled %} {% set sel = "selected" if
strategy_mode == "stat_arb_experiment" else "" %}
<option value="stat_arb_experiment" {{ sel }}>
stat_arb_experiment
</option>
{% endif %}
</select>
</label>
<label class="field">
<span>Strategy profit threshold</span>
<input
name="strategy_profit_threshold"
type="number"
min="0"
step="0.0001"
value="{{ strategy_profit_threshold }}"
/>
</label>
<label class="field">
<span>Max depth levels</span>
<input
name="strategy_max_depth_levels"
type="number"
min="1"
step="1"
value="{{ strategy_max_depth_levels }}"
/>
</label>
</div>
<!-- Alerts -->
<div class="card">
<div class="label">Alerting</div>
<label class="field checkbox">
<input name="alerts_enabled" type="checkbox" {{ alerts_enabled }} />
<span>Alerts enabled</span>
</label>
<label class="field">
<span>Min severity</span>
<select name="alert_min_severity">
{% for sev in ["info", "warning", "error", "critical"] %} {% set sel =
"selected" if alert_min_severity == sev else "" %}
<option value="{{ sev }}" {{ sel }}>{{ sev }}</option>
{% endfor %}
</select>
</label>
<label class="field">
<span>Dedup seconds</span>
<input
name="alert_dedup_seconds"
type="number"
min="0"
step="1"
value="{{ alert_dedup_seconds }}"
/>
</label>
<label class="field checkbox">
<input
name="alert_on_trade_events"
type="checkbox"
{{
alert_on_trade_events
}}
/>
<span>Trade events</span>
</label>
<label class="field checkbox">
<input
name="alert_on_error_events"
type="checkbox"
{{
alert_on_error_events
}}
/>
<span>Error events</span>
</label>
<label class="field checkbox">
<input
name="alert_on_threshold_events"
type="checkbox"
{{
alert_on_threshold_events
}}
/>
<span>Threshold events</span>
</label>
<label class="field checkbox">
<input
name="alert_on_system_events"
type="checkbox"
{{
alert_on_system_events
}}
/>
<span>System events</span>
</label>
<hr
style="
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 12px 0;
"
/>
<label class="field checkbox">
<input
name="telegram_alerts_enabled"
type="checkbox"
{{
telegram_alerts_enabled
}}
/>
<span>Telegram</span>
</label>
<label class="field">
<span>Telegram bot token</span>
<input
name="telegram_bot_token"
type="password"
value="{{ telegram_bot_token }}"
placeholder="Bot token"
/>
</label>
<label class="field">
<span>Telegram chat ID</span>
<input
name="telegram_chat_id"
type="text"
value="{{ telegram_chat_id }}"
placeholder="Chat ID"
/>
</label>
<hr
style="
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 12px 0;
"
/>
<label class="field checkbox">
<input
name="discord_alerts_enabled"
type="checkbox"
{{
discord_alerts_enabled
}}
/>
<span>Discord</span>
</label>
<label class="field">
<span>Discord webhook URL</span>
<input
name="discord_webhook_url"
type="password"
value="{{ discord_webhook_url }}"
placeholder="Webhook URL"
/>
</label>
<hr
style="
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 12px 0;
"
/>
<label class="field checkbox">
<input
name="email_alerts_enabled"
type="checkbox"
{{
email_alerts_enabled
}}
/>
<span>Email</span>
</label>
<label class="field">
<span>SMTP host</span>
<input
name="email_smtp_host"
type="text"
value="{{ email_smtp_host }}"
placeholder="smtp.example.com"
/>
</label>
<label class="field">
<span>SMTP port</span>
<input
name="email_smtp_port"
type="number"
min="1"
max="65535"
value="{{ email_smtp_port }}"
/>
</label>
<label class="field">
<span>SMTP username</span>
<input
name="email_smtp_username"
type="text"
value="{{ email_smtp_username }}"
/>
</label>
<label class="field">
<span>SMTP password</span>
<input
name="email_smtp_password"
type="password"
value="{{ email_smtp_password }}"
placeholder="Leave blank to keep existing"
/>
</label>
<label class="field">
<span>From address</span>
<input
name="email_alert_from"
type="text"
value="{{ email_alert_from }}"
/>
</label>
<label class="field">
<span>To address</span>
<input name="email_alert_to" type="text" value="{{ email_alert_to }}" />
</label>
<label class="field checkbox">
<input
name="email_smtp_use_tls"
type="checkbox"
{{
email_smtp_use_tls
}}
/>
<span>Use TLS</span>
</label>
</div>
<!-- Kraken -->
<div class="card">
<div class="label">Kraken Exchange</div>
<label class="field">
<span>REST URL</span>
<input
name="kraken_rest_url"
type="text"
value="{{ kraken_rest_url }}"
/>
</label>
<label class="field">
<span>WebSocket URL</span>
<input name="kraken_ws_url" type="text" value="{{ kraken_ws_url }}" />
</label>
<label class="field">
<span>Private rate limit (s)</span>
<input
name="kraken_private_rate_limit_seconds"
type="number"
min="0"
step="0.01"
value="{{ kraken_private_rate_limit_seconds }}"
/>
</label>
<label class="field">
<span>HTTP timeout (s)</span>
<input
name="kraken_http_timeout_seconds"
type="number"
min="1"
step="0.5"
value="{{ kraken_http_timeout_seconds }}"
/>
</label>
<label class="field">
<span>Retry attempts</span>
<input
name="kraken_retry_attempts"
type="number"
min="0"
step="1"
value="{{ kraken_retry_attempts }}"
/>
</label>
<label class="field">
<span>Retry base delay (s)</span>
<input
name="kraken_retry_base_delay_seconds"
type="number"
min="0"
step="0.01"
value="{{ kraken_retry_base_delay_seconds }}"
/>
</label>
<label class="field">
<span>API key</span>
<input
name="kraken_api_key"
type="text"
value="{{ kraken_api_key }}"
placeholder="API key"
/>
</label>
<label class="field">
<span>API secret</span>
<input
name="kraken_api_secret"
type="password"
value="{{ kraken_api_secret }}"
placeholder="API secret"
/>
</label>
<label class="field">
<span>Key permissions</span>
<input
name="kraken_api_key_permissions"
type="text"
value="{{ kraken_api_key_permissions }}"
placeholder="query,trade"
/>
</label>
<label class="field">
<span>Heartbeat timeout (s)</span>
<input
name="ws_heartbeat_timeout_seconds"
type="number"
min="1"
step="1"
value="{{ ws_heartbeat_timeout_seconds }}"
/>
</label>
<label class="field">
<span>Max staleness (s)</span>
<input
name="ws_max_staleness_seconds"
type="number"
min="0"
step="0.5"
value="{{ ws_max_staleness_seconds }}"
/>
</label>
</div>
<!-- Pairings -->
<div class="card" id="pairings-card">
<div class="label">Currency Pairings</div>
<div style="display: flex; gap: 8px; margin-bottom: 12px">
<input
id="pairing-search"
type="text"
placeholder="Search pairings..."
hx-get="/dashboard/fragment/pairings"
hx-target="#pairings-table-container"
hx-trigger="keyup changed delay:300ms"
hx-swap="innerHTML"
name="search"
style="
flex: 1;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(0, 0, 0, 0.3);
color: inherit;
"
/>
<button
type="button"
class="button"
id="pairing-sync-btn"
hx-post="/dashboard/api/pairings/sync"
hx-target="#pairings-table-container"
hx-swap="innerHTML"
hx-trigger="click"
style="white-space: nowrap"
>
Sync from Kraken
</button>
</div>
<div
id="pairings-table-container"
hx-get="/dashboard/fragment/pairings"
hx-trigger="load"
>
<div style="text-align: center; padding: 20px; opacity: 0.5">
Loading pairings...
</div>
</div>
</div>
<!-- Risk -->
<div class="card">
<div class="label">Risk Limits</div>
<label class="field">
<span>Daily loss limit USD</span>
<input
name="daily_loss_limit_usd"
type="number"
min="0"
step="0.01"
value="{{ daily_loss_limit_value }}"
placeholder="None"
/>
</label>
<label class="field">
<span>Cumulative loss limit USD</span>
<input
name="cumulative_loss_limit_usd"
type="number"
min="0"
step="0.01"
value="{{ cumulative_loss_limit_value }}"
placeholder="None"
/>
</label>
<label class="field">
<span>Max source latency (ms)</span>
<input
name="max_source_latency_ms"
type="number"
min="0"
step="1"
value="{{ max_source_latency_value }}"
placeholder="None"
/>
</label>
<label class="field">
<span>Max apply latency (ms)</span>
<input
name="max_apply_latency_ms"
type="number"
min="0"
step="1"
value="{{ max_apply_latency_value }}"
placeholder="None"
/>
</label>
<label class="field">
<span>Max consecutive failures</span>
<input
name="max_consecutive_failures"
type="number"
min="1"
step="1"
value="{{ max_consecutive_failures_value }}"
placeholder="None"
/>
</label>
<label class="field checkbox">
<input
name="kill_switch_active"
type="checkbox"
{{
kill_switch_active
}}
/>
<span>Kill switch active</span>
</label>
</div>
<!-- Strategy Stat-Arb -->
<div class="card">
<div class="label">Stat-Arb Strategy</div>
<label class="field checkbox">
<input
name="strategy_enable_stat_arb_experiment"
type="checkbox"
{%
if
strategy_stat_arb_enabled
%}checked{%
endif
%}
/>
<span>Enable stat-arb experiment</span>
</label>
{% if strategy_stat_arb_enabled %}
<label class="field">
<span>Lookback window</span>
<input
name="strategy_stat_arb_lookback_window"
type="number"
min="2"
step="1"
value="{{ strategy_stat_arb_lookback_window }}"
/>
</label>
<label class="field">
<span>Entry z-score</span>
<input
name="strategy_stat_arb_entry_zscore"
type="number"
min="0"
step="0.1"
value="{{ strategy_stat_arb_entry_zscore }}"
/>
</label>
<label class="field">
<span>Exit z-score</span>
<input
name="strategy_stat_arb_exit_zscore"
type="number"
min="0"
step="0.1"
value="{{ strategy_stat_arb_exit_zscore }}"
/>
</label>
<label class="field">
<span>Max holding seconds</span>
<input
name="strategy_stat_arb_max_holding_seconds"
type="number"
min="1"
step="1"
value="{{ strategy_stat_arb_max_holding_seconds }}"
/>
</label>
{% endif %}
</div>
<!-- Submit -->
<div
class="card"
style="display: flex; align-items: center; justify-content: center"
>
<button
type="submit"
class="button"
style="padding: 14px 32px; font-size: 1.1rem"
>
Save configuration
</button>
</div> </div>
</form> </form>
</div> </div>
@@ -0,0 +1,72 @@
<div id="log-table-container">
<div class="toolbar" style="display: flex; gap: 8px; margin-bottom: 12px">
<select
name="level"
hx-get="/dashboard/fragment/logs"
hx-target="#log-table-container"
hx-trigger="change"
hx-swap="outerHTML"
>
<option value="" {{ 'selected' if current_level == 'all' else '' }}>All</option>
<option value="INFO" {{ 'selected' if current_level == 'INFO' else '' }}>INFO</option>
<option value="WARNING" {{ 'selected' if current_level == 'WARNING' else '' }}>WARNING</option>
<option value="ERROR" {{ 'selected' if current_level == 'ERROR' else '' }}>ERROR</option>
<option value="CRITICAL" {{ 'selected' if current_level == 'CRITICAL' else '' }}>CRITICAL</option>
</select>
<span style="opacity: 0.6; font-size: 0.85rem">{{ total }} entries</span>
</div>
<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem">
<thead>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1); text-align: left">
<th style="padding: 6px 8px">Time</th>
<th style="padding: 6px 8px">Level</th>
<th style="padding: 6px 8px">Logger</th>
<th style="padding: 6px 8px">Message</th>
</tr>
</thead>
<tbody>
{% for r in records %}
<tr style="border-bottom: 1px solid rgba(255,255,255,0.04)">
<td style="padding: 4px 8px; white-space: nowrap">
{{ r.recorded_at.strftime('%H:%M:%S') if r.recorded_at else '—' }}
</td>
<td style="padding: 4px 8px">
<span class="badge level-{{ r.level.lower() }}">{{ r.level }}</span>
</td>
<td style="padding: 4px 8px; opacity: 0.7; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
{{ r.logger }}
</td>
<td style="padding: 4px 8px; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
{{ r.message }}
</td>
</tr>
{% endfor %}
{% if not records %}
<tr>
<td colspan="4" style="padding: 20px; text-align: center; opacity: 0.5">No log entries found.</td>
</tr>
{% endif %}
</tbody>
</table>
<div class="toolbar" style="display: flex; gap: 8px; justify-content: center; margin-top: 12px">
{% if page > 1 %}
<button
class="button secondary"
hx-get="/dashboard/fragment/logs?level={{ current_level }}&page={{ page - 1 }}"
hx-target="#log-table-container"
hx-swap="outerHTML"
>Previous</button>
{% endif %}
<span style="opacity: 0.6; font-size: 0.85rem; padding: 0 8px">Page {{ page }} / {{ total_pages }}</span>
{% if page < total_pages %}
<button
class="button secondary"
hx-get="/dashboard/fragment/logs?level={{ current_level }}&page={{ page + 1 }}"
hx-target="#log-table-container"
hx-swap="outerHTML"
>Next</button>
{% endif %}
</div>
</div>