Bladeren bron

Stabilize grid trader state refresh

Lukas Goldschmidt 1 maand geleden
bovenliggende
commit
406314414c
1 gewijzigde bestanden met toevoegingen van 201 en 12 verwijderingen
  1. 201 12
      strategies/grid_trader.py

+ 201 - 12
strategies/grid_trader.py

@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import time
+from datetime import datetime, timezone
 
 from src.trader_mcp.strategy_sdk import Strategy
 
@@ -45,6 +46,8 @@ class Strategy(Strategy):
         "base_available": {"type": "float", "default": 0.0},
         "counter_available": {"type": "float", "default": 0.0},
         "trend_guard_active": {"type": "bool", "default": False},
+        "regimes_updated_at": {"type": "string", "default": ""},
+        "account_snapshot_updated_at": {"type": "string", "default": ""},
     }
 
     def init(self):
@@ -60,6 +63,8 @@ class Strategy(Strategy):
             "base_available": 0.0,
             "counter_available": 0.0,
             "trend_guard_active": False,
+            "regimes_updated_at": "",
+            "account_snapshot_updated_at": "",
         }
 
     def _log(self, message: str) -> None:
@@ -92,6 +97,10 @@ class Strategy(Strategy):
                 snapshot[tf] = {"error": str(exc)}
         return snapshot
 
+    def _refresh_regimes(self) -> None:
+        self.state["regimes"] = self._regime_snapshot()
+        self.state["regimes_updated_at"] = datetime.now(timezone.utc).isoformat()
+
     def _trend_guard_status(self) -> tuple[bool, str]:
         if not bool(self.config.get("enable_trend_guard", True)):
             return False, "disabled"
@@ -159,6 +168,33 @@ class Strategy(Strategy):
                 return 0.0
         return 0.0
 
+    def _refresh_balance_snapshot(self) -> None:
+        try:
+            info = self.context.get_account_info()
+        except Exception as exc:
+            self._log(f"balance refresh failed: {exc}")
+            return
+
+        balances = info.get("balances") if isinstance(info, dict) else []
+        if not isinstance(balances, list):
+            return
+
+        base = self._base_symbol()
+        quote = self.context.counter_currency or "USD"
+        for balance in balances:
+            if not isinstance(balance, dict):
+                continue
+            asset = str(balance.get("asset_code") or "").upper()
+            try:
+                available = float(balance.get("available") if balance.get("available") is not None else balance.get("total") or 0.0)
+            except Exception:
+                continue
+            if asset == base:
+                self.state["base_available"] = available
+            if asset == str(quote).upper():
+                self.state["counter_available"] = available
+        self.state["account_snapshot_updated_at"] = datetime.now(timezone.utc).isoformat()
+
     def _supported_levels(self, side: str, price: float, min_notional: float) -> int:
         if min_notional <= 0 or price <= 0:
             return 0
@@ -183,6 +219,14 @@ class Strategy(Strategy):
             return True
         return selected == side
 
+    def _desired_sides(self) -> set[str]:
+        selected = str(self.config.get("trade_sides", "both") or "both").strip().lower()
+        if selected == "both":
+            return {"buy", "sell"}
+        if selected in {"buy", "sell"}:
+            return {selected}
+        return {"buy", "sell"}
+
     def _suggest_amount(self, side: str, price: float, levels: int, min_notional: float) -> float:
         if levels <= 0 or price <= 0:
             return 0.0
@@ -190,6 +234,7 @@ class Strategy(Strategy):
         fee_rate = float(self.config.get("fee_rate", 0.0025) or 0.0)
         max_notional = float(self.config.get("max_notional_per_order", 0.0) or 0.0)
         manual = float(self.config.get("order_size", 0.0) or 0.0)
+        min_amount = (min_notional / price) if min_notional > 0 else 0.0
         if side == "buy":
             quote = self.context.counter_currency or "USD"
             quote_available = self._available_balance(quote)
@@ -203,12 +248,16 @@ class Strategy(Strategy):
             spendable_base = (base_available * safety) / (1 + fee_rate)
             amount = spendable_base / max(levels, 1)
 
-        min_size = (min_notional / price) if price > 0 else 0.0
-        amount = max(amount, min_size * 1.05)
+        amount = max(amount, min_amount * 1.05)
         if max_notional > 0 and price > 0:
             amount = min(amount, max_notional / (price * (1 + fee_rate)))
         if manual > 0:
-            amount = min(amount, manual)
+            if manual >= min_amount:
+                amount = min(amount, manual)
+            else:
+                self._log(
+                    f"manual order_size below minimum: order_size={manual:.6g} min_amount={min_amount:.6g} price={price} min_notional={min_notional}"
+                )
         return max(amount, 0.0)
 
     def _place_grid(self, center: float) -> None:
@@ -273,29 +322,135 @@ class Strategy(Strategy):
         self.state["order_ids"] = order_ids
         self.state["last_action"] = "seeded grid"
 
