diff --git a/src/arbitrade/api/app.py b/src/arbitrade/api/app.py index b12dd32..0737048 100644 --- a/src/arbitrade/api/app.py +++ b/src/arbitrade/api/app.py @@ -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 diff --git a/src/arbitrade/api/routes.py b/src/arbitrade/api/routes.py index cae679e..74e81a2 100644 --- a/src/arbitrade/api/routes.py +++ b/src/arbitrade/api/routes.py @@ -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)) diff --git a/src/arbitrade/config/service.py b/src/arbitrade/config/service.py index ed2cfce..0f778a6 100644 --- a/src/arbitrade/config/service.py +++ b/src/arbitrade/config/service.py @@ -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) diff --git a/src/arbitrade/exchange/fee_service.py b/src/arbitrade/exchange/fee_service.py index ee48f38..c9289ab 100644 --- a/src/arbitrade/exchange/fee_service.py +++ b/src/arbitrade/exchange/fee_service.py @@ -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") diff --git a/src/arbitrade/storage/db.py b/src/arbitrade/storage/db.py index 1f7f8c9..e33afa7 100644 --- a/src/arbitrade/storage/db.py +++ b/src/arbitrade/storage/db.py @@ -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) diff --git a/src/arbitrade/storage/repositories.py b/src/arbitrade/storage/repositories.py index 769f345..daaa98c 100644 --- a/src/arbitrade/storage/repositories.py +++ b/src/arbitrade/storage/repositories.py @@ -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 diff --git a/src/arbitrade/web/templates/config.html b/src/arbitrade/web/templates/config.html index a2ae4e0..ab94b1f 100644 --- a/src/arbitrade/web/templates/config.html +++ b/src/arbitrade/web/templates/config.html @@ -4,16 +4,6 @@ page_title="Configuration", page_subtitle="Runtime settings, alerts, exchange, risk, and strategy." %} {% include "_header.html" %} {% endwith %} {% endblock %} {% block content %} -
- {% include "partials/config_fees.html" %} -
-
-
-
-

Fee Configuration

- - {% if message %} - - {% endif %} - -
-
-
Configure Pairing Fees
-
-
-
-
- - - - - - - - - - - - {% for pairing in pairings %} - - - - - - - - {% endfor %} - -
PairingCrypto/Crypto MakerCrypto/Crypto TakerCrypto/Fiat MakerCrypto/Fiat Taker
- {{ pairing.base_asset }}/{{ pairing.quote_asset }} - - - - - - - - - -
-
- -
-
-
-
-
- -{% endblock %} diff --git a/src/arbitrade/web/templates/partials/config_fees.html b/src/arbitrade/web/templates/partials/config_fees.html deleted file mode 100644 index 6b43b54..0000000 --- a/src/arbitrade/web/templates/partials/config_fees.html +++ /dev/null @@ -1,220 +0,0 @@ -
-
-

Pair Fees

- -
- - {% if error %} -
- {{ error }} -
- {% endif %} - - - {% if pair_fees %} -
- - - - - - - - - - - - - - {% for fee in pair_fees %} - - - - - - - - - - {% endfor %} - -
Pair - Market Type - - Maker Rate - - Taker Rate - - Updated - - Actions - - Source -
{{ fee.pair_display }}{{ fee.market_type }}{{ "%.4f"|format(fee.maker_fee_rate) }}{{ "%.4f"|format(fee.taker_fee_rate) }} - {{ fee.updated_at or "—" }} - -
- - - - - - - -
-
- - - - - -
-
- {{ fee.source if fee.source else "manual" }} -
-
- {% else %} -
- No pair fees configured. Click "Add / Edit Fee" to create one. -
- {% endif %} - - - -
diff --git a/src/arbitrade/web/templates/partials/overview.html b/src/arbitrade/web/templates/partials/overview.html index 0747a0a..44a2d6d 100644 --- a/src/arbitrade/web/templates/partials/overview.html +++ b/src/arbitrade/web/templates/partials/overview.html @@ -54,6 +54,7 @@ {{ balances }}
Total value {{ total_value }}
+
Equity {{ equity }}
Opportunity Feed