Compare commits
3 Commits
c1dda187af
...
1e4086a0fd
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e4086a0fd | |||
| cf5ff2e2d8 | |||
| db2e02c316 |
@@ -17,6 +17,8 @@ from arbitrade.config.settings import Settings
|
||||
from arbitrade.exchange.fee_service import run_fee_sync_loop
|
||||
from arbitrade.exchange.kraken_rest import KrakenRestClient
|
||||
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.market_data.feed import MarketDataFeed
|
||||
from arbitrade.market_data.feed_builder import (
|
||||
@@ -108,6 +110,7 @@ def create_app(settings: Settings) -> FastAPI:
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
await app.state.store.start()
|
||||
await app.state.store.migrate()
|
||||
get_db_sink().start_consumer(db)
|
||||
await app.state.configuration_service.load_database_settings()
|
||||
await restore_runtime_state(app)
|
||||
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.pairing_sync_task = pairing_sync_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
|
||||
fee_sync_stop_event.set()
|
||||
pairing_sync_stop_event.set()
|
||||
@@ -170,6 +179,7 @@ def create_app(settings: Settings) -> FastAPI:
|
||||
await kraken_client.close()
|
||||
await graceful_shutdown(app)
|
||||
await app.state.store.stop()
|
||||
await get_db_sink().stop_consumer()
|
||||
|
||||
app = FastAPI(title="arbitrade", version="0.1.0", lifespan=lifespan)
|
||||
app.state.settings = settings
|
||||
|
||||
+91
-15
@@ -27,6 +27,7 @@ from arbitrade.storage.repositories import (
|
||||
BacktestJobRepository,
|
||||
ConfigPairingRepository,
|
||||
KrakenAccountSnapshotRepository,
|
||||
LogRepository,
|
||||
)
|
||||
|
||||
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)
|
||||
async def dashboard_config_page(request: Request) -> HTMLResponse:
|
||||
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)
|
||||
|
||||
# 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] = {
|
||||
"source": defaults["source"],
|
||||
"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"]),
|
||||
"start_time": defaults["start_time"],
|
||||
"end_time": defaults["end_time"],
|
||||
"symbols": defaults["symbols"],
|
||||
"symbols": symbols_str,
|
||||
}
|
||||
|
||||
store = request.app.state.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)
|
||||
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")
|
||||
async def dashboard_api_pairings_sync(request: Request) -> JSONResponse:
|
||||
"""Trigger a re-sync of pairings from Kraken."""
|
||||
kraken_client = request.app.state.kraken_client
|
||||
store = request.app.state.store
|
||||
summary = await sync_pairings_from_kraken(kraken_client, store)
|
||||
@router.post("/dashboard/api/pairings/sync", response_class=HTMLResponse)
|
||||
async def dashboard_api_pairings_sync(request: Request) -> HTMLResponse:
|
||||
"""Sync pairings from Kraken and return refreshed table."""
|
||||
from arbitrade.config.pairing_sync import sync_pairings_from_kraken
|
||||
|
||||
await _record_audit(
|
||||
request,
|
||||
actor="dashboard_user",
|
||||
event_type="dashboard.pairings.sync",
|
||||
decision="approved",
|
||||
payload=summary, # type: ignore
|
||||
store = request.app.state.store
|
||||
kraken = getattr(request.app.state, "kraken_client", None)
|
||||
if kraken is not None:
|
||||
await sync_pairings_from_kraken(kraken, store)
|
||||
repo = _pairing_repo(request)
|
||||
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})
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -6,6 +6,8 @@ from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from arbitrade.logging.db_sink import db_sink_processor
|
||||
|
||||
|
||||
def configure_logging(log_level: str = "INFO", json_logs: bool = True) -> None:
|
||||
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_logger_name,
|
||||
timestamper,
|
||||
db_sink_processor,
|
||||
]
|
||||
|
||||
if json_logs:
|
||||
|
||||
@@ -86,7 +86,8 @@ class OrderBook:
|
||||
BookLevel(price=price, volume=self._bids[price])
|
||||
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
|
||||
|
||||
def compute_checksum(self, depth: int = 10) -> int:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import orjson
|
||||
@@ -1009,3 +1009,237 @@ class BacktestJobRepository:
|
||||
elif isinstance(result, str):
|
||||
return result != "DELETE 0"
|
||||
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
|
||||
]
|
||||
|
||||
@@ -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 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';
|
||||
|
||||
-- ========================================
|
||||
-- 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);
|
||||
@@ -5,9 +5,10 @@
|
||||
</div>
|
||||
{% set nav_links = [ {"url": "/dashboard", "label": "Dashboard", "class":
|
||||
"secondary"}, {"url": "/dashboard/config", "label": "Config", "class":
|
||||
"secondary"}, {"url": "/dashboard/backtesting", "label": "Backtesting",
|
||||
"class": "secondary"}, {"url": "/dashboard/health", "label": "Health",
|
||||
"class": "secondary"}, ] %}
|
||||
"secondary"}, {"url": "/dashboard/config/pairings", "label": "Pairings",
|
||||
"class": "secondary"}, {"url": "/dashboard/backtesting", "label":
|
||||
"Backtesting", "class": "secondary"}, {"url": "/dashboard/health", "label":
|
||||
"Health", "class": "secondary"}, ] %}
|
||||
<div class="toolbar">
|
||||
{% for link in nav_links %}
|
||||
<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>
|
||||
@@ -1,8 +1,10 @@
|
||||
{% extends "_base.html" %} {% block title %}Arbitrade Health Check{% endblock %}
|
||||
{% block header %} {% with page_title="Arbitrade Health Check",
|
||||
page_subtitle="Live system state." %} {% include "_header.html" %} {% endwith %}
|
||||
{% endblock %} {% block main_class %}shell{% endblock %} {% block content %}
|
||||
<section class="card">
|
||||
page_subtitle="Live system state and logs." %} {% include "_header.html" %} {%
|
||||
endwith %} {% endblock %} {% block main_class %}shell{% endblock %} {% block
|
||||
content %}
|
||||
|
||||
<section class="card" style="margin-bottom: 24px">
|
||||
<h1>Arbitrade Bootstrap Complete</h1>
|
||||
<p><span class="badge">Status: {{ status }}</span></p>
|
||||
<p>UTC: {{ time }}</p>
|
||||
@@ -18,4 +20,43 @@ page_subtitle="Live system state." %} {% include "_header.html" %} {% endwith %}
|
||||
</p>
|
||||
<pre id="health-json">{"status":"ok","service":"arbitrade"}</pre>
|
||||
</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 %}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<input type="hidden" name="source" value="db" />
|
||||
<label class="field">
|
||||
<span>Pairings</span>
|
||||
<div
|
||||
id="pairing-checkboxes"
|
||||
hx-get="/dashboard/fragment/backtesting-pairings"
|
||||
hx-trigger="load"
|
||||
style="display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px"
|
||||
>
|
||||
<span style="opacity: 0.5">Loading pairings...</span>
|
||||
</div>
|
||||
</label>
|
||||
<input type="hidden" name="symbols" value="" />
|
||||
<div class="meta" style="margin-bottom: 12px">
|
||||
Pairings managed in
|
||||
<a href="/dashboard/config/pairings">Configuration → Pairings</a>. Only
|
||||
enabled pairings are backtested.
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Start time (ISO datetime, optional)</span>
|
||||
<input
|
||||
|
||||
@@ -4,657 +4,14 @@
|
||||
hx-post="{{ config_endpoint }}"
|
||||
hx-target="#config-panel"
|
||||
hx-swap="outerHTML"
|
||||
style="
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
"
|
||||
style="grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 20px"
|
||||
>
|
||||
<!-- Runtime -->
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
{% include "config/runtime.html" %}
|
||||
{% include "config/alerts.html" %}
|
||||
{% include "config/kraken.html" %}
|
||||
{% include "config/risk.html" %}
|
||||
<div style="grid-column: 1 / -1">
|
||||
<button type="submit" class="button">Save Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
Reference in New Issue
Block a user