|
@@ -9,7 +9,7 @@ from src.trader_mcp.logging_utils import log_event
|
|
|
|
|
|
|
|
class Strategy(Strategy):
|
|
class Strategy(Strategy):
|
|
|
LABEL = "Grid Trader"
|
|
LABEL = "Grid Trader"
|
|
|
- TICK_MINUTES = 0.2
|
|
|
|
|
|
|
+ TICK_MINUTES = 1.0
|
|
|
CONFIG_SCHEMA = {
|
|
CONFIG_SCHEMA = {
|
|
|
"grid_levels": {"type": "int", "default": 6, "min": 1, "max": 20},
|
|
"grid_levels": {"type": "int", "default": 6, "min": 1, "max": 20},
|
|
|
"grid_step_pct": {"type": "float", "default": 0.012, "min": 0.001, "max": 0.1},
|
|
"grid_step_pct": {"type": "float", "default": 0.012, "min": 0.001, "max": 0.1},
|
|
@@ -26,6 +26,7 @@ class Strategy(Strategy):
|
|
|
"fee_rate": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.05},
|
|
"fee_rate": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.05},
|
|
|
"trade_sides": {"type": "string", "default": "both"},
|
|
"trade_sides": {"type": "string", "default": "both"},
|
|
|
"max_notional_per_order": {"type": "float", "default": 0.0, "min": 0.0},
|
|
"max_notional_per_order": {"type": "float", "default": 0.0, "min": 0.0},
|
|
|
|
|
+ "dust_collect": {"type": "bool", "default": False},
|
|
|
"order_call_delay_ms": {"type": "int", "default": 250, "min": 0, "max": 10000},
|
|
"order_call_delay_ms": {"type": "int", "default": 250, "min": 0, "max": 10000},
|
|
|
"enable_trend_guard": {"type": "bool", "default": True},
|
|
"enable_trend_guard": {"type": "bool", "default": True},
|
|
|
"trend_guard_reversal_max": {"type": "float", "default": 0.25, "min": 0.0, "max": 1.0},
|
|
"trend_guard_reversal_max": {"type": "float", "default": 0.25, "min": 0.0, "max": 1.0},
|
|
@@ -46,6 +47,8 @@ class Strategy(Strategy):
|
|
|
"trend_guard_active": {"type": "bool", "default": False},
|
|
"trend_guard_active": {"type": "bool", "default": False},
|
|
|
"regimes_updated_at": {"type": "string", "default": ""},
|
|
"regimes_updated_at": {"type": "string", "default": ""},
|
|
|
"account_snapshot_updated_at": {"type": "string", "default": ""},
|
|
"account_snapshot_updated_at": {"type": "string", "default": ""},
|
|
|
|
|
+ "last_balance_log_signature": {"type": "string", "default": ""},
|
|
|
|
|
+ "last_balance_log_at": {"type": "string", "default": ""},
|
|
|
"grid_refresh_pending_until": {"type": "string", "default": ""},
|
|
"grid_refresh_pending_until": {"type": "string", "default": ""},
|
|
|
"mismatch_ticks": {"type": "int", "default": 0},
|
|
"mismatch_ticks": {"type": "int", "default": 0},
|
|
|
"recovery_cooldown_until": {"type": "string", "default": ""},
|
|
"recovery_cooldown_until": {"type": "string", "default": ""},
|
|
@@ -66,6 +69,8 @@ class Strategy(Strategy):
|
|
|
"trend_guard_active": False,
|
|
"trend_guard_active": False,
|
|
|
"regimes_updated_at": "",
|
|
"regimes_updated_at": "",
|
|
|
"account_snapshot_updated_at": "",
|
|
"account_snapshot_updated_at": "",
|
|
|
|
|
+ "last_balance_log_signature": "",
|
|
|
|
|
+ "last_balance_log_at": "",
|
|
|
"grid_refresh_pending_until": "",
|
|
"grid_refresh_pending_until": "",
|
|
|
"mismatch_ticks": 0,
|
|
"mismatch_ticks": 0,
|
|
|
"recovery_cooldown_until": "",
|
|
"recovery_cooldown_until": "",
|
|
@@ -79,6 +84,12 @@ class Strategy(Strategy):
|
|
|
self.state = state
|
|
self.state = state
|
|
|
log_event("grid", message)
|
|
log_event("grid", message)
|
|
|
|
|
|
|
|
|
|
+ def _log_decision(self, action: str, **fields) -> None:
|
|
|
|
|
+ parts = [action]
|
|
|
|
|
+ for key, value in fields.items():
|
|
|
|
|
+ parts.append(f"{key}={value}")
|
|
|
|
|
+ self._log(", ".join(parts))
|
|
|
|
|
+
|
|
|
def _set_grid_refresh_pause(self, seconds: float = 30.0) -> None:
|
|
def _set_grid_refresh_pause(self, seconds: float = 30.0) -> None:
|
|
|
self.state["grid_refresh_pending_until"] = (datetime.now(timezone.utc).timestamp() + max(seconds, 0.0))
|
|
self.state["grid_refresh_pending_until"] = (datetime.now(timezone.utc).timestamp() + max(seconds, 0.0))
|
|
|
|
|
|
|
@@ -141,8 +152,8 @@ class Strategy(Strategy):
|
|
|
return fallback, fallback
|
|
return fallback, fallback
|
|
|
|
|
|
|
|
def _live_fee_rate(self) -> float:
|
|
def _live_fee_rate(self) -> float:
|
|
|
- maker, _taker = self._live_fee_rates()
|
|
|
|
|
- return maker
|
|
|
|
|
|
|
+ _maker, taker = self._live_fee_rates()
|
|
|
|
|
+ return taker
|
|
|
|
|
|
|
|
def _mode(self) -> str:
|
|
def _mode(self) -> str:
|
|
|
return getattr(self.context, "mode", "active") or "active"
|
|
return getattr(self.context, "mode", "active") or "active"
|
|
@@ -286,6 +297,30 @@ class Strategy(Strategy):
|
|
|
if asset == str(quote).upper():
|
|
if asset == str(quote).upper():
|
|
|
self.state["counter_available"] = available
|
|
self.state["counter_available"] = available
|
|
|
self.state["account_snapshot_updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
self.state["account_snapshot_updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
|
|
|
+ signature = f"{base}:{self.state.get('base_available', 0.0):.8f}|{quote}:{self.state.get('counter_available', 0.0):.8f}"
|
|
|
|
|
+ last_signature = str(self.state.get("last_balance_log_signature") or "")
|
|
|
|
|
+ last_logged_at = str(self.state.get("last_balance_log_at") or "")
|
|
|
|
|
+ now_iso = self.state["account_snapshot_updated_at"]
|
|
|
|
|
+ should_log = signature != last_signature or not last_logged_at
|
|
|
|
|
+ if not should_log:
|
|
|
|
|
+ try:
|
|
|
|
|
+ from datetime import datetime as _dt
|
|
|
|
|
+
|
|
|
|
|
+ elapsed = (_dt.fromisoformat(now_iso) - _dt.fromisoformat(last_logged_at)).total_seconds()
|
|
|
|
|
+ should_log = elapsed >= 60
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ should_log = True
|
|
|
|
|
+ if should_log:
|
|
|
|
|
+ self.state["last_balance_log_signature"] = signature
|
|
|
|
|
+ self.state["last_balance_log_at"] = now_iso
|
|
|
|
|
+ self._log_decision(
|
|
|
|
|
+ "balance snapshot",
|
|
|
|
|
+ base=base,
|
|
|
|
|
+ base_available=f"{self.state.get('base_available', 0.0):.6g}",
|
|
|
|
|
+ quote=quote,
|
|
|
|
|
+ quote_available=f"{self.state.get('counter_available', 0.0):.6g}",
|
|
|
|
|
+ updated_at=now_iso,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
def _supported_levels(self, side: str, price: float, min_notional: float) -> int:
|
|
def _supported_levels(self, side: str, price: float, min_notional: float) -> int:
|
|
|
if min_notional <= 0 or price <= 0:
|
|
if min_notional <= 0 or price <= 0:
|
|
@@ -330,8 +365,9 @@ class Strategy(Strategy):
|
|
|
if levels <= 0 or price <= 0:
|
|
if levels <= 0 or price <= 0:
|
|
|
return 0.0
|
|
return 0.0
|
|
|
safety = 0.995
|
|
safety = 0.995
|
|
|
- fee_rate = self._live_fee_rate()
|
|
|
|
|
|
|
+ fee_rate = max(self._live_fee_rate(), 0.0)
|
|
|
max_notional = float(self.config.get("max_notional_per_order", 0.0) or 0.0)
|
|
max_notional = float(self.config.get("max_notional_per_order", 0.0) or 0.0)
|
|
|
|
|
+ dust_collect = bool(self.config.get("dust_collect", False))
|
|
|
manual = float(self.config.get("order_size", 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
|
|
min_amount = (min_notional / price) if min_notional > 0 else 0.0
|
|
|
if side == "buy":
|
|
if side == "buy":
|
|
@@ -339,30 +375,37 @@ class Strategy(Strategy):
|
|
|
quote_available = self._available_balance(quote)
|
|
quote_available = self._available_balance(quote)
|
|
|
self.state["counter_available"] = quote_available
|
|
self.state["counter_available"] = quote_available
|
|
|
spendable_quote = quote_available * safety
|
|
spendable_quote = quote_available * safety
|
|
|
- max_affordable = spendable_quote / (price * (1 + fee_rate))
|
|
|
|
|
- if max_affordable < min_amount:
|
|
|
|
|
|
|
+ quote_cap = spendable_quote if (dust_collect or max_notional <= 0) else min(spendable_quote, max_notional)
|
|
|
|
|
+ if quote_cap <= 0:
|
|
|
|
|
+ return 0.0
|
|
|
|
|
+ per_order_quote = quote_cap / max(levels, 1)
|
|
|
|
|
+ min_quote_needed = min_notional * (1 + fee_rate)
|
|
|
|
|
+ if per_order_quote < min_quote_needed:
|
|
|
return 0.0
|
|
return 0.0
|
|
|
- amount = min(spendable_quote / (max(levels, 1) * price * (1 + fee_rate)), max_affordable)
|
|
|
|
|
|
|
+ amount = per_order_quote / (price * (1 + fee_rate))
|
|
|
else:
|
|
else:
|
|
|
base = self._base_symbol()
|
|
base = self._base_symbol()
|
|
|
base_available = self._available_balance(base)
|
|
base_available = self._available_balance(base)
|
|
|
self.state["base_available"] = base_available
|
|
self.state["base_available"] = base_available
|
|
|
- spendable_base = (base_available * safety) / (1 + fee_rate)
|
|
|
|
|
- max_affordable = spendable_base
|
|
|
|
|
- if max_affordable < min_amount:
|
|
|
|
|
|
|
+ spendable_base = base_available * safety
|
|
|
|
|
+ if not dust_collect and max_notional > 0 and price > 0:
|
|
|
|
|
+ spendable_base = min(spendable_base, max_notional / price)
|
|
|
|
|
+ if spendable_base <= 0:
|
|
|
return 0.0
|
|
return 0.0
|
|
|
- amount = min(spendable_base / max(levels, 1), max_affordable)
|
|
|
|
|
|
|
+ per_order_base = spendable_base / max(levels, 1)
|
|
|
|
|
+ if per_order_base < min_amount:
|
|
|
|
|
+ return 0.0
|
|
|
|
|
+ amount = per_order_base
|
|
|
|
|
|
|
|
- amount = max(amount, min_amount * 1.05)
|
|
|
|
|
- if max_notional > 0 and price > 0:
|
|
|
|
|
- amount = min(amount, max_notional / (price * (1 + fee_rate)))
|
|
|
|
|
|
|
+ if amount < min_amount:
|
|
|
|
|
+ return 0.0
|
|
|
if manual > 0:
|
|
if manual > 0:
|
|
|
- if manual >= min_amount:
|
|
|
|
|
- amount = min(amount, manual)
|
|
|
|
|
- else:
|
|
|
|
|
|
|
+ if manual < min_amount:
|
|
|
self._log(
|
|
self._log(
|
|
|
f"manual order_size below minimum: order_size={manual:.6g} min_amount={min_amount:.6g} price={price} min_notional={min_notional}"
|
|
f"manual order_size below minimum: order_size={manual:.6g} min_amount={min_amount:.6g} price={price} min_notional={min_notional}"
|
|
|
)
|
|
)
|
|
|
|
|
+ return 0.0
|
|
|
|
|
+ amount = min(amount, manual)
|
|
|
return max(amount, 0.0)
|
|
return max(amount, 0.0)
|
|
|
|
|
|
|
|
def _place_grid(self, center: float) -> None:
|
|
def _place_grid(self, center: float) -> None:
|
|
@@ -449,8 +492,13 @@ class Strategy(Strategy):
|
|
|
max_affordable_amount = (quote_available * safety) / (center * (1 + fee_rate)) if center > 0 else 0.0
|
|
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
|
|
min_amount = (min_notional / center) if center > 0 and min_notional > 0 else 0.0
|
|
|
if max_affordable_amount < min_amount:
|
|
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}"
|
|
|
|
|
|
|
+ self._log_decision(
|
|
|
|
|
+ f"skip side {side}",
|
|
|
|
|
+ reason="insufficient_counter_balance",
|
|
|
|
|
+ quote=f"{quote_available:.6g}",
|
|
|
|
|
+ max_affordable_amount=f"{max_affordable_amount:.6g}",
|
|
|
|
|
+ min_amount=f"{min_amount:.6g}",
|
|
|
|
|
+ fee_rate=f"{fee_rate:.6g}",
|
|
|
)
|
|
)
|
|
|
return
|
|
return
|
|
|
amount = min(amount, max_affordable_amount)
|
|
amount = min(amount, max_affordable_amount)
|
|
@@ -461,33 +509,38 @@ class Strategy(Strategy):
|
|
|
side_levels = 1
|
|
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"side {side} restored to 1 level because amount clears minimum: amount={amount:.6g} min_amount={min_amount:.6g}")
|
|
|
self._log(
|
|
self._log(
|
|
|
- f"prepare side {side}: market={market} center={center} levels={side_levels} amount={amount:.6g} min_notional={min_notional} existing_ids={order_ids}"
|
|
|
|
|
|
|
+ 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(start_level, levels + 1):
|
|
for i in range(start_level, levels + 1):
|
|
|
price = round(center * (1 - (step * i)) if side == "buy" else center * (1 + (step * i)), 8)
|
|
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
|
|
min_size = (min_notional / price) if price > 0 else 0.0
|
|
|
if i > side_levels or amount < min_size:
|
|
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}"
|
|
|
|
|
|
|
+ self._log_decision(
|
|
|
|
|
+ f"skip side {side} level {i}",
|
|
|
|
|
+ reason="below_min_size",
|
|
|
|
|
+ amount=f"{amount:.6g}",
|
|
|
|
|
+ min_size=f"{min_size:.6g}",
|
|
|
|
|
+ min_notional=min_notional,
|
|
|
|
|
+ price=price,
|
|
|
)
|
|
)
|
|
|
continue
|
|
continue
|
|
|
try:
|
|
try:
|
|
|
- self._log(f"place side {side} level {i}: price={price} amount={amount:.6g}")
|
|
|
|
|
|
|
+ self._log_decision(f"place side {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)
|
|
result = self.context.place_order(side=side, order_type="limit", amount=amount, price=price, market=market)
|
|
|
status = None
|
|
status = None
|
|
|
order_id = None
|
|
order_id = None
|
|
|
if isinstance(result, dict):
|
|
if isinstance(result, dict):
|
|
|
status = result.get("status")
|
|
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")
|
|
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}")
|
|
|
|
|
|
|
+ self._log_decision(f"place side {side} level {i} result", status=status, order_id=order_id)
|
|
|
orders.append({"side": side, "price": price, "amount": amount, "result": result})
|
|
orders.append({"side": side, "price": price, "amount": amount, "result": result})
|
|
|
if order_id is not None:
|
|
if order_id is not None:
|
|
|
order_ids.append(str(order_id))
|
|
order_ids.append(str(order_id))
|
|
|
- self._log(f"seed side {side} level {i}: {price} amount {amount:.6g}")
|
|
|
|
|
|
|
+ self._log_decision(f"seed side {side} level {i}", price=price, amount=f"{amount:.6g}")
|
|
|
except Exception as exc:
|
|
except Exception as exc:
|
|
|
self.state["last_error"] = str(exc)
|
|
self.state["last_error"] = str(exc)
|
|
|
- self._log(f"seed side {side} level {i} failed: {exc}")
|
|
|
|
|
|
|
+ self._log_decision(f"seed side {side} level {i} failed", error=str(exc))
|
|
|
continue
|
|
continue
|
|
|
|
|
|
|
|
delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
|
|
delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
|
|
@@ -496,7 +549,7 @@ class Strategy(Strategy):
|
|
|
|
|
|
|
|
self.state["orders"] = orders
|
|
self.state["orders"] = orders
|
|
|
self.state["order_ids"] = order_ids
|
|
self.state["order_ids"] = order_ids
|
|
|
- self._log(f"side {side} placement complete: tracked_ids={order_ids}")
|
|
|
|
|
|
|
+ self._log_decision(f"side {side} placement complete", tracked_ids=order_ids)
|
|
|
self._set_grid_refresh_pause()
|
|
self._set_grid_refresh_pause()
|
|
|
|
|
|
|
|
def _top_up_missing_levels(self, center: float, live_orders: list[dict]) -> None:
|
|
def _top_up_missing_levels(self, center: float, live_orders: list[dict]) -> None:
|