feat: Implement pairing synchronization from Kraken and enhance market data feed
- Added `sync_pairings_from_kraken` function to fetch and upsert asset pairs into the config_pairings table. - Introduced `run_pairing_sync_loop` for periodic synchronization of pairings. - Enhanced `KrakenWsClient` to manage subscribed symbols for market data feeds. - Created `build_detector_from_enabled_pairings` to initialize cycle detection based on enabled pairings. - Updated FastAPI app to start market data feed and pairing synchronization tasks. - Added new API routes for managing pairings, including listing, toggling, and syncing from Kraken. - Improved dashboard templates to display pairing options and allow user interaction for backtesting. - Refactored database queries to streamline fetching and updating of pairing data.
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
# EditorConfig is awesome: https://editorconfig.org
|
||||||
|
|
||||||
|
# top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# Unix-style newlines with a newline ending every file
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
# Matches multiple files with brace expansion notation
|
||||||
|
# Set default charset
|
||||||
|
[*.{js,py}]
|
||||||
|
charset = utf-8
|
||||||
|
|
||||||
|
# 4 space indentation
|
||||||
|
[*.py]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
max_line_length = 120
|
||||||
+16
-20
@@ -19,12 +19,10 @@ def _resolve_fee_rate(fee_rate: float | None, db_path: str | None = None) -> flo
|
|||||||
if db_path is not None:
|
if db_path is not None:
|
||||||
try:
|
try:
|
||||||
conn = duckdb.connect(db_path)
|
conn = duckdb.connect(db_path)
|
||||||
row = conn.execute(
|
row = conn.execute("""
|
||||||
"""
|
|
||||||
SELECT maker_fee FROM kraken_account_snapshots
|
SELECT maker_fee FROM kraken_account_snapshots
|
||||||
ORDER BY snapshot_at DESC LIMIT 1
|
ORDER BY snapshot_at DESC LIMIT 1
|
||||||
"""
|
""").fetchone()
|
||||||
).fetchone()
|
|
||||||
conn.close()
|
conn.close()
|
||||||
if row is not None and row[0] is not None:
|
if row is not None and row[0] is not None:
|
||||||
return float(row[0])
|
return float(row[0])
|
||||||
@@ -53,14 +51,13 @@ def _parse_balances(raw: str) -> Mapping[str, float]:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="Run a deterministic replay backtest.")
|
parser = argparse.ArgumentParser(description="Run backtest.")
|
||||||
parser.add_argument("--events", type=Path, required=True)
|
parser.add_argument("--events", type=Path, required=True)
|
||||||
parser.add_argument("--starting-balances", type=str, default="USD=1000.0")
|
parser.add_argument("--starting-balances", type=str, default="USD=1000.0")
|
||||||
parser.add_argument("--trade-capital", type=float, default=100.0)
|
parser.add_argument("--trade-capital", type=float, default=100.0)
|
||||||
parser.add_argument("--fee-rate", type=float, default=None)
|
parser.add_argument("--fee-rate", type=float, default=None)
|
||||||
parser.add_argument("--slippage-bps", type=float, default=4.0)
|
parser.add_argument("--slippage-bps", type=float, default=4.0)
|
||||||
parser.add_argument("--execution-latency-ms", type=float, default=20.0)
|
parser.add_argument("--execution-latency-ms", type=float, default=20.0)
|
||||||
parser.add_argument("--db-path", type=str, default=None, help="DuckDB path for fee lookup")
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
cycles_by_pair, available_pairs = _build_graph()
|
cycles_by_pair, available_pairs = _build_graph()
|
||||||
@@ -79,24 +76,23 @@ def main() -> int:
|
|||||||
config=config,
|
config=config,
|
||||||
started_at=events[0].occurred_at if events else datetime.now(UTC),
|
started_at=events[0].occurred_at if events else datetime.now(UTC),
|
||||||
)
|
)
|
||||||
report = asyncio.run(
|
starting_balances = _parse_balances(args.starting_balances)
|
||||||
engine.run(events, starting_balances=_parse_balances(args.starting_balances))
|
r = asyncio.run(engine.run(events, starting_balances=starting_balances))
|
||||||
)
|
|
||||||
|
|
||||||
print("Backtest report:")
|
print("Backtest report:")
|
||||||
print(f"- processed_events: {report.processed_events}")
|
print(f"- processed_events: {r.processed_events}")
|
||||||
print(f"- opportunities_seen: {report.opportunities_seen}")
|
print(f"- opportunities_seen: {r.opportunities_seen}")
|
||||||
print(f"- trades_executed: {report.trades_executed}")
|
print(f"- trades_executed: {r.trades_executed}")
|
||||||
print(f"- win_rate: {report.win_rate if report.win_rate is not None else 'n/a'}")
|
print(f"- win_rate: {r.win_rate if r.win_rate is not None else 'n/a'}")
|
||||||
print(f"- fill_rate: {report.fill_rate if report.fill_rate is not None else 'n/a'}")
|
print(f"- fill_rate: {r.fill_rate if r.fill_rate is not None else 'n/a'}")
|
||||||
print(f"- realized_pnl_usd: {report.realized_pnl_usd:.4f}")
|
print(f"- realized_pnl_usd: {r.realized_pnl_usd:.4f}")
|
||||||
print(f"- max_drawdown_usd: {report.max_drawdown_usd:.4f}")
|
print(f"- max_drawdown_usd: {r.max_drawdown_usd:.4f}")
|
||||||
print(f"- miss_reasons: {dict(report.miss_reasons)}")
|
print(f"- miss_reasons: {dict(r.miss_reasons)}")
|
||||||
print(
|
print(
|
||||||
"- execution_latency_ms: "
|
"- execution_latency_ms: "
|
||||||
f"p50={report.execution_latency_p50_ms or 0.0:.4f}, "
|
f"p50={r.execution_latency_p50_ms or 0.0:.4f}, "
|
||||||
f"p95={report.execution_latency_p95_ms or 0.0:.4f}, "
|
f"p95={r.execution_latency_p95_ms or 0.0:.4f}, "
|
||||||
f"p99={report.execution_latency_p99_ms or 0.0:.4f}"
|
f"p99={r.execution_latency_p99_ms or 0.0:.4f}"
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ from arbitrade.storage.db import DuckDBStore
|
|||||||
|
|
||||||
def _python_scan_compute(store: DuckDBStore) -> tuple[float, float | None, float | None]:
|
def _python_scan_compute(store: DuckDBStore) -> tuple[float, float | None, float | None]:
|
||||||
with store.connect() as conn:
|
with store.connect() as conn:
|
||||||
trade_rows = conn.execute(
|
trade_rows = conn.execute("""
|
||||||
"""
|
|
||||||
SELECT started_at, finished_at, realized_pnl
|
SELECT started_at, finished_at, realized_pnl
|
||||||
FROM trades
|
FROM trades
|
||||||
WHERE finished_at IS NOT NULL
|
WHERE finished_at IS NOT NULL
|
||||||
"""
|
""").fetchall()
|
||||||
).fetchall()
|
sql_d = "SELECT detected_at FROM opportunities"
|
||||||
opportunity_rows = conn.execute("SELECT detected_at FROM opportunities").fetchall()
|
orows = conn.execute(sql_d).fetchall()
|
||||||
|
|
||||||
realized = sum(float(row[2]) for row in trade_rows if row[2] is not None)
|
realized = sum(float(row[2]) for row in trade_rows if row[2] is not None)
|
||||||
durations = [
|
durations = [
|
||||||
@@ -30,10 +29,10 @@ def _python_scan_compute(store: DuckDBStore) -> tuple[float, float | None, float
|
|||||||
]
|
]
|
||||||
avg_duration = fmean(durations) if durations else None
|
avg_duration = fmean(durations) if durations else None
|
||||||
|
|
||||||
times = [row[0] for row in opportunity_rows if isinstance(row[0], datetime)]
|
times = [row[0] for row in orows if isinstance(row[0], datetime)]
|
||||||
if len(times) >= 2:
|
if len(times) >= 2:
|
||||||
span_seconds = (max(times) - min(times)).total_seconds()
|
ss = (max(times) - min(times)).total_seconds()
|
||||||
opm = len(times) / (span_seconds / 60.0) if span_seconds > 0.0 else float(len(times))
|
opm = len(times) / (ss / 60.0) if ss > 0.0 else float(len(times))
|
||||||
elif len(times) == 1:
|
elif len(times) == 1:
|
||||||
opm = 60.0
|
opm = 60.0
|
||||||
else:
|
else:
|
||||||
|
|||||||
+100
-1
@@ -4,22 +4,88 @@ import asyncio
|
|||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
import structlog
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from arbitrade.alerting.notifier import build_notifier_from_settings
|
from arbitrade.alerting.notifier import build_notifier_from_settings
|
||||||
from arbitrade.api.control_state import DashboardControlState
|
from arbitrade.api.control_state import DashboardControlState
|
||||||
from arbitrade.api.routes import public_router, router
|
from arbitrade.api.routes import public_router, router
|
||||||
from arbitrade.backtesting.runner import backtest_worker
|
from arbitrade.backtesting.runner import backtest_worker
|
||||||
|
from arbitrade.config.pairing_sync import run_pairing_sync_loop
|
||||||
from arbitrade.config.service import ConfigurationService
|
from arbitrade.config.service import ConfigurationService
|
||||||
from arbitrade.config.settings import Settings
|
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.logging_setup import configure_logging
|
from arbitrade.logging_setup import configure_logging
|
||||||
|
from arbitrade.market_data.feed import MarketDataFeed
|
||||||
|
from arbitrade.market_data.feed_builder import (
|
||||||
|
build_detector_from_enabled_pairings,
|
||||||
|
get_enabled_pair_symbols,
|
||||||
|
)
|
||||||
from arbitrade.metrics import MetricsCalculator
|
from arbitrade.metrics import MetricsCalculator
|
||||||
from arbitrade.runtime.lifecycle import graceful_shutdown, restore_runtime_state
|
from arbitrade.runtime.lifecycle import graceful_shutdown, restore_runtime_state
|
||||||
from arbitrade.storage.db import DuckDBStore
|
from arbitrade.storage.db import DuckDBStore
|
||||||
|
from arbitrade.storage.market_snapshots import AsyncMarketSnapshotWriter
|
||||||
|
from arbitrade.storage.opportunities import AsyncOpportunityWriter
|
||||||
from arbitrade.storage.repositories import AuditRepository, RuntimeStateRepository
|
from arbitrade.storage.repositories import AuditRepository, RuntimeStateRepository
|
||||||
|
|
||||||
|
_LOG = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_feed(app: FastAPI, *, kill_switch_only: bool = False) -> asyncio.Task[None] | None:
|
||||||
|
"""Create and start a MarketDataFeed task from enabled pairings.
|
||||||
|
|
||||||
|
If kill_switch_only=True, only create a kill-switch-bound stub (no detector/feed).
|
||||||
|
Returns the task or None if no enabled pairings.
|
||||||
|
"""
|
||||||
|
settings = app.state.settings
|
||||||
|
db = app.state.store
|
||||||
|
alert_notifier = getattr(app.state, "alert_notifier", None)
|
||||||
|
controls = app.state.dashboard_controls
|
||||||
|
|
||||||
|
# Build detector from enabled pairings
|
||||||
|
detector = build_detector_from_enabled_pairings(
|
||||||
|
db,
|
||||||
|
fee_rate=0.0, # will be overridden by fee sync
|
||||||
|
max_depth_levels=controls.strategy_max_depth_levels,
|
||||||
|
min_profit_threshold=controls.strategy_profit_threshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
symbols = get_enabled_pair_symbols(db)
|
||||||
|
if not symbols and not kill_switch_only:
|
||||||
|
_LOG.warning("no_enabled_pair_symbols_feed_not_started")
|
||||||
|
return None
|
||||||
|
|
||||||
|
ws_client: KrakenWsClient = getattr(app.state, "ws_client", None)
|
||||||
|
if ws_client is None:
|
||||||
|
ws_client = KrakenWsClient(settings, alert_notifier=alert_notifier)
|
||||||
|
app.state.ws_client = ws_client
|
||||||
|
|
||||||
|
ws_client.set_subscribed_symbols(symbols)
|
||||||
|
|
||||||
|
snapshot_writer = AsyncMarketSnapshotWriter(db)
|
||||||
|
opportunity_writer = AsyncOpportunityWriter(db)
|
||||||
|
|
||||||
|
feed = MarketDataFeed(
|
||||||
|
ws_client=ws_client,
|
||||||
|
snapshot_writer=snapshot_writer,
|
||||||
|
detector=detector,
|
||||||
|
opportunity_writer=opportunity_writer,
|
||||||
|
paper_trading_mode=settings.paper_trading_mode,
|
||||||
|
trade_capital=settings.trade_capital_usd,
|
||||||
|
max_trade_capital=settings.max_trade_capital_usd,
|
||||||
|
kill_switch=controls.kill_switch,
|
||||||
|
alert_notifier=alert_notifier,
|
||||||
|
audit_repository=getattr(app.state, "audit_repository", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
app.state.feed = feed
|
||||||
|
task = asyncio.create_task(feed.run(), name="market_data_feed")
|
||||||
|
app.state.feed_task = task
|
||||||
|
_LOG.info("market_data_feed_started", symbols=symbols)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
def create_app(settings: Settings) -> FastAPI:
|
def create_app(settings: Settings) -> FastAPI:
|
||||||
configure_logging(settings.log_level, settings.log_json)
|
configure_logging(settings.log_level, settings.log_json)
|
||||||
@@ -28,6 +94,7 @@ def create_app(settings: Settings) -> FastAPI:
|
|||||||
db.migrate()
|
db.migrate()
|
||||||
kraken_client = KrakenRestClient(settings)
|
kraken_client = KrakenRestClient(settings)
|
||||||
fee_sync_stop_event = asyncio.Event()
|
fee_sync_stop_event = asyncio.Event()
|
||||||
|
pairing_sync_stop_event = asyncio.Event()
|
||||||
backtest_queue: asyncio.Queue[tuple[str, str, dict[str, object] | None] | None] = (
|
backtest_queue: asyncio.Queue[tuple[str, str, dict[str, object] | None] | None] = (
|
||||||
asyncio.Queue()
|
asyncio.Queue()
|
||||||
)
|
)
|
||||||
@@ -43,19 +110,49 @@ def create_app(settings: Settings) -> FastAPI:
|
|||||||
),
|
),
|
||||||
name="fee_sync_loop",
|
name="fee_sync_loop",
|
||||||
)
|
)
|
||||||
|
pairing_sync_task = asyncio.create_task(
|
||||||
|
run_pairing_sync_loop(
|
||||||
|
kraken_client,
|
||||||
|
db,
|
||||||
|
pairing_sync_stop_event,
|
||||||
|
),
|
||||||
|
name="pairing_sync_loop",
|
||||||
|
)
|
||||||
backtest_task = asyncio.create_task(
|
backtest_task = asyncio.create_task(
|
||||||
backtest_worker(backtest_queue, db), # type: ignore
|
backtest_worker(backtest_queue, db), # type: ignore
|
||||||
name="backtest_worker",
|
name="backtest_worker",
|
||||||
)
|
)
|
||||||
|
# Start market data feed from enabled pairings
|
||||||
|
_start_feed(app)
|
||||||
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.backtest_task = backtest_task
|
app.state.backtest_task = backtest_task
|
||||||
yield
|
yield
|
||||||
fee_sync_stop_event.set()
|
fee_sync_stop_event.set()
|
||||||
|
pairing_sync_stop_event.set()
|
||||||
|
# Stop feed
|
||||||
|
feed = getattr(app.state, "feed", None)
|
||||||
|
if feed is not None:
|
||||||
|
ws_client = getattr(app.state, "ws_client", None)
|
||||||
|
if ws_client is not None:
|
||||||
|
await ws_client.stop()
|
||||||
|
ft = getattr(app.state, "feed_task", None)
|
||||||
|
if ft is not None:
|
||||||
|
ft.cancel()
|
||||||
|
try:
|
||||||
|
await ft
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
fee_sync_task.cancel()
|
fee_sync_task.cancel()
|
||||||
try:
|
try:
|
||||||
await fee_sync_task
|
await fee_sync_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
pairing_sync_task.cancel()
|
||||||
|
try:
|
||||||
|
await pairing_sync_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
await backtest_queue.put(None) # poison pill
|
await backtest_queue.put(None) # poison pill
|
||||||
backtest_task.cancel()
|
backtest_task.cancel()
|
||||||
try:
|
try:
|
||||||
@@ -70,12 +167,14 @@ def create_app(settings: Settings) -> FastAPI:
|
|||||||
app.state.store = db
|
app.state.store = db
|
||||||
app.state.kraken_client = kraken_client
|
app.state.kraken_client = kraken_client
|
||||||
app.state.fee_sync_stop_event = fee_sync_stop_event
|
app.state.fee_sync_stop_event = fee_sync_stop_event
|
||||||
|
app.state.pairing_sync_stop_event = pairing_sync_stop_event
|
||||||
app.state.backtest_queue = backtest_queue
|
app.state.backtest_queue = backtest_queue
|
||||||
app.state.metrics = MetricsCalculator(db)
|
app.state.metrics = MetricsCalculator(db)
|
||||||
app.state.audit_repository = AuditRepository(db)
|
app.state.audit_repository = AuditRepository(db)
|
||||||
app.state.runtime_state_repository = RuntimeStateRepository(db)
|
app.state.runtime_state_repository = RuntimeStateRepository(db)
|
||||||
app.state.alert_notifier = build_notifier_from_settings(settings)
|
app.state.alert_notifier = build_notifier_from_settings(settings)
|
||||||
app.state.configuration_service = ConfigurationService(settings, db, AuditRepository(db))
|
svc = ConfigurationService(settings, db, app.state.audit_repository)
|
||||||
|
app.state.configuration_service = svc
|
||||||
app.state.backtest_recent_reports = []
|
app.state.backtest_recent_reports = []
|
||||||
app.state.dashboard_controls = DashboardControlState(
|
app.state.dashboard_controls = DashboardControlState(
|
||||||
is_running=not settings.kill_switch_active,
|
is_running=not settings.kill_switch_active,
|
||||||
|
|||||||
+204
-29
@@ -18,11 +18,14 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from arbitrade.alerting.notifier import SupportsAlerts, SupportsAlertStatus
|
from arbitrade.alerting.notifier import SupportsAlerts, SupportsAlertStatus
|
||||||
from arbitrade.api.auth import require_dashboard_auth
|
from arbitrade.api.auth import require_dashboard_auth
|
||||||
from arbitrade.api.control_state import DashboardControlState
|
from arbitrade.api.control_state import DashboardControlState
|
||||||
|
from arbitrade.config.pairing_sync import sync_pairings_from_kraken
|
||||||
|
from arbitrade.config.service import ConfigPairing
|
||||||
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
|
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
|
||||||
from arbitrade.storage.repositories import (
|
from arbitrade.storage.repositories import (
|
||||||
AuditRecord,
|
AuditRecord,
|
||||||
AuditRepository,
|
AuditRepository,
|
||||||
BacktestJobRepository,
|
BacktestJobRepository,
|
||||||
|
ConfigPairingRepository,
|
||||||
KrakenAccountSnapshotRepository,
|
KrakenAccountSnapshotRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,37 +107,29 @@ def _dashboard_overview(request: Request) -> dict[str, object]:
|
|||||||
else:
|
else:
|
||||||
open_trade_filter = "LOWER(status) NOT IN ('filled', 'closed', 'cancelled', 'canceled')"
|
open_trade_filter = "LOWER(status) NOT IN ('filled', 'closed', 'cancelled', 'canceled')"
|
||||||
|
|
||||||
portfolio_row = conn.execute(
|
portfolio_row = conn.execute("""
|
||||||
"""
|
|
||||||
SELECT balances, total_value_usd
|
SELECT balances, total_value_usd
|
||||||
FROM portfolio_snapshots
|
FROM portfolio_snapshots
|
||||||
ORDER BY snapshot_at DESC
|
ORDER BY snapshot_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
""").fetchone()
|
||||||
).fetchone()
|
open_trades = conn.execute(f"""
|
||||||
open_trades = conn.execute(
|
|
||||||
f"""
|
|
||||||
SELECT {trade_ref_expr}, status, started_at, {cycle_expr}
|
SELECT {trade_ref_expr}, status, started_at, {cycle_expr}
|
||||||
FROM trades
|
FROM trades
|
||||||
WHERE {open_trade_filter}
|
WHERE {open_trade_filter}
|
||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
LIMIT 5
|
LIMIT 5
|
||||||
"""
|
""").fetchall()
|
||||||
).fetchall()
|
rpnl = conn.execute("""
|
||||||
rpnl = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT COALESCE(SUM(COALESCE(realized_pnl, 0)), 0)
|
SELECT COALESCE(SUM(COALESCE(realized_pnl, 0)), 0)
|
||||||
FROM trades
|
FROM trades
|
||||||
"""
|
""").fetchone()
|
||||||
).fetchone()
|
latest_opportunities = conn.execute("""
|
||||||
latest_opportunities = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT cycle, net_pct, est_profit, detected_at
|
SELECT cycle, net_pct, est_profit, detected_at
|
||||||
FROM opportunities
|
FROM opportunities
|
||||||
ORDER BY detected_at DESC
|
ORDER BY detected_at DESC
|
||||||
LIMIT 5
|
LIMIT 5
|
||||||
"""
|
""").fetchall()
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
balances_value = "—"
|
balances_value = "—"
|
||||||
total_value = "—"
|
total_value = "—"
|
||||||
@@ -164,14 +159,12 @@ def _dashboard_overview(request: Request) -> dict[str, object]:
|
|||||||
|
|
||||||
# Query equity from kraken_account_snapshots
|
# Query equity from kraken_account_snapshots
|
||||||
try:
|
try:
|
||||||
equity_row = conn.execute(
|
equity_row = conn.execute("""
|
||||||
"""
|
|
||||||
SELECT trade_balance_raw
|
SELECT trade_balance_raw
|
||||||
FROM kraken_account_snapshots
|
FROM kraken_account_snapshots
|
||||||
ORDER BY snapshot_at DESC
|
ORDER BY snapshot_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
""").fetchone()
|
||||||
).fetchone()
|
|
||||||
if equity_row is not None and equity_row[0] is not None:
|
if equity_row is not None and equity_row[0] is not None:
|
||||||
tb_raw = equity_row[0]
|
tb_raw = equity_row[0]
|
||||||
if isinstance(tb_raw, str):
|
if isinstance(tb_raw, str):
|
||||||
@@ -207,14 +200,12 @@ def _dashboard_overview(request: Request) -> dict[str, object]:
|
|||||||
taker_fee = "—"
|
taker_fee = "—"
|
||||||
thirty_day_volume = "—"
|
thirty_day_volume = "—"
|
||||||
try:
|
try:
|
||||||
acct_row = conn.execute(
|
acct_row = conn.execute("""
|
||||||
"""
|
|
||||||
SELECT fee_tier, maker_fee, taker_fee, thirty_day_volume
|
SELECT fee_tier, maker_fee, taker_fee, thirty_day_volume
|
||||||
FROM kraken_account_snapshots
|
FROM kraken_account_snapshots
|
||||||
ORDER BY snapshot_at DESC
|
ORDER BY snapshot_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
""").fetchone()
|
||||||
).fetchone()
|
|
||||||
if acct_row is not None:
|
if acct_row is not None:
|
||||||
fee_tier = str(acct_row[0]) if acct_row[0] is not None else "—"
|
fee_tier = str(acct_row[0]) if acct_row[0] is not None else "—"
|
||||||
maker_fee = f"{float(acct_row[1]):.4%}" if acct_row[1] is not None else "—"
|
maker_fee = f"{float(acct_row[1]):.4%}" if acct_row[1] is not None else "—"
|
||||||
@@ -244,14 +235,12 @@ def _dashboard_overview(request: Request) -> dict[str, object]:
|
|||||||
def _dashboard_charts(request: Request) -> dict[str, object]:
|
def _dashboard_charts(request: Request) -> dict[str, object]:
|
||||||
store = request.app.state.store
|
store = request.app.state.store
|
||||||
with store.connect() as conn:
|
with store.connect() as conn:
|
||||||
opportunity_rows = conn.execute(
|
opportunity_rows = conn.execute("""
|
||||||
"""
|
|
||||||
SELECT detected_at, cycle, net_pct, est_profit
|
SELECT detected_at, cycle, net_pct, est_profit
|
||||||
FROM opportunities
|
FROM opportunities
|
||||||
ORDER BY detected_at DESC
|
ORDER BY detected_at DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
"""
|
""").fetchall()
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
cr = list(reversed(opportunity_rows))
|
cr = list(reversed(opportunity_rows))
|
||||||
labels = []
|
labels = []
|
||||||
@@ -588,7 +577,16 @@ def _dashboard_controls(request: Request) -> dict[str, object]:
|
|||||||
|
|
||||||
def _parse_form_body(body: bytes) -> dict[str, str]:
|
def _parse_form_body(body: bytes) -> dict[str, str]:
|
||||||
parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
|
parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
|
||||||
return {key: values[-1] for key, values in parsed.items() if values}
|
result: dict[str, str] = {}
|
||||||
|
for key, values in parsed.items():
|
||||||
|
if not values:
|
||||||
|
continue
|
||||||
|
if len(values) == 1:
|
||||||
|
result[key] = values[0]
|
||||||
|
else:
|
||||||
|
# Multi-value fields (e.g. checkboxes) -> join with comma
|
||||||
|
result[key] = ",".join(values)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _form_bool(value: str | None) -> bool:
|
def _form_bool(value: str | None) -> bool:
|
||||||
@@ -823,6 +821,20 @@ async def dashboard_backtesting_fragment(request: Request) -> HTMLResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/fragment/backtesting-pairings", response_class=HTMLResponse)
|
||||||
|
async def dashboard_backtesting_pairings_fragment(request: Request) -> HTMLResponse:
|
||||||
|
"""HTMX fragment: pairing checkboxes for backtest form."""
|
||||||
|
store = request.app.state.store
|
||||||
|
repo = ConfigPairingRepository(store)
|
||||||
|
pairings = repo.list_pairings()
|
||||||
|
pairings.sort(key=lambda p: (p.base_asset, p.quote_asset))
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="partials/backtesting_pairings.html",
|
||||||
|
context={"request": request, "pairings": pairings},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard/fragment/metrics", response_class=HTMLResponse)
|
@router.get("/dashboard/fragment/metrics", response_class=HTMLResponse)
|
||||||
async def dashboard_metrics(request: Request) -> HTMLResponse:
|
async def dashboard_metrics(request: Request) -> HTMLResponse:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@@ -1282,3 +1294,166 @@ async def dashboard_overview_stream(request: Request) -> StreamingResponse:
|
|||||||
@public_router.get("/health", response_class=JSONResponse)
|
@public_router.get("/health", response_class=JSONResponse)
|
||||||
async def health() -> JSONResponse:
|
async def health() -> JSONResponse:
|
||||||
return JSONResponse({"status": "ok", "service": "arbitrade"})
|
return JSONResponse({"status": "ok", "service": "arbitrade"})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pairing API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _pairing_repo(request: Request) -> ConfigPairingRepository:
|
||||||
|
return ConfigPairingRepository(request.app.state.store)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/api/pairings", response_class=JSONResponse)
|
||||||
|
async def dashboard_api_pairings(
|
||||||
|
request: Request,
|
||||||
|
search: str | None = None,
|
||||||
|
enabled: str | None = None,
|
||||||
|
source: str | None = None,
|
||||||
|
base: str | None = None,
|
||||||
|
quote: str | None = None,
|
||||||
|
sort: str = "base_asset",
|
||||||
|
order: str = "asc",
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""List pairings with optional filters."""
|
||||||
|
repo = _pairing_repo(request)
|
||||||
|
pairings = repo.list_pairings()
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if search:
|
||||||
|
search_lower = search.lower()
|
||||||
|
pairings = [
|
||||||
|
p
|
||||||
|
for p in pairings
|
||||||
|
if search_lower in p.base_asset.lower() or search_lower in p.quote_asset.lower()
|
||||||
|
]
|
||||||
|
if enabled is not None:
|
||||||
|
enabled_bool = enabled.lower() == "true"
|
||||||
|
pairings = [p for p in pairings if p.enabled == enabled_bool]
|
||||||
|
if source:
|
||||||
|
pairings = [p for p in pairings if p.source.lower() == source.lower()]
|
||||||
|
if base:
|
||||||
|
pairings = [p for p in pairings if p.base_asset.lower() == base.lower()]
|
||||||
|
if quote:
|
||||||
|
pairings = [p for p in pairings if p.quote_asset.lower() == quote.lower()]
|
||||||
|
|
||||||
|
# Sort
|
||||||
|
reverse = order.lower() == "desc"
|
||||||
|
if sort == "base_asset":
|
||||||
|
pairings.sort(key=lambda p: p.base_asset, reverse=reverse)
|
||||||
|
elif sort == "quote_asset":
|
||||||
|
pairings.sort(key=lambda p: p.quote_asset, reverse=reverse)
|
||||||
|
elif sort == "enabled":
|
||||||
|
pairings.sort(key=lambda p: p.enabled, reverse=reverse)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": p.id,
|
||||||
|
"base_asset": p.base_asset,
|
||||||
|
"quote_asset": p.quote_asset,
|
||||||
|
"pair": f"{p.base_asset}/{p.quote_asset}",
|
||||||
|
"enabled": p.enabled,
|
||||||
|
"source": p.source,
|
||||||
|
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||||
|
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
|
||||||
|
}
|
||||||
|
for p in pairings
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/fragment/pairings", response_class=HTMLResponse)
|
||||||
|
async def dashboard_pairings_fragment(
|
||||||
|
request: Request,
|
||||||
|
search: str | None = None,
|
||||||
|
enabled: str | None = None,
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""HTMX fragment: pairing table for config page."""
|
||||||
|
repo = _pairing_repo(request)
|
||||||
|
pairings = repo.list_pairings()
|
||||||
|
|
||||||
|
# Apply search filter
|
||||||
|
if search:
|
||||||
|
sl = search.lower()
|
||||||
|
pairings = [
|
||||||
|
p for p in pairings if sl in p.base_asset.lower() or sl in p.quote_asset.lower()
|
||||||
|
]
|
||||||
|
if enabled is not None and enabled.lower() != "all":
|
||||||
|
eb = enabled.lower() == "true"
|
||||||
|
pairings = [p for p in pairings if p.enabled == eb]
|
||||||
|
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dashboard/api/pairings/toggle")
|
||||||
|
async def dashboard_api_pairings_toggle(request: Request) -> HTMLResponse:
|
||||||
|
"""Toggle enabled/disabled for a pairing. Expects JSON or form body with base_asset and quote_asset."""
|
||||||
|
ctype = request.headers.get("content-type", "")
|
||||||
|
if "application/json" in ctype:
|
||||||
|
body = await request.json()
|
||||||
|
else:
|
||||||
|
form = _parse_form_body(await request.body())
|
||||||
|
body = form
|
||||||
|
|
||||||
|
base_asset = str(body.get("base_asset", "")).upper()
|
||||||
|
quote_asset = str(body.get("quote_asset", "")).upper()
|
||||||
|
if not base_asset or not quote_asset:
|
||||||
|
return HTMLResponse("Missing base_asset or quote_asset", status_code=400)
|
||||||
|
|
||||||
|
repo = _pairing_repo(request)
|
||||||
|
existing = repo.get_pairing(base_asset, quote_asset)
|
||||||
|
if existing is None:
|
||||||
|
return HTMLResponse("Pairing not found", status_code=404)
|
||||||
|
|
||||||
|
toggled = ConfigPairing(
|
||||||
|
base_asset=existing.base_asset,
|
||||||
|
quote_asset=existing.quote_asset,
|
||||||
|
enabled=not existing.enabled,
|
||||||
|
source=existing.source,
|
||||||
|
)
|
||||||
|
repo.update_pairing(base_asset, quote_asset, toggled)
|
||||||
|
|
||||||
|
_record_audit(
|
||||||
|
request,
|
||||||
|
actor="dashboard_user",
|
||||||
|
event_type="dashboard.pairings.toggle",
|
||||||
|
decision="approved",
|
||||||
|
payload={
|
||||||
|
"base_asset": base_asset,
|
||||||
|
"quote_asset": quote_asset,
|
||||||
|
"enabled": toggled.enabled,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return refreshed fragment
|
||||||
|
pairings_repo = _pairing_repo(request)
|
||||||
|
pairings = pairings_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},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
_record_audit(
|
||||||
|
request,
|
||||||
|
actor="dashboard_user",
|
||||||
|
event_type="dashboard.pairings.sync",
|
||||||
|
decision="approved",
|
||||||
|
payload=summary, # type: ignore
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(summary)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Sync available Kraken asset pairs into the config_pairings table."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from arbitrade.config.service import ConfigPairing
|
||||||
|
from arbitrade.detection.graph import CurrencyGraph
|
||||||
|
from arbitrade.exchange.kraken_rest import KrakenRestClient
|
||||||
|
from arbitrade.storage.db import DuckDBStore
|
||||||
|
from arbitrade.storage.repositories import ConfigPairingRepository
|
||||||
|
|
||||||
|
_LOG = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_pairings_from_kraken(
|
||||||
|
kraken_client: KrakenRestClient,
|
||||||
|
store: DuckDBStore,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Fetch all asset pairs from Kraken and upsert into config_pairings.
|
||||||
|
|
||||||
|
Returns a summary dict with 'added', 'updated', 'total' counts.
|
||||||
|
"""
|
||||||
|
asset_pairs = await kraken_client.asset_pairs()
|
||||||
|
graph = CurrencyGraph.from_kraken_asset_pairs(asset_pairs)
|
||||||
|
repo = ConfigPairingRepository(store)
|
||||||
|
|
||||||
|
added = 0
|
||||||
|
updated = 0
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
# Dedupe: pair_by_direction has entries for both (base,quote) and (quote,base).
|
||||||
|
seen_symbols: set[str] = set()
|
||||||
|
for (base, quote), symbol in graph.pair_by_direction.items():
|
||||||
|
if symbol in seen_symbols:
|
||||||
|
continue
|
||||||
|
seen_symbols.add(symbol)
|
||||||
|
existing = repo.get_pairing(base, quote)
|
||||||
|
pairing = ConfigPairing(
|
||||||
|
base_asset=base,
|
||||||
|
quote_asset=quote,
|
||||||
|
enabled=existing.enabled if existing else False,
|
||||||
|
source="kraken",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
repo.upsert_pairing(pairing)
|
||||||
|
total += 1
|
||||||
|
if existing:
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
added += 1
|
||||||
|
except Exception:
|
||||||
|
_LOG.warning("sync_pairing_failed", base=base, quote=quote)
|
||||||
|
|
||||||
|
_LOG.info(
|
||||||
|
"pairing_sync_complete",
|
||||||
|
added=added,
|
||||||
|
updated=updated,
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
return {"added": added, "updated": updated, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_pairing_sync_loop(
|
||||||
|
kraken_client: KrakenRestClient,
|
||||||
|
store: DuckDBStore,
|
||||||
|
stop_event: asyncio.Event,
|
||||||
|
interval_seconds: int = 86400,
|
||||||
|
) -> None:
|
||||||
|
"""Periodically sync pairings from Kraken (default daily)."""
|
||||||
|
await sync_pairings_from_kraken(kraken_client, store)
|
||||||
|
try:
|
||||||
|
while not stop_event.is_set():
|
||||||
|
await asyncio.wait_for(stop_event.wait(), timeout=interval_seconds)
|
||||||
|
await sync_pairings_from_kraken(kraken_client, store)
|
||||||
|
except (TimeoutError, asyncio.CancelledError):
|
||||||
|
pass
|
||||||
@@ -32,6 +32,7 @@ class KrakenWsClient:
|
|||||||
self._alert_notifier = alert_notifier
|
self._alert_notifier = alert_notifier
|
||||||
self._has_connected_once = False
|
self._has_connected_once = False
|
||||||
self._was_disconnected = False
|
self._was_disconnected = False
|
||||||
|
self._subscribed_symbols: list[str] = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_stale(self) -> bool:
|
def is_stale(self) -> bool:
|
||||||
@@ -44,6 +45,35 @@ class KrakenWsClient:
|
|||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
self._stop.set()
|
self._stop.set()
|
||||||
|
|
||||||
|
def set_subscribed_symbols(self, symbols: list[str]) -> None:
|
||||||
|
"""Set the list of symbols to subscribe to on (re)connect."""
|
||||||
|
self._subscribed_symbols = list(symbols)
|
||||||
|
|
||||||
|
async def _subscribe(self, ws: Any) -> None:
|
||||||
|
"""Send Kraken WS v2 subscribe message for book channel."""
|
||||||
|
if not self._subscribed_symbols:
|
||||||
|
_LOG.warning("kraken_ws_no_symbols_to_subscribe")
|
||||||
|
return
|
||||||
|
depth = 10
|
||||||
|
if hasattr(self._settings, "kraken_ws_book_depth"):
|
||||||
|
depth = self._settings.kraken_ws_book_depth
|
||||||
|
msg = orjson.dumps(
|
||||||
|
{
|
||||||
|
"method": "subscribe",
|
||||||
|
"params": {
|
||||||
|
"channel": "book",
|
||||||
|
"symbol": self._subscribed_symbols,
|
||||||
|
"depth": depth,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await ws.send(msg)
|
||||||
|
_LOG.info(
|
||||||
|
"kraken_ws_subscribed",
|
||||||
|
symbol_count=len(self._subscribed_symbols),
|
||||||
|
symbols=self._subscribed_symbols,
|
||||||
|
)
|
||||||
|
|
||||||
async def connect_stream(self) -> AsyncIterator[WsMessage]:
|
async def connect_stream(self) -> AsyncIterator[WsMessage]:
|
||||||
delay = 1.0
|
delay = 1.0
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
@@ -51,7 +81,8 @@ class KrakenWsClient:
|
|||||||
async with websockets.connect(
|
async with websockets.connect(
|
||||||
self._settings.kraken_ws_url, max_size=2_000_000
|
self._settings.kraken_ws_url, max_size=2_000_000
|
||||||
) as ws:
|
) as ws:
|
||||||
_LOG.info("kraken_ws_connected", url=self._settings.kraken_ws_url)
|
_LOG.info("kraken_ws_connected",
|
||||||
|
url=self._settings.kraken_ws_url)
|
||||||
if self._has_connected_once and self._was_disconnected:
|
if self._has_connected_once and self._was_disconnected:
|
||||||
await self._notify(
|
await self._notify(
|
||||||
category="system",
|
category="system",
|
||||||
@@ -63,10 +94,12 @@ class KrakenWsClient:
|
|||||||
self._has_connected_once = True
|
self._has_connected_once = True
|
||||||
self._was_disconnected = False
|
self._was_disconnected = False
|
||||||
delay = 1.0
|
delay = 1.0
|
||||||
|
await self._subscribe(ws)
|
||||||
async for raw in self._recv_loop(ws):
|
async for raw in self._recv_loop(ws):
|
||||||
yield raw
|
yield raw
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_LOG.warning("kraken_ws_disconnected", error=str(exc), reconnect_in=delay)
|
_LOG.warning("kraken_ws_disconnected",
|
||||||
|
error=str(exc), reconnect_in=delay)
|
||||||
self._was_disconnected = True
|
self._was_disconnected = True
|
||||||
await self._notify(
|
await self._notify(
|
||||||
category="system",
|
category="system",
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Build production MarketDataFeed components from enabled pairings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from arbitrade.detection.engine import IncrementalCycleDetector
|
||||||
|
from arbitrade.detection.graph import CurrencyGraph
|
||||||
|
from arbitrade.storage.db import DuckDBStore
|
||||||
|
from arbitrade.storage.repositories import ConfigPairingRepository
|
||||||
|
|
||||||
|
_LOG = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_detector_from_enabled_pairings(
|
||||||
|
store: DuckDBStore,
|
||||||
|
*,
|
||||||
|
fee_rate: float = 0.0,
|
||||||
|
max_depth_levels: int = 10,
|
||||||
|
min_profit_threshold: float = 0.0005,
|
||||||
|
) -> IncrementalCycleDetector | None:
|
||||||
|
"""Build an IncrementalCycleDetector using only enabled pairings from DB.
|
||||||
|
|
||||||
|
Returns None if no enabled pairings exist.
|
||||||
|
"""
|
||||||
|
repo = ConfigPairingRepository(store)
|
||||||
|
pairings = repo.list_pairings(enabled_only=True)
|
||||||
|
if not pairings:
|
||||||
|
_LOG.warning("no_enabled_pairings_found_detector_not_created")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Build CurrencyGraph from enabled pairings and discover cycles
|
||||||
|
graph = CurrencyGraph()
|
||||||
|
for p in pairings:
|
||||||
|
symbol = f"{p.base_asset}/{p.quote_asset}"
|
||||||
|
graph.add_pair(p.base_asset, p.quote_asset, symbol)
|
||||||
|
|
||||||
|
cycles = graph.triangular_cycles()
|
||||||
|
if not cycles:
|
||||||
|
_LOG.warning("no_triangular_cycles_from_enabled_pairings")
|
||||||
|
return None
|
||||||
|
|
||||||
|
cycles_by_pair = graph.index_cycles_by_pair(cycles)
|
||||||
|
_LOG.info(
|
||||||
|
"detector_built_from_enabled_pairings",
|
||||||
|
enabled_count=len(pairings),
|
||||||
|
cycle_count=len(cycles),
|
||||||
|
)
|
||||||
|
|
||||||
|
return IncrementalCycleDetector(
|
||||||
|
cycles_by_pair,
|
||||||
|
fee_rate=fee_rate,
|
||||||
|
max_depth_levels=max_depth_levels,
|
||||||
|
min_profit_threshold=min_profit_threshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_enabled_pair_symbols(store: DuckDBStore) -> list[str]:
|
||||||
|
"""Return list of enabled pair symbols (e.g. ['BTC/USD', 'ETH/BTC'])."""
|
||||||
|
repo = ConfigPairingRepository(store)
|
||||||
|
pairings = repo.list_pairings(enabled_only=True)
|
||||||
|
return [f"{p.base_asset}/{p.quote_asset}" for p in pairings if p.enabled]
|
||||||
@@ -24,8 +24,7 @@ class MetricsCalculator:
|
|||||||
|
|
||||||
def compute(self) -> PerformanceMetrics:
|
def compute(self) -> PerformanceMetrics:
|
||||||
with self._store.connect() as conn:
|
with self._store.connect() as conn:
|
||||||
tm = conn.execute(
|
tm = conn.execute("""
|
||||||
"""
|
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(COALESCE(realized_pnl, 0)), 0) AS realized_pnl_usd,
|
COALESCE(SUM(COALESCE(realized_pnl, 0)), 0) AS realized_pnl_usd,
|
||||||
COUNT(*) AS total_trades,
|
COUNT(*) AS total_trades,
|
||||||
@@ -45,26 +44,21 @@ class MetricsCalculator:
|
|||||||
) AS latency_p99_seconds
|
) AS latency_p99_seconds
|
||||||
FROM trades
|
FROM trades
|
||||||
WHERE finished_at IS NOT NULL
|
WHERE finished_at IS NOT NULL
|
||||||
"""
|
""").fetchone()
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
om = conn.execute(
|
om = conn.execute("""
|
||||||
"""
|
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) AS opportunity_count,
|
COUNT(*) AS opportunity_count,
|
||||||
MIN(detected_at) AS first_detected_at,
|
MIN(detected_at) AS first_detected_at,
|
||||||
MAX(detected_at) AS last_detected_at
|
MAX(detected_at) AS last_detected_at
|
||||||
FROM opportunities
|
FROM opportunities
|
||||||
"""
|
""").fetchone()
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
fm = conn.execute(
|
fm = conn.execute("""
|
||||||
"""
|
|
||||||
SELECT AVG(filled_volume / volume) AS fill_rate
|
SELECT AVG(filled_volume / volume) AS fill_rate
|
||||||
FROM orders
|
FROM orders
|
||||||
WHERE volume > 0 AND filled_volume IS NOT NULL
|
WHERE volume > 0 AND filled_volume IS NOT NULL
|
||||||
"""
|
""").fetchone()
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
r_pnl_usd = float(tm[0]) if tm and tm[0] is not None else 0.0
|
r_pnl_usd = float(tm[0]) if tm and tm[0] is not None else 0.0
|
||||||
tt = int(tm[1]) if tm and tm[1] is not None else 0
|
tt = int(tm[1]) if tm and tm[1] is not None else 0
|
||||||
|
|||||||
@@ -45,26 +45,22 @@ def _runtime_repository(app: FastAPI) -> RuntimeStateRepository | None:
|
|||||||
|
|
||||||
def _open_trade_count(store: DuckDBStore) -> int:
|
def _open_trade_count(store: DuckDBStore) -> int:
|
||||||
with store.connect() as conn:
|
with store.connect() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute("""
|
||||||
"""
|
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM trades
|
FROM trades
|
||||||
WHERE finished_at IS NULL
|
WHERE finished_at IS NULL
|
||||||
"""
|
""").fetchone()
|
||||||
).fetchone()
|
|
||||||
return int(row[0]) if row is not None else 0
|
return int(row[0]) if row is not None else 0
|
||||||
|
|
||||||
|
|
||||||
def _latest_balances(store: DuckDBStore) -> dict[str, Any] | None:
|
def _latest_balances(store: DuckDBStore) -> dict[str, Any] | None:
|
||||||
with store.connect() as conn:
|
with store.connect() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute("""
|
||||||
"""
|
|
||||||
SELECT balances
|
SELECT balances
|
||||||
FROM portfolio_snapshots
|
FROM portfolio_snapshots
|
||||||
ORDER BY snapshot_at DESC
|
ORDER BY snapshot_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
""").fetchone()
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if row is None or row[0] is None:
|
if row is None or row[0] is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -219,87 +219,12 @@ class DuckDBStore:
|
|||||||
|
|
||||||
# Ensure schema_migrations table exists and get current version
|
# Ensure schema_migrations table exists and get current version
|
||||||
if not self._table_exists(conn, "schema_migrations"):
|
if not self._table_exists(conn, "schema_migrations"):
|
||||||
conn.execute(
|
conn.execute("""
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
version INTEGER PRIMARY KEY,
|
version INTEGER PRIMARY KEY,
|
||||||
applied_at TIMESTAMP DEFAULT current_timestamp
|
applied_at TIMESTAMP DEFAULT current_timestamp
|
||||||
)
|
)
|
||||||
"""
|
""")
|
||||||
)
|
|
||||||
|
|
||||||
# Get current schema version
|
|
||||||
try:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1"
|
|
||||||
).fetchone()
|
|
||||||
current_version = row[0] if row else 0
|
|
||||||
except Exception:
|
|
||||||
current_version = 0
|
|
||||||
|
|
||||||
# Apply migrations for each version
|
|
||||||
if current_version < 1:
|
|
||||||
# Migration v1: Add missing columns to trades table
|
|
||||||
# Note: DuckDB does not support ADD COLUMN with constraints
|
|
||||||
conn.execute("ALTER TABLE trades ADD COLUMN IF NOT EXISTS trade_ref VARCHAR")
|
|
||||||
conn.execute("ALTER TABLE trades ADD COLUMN IF NOT EXISTS estimated_pnl DOUBLE")
|
|
||||||
conn.execute("ALTER TABLE trades ADD COLUMN IF NOT EXISTS capital_used DOUBLE")
|
|
||||||
conn.execute("ALTER TABLE trades ADD COLUMN IF NOT EXISTS cycle VARCHAR")
|
|
||||||
conn.execute("ALTER TABLE trades ADD COLUMN IF NOT EXISTS leg_count INTEGER")
|
|
||||||
conn.execute("INSERT OR IGNORE INTO schema_migrations (version) VALUES (1)")
|
|
||||||
_LOG.info("migration_applied", version=1)
|
|
||||||
|
|
||||||
if current_version < 2:
|
|
||||||
# Migration v2: Ensure config_backtesting_defaults table
|
|
||||||
# config_backtesting_defaults already created by SCHEMA_SQL
|
|
||||||
conn.execute("INSERT OR IGNORE INTO schema_migrations (version) VALUES (2)")
|
|
||||||
_LOG.info("migration_applied", version=2)
|
|
||||||
|
|
||||||
if current_version < 3:
|
|
||||||
# Migration v3: Add kraken_account_snapshots table
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS kraken_account_snapshots (
|
|
||||||
snapshot_at TIMESTAMP NOT NULL,
|
|
||||||
fee_tier VARCHAR,
|
|
||||||
maker_fee DOUBLE,
|
|
||||||
taker_fee DOUBLE,
|
|
||||||
thirty_day_volume DOUBLE,
|
|
||||||
trade_balance_raw JSON,
|
|
||||||
fee_schedule_raw JSON
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
conn.execute("INSERT OR IGNORE INTO schema_migrations (version) VALUES (3)")
|
|
||||||
_LOG.info("migration_applied", version=3)
|
|
||||||
|
|
||||||
if current_version < 4:
|
|
||||||
# Migration v4: Add fee_source to backtesting defaults
|
|
||||||
conn.execute(
|
|
||||||
"ALTER TABLE config_backtesting_defaults"
|
|
||||||
" ADD COLUMN IF NOT EXISTS fee_source VARCHAR DEFAULT 'api'"
|
|
||||||
)
|
|
||||||
conn.execute("INSERT OR IGNORE INTO schema_migrations (version) VALUES (4)")
|
|
||||||
_LOG.info("migration_applied", version=4)
|
|
||||||
|
|
||||||
if current_version < 5:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS backtest_jobs (
|
|
||||||
id UUID DEFAULT uuid(),
|
|
||||||
status VARCHAR NOT NULL DEFAULT 'pending',
|
|
||||||
events_path VARCHAR NOT NULL,
|
|
||||||
config JSON,
|
|
||||||
report JSON,
|
|
||||||
error VARCHAR,
|
|
||||||
created_at TIMESTAMP DEFAULT current_timestamp,
|
|
||||||
started_at TIMESTAMP,
|
|
||||||
finished_at TIMESTAMP
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
conn.execute("INSERT OR IGNORE INTO schema_migrations (version) VALUES (5)")
|
|
||||||
_LOG.info("migration_applied", version=5)
|
|
||||||
|
|
||||||
# Update version to current
|
# Update version to current
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -349,8 +349,7 @@ class RuntimeStateRepository:
|
|||||||
|
|
||||||
def latest(self) -> RuntimeStateRecord | None:
|
def latest(self) -> RuntimeStateRecord | None:
|
||||||
with self._store.connect() as conn:
|
with self._store.connect() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute("""
|
||||||
"""
|
|
||||||
SELECT
|
SELECT
|
||||||
snapshot_at,
|
snapshot_at,
|
||||||
is_running,
|
is_running,
|
||||||
@@ -362,8 +361,7 @@ class RuntimeStateRepository:
|
|||||||
FROM runtime_state_snapshots
|
FROM runtime_state_snapshots
|
||||||
ORDER BY snapshot_at DESC
|
ORDER BY snapshot_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
""").fetchone()
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
@@ -426,15 +424,14 @@ class ConfigSectionRepository:
|
|||||||
def list_sections(self) -> list[ConfigSection]:
|
def list_sections(self) -> list[ConfigSection]:
|
||||||
"""List all configuration sections."""
|
"""List all configuration sections."""
|
||||||
with self._store.connect() as conn:
|
with self._store.connect() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute("""
|
||||||
"""
|
|
||||||
SELECT id, name, description, updated_at
|
SELECT id, name, description, updated_at
|
||||||
FROM config_sections
|
FROM config_sections
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
"""
|
""")
|
||||||
)
|
|
||||||
return [
|
return [
|
||||||
ConfigSection(id=row[0], name=row[1], description=row[2], updated_at=row[3])
|
ConfigSection(id=row[0], name=row[1],
|
||||||
|
description=row[2], updated_at=row[3])
|
||||||
for row in cursor.fetchall()
|
for row in cursor.fetchall()
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -561,13 +558,11 @@ class ConfigSettingRepository:
|
|||||||
(section,),
|
(section,),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute("""
|
||||||
"""
|
|
||||||
SELECT key, section, value_json, value_type, is_secret, is_runtime_reloadable, updated_at, updated_by
|
SELECT key, section, value_json, value_type, is_secret, is_runtime_reloadable, updated_at, updated_by
|
||||||
FROM config_settings
|
FROM config_settings
|
||||||
ORDER BY key
|
ORDER BY key
|
||||||
"""
|
""")
|
||||||
)
|
|
||||||
return [
|
return [
|
||||||
ConfigSetting(
|
ConfigSetting(
|
||||||
key=row[0],
|
key=row[0],
|
||||||
@@ -585,12 +580,10 @@ class ConfigSettingRepository:
|
|||||||
def get_latest_updated_at(self) -> datetime | None:
|
def get_latest_updated_at(self) -> datetime | None:
|
||||||
"""Get the latest updated_at timestamp from config_settings table."""
|
"""Get the latest updated_at timestamp from config_settings table."""
|
||||||
with self._store.connect() as conn:
|
with self._store.connect() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute("""
|
||||||
"""
|
|
||||||
SELECT MAX(updated_at) as latest_updated_at
|
SELECT MAX(updated_at) as latest_updated_at
|
||||||
FROM config_settings
|
FROM config_settings
|
||||||
"""
|
""")
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if row and row[0]:
|
if row and row[0]:
|
||||||
# Convert string timestamp to datetime
|
# Convert string timestamp to datetime
|
||||||
@@ -699,16 +692,51 @@ class ConfigPairingRepository:
|
|||||||
)
|
)
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
def list_pairings(self) -> list[ConfigPairing]:
|
def upsert_pairing(self, pairing: ConfigPairing) -> ConfigPairing:
|
||||||
"""List all currency pairings."""
|
"""Insert or update a currency pairing (upsert on base_asset, quote_asset)."""
|
||||||
with self._store.connect() as conn:
|
with self._store.connect() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
|
INSERT INTO config_pairings (base_asset, quote_asset, enabled, source)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(base_asset, quote_asset) DO UPDATE SET
|
||||||
|
enabled = EXCLUDED.enabled,
|
||||||
|
source = EXCLUDED.source,
|
||||||
|
updated_at = current_timestamp
|
||||||
|
RETURNING id, base_asset, quote_asset, enabled, source, created_at, updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
pairing.base_asset,
|
||||||
|
pairing.quote_asset,
|
||||||
|
pairing.enabled,
|
||||||
|
pairing.source,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return ConfigPairing(
|
||||||
|
id=row[0],
|
||||||
|
base_asset=row[1],
|
||||||
|
quote_asset=row[2],
|
||||||
|
enabled=bool(row[3]),
|
||||||
|
source=row[4],
|
||||||
|
created_at=row[5],
|
||||||
|
updated_at=row[6],
|
||||||
|
)
|
||||||
|
raise ValueError("Failed to upsert pairing")
|
||||||
|
|
||||||
|
def list_pairings(self, enabled_only: bool = False) -> list[ConfigPairing]:
|
||||||
|
"""List all currency pairings. If enabled_only=True, only enabled pairings."""
|
||||||
|
with self._store.connect() as conn:
|
||||||
|
query = """
|
||||||
SELECT id, base_asset, quote_asset, enabled, source, created_at, updated_at
|
SELECT id, base_asset, quote_asset, enabled, source, created_at, updated_at
|
||||||
FROM config_pairings
|
FROM config_pairings
|
||||||
ORDER BY base_asset, quote_asset
|
|
||||||
"""
|
"""
|
||||||
)
|
params: list[object] = []
|
||||||
|
if enabled_only:
|
||||||
|
query += " WHERE enabled = TRUE"
|
||||||
|
query += " ORDER BY base_asset, quote_asset"
|
||||||
|
cursor = conn.execute(query, params)
|
||||||
return [
|
return [
|
||||||
ConfigPairing(
|
ConfigPairing(
|
||||||
id=row[0],
|
id=row[0],
|
||||||
@@ -738,7 +766,8 @@ class ConfigBacktestingDefaultsRepository:
|
|||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
(
|
(
|
||||||
orjson.dumps(defaults.starting_balances).decode("utf-8")
|
orjson.dumps(
|
||||||
|
defaults.starting_balances).decode("utf-8")
|
||||||
if defaults.starting_balances
|
if defaults.starting_balances
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
@@ -762,14 +791,12 @@ class ConfigBacktestingDefaultsRepository:
|
|||||||
def get_defaults(self) -> ConfigBacktestingDefaults | None:
|
def get_defaults(self) -> ConfigBacktestingDefaults | None:
|
||||||
"""Get the current backtesting defaults."""
|
"""Get the current backtesting defaults."""
|
||||||
with self._store.connect() as conn:
|
with self._store.connect() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute("""
|
||||||
"""
|
|
||||||
SELECT id, starting_balances, trade_capital, min_profit_threshold, slippage_bps, execution_latency_ms
|
SELECT id, starting_balances, trade_capital, min_profit_threshold, slippage_bps, execution_latency_ms
|
||||||
FROM config_backtesting_defaults
|
FROM config_backtesting_defaults
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
""")
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if row:
|
if row:
|
||||||
return ConfigBacktestingDefaults(
|
return ConfigBacktestingDefaults(
|
||||||
@@ -795,7 +822,8 @@ class ConfigBacktestingDefaultsRepository:
|
|||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
(
|
(
|
||||||
orjson.dumps(defaults.starting_balances).decode("utf-8")
|
orjson.dumps(
|
||||||
|
defaults.starting_balances).decode("utf-8")
|
||||||
if defaults.starting_balances
|
if defaults.starting_balances
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
@@ -848,7 +876,8 @@ class KrakenAccountSnapshotRepository:
|
|||||||
snapshot.taker_fee,
|
snapshot.taker_fee,
|
||||||
snapshot.thirty_day_volume,
|
snapshot.thirty_day_volume,
|
||||||
(
|
(
|
||||||
orjson.dumps(snapshot.trade_balance_raw).decode("utf-8")
|
orjson.dumps(
|
||||||
|
snapshot.trade_balance_raw).decode("utf-8")
|
||||||
if snapshot.trade_balance_raw
|
if snapshot.trade_balance_raw
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
@@ -862,15 +891,13 @@ class KrakenAccountSnapshotRepository:
|
|||||||
|
|
||||||
def latest_snapshot(self) -> KrakenAccountSnapshot | None:
|
def latest_snapshot(self) -> KrakenAccountSnapshot | None:
|
||||||
with self._store.connect() as conn:
|
with self._store.connect() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute("""
|
||||||
"""
|
|
||||||
SELECT snapshot_at, fee_tier, maker_fee, taker_fee,
|
SELECT snapshot_at, fee_tier, maker_fee, taker_fee,
|
||||||
thirty_day_volume, trade_balance_raw, fee_schedule_raw
|
thirty_day_volume, trade_balance_raw, fee_schedule_raw
|
||||||
FROM kraken_account_snapshots
|
FROM kraken_account_snapshots
|
||||||
ORDER BY snapshot_at DESC
|
ORDER BY snapshot_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
""").fetchone()
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
return KrakenAccountSnapshot(
|
return KrakenAccountSnapshot(
|
||||||
@@ -911,7 +938,8 @@ class BacktestJobRepository:
|
|||||||
VALUES (?, ?)
|
VALUES (?, ?)
|
||||||
RETURNING id, status, events_path, config, created_at
|
RETURNING id, status, events_path, config, created_at
|
||||||
""",
|
""",
|
||||||
(events_path, orjson.dumps(config).decode("utf-8") if config else None),
|
(events_path, orjson.dumps(config).decode(
|
||||||
|
"utf-8") if config else None),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ValueError("Failed to create backtest job")
|
raise ValueError("Failed to create backtest job")
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% for p in pairings %}
|
||||||
|
<label
|
||||||
|
style="
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="symbols"
|
||||||
|
value="{{ p.base_asset }}/{{ p.quote_asset }}"
|
||||||
|
{%
|
||||||
|
if
|
||||||
|
p.enabled
|
||||||
|
%}checked{%
|
||||||
|
endif
|
||||||
|
%}
|
||||||
|
/>
|
||||||
|
{{ p.base_asset }}/{{ p.quote_asset }}
|
||||||
|
</label>
|
||||||
|
{% endfor %} {% if not pairings %}
|
||||||
|
<span style="opacity: 0.5"
|
||||||
|
>No pairings available. Sync from Kraken in config page.</span
|
||||||
|
>
|
||||||
|
{% endif %}
|
||||||
@@ -53,13 +53,15 @@
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="source" value="db" />
|
<input type="hidden" name="source" value="db" />
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Symbols (comma-separated, blank=all)</span>
|
<span>Pairings</span>
|
||||||
<input
|
<div
|
||||||
name="symbols"
|
id="pairing-checkboxes"
|
||||||
type="text"
|
hx-get="/dashboard/fragment/backtesting-pairings"
|
||||||
value="{{ symbols | default('') }}"
|
hx-trigger="load"
|
||||||
placeholder="BTC/USD,ETH/BTC"
|
style="display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px"
|
||||||
/>
|
>
|
||||||
|
<span style="opacity: 0.5">Loading pairings...</span>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Start time (ISO datetime, optional)</span>
|
<span>Start time (ISO datetime, optional)</span>
|
||||||
|
|||||||
@@ -467,6 +467,52 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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 -->
|
<!-- Risk -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">Risk Limits</div>
|
<div class="label">Risk Limits</div>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<table
|
||||||
|
class="pairings-table"
|
||||||
|
style="width: 100%; border-collapse: collapse; font-size: 0.85rem"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr
|
||||||
|
style="
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
text-align: left;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<th style="padding: 6px 8px">Base</th>
|
||||||
|
<th style="padding: 6px 8px">Quote</th>
|
||||||
|
<th style="padding: 6px 8px">Source</th>
|
||||||
|
<th style="padding: 6px 8px; text-align: center">Enabled</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in pairings %}
|
||||||
|
<tr style="border-bottom: 1px solid rgba(255, 255, 255, 0.05)">
|
||||||
|
<td style="padding: 6px 8px">{{ p.base_asset }}</td>
|
||||||
|
<td style="padding: 6px 8px">{{ p.quote_asset }}</td>
|
||||||
|
<td style="padding: 6px 8px; opacity: 0.6">{{ p.source }}</td>
|
||||||
|
<td style="padding: 6px 8px; text-align: center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
hx-post="/dashboard/api/pairings/toggle"
|
||||||
|
hx-vals='{"base_asset": "{{ p.base_asset }}", "quote_asset": "{{ p.quote_asset }}"}'
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target="#pairings-table-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-include="#pairing-search"
|
||||||
|
{%
|
||||||
|
if
|
||||||
|
p.enabled
|
||||||
|
%}checked{%
|
||||||
|
endif
|
||||||
|
%}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %} {% if not pairings %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" style="padding: 20px; text-align: center; opacity: 0.5">
|
||||||
|
No pairings found. Click "Sync from Kraken" to fetch available pairs.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
Reference in New Issue
Block a user