+    def _place_side_grid(self, side: str, center: float) -> None:
+        levels = int(self.config.get("grid_levels", 6) or 6)
+        step = self._grid_step_pct()
+        min_notional = float(self.context.minimum_order_value or 0.0)
+        fee_rate = float(self.config.get("fee_rate", 0.0025) or 0.0)
+        safety = 0.995
+        market = self._market_symbol()
+        orders = list(self.state.get("orders") or [])
+        order_ids = list(self.state.get("order_ids") or [])
+
+        side_levels = min(levels, self._supported_levels(side, center, min_notional))
+        amount = self._suggest_amount(side, center, max(side_levels, 1), min_notional)
+
+        if side == "buy":
+            quote = self.context.counter_currency or "USD"
+            quote_available = self._available_balance(quote)
+            max_affordable_amount = (quote_available * safety) / (center * (1 + fee_rate)) if center > 0 else 0.0
+            min_amount = (min_notional / center) if center > 0 and min_notional > 0 else 0.0
+            if max_affordable_amount < min_amount:
+                self._log(
+                    f"skip side buy: insufficient counter balance quote={quote_available:.6g} max_affordable_amount={max_affordable_amount:.6g} min_amount={min_amount:.6g} fee_rate={fee_rate}"
+                )
+                return
+            amount = min(amount, max_affordable_amount)
+
+        if side_levels <= 0 and min_notional > 0 and center > 0:
+            min_amount = min_notional / center
+            if amount >= min_amount:
+                side_levels = 1
+                self._log(f"side {side} restored to 1 level because amount clears minimum: amount={amount:.6g} min_amount={min_amount:.6g}")
+        self._log(
+            f"prepare side {side}: market={market} center={center} levels={side_levels} amount={amount:.6g} min_notional={min_notional} existing_ids={order_ids}"
+        )
+
+        for i in range(1, levels + 1):
+            price = round(center * (1 - (step * i)) if side == "buy" else center * (1 + (step * i)), 8)
+            min_size = (min_notional / price) if price > 0 else 0.0
+            if i > side_levels or amount < min_size:
+                self._log(
+                    f"skip side {side} level {i}: amount={amount:.6g} below min_size={min_size:.6g} min_notional={min_notional} price={price}"
+                )
+                continue
+            try:
+                self._log(f"place side {side} level {i}: price={price} amount={amount:.6g}")
+                result = self.context.place_order(side=side, order_type="limit", amount=amount, price=price, market=market)
+                status = None
+                order_id = None
+                if isinstance(result, dict):
+                    status = result.get("status")
+                    order_id = result.get("bitstamp_order_id") or result.get("order_id") or result.get("id") or result.get("client_order_id")
+                self._log(f"place side {side} level {i} result status={status} order_id={order_id} raw={result}")
+                orders.append({"side": side, "price": price, "amount": amount, "result": result})
+                if order_id is not None:
+                    order_ids.append(str(order_id))
+                self._log(f"seed side {side} level {i}: {price} amount {amount:.6g}")
+            except Exception as exc:
+                self.state["last_error"] = str(exc)
+                self._log(f"seed side {side} level {i} failed: {exc}")
+                continue
+
+            delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
+            if delay > 0:
+                time.sleep(delay)
+
+        self.state["orders"] = orders
+        self.state["order_ids"] = order_ids
+        self._log(f"side {side} placement complete: tracked_ids={order_ids}")
+
+    def _cancel_obsolete_side_orders(self, open_orders: list[dict], desired_sides: set[str]) -> list[str]:
+        removed: list[str] = []
+        for order in open_orders:
+            if not isinstance(order, dict):
+                continue
+            side = str(order.get("side") or "").lower()
+            order_id = str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or "")
+            if not order_id or side in desired_sides:
+                continue
+            try:
+                self.context.cancel_order(order_id)
+                removed.append(order_id)
+                self._log(f"cancelled obsolete {side} order {order_id}")
+            except Exception as exc:
+                self.state["last_error"] = str(exc)
+                self._log(f"cancel obsolete {side} order {order_id} failed: {exc}")
+        return removed
+
+    def _sync_open_orders_state(self) -> list[dict]:
+        try:
+            open_orders = self.context.get_open_orders()
+        except Exception as exc:
+            self.state["last_error"] = str(exc)
+            self._log(f"open orders sync failed: {exc}")
+            return []
+
+        if not isinstance(open_orders, list):
+            open_orders = []
+
+        live_orders = [order for order in open_orders if isinstance(order, dict)]
+        live_ids = [str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or "") for order in live_orders]
+        live_ids = [oid for oid in live_ids if oid]
+        live_sides = [str(order.get("side") or "").lower() for order in live_orders]
+
+        self.state["orders"] = live_orders
+        self.state["order_ids"] = live_ids
+        self.state["open_order_count"] = len(live_ids)
+        self._log(f"sync live orders: count={len(live_ids)} sides={live_sides} ids={live_ids}")
+        return live_orders
+
     def _cancel_orders(self, order_ids) -> None:
         for order_id in order_ids or []:
             self._log(f"dropping stale order {order_id} from state")
 
     def on_tick(self, tick):
