Skip to content

[Bug] Dashboard Balance: Cascading O(N) WAN CCXT / REST order-classification storm and unthrottled 5s background polling #397

Description

@bakiabaci

Summary

When displaying or polling live balance metrics via GET /api/dashboard/balance?live=1 (initiated every 5 seconds by frontend/dashboard_editor.html:_connectLiveBal), the backend calculation helper _live_balance_for_user() naively invokes _live_positions_for_user() (and on Hyperliquid, _hyperliquid_live_positions_for_user()).

Although calculating portfolio balance and gross exposure ($\sum |size \times entry|$) only requires each position's signed size, entryPrice, and unrealizedPnl (which exchange positions payloads already supply in a single request), _live_positions_for_user() unconditionally executes _classify_orders_for_position(..., live=True) for every single position in the account.

This triggers an $O(N)$ cascading storm of synchronous WAN exchange REST API requests:

  1. CCXT Exchanges (Binance, Bybit, Bitget, OKX): For an account with 10 open positions, a single 5-second balance poll issues 22 WAN REST API calls sequentially (1 fetch_balance + 1 fetch_positions + 10 fetch_open_orders + 10 fetch_ticker).
  2. Hyperliquid: A single balance poll issues 12 synchronous WAN HTTP POST requests (2 redundant clearinghouseState POSTs + 10 identical openOrders POSTs) to https://api.hyperliquid.xyz/info.
  3. 100% Discarded Payload: The balance calculator immediately discards all DCA counts, next DCA prices, next TP prices, and classified open orders; they are computed in vain.
  4. Unthrottled Background Polling: frontend/dashboard_editor.html:1140-1168 (refreshLiveBalance) and 1100-1129 (refreshLivePositions) poll every 5,000 ms with no document.hidden check, meaning background and minimized tabs continuously barrage exchange APIs at 264+ WAN requests per minute, risking exchange IP bans (HTTP 429 Too Many Requests).

Static AST Call-Graph Trace & Root Cause Analysis

Static AST dependency tracing highlights the cascading call path:

GET /api/dashboard/balance?live=1 (api/dashboard.py:1800-1854)
  └─► _live_balance_for_user(user_obj, db) (api/dashboard.py:454-465)
        ├─► exchange.fetch_balance("swap")                          [1 WAN call]
        └─► _live_positions_for_user(user_obj, db)                  [1 WAN call]
              └─► for position in raw_positions: (N positions)
                    ├─► _live_position_price(..., exchange, ...)     [N fetch_ticker WAN calls]
                    └─► _classify_orders_for_position(..., live=True)
                          └─► _live_open_orders_for_symbol(...)      [N fetch_open_orders WAN calls]
                                └─► exchange.instance.fetch_open_orders(symbol)
        └─► Discards orders & prices:
              upnl = sum(pos["upnl"] for pos in positions)          [Already in fetch_positions!]
              pprices = sum(|size * entry| for pos in positions)    [Already in fetch_positions!]

1. In api/dashboard.py:454–465:

def _live_balance_for_user(user_obj: Any, db: Any) -> tuple[float, float, float]:
    """Return live balance, uPnL and position entry exposure for a dashboard user."""
    if str(getattr(user_obj, "exchange", "")).lower() == "hyperliquid":
        balance, upnl = _hyperliquid_live_balance_for_user(user_obj)
        positions = _hyperliquid_live_positions_for_user(user_obj, db)
    else:
        exchange = _get_exchange(user_obj)
        balance = _safe_float(exchange.fetch_balance("swap"), 0.0)
        positions = _live_positions_for_user(user_obj, db)
        upnl = sum(_safe_float(pos.get("upnl"), 0.0) for pos in positions)
    pprices = sum(abs(_safe_float(pos.get("size"), 0.0) * _safe_float(pos.get("entry"), 0.0)) for pos in positions)
    return balance, upnl, pprices

Calling _live_positions_for_user invokes full DCA/TP order classification for each position symbol. But _live_balance_for_user only needs upnl and pprices = sum(abs(size * entry)). Both entryPrice and unrealizedPnl are already part of exchange.fetch_positions()!

