Lukas Goldschmidt 1 тиждень тому
батько
коміт
6d86a78f5a
4 змінених файлів з 195 додано та 159 видалено
  1. 11 0
      AGENTS.md
  2. 67 0
      strategies/AGENTS.md
  3. 1 1
      strategies/grid_trader.md
  4. 116 158
      strategies/grid_trader.py

+ 11 - 0
AGENTS.md

@@ -53,6 +53,10 @@ TRADER-MCP is structured in three layers:
 - NO market-data-driven decision making outside strategies
 - NO cross-strategy coupling
 
+## Strategy Notes
+
+- See `strategies/AGENTS.md` for shared strategy-folder rules and strategy-specific notes such as `grid_trader` shape and rebuild behavior.
+
 ---
 
 ## Failure Model
@@ -68,6 +72,13 @@ TRADER-MCP is structured in three layers:
 - before claiming a Python dependency is missing, source the repo-local `.venv`
 - prefer running Python, pytest, and helper scripts through the activated `.venv`
 
+## Runtime Trace Inspection
+
+- `grid_trader` does not write reseed events into a separate database table.
+- The strategy appends trace lines to `state["debug_log"]` in `strategies/grid_trader.py::_log()`, and `src/trader_mcp/strategy_engine.py::tick_strategy()` persists that state into `data/trader_mcp.sqlite3` via `strategy_instances.state_json`.
+- To inspect a running instance's trace from the DB, read `strategy_instances.state_json` for the target strategy id and extract `debug_log`.
+- The live file log is `logs/trader_mcp.log`; `src/trader_mcp/logging_utils.py::log_event()` writes the same `grid ...` messages there.
+
 ---
 
 ## Interface Stability

+ 67 - 0
strategies/AGENTS.md

@@ -0,0 +1,67 @@
+# Strategies AGENTS
+
+## Purpose
+
+This folder holds strategy modules and their adjacent contract notes.
+
+The code in `strategies/*.py` should stay focused on strategy behavior. Shared mechanics belong in `src/trader_mcp/`.
+
+---
+
+## Shared Rules
+
+- Keep strategy modules narrow: strategy logic, state transitions, and placement decisions.
+- Put shared sizing, balance, fee, and venue-awareness logic in `src/trader_mcp/strategy_context.py` and `src/trader_mcp/strategy_sizing.py`.
+- Treat `StrategyContext` as the authoritative place for venue/account-aware order sizing and balance access.
+- Do not duplicate dust handling, minimum-order checks, or fee math inside a strategy unless there is a strategy-specific reason that cannot live in shared helpers.
+- Keep adjacent `strategies/*.md` files aligned with what the code actually does.
+- Preserve uncertainty in docs when behavior is still provisional. Use `TODO` instead of guessing.
+
+---
+
+## Shared Helper Map
+
+- `StrategyContext.suggest_order_amount()` is the venue/account-aware sizing primitive.
+- `src/trader_mcp/strategy_sizing.py` is the policy layer for quote-sized helpers and balance-target helpers.
+- `StrategyContext.get_account_info()` and `StrategyContext.get_open_orders()` are the primary live inputs for balance and order-shape decisions.
+- `StrategyContext.cancel_all_orders_confirmed()` is the preferred cancel contract when a strategy needs authoritative cleanup semantics.
+
+---
+
+## Grid Trader
+
+### Grid Shape
+
+- `grid_trader` must compare the active order shape against the best possible funded grid.
+- The best possible grid includes both free balances and funds already locked in active orders.
+- The shape check must use the same side-aware funded capacity that seeding uses.
+- A mismatch between the best possible shape and the actual active orders should trigger a full rebuild.
+- Do not treat the grid as paired buy/sell levels. Each side is filled independently.
+
+### Seeding And Rebuilds
+
+- Seeding should place as many levels as are actually fundable on each side.
+- Rebuilds should remain full-grid rebuilds, not partial top-ups.
+- Restart should not suppress mismatch-driven reseed checks.
+- Track and log rebuild reasons clearly in the strategy debug log.
+
+### Sizing Boundary
+
+- `grid_trader` should not implement its own dust or fee policy.
+- If sizing behavior changes, update the shared helpers first and keep the strategy thin.
+- The strategy may ask for a size and may compare funded capacity, but it should not re-encode venue rules.
+
+### Live Trace
+
+- `state["debug_log"]` is the live trace for the strategy.
+- The DB snapshot stores the current state, not a separate event log table.
+
+---
+
+## Future Strategy Sections
+
+- `dumb_trader`: TODO
+- `trend_follower`: TODO
+- `exposure_protector`: TODO
+- any new strategy: document its own shape, sizing, and restart invariants here
+