+        self._refresh_balance_snapshot()
         price = self._price()
         self.state["last_price"] = price
         self.state["last_error"] = ""
+        self._refresh_regimes()
 
         try:
-            open_orders = self.context.get_open_orders()
-            live_ids = []
-            if isinstance(open_orders, list):
-                for order in open_orders:
-                    if isinstance(order, dict):
-                        live_ids.append(str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or ""))
-            live_ids = [oid for oid in live_ids if oid]
+            live_orders = self._sync_open_orders_state()
+            live_ids = list(self.state.get("order_ids") or [])
             open_order_count = len(live_ids)
             expected_ids = [str(oid) for oid in (self.state.get("order_ids") or []) if oid]
-            stale_ids = [oid for oid in live_ids if oid not in expected_ids]
-            missing_ids = [oid for oid in expected_ids if oid not in live_ids]
+            stale_ids = []
+            missing_ids = []
         except Exception as exc:
             open_order_count = -1
+            live_orders = []
             live_ids = []
             expected_ids = []
             stale_ids = []
@@ -306,6 +461,7 @@ class Strategy(Strategy):
         # Workaround: after a reset, trust the fresh strategy state first.
         # This prevents stale exec-mcp records from blocking the next clean test.
         if not (self.state.get("order_ids") or []):
+            live_orders = []
             live_ids = []
             open_order_count = 0
             expected_ids = []
@@ -313,6 +469,7 @@ class Strategy(Strategy):
             missing_ids = []
 
         self.state["open_order_count"] = open_order_count
+        desired_sides = self._desired_sides()
 
         mode = self._mode()
         guard_active, guard_reason = self._trend_guard_status()
@@ -358,9 +515,39 @@ class Strategy(Strategy):
             self._log(f"missing tracked orders: {missing_ids}")
             self.state["order_ids"] = live_ids
 
+        cancelled_obsolete = self._cancel_obsolete_side_orders(live_orders, desired_sides)
+        if cancelled_obsolete:
+            live_orders = self._sync_open_orders_state()
+            live_ids = list(self.state.get("order_ids") or [])
+            open_order_count = len(live_ids)
+
+        if desired_sides != {"buy", "sell"}:
+            current_sides = {str(order.get("side") or "").lower() for order in live_orders if isinstance(order, dict)}
+            missing_side = next((side for side in desired_sides if side not in current_sides), None)
+            if missing_side and self.state.get("center_price"):
+                self._log(f"adding missing {missing_side} side after trade_sides change, live_sides={sorted(current_sides)} live_ids={live_ids}")
+                self._place_side_grid(missing_side, float(self.state.get("center_price") or price))
+                live_orders = self._sync_open_orders_state()
+                self._log(f"post-add sync: open_order_count={self.state.get('open_order_count', 0)} live_ids={self.state.get('order_ids') or []}")
+                self.state["last_action"] = f"added {missing_side} side"
+                return {"action": "add_side", "price": price, "side": missing_side}
+
+        if desired_sides == {"buy", "sell"}:
+            current_sides = {str(order.get("side") or "").lower() for order in live_orders if isinstance(order, dict)}
+            missing_sides = [side for side in ("buy", "sell") if side not in current_sides]
+            if missing_sides and self.state.get("center_price"):
+                for side in missing_sides:
+                    self._log(f"adding missing {side} side after trade_sides change, live_sides={sorted(current_sides)} live_ids={live_ids}")
+                    self._place_side_grid(side, float(self.state.get("center_price") or price))
+                live_orders = self._sync_open_orders_state()
+                self._log(f"post-add sync: open_order_count={self.state.get('open_order_count', 0)} live_ids={self.state.get('order_ids') or []}")
+                self.state["last_action"] = f"added {','.join(missing_sides)} side(s)"
+                return {"action": "add_side", "price": price, "side": ",".join(missing_sides)}
+
         if not self.state.get("seeded") or not self.state.get("center_price"):
             self.state["center_price"] = price
             self._place_grid(price)
+            live_orders = self._sync_open_orders_state()
             self.state["seeded"] = True
             mode = self._mode()
             self._log(f"{'seeded' if mode == 'active' else 'planned'} grid at {price}")
@@ -370,6 +557,7 @@ class Strategy(Strategy):
             self._log("no open orders, reseeding grid")
             self.state["center_price"] = price
             self._place_grid(price)
+            live_orders = self._sync_open_orders_state()
             mode = self._mode()
             self.state["last_action"] = "reseeded" if mode == "active" else f"{mode} monitor"
             return {"action": "reseed" if mode == "active" else "plan", "price": price}
@@ -385,6 +573,7 @@ class Strategy(Strategy):
                 self.state["last_error"] = str(exc)
             self.state["center_price"] = price
             self._place_grid(price)
+            live_orders = self._sync_open_orders_state()
             mode = self._mode()
             self.state["last_action"] = "recentered" if mode == "active" else f"{mode} monitor"
             self._log(f"recentered grid to {price}")