feat: refactor fee management by removing deprecated pair fee handling and updating dashboard to display equity

This commit is contained in:
2026-06-04 17:48:41 +02:00
parent a0366f06ff
commit 1c2558cfb3
10 changed files with 67 additions and 729 deletions
-19
View File
@@ -39,7 +39,6 @@ def create_app(settings: Settings) -> FastAPI:
run_fee_sync_loop( run_fee_sync_loop(
kraken_client, kraken_client,
db, db,
lambda: _active_pairings(app),
fee_sync_stop_event, fee_sync_stop_event,
), ),
name="fee_sync_loop", name="fee_sync_loop",
@@ -66,24 +65,6 @@ def create_app(settings: Settings) -> FastAPI:
await kraken_client.close() await kraken_client.close()
await graceful_shutdown(app) await graceful_shutdown(app)
def _active_pairings(app: FastAPI) -> list[str]:
ctl = app.state.dashboard_controls
cfg_svc = app.state.configuration_service
pairs: list[str] = []
if ctl.tradable_pairs:
pairs = [p.upper() for p in ctl.tradable_pairs]
elif cfg_svc is not None:
try:
all_pairings = cfg_svc.list_pairings()
pairs = [
f"{p.base_asset}/{p.quote_asset}"
for p in all_pairings
if getattr(p, "enabled", True)
]
except Exception:
pass
return pairs
app = FastAPI(title="arbitrade", version="0.1.0", lifespan=lifespan) app = FastAPI(title="arbitrade", version="0.1.0", lifespan=lifespan)
app.state.settings = settings app.state.settings = settings
app.state.store = db app.state.store = db
+39 -86
View File
@@ -127,12 +127,49 @@ def _dashboard_overview(request: Request) -> dict[str, object]:
balances_value = "" balances_value = ""
total_value = "" total_value = ""
equity_value = ""
if portfolio_row is not None: if portfolio_row is not None:
balances_raw, total_value_raw = portfolio_row balances_raw, total_value_raw = portfolio_row
balances_value = str(balances_raw) if balances_raw is not None else "" if isinstance(balances_raw, str) and balances_raw:
try:
parsed = json.loads(balances_raw)
if isinstance(parsed, dict):
# Filter out zero balances, show non-zero as "AMT ASSET"
non_zero = {k: float(v)
for k, v in parsed.items() if float(v) > 0.0}
if non_zero:
balances_value = ", ".join(
f"{v:.6g} {k}" for k, v in sorted(non_zero.items())
)
else:
balances_value = "No balances"
else:
balances_value = str(balances_raw)
except (json.JSONDecodeError, ValueError, TypeError):
balances_value = str(balances_raw)
elif balances_raw is not None:
balances_value = str(balances_raw)
if total_value_raw is not None: if total_value_raw is not None:
total_value = f"{float(total_value_raw):.2f} USD" total_value = f"{float(total_value_raw):.2f} USD"
# Query equity from kraken_account_snapshots
try:
equity_row = conn.execute("""
SELECT trade_balance_raw
FROM kraken_account_snapshots
ORDER BY snapshot_at DESC
LIMIT 1
""").fetchone()
if equity_row is not None and equity_row[0] is not None:
tb_raw = equity_row[0]
if isinstance(tb_raw, str):
tb_raw = json.loads(tb_raw)
if isinstance(tb_raw, dict):
eb = tb_raw.get("eb")
equity_value = f"{float(eb):.2f} USD" if eb is not None else ""
except Exception:
pass
open_trade_rows = [ open_trade_rows = [
{ {
"trade_ref": str(row[0]), "trade_ref": str(row[0]),
@@ -177,6 +214,7 @@ def _dashboard_overview(request: Request) -> dict[str, object]:
"generated_at": datetime.now(UTC).isoformat(), "generated_at": datetime.now(UTC).isoformat(),
"balances": balances_value, "balances": balances_value,
"total_value": total_value, "total_value": total_value,
"equity": equity_value,
"open_trade_count": len(open_trade_rows), "open_trade_count": len(open_trade_rows),
"open_trades": open_trade_rows, "open_trades": open_trade_rows,
"realized_pnl_total": f"{float(rpnl[0]):.2f} USD" if rpnl else "", "realized_pnl_total": f"{float(rpnl[0]):.2f} USD" if rpnl else "",
@@ -460,11 +498,6 @@ def _dashboard_config_context(request: Request) -> dict[str, object]:
"strategy_stat_arb_max_holding_seconds": f"{rs.strategy_stat_arb_max_holding_seconds:.0f}", "strategy_stat_arb_max_holding_seconds": f"{rs.strategy_stat_arb_max_holding_seconds:.0f}",
# UI # UI
"config_endpoint": "/dashboard/control/config", "config_endpoint": "/dashboard/control/config",
"config_fees_endpoint": "/dashboard/control/config/fees",
"config_fees_fragment": "/dashboard/fragment/config/fees",
# Fee data (for config page)
"pair_fees": [],
"pairings": [],
} }
@@ -850,8 +883,6 @@ async def dashboard_config_page(request: Request) -> HTMLResponse:
"title": "Arbitrade Configuration", "title": "Arbitrade Configuration",
"request": request, "request": request,
"config_endpoint": "/dashboard/control/config", "config_endpoint": "/dashboard/control/config",
"config_fees_endpoint": "/dashboard/control/config/fees",
"config_fees_fragment": "/dashboard/fragment/config/fees",
**_dashboard_config_context(request), **_dashboard_config_context(request),
}, },
) )
@@ -866,84 +897,6 @@ async def dashboard_config_fragment(request: Request) -> HTMLResponse:
) )
@router.get("/dashboard/fragment/config/fees", response_class=HTMLResponse)
async def dashboard_config_fees_fragment(request: Request) -> HTMLResponse:
svc = getattr(request.app.state, "configuration_service", None)
if svc is None:
return templates.TemplateResponse(
request=request,
name="partials/config_fees.html",
context={"request": request, "pair_fees": [], "pairings": [],
"config_fees_endpoint": "/dashboard/control/config/fees",
"error": "Configuration service unavailable"},
)
pair_fees = svc.list_pair_fees_with_details()
pairings = svc.list_pairings()
return templates.TemplateResponse(
request=request,
name="partials/config_fees.html",
context={"request": request, "pair_fees": pair_fees,
"pairings": pairings,
"config_fees_endpoint": "/dashboard/control/config/fees",
"error": None},
)
@router.post("/dashboard/control/config/fees", response_class=HTMLResponse)
async def dashboard_config_fees_control(request: Request) -> HTMLResponse:
svc = getattr(request.app.state, "configuration_service", None)
if svc is None:
return templates.TemplateResponse(
request=request,
name="partials/config_fees.html",
context={"request": request, "pair_fees": [], "pairings": [],
"error": "Configuration service unavailable"},
)
form = _parse_form_body(await request.body())
action = form.get("action", "")
try:
if action == "add" or action == "update":
base_asset = form.get("base_asset", "").strip().upper()
quote_asset = form.get("quote_asset", "").strip().upper()
market_type = form.get("market_type", "crypto_crypto").strip()
maker_rate = float(form.get("maker_fee_rate", 0))
taker_rate = float(form.get("taker_fee_rate", 0))
if not base_asset or not quote_asset:
raise ValueError("base_asset and quote_asset are required")
result = svc.upsert_pair_fee(
base_asset, quote_asset, market_type, maker_rate, taker_rate)
elif action == "delete":
base_asset = form.get("base_asset", "").strip().upper()
quote_asset = form.get("quote_asset", "").strip().upper()
market_type = form.get("market_type", "crypto_crypto").strip()
if not base_asset or not quote_asset:
raise ValueError("base_asset and quote_asset are required")
svc.delete_pair_fee(base_asset, quote_asset, market_type)
else:
raise ValueError(f"Unknown action: {action}")
except ValueError as exc:
pair_fees = svc.list_pair_fees_with_details()
pairings = svc.list_pairings()
return templates.TemplateResponse(
request=request,
name="partials/config_fees.html",
context={"request": request, "pair_fees": pair_fees,
"pairings": pairings,
"config_fees_endpoint": "/dashboard/control/config/fees",
"error": str(exc)},
)
pair_fees = svc.list_pair_fees_with_details()
pairings = svc.list_pairings()
return templates.TemplateResponse(
request=request,
name="partials/config_fees.html",
context={"request": request, "pair_fees": pair_fees,
"pairings": pairings,
"config_fees_endpoint": "/dashboard/control/config/fees",
"error": None},
)
@router.get("/dashboard/api/alerts/status", response_class=JSONResponse) @router.get("/dashboard/api/alerts/status", response_class=JSONResponse)
async def dashboard_alert_status(request: Request) -> JSONResponse: async def dashboard_alert_status(request: Request) -> JSONResponse:
return JSONResponse(_alert_status_snapshot(request)) return JSONResponse(_alert_status_snapshot(request))
-75
View File
@@ -39,15 +39,6 @@ class ConfigPairing(BaseModel):
updated_at: datetime | None = None updated_at: datetime | None = None
class ConfigPairFee(BaseModel):
pairing_id: int
market_type: str # 'crypto_crypto' or 'crypto_fiat'
maker_fee_rate: float
taker_fee_rate: float
source: str = "manual" # 'manual' or 'kraken_api'
updated_at: datetime | None = None
class ConfigBacktestingDefaults(BaseModel): class ConfigBacktestingDefaults(BaseModel):
starting_balances: dict[str, float] | None = None starting_balances: dict[str, float] | None = None
trade_capital: float | None = None trade_capital: float | None = None
@@ -216,10 +207,6 @@ class ConfigurationService:
from arbitrade.storage.repositories import ConfigPairingRepository from arbitrade.storage.repositories import ConfigPairingRepository
return ConfigPairingRepository(self._store) return ConfigPairingRepository(self._store)
def _fee_repo(self):
from arbitrade.storage.repositories import ConfigPairFeeRepository
return ConfigPairFeeRepository(self._store)
def list_pairings(self) -> list[ConfigPairing]: def list_pairings(self) -> list[ConfigPairing]:
"""List all currency pairings.""" """List all currency pairings."""
return self._pairing_repo().list_pairings() return self._pairing_repo().list_pairings()
@@ -232,65 +219,3 @@ class ConfigurationService:
pairing = ConfigPairing( pairing = ConfigPairing(
base_asset=base_asset, quote_asset=quote_asset, enabled=True, source=source) base_asset=base_asset, quote_asset=quote_asset, enabled=True, source=source)
return self._pairing_repo().create_pairing(pairing) return self._pairing_repo().create_pairing(pairing)
def list_pair_fees_with_details(self) -> list[dict[str, Any]]:
"""List all pair fees with pairing details (base/quote asset)."""
pairings = self._pairing_repo().list_pairings()
result: list[dict[str, Any]] = []
for pairing in pairings:
fees = self._fee_repo().list_pair_fees(pairing.id or 0)
for fee in fees:
result.append({
"pairing_id": pairing.id,
"base_asset": pairing.base_asset,
"quote_asset": pairing.quote_asset,
"pair_display": f"{pairing.base_asset}/{pairing.quote_asset}",
"market_type": fee.market_type,
"maker_fee_rate": fee.maker_fee_rate,
"taker_fee_rate": fee.taker_fee_rate,
"source": fee.source,
"updated_at": fee.updated_at.isoformat() if fee.updated_at else None,
})
return result
def upsert_pair_fee(
self,
base_asset: str,
quote_asset: str,
market_type: str,
maker_fee_rate: float,
taker_fee_rate: float,
) -> dict[str, Any]:
"""Create or update a pair fee. Creates pairing if it doesn't exist."""
pairing = self._pairing_repo().get_pairing(base_asset, quote_asset)
if not pairing:
pairing = self.create_pairing(base_asset, quote_asset)
fee = ConfigPairFee(
pairing_id=pairing.id or 0,
market_type=market_type,
maker_fee_rate=maker_fee_rate,
taker_fee_rate=taker_fee_rate,
)
existing = self._fee_repo().get_pair_fee(pairing.id or 0, market_type)
if existing:
updated = self._fee_repo().update_pair_fee(pairing.id or 0, market_type, fee)
else:
updated = self._fee_repo().create_pair_fee(fee)
return {
"pairing_id": pairing.id,
"base_asset": pairing.base_asset,
"quote_asset": pairing.quote_asset,
"pair_display": f"{pairing.base_asset}/{pairing.quote_asset}",
"market_type": updated.market_type,
"maker_fee_rate": updated.maker_fee_rate,
"taker_fee_rate": updated.taker_fee_rate,
"source": updated.source,
"updated_at": updated.updated_at.isoformat() if updated.updated_at else None,
}
def delete_pair_fee(self, base_asset: str, quote_asset: str, market_type: str) -> bool:
"""Delete a pair fee."""
pairing = self._pairing_repo().get_pairing(base_asset, quote_asset)
if not pairing or not pairing.id:
return False
return self._fee_repo().delete_pair_fee(pairing.id, market_type)
+25 -75
View File
@@ -7,6 +7,8 @@ from datetime import datetime, timezone
import structlog import structlog
import orjson
from arbitrade.exchange.kraken_rest import KrakenRestClient from arbitrade.exchange.kraken_rest import KrakenRestClient
from arbitrade.storage.db import DuckDBStore from arbitrade.storage.db import DuckDBStore
from arbitrade.storage.repositories import ( from arbitrade.storage.repositories import (
@@ -94,86 +96,37 @@ async def fetch_and_store_account_snapshot(
maker_fee=maker_fee, maker_fee=maker_fee,
taker_fee=taker_fee, taker_fee=taker_fee,
) )
# Fetch wallet balances and write to portfolio_snapshots
try:
wallet_balances = await client.balances()
total_value = 0.0
if isinstance(balance_data, dict):
eb = balance_data.get("eb")
total_value = float(eb) if eb is not None else 0.0
with store.connect() as conn:
conn.execute(
"INSERT INTO portfolio_snapshots (snapshot_at, balances, total_value_usd) VALUES (?, ?, ?)",
(
datetime.now(timezone.utc),
orjson.dumps(wallet_balances).decode(
"utf-8") if wallet_balances else None,
total_value,
),
)
_LOG.info("portfolio_snapshot_stored", total_value_usd=total_value)
except Exception:
_LOG.exception("balances_fetch_or_store_failed")
return snapshot return snapshot
async def fetch_and_store_pair_fees(
client: KrakenRestClient,
store: DuckDBStore,
pairings: list[str],
) -> dict[str, dict[str, object]]:
"""Query TradeVolume per pair, upsert maker/taker into config_pair_fees.
Returns dict of pair -> fee info for successfully fetched pairs.
"""
results: dict[str, dict[str, object]] = {}
for pair in pairings:
try:
volume_data = await client.trade_volume(pair=pair)
except Exception:
_LOG.exception("trade_volume_pair_failed", pair=pair)
continue
if not isinstance(volume_data, dict):
continue
fees_dict = volume_data.get("fees")
fee_tier = volume_data.get("fee_tier")
fee_tier_str = str(fee_tier) if fee_tier is not None else None
maker_fee = None
taker_fee = None
if isinstance(fees_dict, dict) and fee_tier_str is not None:
tier_fees = fees_dict.get(fee_tier_str)
if isinstance(tier_fees, dict):
maker_fee = tier_fees.get("maker")
taker_fee = tier_fees.get("taker")
if maker_fee is not None and taker_fee is not None:
base, quote = pair.split("/", 1) if "/" in pair else (pair, "")
market_type = "crypto_fiat" if quote.upper(
) in {"USD", "EUR", "GBP", "JPY", "CAD", "CHF", "AUD"} else "crypto_crypto"
with store.connect() as conn:
# Find pairing_id
pairing_row = conn.execute(
"SELECT id FROM config_pairings WHERE base_asset = ? AND quote_asset = ?",
(base.upper() if "/" in pair else pair,
quote.upper() if "/" in pair else ""),
).fetchone()
if pairing_row is not None:
pairing_id = pairing_row[0]
conn.execute(
"""
INSERT OR REPLACE INTO config_pair_fees
(pairing_id, market_type, maker_fee_rate, taker_fee_rate, updated_at, source)
VALUES (?, ?, ?, ?, current_timestamp, 'kraken_api')
""",
(pairing_id, market_type, float(
maker_fee), float(taker_fee)),
)
results[pair] = {
"market_type": market_type,
"maker_fee": float(maker_fee),
"taker_fee": float(taker_fee),
"fee_tier": fee_tier_str,
}
_LOG.info("pair_fee_synced", pair=pair,
maker=maker_fee, taker=taker_fee)
return results
async def run_fee_sync_loop( async def run_fee_sync_loop(
client: KrakenRestClient, client: KrakenRestClient,
store: DuckDBStore, store: DuckDBStore,
get_active_pairings: callable[[], list[str]],
stop_event: asyncio.Event, stop_event: asyncio.Event,
) -> None: ) -> None:
"""Periodic loop: fetch account snapshot + sync pair fees every hour. """Periodic loop: fetch account snapshot every hour.
Runs until stop_event is set. Runs until stop_event is set.
""" """
@@ -183,9 +136,6 @@ async def run_fee_sync_loop(
while not stop_event.is_set(): while not stop_event.is_set():
try: try:
await fetch_and_store_account_snapshot(client, store) await fetch_and_store_account_snapshot(client, store)
pairings = get_active_pairings()
if pairings:
await fetch_and_store_pair_fees(client, store, pairings)
except Exception: except Exception:
_LOG.exception("fee_sync_loop_iteration_failed") _LOG.exception("fee_sync_loop_iteration_failed")
+1 -14
View File
@@ -46,15 +46,6 @@ CREATE TABLE IF NOT EXISTS config_pairings (
UNIQUE(base_asset, quote_asset) UNIQUE(base_asset, quote_asset)
); );
CREATE TABLE IF NOT EXISTS config_pair_fees (
pairing_id INTEGER NOT NULL,
market_type VARCHAR NOT NULL, -- 'crypto_crypto' or 'crypto_fiat'
maker_fee_rate DOUBLE NOT NULL,
taker_fee_rate DOUBLE NOT NULL,
updated_at TIMESTAMP DEFAULT current_timestamp,
FOREIGN KEY (pairing_id) REFERENCES config_pairings(id)
);
CREATE TABLE IF NOT EXISTS config_backtesting_defaults ( CREATE TABLE IF NOT EXISTS config_backtesting_defaults (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
starting_balances JSON, starting_balances JSON,
@@ -260,8 +251,7 @@ class DuckDBStore:
_LOG.info("migration_applied", version=1) _LOG.info("migration_applied", version=1)
if current_version < 2: if current_version < 2:
# Migration v2: Ensure config_pair_fees FK and new tables # Migration v2: Ensure config_backtesting_defaults table
# config_pair_fees and config_pairings already created by SCHEMA_SQL
# config_backtesting_defaults already created by SCHEMA_SQL # config_backtesting_defaults already created by SCHEMA_SQL
conn.execute( conn.execute(
"INSERT OR IGNORE INTO schema_migrations (version) VALUES (2)") "INSERT OR IGNORE INTO schema_migrations (version) VALUES (2)")
@@ -280,9 +270,6 @@ class DuckDBStore:
fee_schedule_raw JSON fee_schedule_raw JSON
) )
""") """)
# Add source column to config_pair_fees
conn.execute(
"ALTER TABLE config_pair_fees ADD COLUMN IF NOT EXISTS source VARCHAR DEFAULT 'manual'")
conn.execute( conn.execute(
"INSERT OR IGNORE INTO schema_migrations (version) VALUES (3)") "INSERT OR IGNORE INTO schema_migrations (version) VALUES (3)")
_LOG.info("migration_applied", version=3) _LOG.info("migration_applied", version=3)
+1 -124
View File
@@ -6,7 +6,7 @@ from typing import Any
import orjson import orjson
from arbitrade.config.service import ConfigBacktestingDefaults, ConfigPairing, ConfigSection, ConfigSetting, ConfigPairFee from arbitrade.config.service import ConfigBacktestingDefaults, ConfigPairing, ConfigSection, ConfigSetting
from arbitrade.storage.db import DuckDBStore from arbitrade.storage.db import DuckDBStore
@@ -729,129 +729,6 @@ class ConfigPairingRepository:
] ]
class ConfigPairFeeRepository:
def __init__(self, store: DuckDBStore) -> None:
self._store = store
def create_pair_fee(self, pair_fee: ConfigPairFee) -> ConfigPairFee:
"""Create a new pairing fee record."""
with self._store.connect() as conn:
cursor = conn.execute(
"""
INSERT INTO config_pair_fees (pairing_id, market_type, maker_fee_rate, taker_fee_rate, source)
VALUES (?, ?, ?, ?, ?)
RETURNING pairing_id, market_type, maker_fee_rate, taker_fee_rate, updated_at, source
""",
(
pair_fee.pairing_id,
pair_fee.market_type,
pair_fee.maker_fee_rate,
pair_fee.taker_fee_rate,
pair_fee.source,
),
)
row = cursor.fetchone()
if row:
return ConfigPairFee(
pairing_id=row[0],
market_type=row[1],
maker_fee_rate=row[2],
taker_fee_rate=row[3],
updated_at=row[4],
source=row[5],
)
raise ValueError("Failed to create pair fee")
def get_pair_fee(self, pairing_id: int, market_type: str) -> ConfigPairFee | None:
"""Get a pairing fee by pairing ID and market type."""
with self._store.connect() as conn:
cursor = conn.execute(
"""
SELECT pairing_id, market_type, maker_fee_rate, taker_fee_rate, updated_at, COALESCE(source, 'manual') AS source
FROM config_pair_fees
WHERE pairing_id = ? AND market_type = ?
""",
(pairing_id, market_type),
)
row = cursor.fetchone()
if row:
return ConfigPairFee(
pairing_id=row[0],
market_type=row[1],
maker_fee_rate=row[2],
taker_fee_rate=row[3],
updated_at=row[4],
source=row[5],
)
return None
def update_pair_fee(self, pairing_id: int, market_type: str, pair_fee: ConfigPairFee) -> ConfigPairFee:
"""Update an existing pairing fee."""
with self._store.connect() as conn:
cursor = conn.execute(
"""
UPDATE config_pair_fees
SET maker_fee_rate = ?, taker_fee_rate = ?, source = ?
WHERE pairing_id = ? AND market_type = ?
RETURNING pairing_id, market_type, maker_fee_rate, taker_fee_rate, updated_at, source
""",
(
pair_fee.maker_fee_rate,
pair_fee.taker_fee_rate,
pair_fee.source,
pairing_id,
market_type,
),
)
row = cursor.fetchone()
if row:
return ConfigPairFee(
pairing_id=row[0],
market_type=row[1],
maker_fee_rate=row[2],
taker_fee_rate=row[3],
updated_at=row[4],
source=row[5],
)
raise ValueError("Failed to update pair fee")
def delete_pair_fee(self, pairing_id: int, market_type: str) -> bool:
"""Delete a pairing fee."""
with self._store.connect() as conn:
cursor = conn.execute(
"""
DELETE FROM config_pair_fees
WHERE pairing_id = ? AND market_type = ?
""",
(pairing_id, market_type),
)
return cursor.rowcount > 0
def list_pair_fees(self, pairing_id: int) -> list[ConfigPairFee]:
"""List all fees for a pairing."""
with self._store.connect() as conn:
cursor = conn.execute(
"""
SELECT pairing_id, market_type, maker_fee_rate, taker_fee_rate, updated_at, COALESCE(source, 'manual') AS source
FROM config_pair_fees
WHERE pairing_id = ?
ORDER BY market_type
""",
(pairing_id,),
)
return [
ConfigPairFee(
pairing_id=row[0],
market_type=row[1],
maker_fee_rate=row[2],
taker_fee_rate=row[3],
updated_at=row[4],
source=row[5],
)
for row in cursor.fetchall()
]
class ConfigBacktestingDefaultsRepository: class ConfigBacktestingDefaultsRepository:
def __init__(self, store: DuckDBStore) -> None: def __init__(self, store: DuckDBStore) -> None:
self._store = store self._store = store
-10
View File
@@ -4,16 +4,6 @@ page_title="Configuration", page_subtitle="Runtime settings, alerts, exchange,
risk, and strategy." %} {% include "_header.html" %} {% endwith %} {% endblock risk, and strategy." %} {% include "_header.html" %} {% endwith %} {% endblock
%} {% block content %} %} {% block content %}
<section
id="fees-shell"
hx-get="{{ config_fees_fragment }}"
hx-target="this"
hx-trigger="load"
hx-swap="outerHTML"
>
{% include "partials/config_fees.html" %}
</section>
<section <section
id="config-shell" id="config-shell"
hx-get="/dashboard/fragment/config" hx-get="/dashboard/fragment/config"
@@ -1,106 +0,0 @@
{% extends "base.html" %} {% block title %}Fee Configuration{% endblock %} {%
block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<h2>Fee Configuration</h2>
{% if message %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ message }}
<button
type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close"
></button>
</div>
{% endif %}
<div class="card">
<div class="card-header">
<h5>Configure Pairing Fees</h5>
</div>
<div class="card-body">
<form method="post" action="/dashboard/config/fees/save">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Pairing</th>
<th>Crypto/Crypto Maker</th>
<th>Crypto/Crypto Taker</th>
<th>Crypto/Fiat Maker</th>
<th>Crypto/Fiat Taker</th>
</tr>
</thead>
<tbody>
{% for pairing in pairings %}
<tr>
<td>
{{ pairing.base_asset }}/{{ pairing.quote_asset }}
<input
type="hidden"
name="pairing_id_{{ pairing.id }}"
value="{{ pairing.id }}"
/>
</td>
<td>
<input
type="number"
step="0.0001"
min="0"
max="0.05"
class="form-control"
name="maker_fee_{{ pairing.id }}"
placeholder="0.0010"
/>
</td>
<td>
<input
type="number"
step="0.0001"
min="0"
max="0.05"
class="form-control"
name="taker_fee_{{ pairing.id }}"
placeholder="0.0020"
/>
</td>
<td>
<input
type="number"
step="0.0001"
min="0"
max="0.05"
class="form-control"
name="maker_fee_{{ pairing.id }}_fiat"
placeholder="0.0010"
readonly
/>
</td>
<td>
<input
type="number"
step="0.0001"
min="0"
max="0.05"
class="form-control"
name="taker_fee_{{ pairing.id }}_fiat"
placeholder="0.0020"
readonly
/>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<button type="submit" class="btn btn-primary">Save Fees</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -1,220 +0,0 @@
<div id="config-fees-panel" class="panel" style="margin-top: 16px">
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
"
>
<h2 style="margin: 0; font-size: 1.3rem">Pair Fees</h2>
<button
type="button"
class="button"
onclick="
document.getElementById('fee-form-section').style.display =
document.getElementById('fee-form-section').style.display === 'none'
? 'block'
: 'none'
"
>
Add / Edit Fee
</button>
</div>
{% if error %}
<div
style="
background: rgba(186, 61, 79, 0.2);
border: 1px solid #ba3d4f;
border-radius: 10px;
padding: 12px;
margin-bottom: 16px;
"
>
{{ error }}
</div>
{% endif %}
<!-- Fee Table -->
{% if pair_fees %}
<div style="overflow-x: auto">
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem">
<thead>
<tr style="border-bottom: 1px solid rgba(255, 255, 255, 0.14)">
<th style="text-align: left; padding: 10px; color: #9fb2d0">Pair</th>
<th style="text-align: left; padding: 10px; color: #9fb2d0">
Market Type
</th>
<th style="text-align: left; padding: 10px; color: #9fb2d0">
Maker Rate
</th>
<th style="text-align: left; padding: 10px; color: #9fb2d0">
Taker Rate
</th>
<th style="text-align: left; padding: 10px; color: #9fb2d0">
Updated
</th>
<th style="text-align: left; padding: 10px; color: #9fb2d0">
Actions
</th>
<th style="text-align: left; padding: 10px; color: #9fb2d0">
Source
</th>
</tr>
</thead>
<tbody>
{% for fee in pair_fees %}
<tr style="border-bottom: 1px solid rgba(255, 255, 255, 0.06)">
<td style="padding: 10px">{{ fee.pair_display }}</td>
<td style="padding: 10px">{{ fee.market_type }}</td>
<td style="padding: 10px">{{ "%.4f"|format(fee.maker_fee_rate) }}</td>
<td style="padding: 10px">{{ "%.4f"|format(fee.taker_fee_rate) }}</td>
<td style="padding: 10px; color: #7f95b7">
{{ fee.updated_at or "—" }}
</td>
<td style="padding: 10px">
<form
hx-post="{{ config_fees_endpoint }}"
hx-target="#config-fees-panel"
hx-swap="outerHTML"
style="display: inline"
>
<input type="hidden" name="action" value="update" />
<input
type="hidden"
name="base_asset"
value="{{ fee.base_asset }}"
/>
<input
type="hidden"
name="quote_asset"
value="{{ fee.quote_asset }}"
/>
<input
type="hidden"
name="market_type"
value="{{ fee.market_type }}"
/>
<input
type="hidden"
name="maker_fee_rate"
value="{{ fee.maker_fee_rate }}"
/>
<input
type="hidden"
name="taker_fee_rate"
value="{{ fee.taker_fee_rate }}"
/>
<button
type="submit"
class="button secondary"
style="padding: 4px 10px; font-size: 0.8rem"
>
Edit
</button>
</form>
<form
hx-post="{{ config_fees_endpoint }}"
hx-target="#config-fees-panel"
hx-swap="outerHTML"
style="display: inline"
>
<input type="hidden" name="action" value="delete" />
<input
type="hidden"
name="base_asset"
value="{{ fee.base_asset }}"
/>
<input
type="hidden"
name="quote_asset"
value="{{ fee.quote_asset }}"
/>
<input
type="hidden"
name="market_type"
value="{{ fee.market_type }}"
/>
<button
type="submit"
class="button danger"
style="padding: 4px 10px; font-size: 0.8rem"
onclick="return confirm('Delete this fee?');"
>
Delete
</button>
</form>
</td>
<td style="padding: 10px; color: #7f95b7">
{{ fee.source if fee.source else "manual" }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="color: #7f95b7; padding: 20px; text-align: center">
No pair fees configured. Click "Add / Edit Fee" to create one.
</div>
{% endif %}
<!-- Add/Edit Form -->
<div id="fee-form-section" style="display: none; margin-top: 20px">
<div class="card">
<div class="label">Add / Edit Fee</div>
<form
class="form-grid"
hx-post="{{ config_fees_endpoint }}"
hx-target="#config-fees-panel"
hx-swap="outerHTML"
style="
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
"
>
<input type="hidden" name="action" value="add" />
<label class="field">
<span>Base Asset</span>
<input name="base_asset" type="text" placeholder="BTC" required />
</label>
<label class="field">
<span>Quote Asset</span>
<input name="quote_asset" type="text" placeholder="USD" required />
</label>
<label class="field">
<span>Market Type</span>
<select name="market_type">
<option value="crypto_crypto">crypto_crypto</option>
<option value="crypto_fiat">crypto_fiat</option>
</select>
</label>
<label class="field">
<span>Maker Fee Rate</span>
<input
name="maker_fee_rate"
type="number"
min="0"
step="0.0001"
placeholder="API default"
/>
</label>
<label class="field">
<span>Taker Fee Rate</span>
<input
name="taker_fee_rate"
type="number"
min="0"
step="0.0001"
placeholder="API default"
/>
</label>
<label class="field">
<span>&nbsp;</span>
<button type="submit" class="button">Save Fee</button>
</label>
</form>
</div>
</div>
</div>
@@ -54,6 +54,7 @@
{{ balances }} {{ balances }}
</div> </div>
<div class="meta">Total value {{ total_value }}</div> <div class="meta">Total value {{ total_value }}</div>
<div class="meta">Equity {{ equity }}</div>
</article> </article>
<article class="card"> <article class="card">
<div class="label">Opportunity Feed</div> <div class="label">Opportunity Feed</div>