+ 1 - 1
strategies/grid_trader.md

@@ -38,7 +38,7 @@ Passive, structure-based liquidity strategy.
 - `recenter_max_pct`: Maximum recenter threshold ceiling.
 - `trade_sides`: Intended side selection. `both` is the normal mode; other values are not a separate one-sided grid design and may be constrained by rebuild logic.
 - `dust_collect`: Allows the shared sizing helper to use leftover size more aggressively when a venue minimum would otherwise strand a small remainder.
-- `order_call_delay_ms`: Delay between sequential order placements during seeding or top-up.
+- `order_call_delay_ms`: Delay between sequential order placements during seeding or rebuild.
 - `debug_orders`: Enables order-placement debug logging.
 
 ## Hermes Policy Mapping

+ 116 - 158
strategies/grid_trader.py

@@ -597,8 +597,8 @@ class Strategy(Strategy):
             )
         return True
 
-    def _supported_levels(self, side: str, price: float, min_notional: float, *, balance_total: float | None = None) -> int:
-        if min_notional <= 0 or price <= 0:
+    def _max_fundable_levels(self, side: str, price: float, amount: float, min_notional: float, *, balance_total: float | None = None) -> int:
+        if min_notional <= 0 or price <= 0 or amount <= 0:
             return 0
         safety = 0.995
         fee_rate = self._live_fee_rate()
@@ -607,14 +607,42 @@ class Strategy(Strategy):
             quote_available = self._available_balance(quote)
             self.state["counter_available"] = quote_available
             usable_notional = (quote_available if balance_total is None else balance_total) * safety
-            return max(int(usable_notional / min_notional), 0)
+            max_levels = 0
+            for level_count in range(1, 1000):
+                needed = 0.0
+                for i in range(1, level_count + 1):
+                    level_price = price
+                    if level_price <= 0:
+                        return max_levels
+                    min_size = min_notional / level_price if min_notional > 0 else 0.0
+                    if amount < min_size:
+                        return max_levels
+                    needed += amount * level_price * (1 + fee_rate)
+                if needed <= usable_notional + 1e-9:
+                    max_levels = level_count
+                else:
+                    break
+            return max_levels
 
         base = self._base_symbol()
         base_available = self._available_balance(base)
         self.state["base_available"] = base_available
         usable_base = base_available if balance_total is None else balance_total
-        usable_notional = usable_base * safety * price / (1 + fee_rate)
-        return max(int(usable_notional / min_notional), 0)
+        spendable_base = usable_base * safety
+        max_levels = 0
+        for level_count in range(1, 1000):
+            needed = amount * level_count
+            if amount < (min_notional / price):
+                return max_levels
+            if needed <= spendable_base + 1e-9:
+                max_levels = level_count
+            else:
+                break
+        return max_levels
+
+    def _supported_levels(self, side: str, price: float, min_notional: float, *, balance_total: float | None = None) -> int:
+        amount = self._suggest_amount(side, price, int(self.config.get("grid_levels", 6) or 6), min_notional)
+        return self._max_fundable_levels(side, price, amount, min_notional, balance_total=balance_total)
 
     def _side_allowed(self, side: str) -> bool:
         selected = str(self.config.get("trade_sides", "both") or "both").strip().lower()
@@ -644,6 +672,15 @@ class Strategy(Strategy):
             order_size=0.0,
         )
 
+    def _grid_extreme_price(self, center: float, side: str, levels: int) -> float:
+        step_profile = self._effective_grid_steps(center)
+        step = float(step_profile.get(side) or step_profile.get("base") or 0.0)
+        if center <= 0 or levels <= 0 or step <= 0:
+            return center
+        if side == "buy":
+            return round(center * (1 - (step * levels)), 8)
+        return round(center * (1 + (step * levels)), 8)
+
     def _target_levels_for_side(self, side: str, center: float, live_orders: list[dict], balance_total: float, expected_levels: int, min_notional: float) -> int:
         if expected_levels <= 0 or center <= 0 or balance_total <= 0:
             return 0