2. In frontend/dashboard_editor.html:1140–1168:

  function _connectLiveBal(container, pos, users) {
...
    function refreshLiveBalance() {
      if (!container.isConnected) { _disconnectLive(lKey); return; }
      if (document.querySelector('.msel-drop.open')) return;
      if (st.loading) return;
      if (st.lastFetch && Date.now() - st.lastFetch < 5000) return;
      st.lastFetch = Date.now();
      st.loading = true;
      var url = API_BASE + '/dashboard/balance?users=' + encodeURIComponent(users.join(',')) + '&live=1';
      fetch(url)...
    st.timer = setInterval(function () {
      _setSourceStatus(container, '.db-status', st.source, st.lastTs);
      refreshLiveBalance();
    }, 1000);

refreshLiveBalance() checks isConnected and dropdown state, but completely lacks if (document.hidden) return;. An inactive tab in the background continues this 22-call barrage indefinitely.


Live Profiling & Benchmark Verification

We profiled _live_balance_for_user under controlled simulated exchange network roundtrips (30 ms WAN latency per REST call) on a realistic account with 10 open positions (BTC, ETH, SOL, BNB, XRP, ADA, DOGE, AVAX, LINK, SUI):

Metric Current Baseline (api/dashboard.py) Surgical Direct Extraction Improvement
CCXT WAN REST Calls (10 pos) 22 calls (1 bal + 1 pos + 10 orders + 10 tickers) 2 calls (1 bal + 1 pos) -90.9% (20 calls eliminated)
CCXT Response Latency (30ms RTT) 664.43 ms (Real WAN: 1,500 – 3,500 ms) 60.46 ms (Real WAN: 150 – 300 ms) -90.9% (10x faster)
Hyperliquid WAN POSTs (10 pos) 12 HTTP POSTs (2 state + 10 openOrders) 1 HTTP POST (1 state payload) -91.7% (11 POSTs eliminated)
Discarded Order Responses 10 of 10 order payloads thrown away 0 unnecessary queries Zero waste
Background Tab WAN Churn ~264 REST calls / min (no visibility guard) 0 calls when tab is hidden 100% idle efficiency
Computed Math Output balance: 10000.0, upnl: 50.0, pprices: 1000.0 balance: 10000.0, upnl: 50.0, pprices: 1000.0 100% Identical

Visual Pinpoint Evidence

Dashboard Live Balance Cascading Order Calls


Proposed Surgical Patch

1. Backend (api/dashboard.py): Skip order classification and ticker calls when only calculating balance/exposure

Add classify_orders: bool = True to _live_positions_for_user and _hyperliquid_live_positions_for_user, and pass classify_orders=False from _live_balance_for_user:

--- a/api/dashboard.py
+++ b/api/dashboard.py
@@ -397,7 +397,7 @@ def _hyperliquid_open_orders(user_obj: Any, symbol: str | None = None) -> list[
-def _hyperliquid_live_positions_for_user(user_obj: Any, db: Any) -> list[dict[str, Any]]:
+def _hyperliquid_live_positions_for_user(user_obj: Any, db: Any, classify_orders: bool = True) -> list[dict[str, Any]]:
     """Build dashboard positions from Hyperliquid's authoritative account state."""
     state = _hyperliquid_user_state(user_obj)
     result: list[dict[str, Any]] = []
@@ -418,9 +418,10 @@ def _hyperliquid_live_positions_for_user(user_obj: Any, db: Any) -> list[dict[s
         dca = 0
         next_dca = 0.0
         next_tp = 0.0
-        try:
-            dca, next_dca, next_tp = _classify_orders_for_position(user_obj, db, symbol, side, live=True)
-        except Exception:
-            pass
+        if classify_orders:
+            try:
+                dca, next_dca, next_tp = _classify_orders_for_position(user_obj, db, symbol, side, live=True)
+            except Exception:
+                pass
         result.append({
             "user":     user_obj.name,
@@ -455,8 +456,8 @@ def _live_balance_for_user(user_obj: Any, db: Any) -> tuple[float, float, float
     """Return live balance, uPnL and position entry exposure for a dashboard user."""
     if str(getattr(user_obj, "exchange", "")).lower() == "hyperliquid":
         balance, upnl = _hyperliquid_live_balance_for_user(user_obj)
-        positions = _hyperliquid_live_positions_for_user(user_obj, db)
+        positions = _hyperliquid_live_positions_for_user(user_obj, db, classify_orders=False)
     else:
         exchange = _get_exchange(user_obj)
         balance = _safe_float(exchange.fetch_balance("swap"), 0.0)
-        positions = _live_positions_for_user(user_obj, db)
+        positions = _live_positions_for_user(user_obj, db, classify_orders=False)
         upnl = sum(_safe_float(pos.get("upnl"), 0.0) for pos in positions)
     pprices = sum(abs(_safe_float(pos.get("size"), 0.0) * _safe_float(pos.get("entry"), 0.0)) for pos in positions)
     return balance, upnl, pprices
@@ -468,7 +469,7 @@ def _live_balance_for_user(user_obj: Any, db: Any) -> tuple[float, float, float
-def _live_positions_for_user(user_obj: Any, db: Any) -> list[dict[str, Any]]:
+def _live_positions_for_user(user_obj: Any, db: Any, classify_orders: bool = True) -> list[dict[str, Any]]:
     """Fetch open positions directly from the user's exchange for dashboard display."""
     if str(getattr(user_obj, "exchange", "")).lower() == "hyperliquid":
-        return _hyperliquid_live_positions_for_user(user_obj, db)
+        return _hyperliquid_live_positions_for_user(user_obj, db, classify_orders=classify_orders)
     exchange = _get_exchange(user_obj)
     raw_positions = exchange.fetch_positions() or []
     result: list[dict[str, Any]] = []
@@ -485,13 +486,14 @@ def _live_positions_for_user(user_obj: Any, db: Any) -> list[dict[str, Any]]:
         side = _live_position_side(position)
         entry = _safe_float(position.get("entryPrice") or position.get("entry_price"), 0.0)
         upnl = _safe_float(position.get("unrealizedPnl") or position.get("unrealisedPnl"), 0.0)
-        price = _live_position_price(position, exchange, symbol_ccxt or _symbol_to_ccxt(symbol))
+        price = _live_position_price(position, exchange, symbol_ccxt or _symbol_to_ccxt(symbol)) if classify_orders else entry
         dca = 0
         next_dca = 0.0
         next_tp = 0.0
-        try:
-            dca, next_dca, next_tp = _classify_orders_for_position(user_obj, db, symbol, side, live=True)
-        except Exception:
-            pass
+        if classify_orders:
+            try:
+                dca, next_dca, next_tp = _classify_orders_for_position(user_obj, db, symbol, side, live=True)
+            except Exception:
+                pass
         result.append({
             "user":     user_obj.name,

2. Frontend (frontend/dashboard_editor.html): Add document.hidden guards to live polling loops

--- a/frontend/dashboard_editor.html
+++ b/frontend/dashboard_editor.html
@@ -1106,6 +1106,7 @@
     function refreshLivePositions() {
       if (!container.isConnected) { _disconnectLive(lKey); return; }
       if (document.querySelector('.msel-drop.open')) return;
+      if (document.hidden) return;
       if (st.loading) return;
       if (st.lastFetch && Date.now() - st.lastFetch < 5000) return;
       st.lastFetch = Date.now();
@@ -1143,6 +1144,7 @@
     function refreshLiveBalance() {
       if (!container.isConnected) { _disconnectLive(lKey); return; }
       if (document.querySelector('.msel-drop.open')) return;
+      if (document.hidden) return;
       if (st.loading) return;
       if (st.lastFetch && Date.now() - st.lastFetch < 5000) return;
       st.lastFetch = Date.now();

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions