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(
kraken_client,
db,
lambda: _active_pairings(app),
fee_sync_stop_event,
),
name="fee_sync_loop",
@@ -66,24 +65,6 @@ def create_app(settings: Settings) -> FastAPI:
await kraken_client.close()
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.state.settings = settings
app.state.store = db
+39 -86
View File
@@ -127,12 +127,49 @@ def _dashboard_overview(request: Request) -> dict[str, object]:
balances_value = ""
total_value = ""
equity_value = ""
if portfolio_row is not None:
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:
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 = [
{
"trade_ref": str(row[0]),
@@ -177,6 +214,7 @@ def _dashboard_overview(request: Request) -> dict[str, object]:
"generated_at": datetime.now(UTC).isoformat(),
"balances": balances_value,
"total_value": total_value,
"equity": equity_value,
"open_trade_count": len(open_trade_rows),
"open_trades": open_trade_rows,
"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}",
# UI
"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",
"request": request,
"config_endpoint": "/dashboard/control/config",
"config_fees_endpoint": "/dashboard/control/config/fees",
"config_fees_fragment": "/dashboard/fragment/config/fees",
**_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)
async def dashboard_alert_status(request: Request) -> JSONResponse:
return JSONResponse(_alert_status_snapshot(request))
-75
View File
@@ -39,15 +39,6 @@ class ConfigPairing(BaseModel):
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):
starting_balances: dict[str, float] | None = None
trade_capital: float | None = None
@@ -216,10 +207,6 @@ class ConfigurationService:
from arbitrade.storage.repositories import ConfigPairingRepository
return ConfigPairingRepository(self._store)
def _fee_repo(self):
from arbitrade.storage.repositories import ConfigPairFeeRepository
return ConfigPairFeeRepository(self._store)
def list_pairings(self) -> list[ConfigPairing]:
"""List all currency pairings."""
return self._pairing_repo().list_pairings()
@@ -232,65 +219,3 @@ class ConfigurationService:
pairing = ConfigPairing(
base_asset=base_asset, quote_asset=quote_asset, enabled=True, source=source)
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 orjson
from arbitrade.exchange.kraken_rest import KrakenRestClient
from arbitrade.storage.db import DuckDBStore
from arbitrade.storage.repositories import (
@@ -94,86 +96,37 @@ async def fetch_and_store_account_snapshot(
maker_fee=maker_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
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(
client: KrakenRestClient,
store: DuckDBStore,
get_active_pairings: callable[[], list[str]],
stop_event: asyncio.Event,
) -> None:
"""Periodic loop: fetch account snapshot + sync pair fees every hour.
"""Periodic loop: fetch account snapshot every hour.
Runs until stop_event is set.
"""
@@ -183,9 +136,6 @@ async def run_fee_sync_loop(
while not stop_event.is_set():
try:
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:
_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)
);
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 (
id INTEGER PRIMARY KEY,
starting_balances JSON,
@@ -260,8 +251,7 @@ class DuckDBStore:
_LOG.info("migration_applied", version=1)
if current_version < 2:
# Migration v2: Ensure config_pair_fees FK and new tables
# config_pair_fees and config_pairings already created by SCHEMA_SQL
# 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)")
@@ -280,9 +270,6 @@ class DuckDBStore:
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(
"INSERT OR IGNORE INTO schema_migrations (version) VALUES (3)")
_LOG.info("migration_applied", version=3)
+1 -124
View File
@@ -6,7 +6,7 @@ from typing import Any
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
@@ -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:
def __init__(self, store: DuckDBStore) -> None:
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
%} {% 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
id="config-shell"
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 }}
</div>
<div class="meta">Total value {{ total_value }}</div>
<div class="meta">Equity {{ equity }}</div>
</article>
<article class="card">
<div class="label">Opportunity Feed</div>