@@ -663,8 +700,9 @@ class Strategy(Strategy):
             if amounts:
                 amount = sum(amounts) / len(amounts)
 
-        if amount <= 0:
-            amount = self._suggest_amount(side, center, max(expected_levels, 1), min_notional)
+        reference_price = self._grid_extreme_price(center, side, expected_levels)
+        seeded_amount = self._suggest_amount(side, reference_price, max(expected_levels, 1), min_notional)
+        amount = max(amount, seeded_amount)
         if amount <= 0:
             return 0
 
@@ -672,32 +710,48 @@ class Strategy(Strategy):
         safety = 0.995
         step_profile = self._effective_grid_steps(center)
         step = float(step_profile.get(side) or step_profile.get("base") or 0.0)
-        spendable_total = balance_total * safety
-
-        for level_count in range(expected_levels, 0, -1):
-            feasible = True
-            if side == "buy":
-                needed = 0.0
-                for i in range(1, level_count + 1):
-                    level_price = center * (1 - (step * i))
-                    min_size = (min_notional / level_price) if level_price > 0 and min_notional > 0 else 0.0
-                    if amount < min_size:
-                        feasible = False
-                        break
-                    needed += amount * level_price * (1 + fee_rate)
-            else:
-                needed = amount * level_count
-                for i in range(1, level_count + 1):
-                    level_price = center * (1 + (step * i))
-                    min_size = (min_notional / level_price) if level_price > 0 and min_notional > 0 else 0.0
-                    if amount < min_size:
-                        feasible = False
-                        break
+        current_levels = len(side_orders)
+        free_balance = max(balance_total - sum(
+            float(order.get("price") or 0.0) * float(order.get("amount") or 0.0)
+            for order in side_orders
+        ) if side == "buy" else balance_total - sum(
+            float(order.get("amount") or 0.0)
+            for order in side_orders
+        ), 0.0)
+        spendable_free = free_balance * safety
+        additional_levels = 0
 
-            if feasible and needed <= spendable_total + 1e-9:
-                return level_count
+        if side == "buy":
+            per_level_quote = 0.0
+            for i in range(1, expected_levels + 1):
+                level_price = center * (1 - (step * (current_levels + i)))
+                if level_price <= 0:
+                    break
+                min_size = (min_notional / level_price) if min_notional > 0 else 0.0
+                if amount < min_size:
+                    break
+                level_quote = amount * level_price * (1 + fee_rate)
+                if per_level_quote <= 0:
+                    per_level_quote = level_quote
+                if (additional_levels + 1) * per_level_quote <= spendable_free + 1e-9:
+                    additional_levels += 1
+                else:
+                    break
+        else:
+            per_level_base = max(amount, 0.0)
+            for i in range(1, expected_levels + 1):
+                level_price = center * (1 + (step * (current_levels + i)))
+                if level_price <= 0:
+                    break
+                min_size = (min_notional / level_price) if min_notional > 0 else 0.0
+                if amount < min_size:
+                    break
+                if (additional_levels + 1) * per_level_base <= spendable_free + 1e-9:
+                    additional_levels += 1
+                else:
+                    break
 
-        return 0
+        return min(expected_levels, current_levels + additional_levels)
 
     def _place_grid(self, center: float) -> None:
         center = self._maybe_refresh_center(center)
@@ -716,142 +770,46 @@ class Strategy(Strategy):
                 return result.get("bitstamp_order_id") or result.get("order_id") or result.get("id") or result.get("client_order_id")
             return None
 
+        buy_amount = self._suggest_amount("buy", self._grid_extreme_price(center, "buy", levels), max(levels, 1), min_notional)
+        sell_amount = self._suggest_amount("sell", self._grid_extreme_price(center, "sell", levels), max(levels, 1), min_notional)
         buy_levels = min(levels, self._supported_levels("buy", center, min_notional)) if (mode == "active" and self._side_allowed("buy")) else (levels if self._side_allowed("buy") else 0)
         sell_levels = min(levels, self._supported_levels("sell", center, min_notional)) if (mode == "active" and self._side_allowed("sell")) else (levels if self._side_allowed("sell") else 0)
