feat: refactor fee management by removing deprecated pair fee handling and updating dashboard to display equity
This commit is contained in:
@@ -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
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> </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>
|
||||
|
||||
Reference in New Issue
Block a user