341 lines
14 KiB
Plaintext
341 lines
14 KiB
Plaintext
"""
|
||
Margin Grid Bot — Grid engine.
|
||
Builds a grid of BUY/SELL orders around a center price.
|
||
Supports both real trading and demo simulation.
|
||
"""
|
||
|
||
import math
|
||
import time
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timezone
|
||
from typing import Optional
|
||
|
||
@dataclass
|
||
class GridLevel:
|
||
level_id: int # negative=below center (BUY), positive=above center (SELL)
|
||
price: float
|
||
side: str # "BUY" or "SELL"
|
||
qty: float
|
||
step_num: int # 1..N from center
|
||
filled: bool = False
|
||
|
||
def to_dict(self):
|
||
return {
|
||
"level_id": self.level_id,
|
||
"price": round(self.price, 8),
|
||
"side": self.side,
|
||
"qty": self.qty,
|
||
"step_num": self.step_num,
|
||
"filled": self.filled,
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class GridState:
|
||
center_price: float = 0.0
|
||
step_percent: float = 0.005
|
||
step_price: float = 0.0
|
||
levels: list = field(default_factory=list)
|
||
total_levels: int = 0
|
||
take_profit_percent: float = 0.5
|
||
symbol: str = "USDC_BTC"
|
||
mode: str = "demo" # "demo" or "live"
|
||
last_update: float = 0.0
|
||
|
||
|
||
class GridEngine:
|
||
"""
|
||
Grid trading engine.
|
||
|
||
Buy orders below center: center - n×step (level_id: -1, -2, ... -N)
|
||
Sell orders above center: center + n×step (level_id: +1, +2, ... +N)
|
||
|
||
Each round-trip = profit = step × qty.
|
||
When a level is filled → check if total PnL >= take_profit → close position.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
levels: int = 10,
|
||
step_percent: float = 0.005,
|
||
take_profit_percent: float = 0.5,
|
||
symbol: str = "USDC_BTC",
|
||
mode: str = "demo",
|
||
):
|
||
self.levels_count = levels
|
||
self.step_percent = step_percent
|
||
self.take_profit_percent = take_profit_percent
|
||
self.symbol = symbol
|
||
self.mode = mode
|
||
|
||
def calc_step(self, center_price: float) -> float:
|
||
return center_price * self.step_percent
|
||
|
||
def get_grid_levels(
|
||
self,
|
||
center_price: float,
|
||
base_qty: float = 0.001,
|
||
buy_center: float | None = None,
|
||
sell_center: float | None = None,
|
||
) -> list[GridLevel]:
|
||
"""Build grid levels.
|
||
|
||
buy_center - anchor for BUY levels (default: center_price)
|
||
sell_center - anchor for SELL levels (default: center_price)
|
||
If they equal center_price, behaviour matches the legacy symmetric grid.
|
||
If they differ, BUY is built around buy_center and SELL around sell_center.
|
||
Used by Kronos logic: at bias=up the BUY anchor stays near current_price (aggressive),
|
||
the SELL anchor is shifted toward the forecasted price; at bias=down - mirrored.
|
||
"""
|
||
if buy_center is None:
|
||
buy_center = center_price
|
||
if sell_center is None:
|
||
sell_center = center_price
|
||
step = self.calc_step(center_price)
|
||
levels = []
|
||
|
||
for n in range(1, self.levels_count + 1):
|
||
offset = n * step
|
||
|
||
buy_price = buy_center - offset
|
||
if buy_price <= 0:
|
||
continue
|
||
levels.append(GridLevel(
|
||
level_id=-n,
|
||
price=round(buy_price, 8),
|
||
side="BUY",
|
||
qty=base_qty,
|
||
step_num=n,
|
||
))
|
||
|
||
sell_price = sell_center + offset
|
||
levels.append(GridLevel(
|
||
level_id=+n,
|
||
price=round(sell_price, 8),
|
||
side="SELL",
|
||
qty=base_qty,
|
||
step_num=n,
|
||
))
|
||
|
||
# Sort: BUY first (negatives), then SELL (positives)
|
||
levels.sort(key=lambda x: x.level_id)
|
||
return levels
|
||
|
||
def build_state(self, current_price: float, base_qty: float = 0.001) -> GridState:
|
||
levels = self.get_grid_levels(current_price, base_qty)
|
||
step = self.calc_step(current_price)
|
||
|
||
return GridState(
|
||
center_price=current_price,
|
||
step_percent=self.step_percent,
|
||
step_price=step,
|
||
levels=[l.to_dict() for l in levels],
|
||
total_levels=len(levels),
|
||
take_profit_percent=self.take_profit_percent,
|
||
symbol=self.symbol,
|
||
mode=self.mode,
|
||
last_update=0.0,
|
||
)
|
||
|
||
def should_rebuild(self, current_price: float, center_price: float, threshold: float = 3) -> bool:
|
||
"""Rebuild grid if price moved by threshold levels."""
|
||
step = self.calc_step(center_price)
|
||
if step == 0:
|
||
return False
|
||
levels_moved = abs(current_price - center_price) / step
|
||
return levels_moved >= threshold
|
||
|
||
|
||
@dataclass
|
||
class DemoPosition:
|
||
entry_price: float
|
||
qty: float
|
||
side: str # "BUY" or "SELL"
|
||
opened_at: float = 0.0
|
||
|
||
def current_value(self, current_price: float) -> float:
|
||
if self.side == "BUY":
|
||
return self.qty * current_price
|
||
else:
|
||
return self.qty * (2 * self.entry_price - current_price)
|
||
|
||
def pnl_percent(self, current_price: float) -> float:
|
||
if self.side == "BUY":
|
||
return (current_price - self.entry_price) / self.entry_price * 100
|
||
else:
|
||
return (self.entry_price - current_price) / self.entry_price * 100
|
||
|
||
|
||
class DemoSimulator:
|
||
"""
|
||
Simulates trading on a virtual wallet of DEMO_START_BALANCE USDC.
|
||
Uses real market prices from Tradernet.
|
||
"""
|
||
|
||
def __init__(self, start_balance: float = 100.0):
|
||
self.start_balance = start_balance
|
||
self.balance = start_balance # USDC
|
||
self.position: Optional[DemoPosition] = None
|
||
self.orders: list = [] # simulated open orders
|
||
self.trade_log: list = []
|
||
|
||
def reset(self):
|
||
self.balance = self.start_balance
|
||
self.position = None
|
||
self.orders = []
|
||
self.trade_log = []
|
||
|
||
def place_buy_order(self, price: float, qty: float, level_id: int):
|
||
"""Simulate placing a BUY limit order.
|
||
Auto-shrinks qty if balance is insufficient for full size.
|
||
Min qty = 0.0001 BTC (Tradernet minimum lot for BTC-USDT).
|
||
"""
|
||
min_qty = 0.0001
|
||
cost = price * qty
|
||
if self.balance >= cost:
|
||
# Full size fits
|
||
self.balance -= cost
|
||
self.orders.append({"side": "BUY", "price": price, "qty": qty, "level_id": level_id, "filled": False})
|
||
return True
|
||
# Try to fit a smaller qty into available balance
|
||
affordable_qty = round(self.balance / price, 8) if price > 0 else 0
|
||
if affordable_qty >= min_qty:
|
||
cost = price * affordable_qty
|
||
self.balance -= cost
|
||
self.orders.append({"side": "BUY", "price": price, "qty": affordable_qty, "level_id": level_id, "filled": False})
|
||
return True
|
||
return False
|
||
|
||
def place_sell_order(self, price: float, qty: float, level_id: int):
|
||
"""Simulate placing a SELL limit order."""
|
||
if self.position and self.position.side == "BUY" and self.position.qty >= qty:
|
||
self.orders.append({"side": "SELL", "price": price, "qty": qty, "level_id": level_id, "filled": False})
|
||
return True
|
||
return False
|
||
|
||
def check_fill_buy(self, current_price: float) -> list[dict]:
|
||
"""Check if any BUY orders should be filled at current_price."""
|
||
filled = []
|
||
for order in self.orders:
|
||
if not order["filled"] and order["side"] == "BUY" and current_price <= order["price"]:
|
||
order["filled"] = True
|
||
self.position = DemoPosition(
|
||
entry_price=order["price"],
|
||
qty=order["qty"],
|
||
side="BUY",
|
||
opened_at=time.time(),
|
||
)
|
||
self.balance += order["price"] * order["qty"] # rest of balance
|
||
filled.append(order)
|
||
self.orders = [o for o in self.orders if not o["filled"]]
|
||
return filled
|
||
|
||
def _cancel_open_sells(self, reason: str) -> int:
|
||
"""Отменить все висящие (не-filled) SELL-ордера. Возвращает кол-во отменённых.
|
||
Вызывается при ЗАКРЫТИИ позиции — иначе SELL-ордер висит и при fill
|
||
без открытой позиции даёт фантомный pnl=0 и теряет proceeds.
|
||
"""
|
||
before = len(self.orders)
|
||
self.orders = [o for o in self.orders if o["filled"] or o["side"] != "SELL"]
|
||
cancelled = before - len(self.orders)
|
||
if cancelled > 0:
|
||
# Логгируем на уровне INFO через глобальный logger, если есть
|
||
import logging
|
||
logging.getLogger("grid").info(
|
||
f"[DEMO] cancelled {cancelled} open SELL order(s): {reason}"
|
||
)
|
||
return cancelled
|
||
|
||
def check_fill_sell(self, current_price: float) -> list[dict]:
|
||
"""Check if any SELL orders should be filled at current_price."""
|
||
filled = []
|
||
for order in self.orders:
|
||
if not order["filled"] and order["side"] == "SELL" and current_price >= order["price"]:
|
||
order["filled"] = True
|
||
# Close BUY position
|
||
if self.position and self.position.side == "BUY":
|
||
proceeds = order["qty"] * order["price"]
|
||
self.balance += proceeds
|
||
pnl = proceeds - (self.position.qty * self.position.entry_price)
|
||
exit_ts = time.time()
|
||
self.trade_log.append({
|
||
"side": "BUY→SELL",
|
||
"entry": self.position.entry_price,
|
||
"exit": order["price"],
|
||
"qty": order["qty"],
|
||
"pnl_usdc": round(pnl, 8),
|
||
"pnl_pct": round(pnl / (self.position.qty * self.position.entry_price) * 100, 2),
|
||
"entry_time": datetime.fromtimestamp(self.position.opened_at, tz=timezone.utc).isoformat() if self.position.opened_at else None,
|
||
"exit_time": datetime.fromtimestamp(exit_ts, tz=timezone.utc).isoformat(),
|
||
})
|
||
self.position = None
|
||
# BUGFIX: SELL закрыл позицию → остальные SELL ордера-зомби
|
||
# больше не нужны, отменяем.
|
||
self._cancel_open_sells("position closed via SELL fill")
|
||
else:
|
||
# SELL-zombie: ордер заполнился, но позиции уже нет.
|
||
# Не начисляем proceeds (нет BTC), логгируем warning.
|
||
import logging
|
||
logging.getLogger("grid").warning(
|
||
f"[DEMO] SELL-zombie fill @ ${order['price']:,.2f} qty={order['qty']} BTC "
|
||
f"— no position to close, no proceeds credited"
|
||
)
|
||
filled.append(order)
|
||
self.orders = [o for o in self.orders if not o["filled"]]
|
||
return filled
|
||
|
||
def check_take_profit(self, current_price: float, take_profit_pct: float) -> bool:
|
||
"""If position PnL >= take_profit_pct, close it."""
|
||
if not self.position:
|
||
return False
|
||
pnl_pct = self.position.pnl_percent(current_price)
|
||
if pnl_pct >= take_profit_pct:
|
||
# Force close at current price
|
||
if self.position.side == "BUY":
|
||
proceeds = self.position.qty * current_price
|
||
self.balance += proceeds
|
||
pnl = proceeds - (self.position.qty * self.position.entry_price)
|
||
exit_ts = time.time()
|
||
self.trade_log.append({
|
||
"side": "TAKE_PROFIT",
|
||
"entry": self.position.entry_price,
|
||
"exit": current_price,
|
||
"qty": self.position.qty,
|
||
"pnl_usdc": round(pnl, 8),
|
||
"pnl_pct": round(pnl_pct, 2),
|
||
"entry_time": datetime.fromtimestamp(self.position.opened_at, tz=timezone.utc).isoformat() if self.position.opened_at else None,
|
||
"exit_time": datetime.fromtimestamp(exit_ts, tz=timezone.utc).isoformat(),
|
||
})
|
||
self.position = None
|
||
# BUGFIX: TP закрыл позицию → SELL-ордера-зомби отменяем
|
||
self._cancel_open_sells("position closed via TAKE_PROFIT")
|
||
return True
|
||
return False
|
||
|
||
def get_status(self, current_price: float) -> dict:
|
||
pnl_usdc = 0.0
|
||
pnl_pct = 0.0
|
||
unrealized = 0.0
|
||
|
||
if self.position:
|
||
if self.position.side == "BUY":
|
||
unrealized = (current_price - self.position.entry_price) * self.position.qty
|
||
pnl_pct = self.position.pnl_percent(current_price)
|
||
pnl_usdc = unrealized
|
||
|
||
return {
|
||
"balance": round(self.balance, 8),
|
||
"position": {
|
||
"side": self.position.side if self.position else None,
|
||
"entry_price": self.position.entry_price if self.position else None,
|
||
"qty": self.position.qty if self.position else None,
|
||
"current_value": round(self.position.current_value(current_price) if self.position else 0.0, 8),
|
||
"unrealized_pnl": round(unrealized, 8),
|
||
"pnl_pct": round(pnl_pct, 2),
|
||
} if self.position else None,
|
||
"open_orders": len(set(o["level_id"] for o in self.orders if not o["filled"])),
|
||
"total_trades": len(self.trade_log),
|
||
"total_pnl": round(sum(t["pnl_usdc"] for t in self.trade_log), 8),
|
||
"last_trade": self.trade_log[-1] if self.trade_log else None,
|
||
}
|