-        buy_amount = self._suggest_amount("buy", center, max(buy_levels, 1), min_notional)
-        sell_amount = self._suggest_amount("sell", center, max(sell_levels, 1), min_notional)
-
-        for i in range(1, levels + 1):
-            buy_price = round(center * (1 - (buy_step * i)), 8)
-            sell_price = round(center * (1 + (sell_step * i)), 8)
-            if mode != "active":
-                orders.append({"side": "buy", "price": buy_price, "amount": buy_amount, "result": {"simulated": True}})
-                orders.append({"side": "sell", "price": sell_price, "amount": sell_amount, "result": {"simulated": True}})
-                self._log(f"plan level {i}: buy {buy_price} amount {buy_amount:.6g} / sell {sell_price} amount {sell_amount:.6g}")
-                continue
-
-            if i > buy_levels and i > sell_levels:
-                self._log(f"skip level {i}: no capacity on either side")
-                continue
-
-            min_size_buy = (min_notional / buy_price) if buy_price > 0 else 0.0
-            min_size_sell = (min_notional / sell_price) if sell_price > 0 else 0.0
-
-            buy_error = None
-            sell_error = None
-            placed_any = False
-
-            if i <= buy_levels and buy_amount >= min_size_buy:
-                try:
-                    buy = self.context.place_order(side="buy", order_type="limit", amount=buy_amount, price=buy_price, market=market)
-                    orders.append({"side": "buy", "price": buy_price, "amount": buy_amount, "result": buy})
-                    buy_id = _capture_order_id(buy)
-                    if buy_id is not None:
-                        order_ids.append(str(buy_id))
-                    placed_any = True
-                except Exception as exc:  # best effort for first draft
-                    buy_error = str(exc)
-                    self.state["last_error"] = buy_error
-                    self._log(f"seed level {i} buy failed: {exc}")
-
-            if i <= sell_levels and sell_amount >= min_size_sell:
-                try:
-                    sell = self.context.place_order(side="sell", order_type="limit", amount=sell_amount, price=sell_price, market=market)
-                    orders.append({"side": "sell", "price": sell_price, "amount": sell_amount, "result": sell})
-                    sell_id = _capture_order_id(sell)
-                    if sell_id is not None:
-                        order_ids.append(str(sell_id))
-                    placed_any = True
-                except Exception as exc:  # best effort for first draft
-                    sell_error = str(exc)
-                    self.state["last_error"] = sell_error
-                    self._log(f"seed level {i} sell failed: {exc}")
-
-            if placed_any:
-                if buy_error or sell_error:
-                    self._log(
-                        f"seed level {i} partial success: buy_error={buy_error or 'none'} sell_error={sell_error or 'none'}"
-                    )
-                else:
-                    self._log(f"seed level {i}: buy {buy_price} amount {buy_amount:.6g} / sell {sell_price} amount {sell_amount:.6g}")
-                delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
-                if delay > 0:
-                    time.sleep(delay)
-                self._refresh_balance_snapshot()
-            else:
-                if buy_error or sell_error:
-                    self._log(
-                        f"seed level {i} failed: buy_error={buy_error or 'none'} sell_error={sell_error or 'none'}"
-                    )
-                else:
-                    self._log(f"seed level {i} skipped: no order placed")
-                continue
-
-        self.state["orders"] = orders
-        self.state["order_ids"] = order_ids
-        self.state["last_action"] = "seeded grid"
-        self._set_grid_refresh_pause()
-
-    def _top_up_grid(self, center: float, live_orders: list[dict]) -> list[str]:
-        center = self._maybe_refresh_center(center)
-        levels = int(self.config.get("grid_levels", 6) or 6)
-        if levels <= 0 or center <= 0:
-            return []
 
-        min_notional = float(self.context.minimum_order_value or 0.0)
-        step_profile = self._effective_grid_steps(center)
-        market = self._market_symbol()
-        placed: list[str] = []
-
-        live_by_side: dict[str, int] = {"buy": 0, "sell": 0}
-        for order in live_orders:
-            if not isinstance(order, dict):
-                continue
-            side = str(order.get("side") or "").lower()
-            if side in live_by_side:
-                live_by_side[side] += 1
-
-        for side in ("buy", "sell"):
-            live_count = live_by_side.get(side, 0)
-            max_levels = self._supported_levels(side, center, min_notional)
-            target_levels = min(levels, max_levels)
-            if target_levels <= live_count:
-                continue
-
-            amount = self._suggest_amount(side, center, max(target_levels, 1), min_notional)
-            side_step = float(step_profile.get(side) or step_profile.get("base") or 0.0)
-            for i in range(live_count + 1, target_levels + 1):
-                price = round(center * (1 - (side_step * i)) if side == "buy" else center * (1 + (side_step * i)), 8)
+        def _place_side(side: str, side_levels: int, side_amount: float, step: float) -> None:
+            if side_levels <= 0 or side_amount <= 0:
+                return
+            for i in range(1, side_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 amount < min_size:
-                    self._log_decision(
-                        f"skip top-up {side} level {i}",
-                        reason="below_min_size",
-                        amount=f"{amount:.6g}",
-                        min_size=f"{min_size:.6g}",
-                        price=price,
-                    )
+                if side_amount < min_size:
+                    self._log(f"seed level {i} {side} skipped: below minimum size")
+                    continue
+                if mode != "active":
+                    orders.append({"side": side, "price": price, "amount": side_amount, "result": {"simulated": True}})
+                    self._log(f"plan level {i}: {side} {price} amount {side_amount:.6g}")
                     continue
                 try:
-                    self._log_decision(f"top-up {side} level {i}", price=price, amount=f"{amount:.6g}")
-                    result = self.context.place_order(side=side, order_type="limit", amount=amount, price=price, market=market)
-                    order_id = None
-                    if isinstance(result, dict):
-                        order_id = result.get("bitstamp_order_id") or result.get("order_id") or result.get("id") or result.get("client_order_id")
+                    result = self.context.place_order(side=side, order_type="limit", amount=side_amount, price=price, market=market)
+                    orders.append({"side": side, "price": price, "amount": side_amount, "result": result})
+                    order_id = _capture_order_id(result)
                     if order_id is not None:
-                        placed.append(str(order_id))
-                    live_orders.append({"side": side, "price": price, "amount": amount, "result": result})
+                        order_ids.append(str(order_id))
+                    self._log(f"seed level {i}: {side} {price} amount {side_amount:.6g}")
+                    delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
+                    if delay > 0:
+                        time.sleep(delay)
                     self._refresh_balance_snapshot()
-                except Exception as exc:
+                except Exception as exc:  # best effort for first draft
                     self.state["last_error"] = str(exc)
-                    self._log_decision(f"top-up {side} level {i} failed", error=str(exc))
-                    continue
+                    self._log(f"seed level {i} {side} failed: {exc}")
 
-                delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
-                if delay > 0:
-                    time.sleep(delay)
+        _place_side("buy", buy_levels, buy_amount, buy_step)
+        _place_side("sell", sell_levels, sell_amount, sell_step)
 
-        return placed
+        self.state["orders"] = orders
+        self.state["order_ids"] = order_ids
+        self.state["last_action"] = "seeded grid"
+        self._set_grid_refresh_pause()
 
     def _current_market_anchor(self, fallback: float = 0.0) -> float:
         try:
@@ -1134,7 +1092,7 @@ class Strategy(Strategy):
 
         if balance_shape_inconclusive:
             self._log("balance info not conclusive, skipping grid shape rebuild checks this tick")
-        elif grid_not_as_expected and can_make_better and not self._grid_refresh_paused():
+        elif grid_not_as_expected and can_make_better:
             if rebuild_done:
                 return {"action": "hold", "price": price}
             self._log(
@@ -1174,7 +1132,7 @@ class Strategy(Strategy):
             self._log(f"{'seeded' if mode == 'active' else 'planned'} grid at {price}")
             return {"action": "seed" if mode == "active" else "plan", "price": price}
 
-        if not balance_shape_inconclusive and ((open_order_count == 0) or missing_tracked) and not self._grid_refresh_paused():
+        if not balance_shape_inconclusive and ((open_order_count == 0) or missing_tracked):
             if rebuild_done:
                 return {"action": "hold", "price": price}
             self._log("missing tracked order(s), rebuilding full grid")