920 lines
38 KiB
Python
920 lines
38 KiB
Python
from __future__ import annotations
|
|
|
|
from collections import deque
|
|
from datetime import timedelta
|
|
from decimal import Decimal, ROUND_DOWN, ROUND_UP
|
|
from typing import Any, Iterable
|
|
from uuid import uuid4
|
|
|
|
from crypto_spot_bot.bybit import BybitClient, Instrument
|
|
from crypto_spot_bot.config import Settings
|
|
from crypto_spot_bot.models import Position, Signal, Ticker, Trade, utc_now
|
|
from crypto_spot_bot.storage import Storage
|
|
|
|
|
|
class BrokerError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def _round_step(value: float, step: float) -> float:
|
|
if step <= 0:
|
|
return value
|
|
value_decimal = Decimal(str(value))
|
|
step_decimal = Decimal(str(step))
|
|
rounded = (value_decimal / step_decimal).to_integral_value(rounding=ROUND_DOWN)
|
|
return float(rounded * step_decimal)
|
|
|
|
|
|
def _round_step_up(value: float, step: float) -> float:
|
|
if step <= 0:
|
|
return value
|
|
value_decimal = Decimal(str(value))
|
|
step_decimal = Decimal(str(step))
|
|
rounded = (value_decimal / step_decimal).to_integral_value(rounding=ROUND_UP)
|
|
return float(rounded * step_decimal)
|
|
|
|
|
|
class PaperBroker:
|
|
def __init__(self, settings: Settings, storage: Storage):
|
|
self.settings = settings
|
|
self.storage = storage
|
|
self.positions = storage.open_positions(settings.trading_mode)
|
|
self.cash = float(storage.get_runtime("paper_cash", settings.starting_balance_usdt))
|
|
today = utc_now().date().isoformat()
|
|
stored_peak_day = str(storage.get_runtime("paper_peak_equity_day", ""))
|
|
self.peak_equity_day = today
|
|
self.peak_equity = float(
|
|
storage.get_runtime("paper_daily_peak_equity", settings.starting_balance_usdt)
|
|
if stored_peak_day == today
|
|
else settings.starting_balance_usdt
|
|
)
|
|
self.lifetime_peak_equity = float(
|
|
storage.get_runtime("paper_lifetime_peak_equity", settings.starting_balance_usdt)
|
|
)
|
|
self._entry_timestamps = deque()
|
|
|
|
def open_positions(self) -> list[Position]:
|
|
return list(self.positions)
|
|
|
|
def positions_for_symbol(self, symbol: str) -> list[Position]:
|
|
return [position for position in self.positions if position.symbol == symbol]
|
|
|
|
def exposure(self) -> float:
|
|
return sum(position.notional_usdt for position in self.positions)
|
|
|
|
def symbol_exposure(self, symbol: str) -> float:
|
|
return sum(position.notional_usdt for position in self.positions_for_symbol(symbol))
|
|
|
|
def equity(self, prices: dict[str, float]) -> float:
|
|
value = self.cash
|
|
for position in self.positions:
|
|
value += position.mark_price(prices.get(position.symbol, position.entry_price))
|
|
return value
|
|
|
|
def mark_equity(self, prices: dict[str, float]) -> dict[str, float]:
|
|
state = self.account_state(prices)
|
|
equity = state["equity"]
|
|
today = utc_now().date().isoformat()
|
|
if today != self.peak_equity_day:
|
|
self.peak_equity_day = today
|
|
self.peak_equity = equity
|
|
self.peak_equity = max(self.peak_equity, equity)
|
|
self.lifetime_peak_equity = max(self.lifetime_peak_equity, equity)
|
|
state["drawdown"] = max(0.0, self.peak_equity - equity)
|
|
self.storage.set_runtime("paper_cash", self.cash)
|
|
self.storage.set_runtime("paper_peak_equity_day", self.peak_equity_day)
|
|
self.storage.set_runtime("paper_daily_peak_equity", self.peak_equity)
|
|
self.storage.set_runtime("paper_lifetime_peak_equity", self.lifetime_peak_equity)
|
|
self.storage.insert_equity(
|
|
equity,
|
|
self.cash,
|
|
self.exposure(),
|
|
state["drawdown"],
|
|
mode=self.settings.trading_mode,
|
|
)
|
|
return state
|
|
|
|
def account_state(self, prices: dict[str, float]) -> dict[str, float]:
|
|
equity = self.equity(prices)
|
|
return {
|
|
"equity": equity,
|
|
"cash": self.cash,
|
|
"exposure": self.exposure(),
|
|
"drawdown": max(0.0, self.peak_equity - equity),
|
|
}
|
|
|
|
def update_highs(self, tickers: dict[str, Ticker]) -> None:
|
|
for position in self.positions:
|
|
ticker = tickers.get(position.symbol)
|
|
if not ticker:
|
|
continue
|
|
price = ticker.last_price
|
|
if price > position.highest_price:
|
|
position.highest_price = price
|
|
if position.id is not None:
|
|
self.storage.update_position_highest(position.id, price)
|
|
|
|
def can_open(
|
|
self,
|
|
symbol: str,
|
|
prices: dict[str, float],
|
|
requested_notional: float | None = None,
|
|
) -> tuple[bool, str]:
|
|
if not self._entry_rate_limit_allows():
|
|
return False, "достигнут лимит новых входов в минуту"
|
|
if len(self.positions) >= self.settings.max_open_positions:
|
|
return False, "достигнут общий лимит открытых позиций"
|
|
if self.settings.strategy_mode == "trend_macd" and len(self.positions_for_symbol(symbol)) >= 1:
|
|
return False, "DCA/усреднение отключено: позиция по паре уже открыта"
|
|
dynamic_pair_limit = _symbol_position_limit(self.settings)
|
|
if len(self.positions_for_symbol(symbol)) >= dynamic_pair_limit:
|
|
return False, "достигнут лимит позиций по паре"
|
|
requested = requested_notional if requested_notional is not None else self.settings.min_position_usdt
|
|
symbol_room = max(0.0, self.settings.max_symbol_exposure_usdt - self.symbol_exposure(symbol))
|
|
if symbol_room < min(requested, self.settings.min_position_usdt):
|
|
return False, "достигнут лимит экспозиции по паре"
|
|
if self.cash <= self.settings.min_cash_reserve_usdt:
|
|
return False, "недостаточно свободного USDT после резерва"
|
|
if self.exposure() >= self.settings.max_total_exposure_usdt:
|
|
return False, "достигнут лимит общей экспозиции"
|
|
equity_state = self.mark_equity(prices)
|
|
if equity_state["drawdown"] >= self.settings.max_daily_drawdown_usdt:
|
|
return False, "достигнут лимит просадки"
|
|
return True, "ok"
|
|
|
|
def buy(
|
|
self,
|
|
signal: Signal,
|
|
ticker: Ticker,
|
|
instrument: Instrument | None,
|
|
prices: dict[str, float],
|
|
) -> Position | None:
|
|
requested_notional = self._signal_notional(signal)
|
|
allowed, reason = self.can_open(ticker.symbol, prices, requested_notional)
|
|
if not allowed:
|
|
self.storage.event(f"{ticker.symbol}: покупка пропущена, {reason}", "WARN")
|
|
return None
|
|
return self._record_buy(signal, ticker, instrument, "демо-покупка")
|
|
|
|
def _record_buy(
|
|
self,
|
|
signal: Signal,
|
|
ticker: Ticker,
|
|
instrument: Instrument | None,
|
|
event_label: str,
|
|
) -> Position | None:
|
|
|
|
fill_price = self._buy_price(ticker)
|
|
minimum_budget = self._minimum_entry_budget(instrument, fill_price)
|
|
budget = self._entry_budget(signal, ticker, minimum_notional=minimum_budget)
|
|
if budget < max(self.settings.min_position_usdt, minimum_budget):
|
|
self.storage.event(f"{ticker.symbol}: покупка пропущена, adaptive-лимит экспозиции исчерпан", "WARN")
|
|
return None
|
|
notional = budget / (1 + self.settings.taker_fee_rate)
|
|
qty = _round_step(notional / fill_price, instrument.qty_step if instrument else 0)
|
|
if instrument:
|
|
qty = self._raise_qty_to_exchange_minimum(qty, fill_price, instrument, budget)
|
|
if instrument and qty < instrument.min_order_qty:
|
|
self.storage.event(f"{ticker.symbol}: количество ниже minOrderQty Bybit", "WARN")
|
|
return None
|
|
executed_notional = qty * fill_price
|
|
if instrument and executed_notional < instrument.min_notional_value:
|
|
self.storage.event(f"{ticker.symbol}: сумма ниже minNotionalValue Bybit", "WARN")
|
|
return None
|
|
fee = executed_notional * self.settings.taker_fee_rate
|
|
if executed_notional + fee > self.cash:
|
|
self.storage.event(f"{ticker.symbol}: недостаточно cash для комиссии", "WARN")
|
|
return None
|
|
|
|
stop_loss_percent = self._signal_percent(signal, "stop_loss_percent", self.settings.stop_loss_percent, 0.003, 0.08)
|
|
take_profit_percent = self._signal_percent(
|
|
signal, "take_profit_percent", self.settings.take_profit_percent, 0.003, 0.20
|
|
)
|
|
position = Position(
|
|
id=None,
|
|
symbol=ticker.symbol,
|
|
qty=qty,
|
|
entry_price=fill_price,
|
|
notional_usdt=executed_notional,
|
|
entry_fee_usdt=fee,
|
|
stop_loss=fill_price * (1 - stop_loss_percent),
|
|
take_profit=fill_price * (1 + take_profit_percent),
|
|
highest_price=fill_price,
|
|
entry_reason=signal.reason,
|
|
entry_confidence=signal.confidence,
|
|
entry_pattern=str(signal.diagnostics.get("pattern", {}).get("label", "")),
|
|
entry_diagnostics=signal.diagnostics,
|
|
mode=self.settings.trading_mode,
|
|
)
|
|
position.id = self.storage.insert_position(position)
|
|
self.positions.append(position)
|
|
self._record_entry_timestamp()
|
|
self.cash -= executed_notional + fee
|
|
self.storage.set_runtime("paper_cash", self.cash)
|
|
self.storage.insert_trade(
|
|
Trade(
|
|
id=None,
|
|
symbol=ticker.symbol,
|
|
side="BUY",
|
|
qty=qty,
|
|
entry_price=fill_price,
|
|
fee_usdt=fee,
|
|
net_pnl=-fee,
|
|
reason=signal.reason,
|
|
entry_pattern=position.entry_pattern,
|
|
entry_confidence=position.entry_confidence,
|
|
entry_diagnostics=position.entry_diagnostics,
|
|
opened_at=position.opened_at,
|
|
mode=self.settings.trading_mode,
|
|
)
|
|
)
|
|
self.storage.event(
|
|
f"{ticker.symbol}: {event_label} кол-во={qty:.8f} цена={fill_price:.8f} сумма={executed_notional:.2f} уверенность={signal.confidence:.2f}"
|
|
)
|
|
return position
|
|
|
|
def sell(self, position: Position, ticker: Ticker, reason: str) -> Trade:
|
|
return self._record_sell(position, ticker, reason, "демо-продажа")
|
|
|
|
def _record_sell(
|
|
self,
|
|
position: Position,
|
|
ticker: Ticker,
|
|
reason: str,
|
|
event_label: str,
|
|
) -> Trade:
|
|
fill_price = self._sell_price(ticker)
|
|
exit_notional = position.qty * fill_price
|
|
exit_fee = exit_notional * self.settings.taker_fee_rate
|
|
gross_pnl = (fill_price - position.entry_price) * position.qty
|
|
net_pnl = gross_pnl - position.entry_fee_usdt - exit_fee
|
|
self.cash += exit_notional - exit_fee
|
|
if position.id is not None:
|
|
self.storage.close_position(position.id)
|
|
self.positions = [item for item in self.positions if item.id != position.id]
|
|
self.storage.set_runtime("paper_cash", self.cash)
|
|
trade = Trade(
|
|
id=None,
|
|
symbol=position.symbol,
|
|
side="SELL",
|
|
qty=position.qty,
|
|
entry_price=position.entry_price,
|
|
exit_price=fill_price,
|
|
gross_pnl=gross_pnl,
|
|
fee_usdt=position.entry_fee_usdt + exit_fee,
|
|
net_pnl=net_pnl,
|
|
reason=reason,
|
|
entry_pattern=position.entry_pattern,
|
|
entry_confidence=position.entry_confidence,
|
|
entry_diagnostics=position.entry_diagnostics,
|
|
opened_at=position.opened_at,
|
|
closed_at=utc_now(),
|
|
mode=self.settings.trading_mode,
|
|
)
|
|
trade.id = self.storage.insert_trade(trade)
|
|
self.storage.event(
|
|
f"{position.symbol}: {event_label} кол-во={position.qty:.8f} цена={fill_price:.8f} итог={net_pnl:.4f} причина={reason}"
|
|
)
|
|
return trade
|
|
|
|
def _buy_price(self, ticker: Ticker) -> float:
|
|
base = ticker.ask if ticker.ask > 0 else ticker.last_price
|
|
return base * (1 + self.settings.slippage_rate)
|
|
|
|
def _sell_price(self, ticker: Ticker) -> float:
|
|
base = ticker.bid if ticker.bid > 0 else ticker.last_price
|
|
return base * (1 - self.settings.slippage_rate)
|
|
|
|
def _signal_notional(self, signal: Signal) -> float:
|
|
raw = signal.diagnostics.get("position_notional_usdt", self.settings.max_position_usdt)
|
|
try:
|
|
value = float(raw)
|
|
except (TypeError, ValueError):
|
|
value = self.settings.max_position_usdt
|
|
low = max(0.0, self.settings.min_position_usdt)
|
|
high = max(low, self.settings.max_position_usdt)
|
|
return max(low, min(high, value))
|
|
|
|
def _signal_percent(self, signal: Signal, key: str, default: float, low: float, high: float) -> float:
|
|
rules = signal.diagnostics.get("adaptive_rules") or {}
|
|
raw = signal.diagnostics.get(key, rules.get(key, default) if isinstance(rules, dict) else default)
|
|
try:
|
|
value = float(raw)
|
|
except (TypeError, ValueError):
|
|
value = default
|
|
return max(low, min(high, value))
|
|
|
|
def minimum_entry_budget(self, instrument: Instrument | None, ticker: Ticker | None = None) -> float:
|
|
fill_price = self._buy_price(ticker) if ticker is not None else None
|
|
return self._minimum_entry_budget(instrument, fill_price)
|
|
|
|
def _minimum_entry_budget(self, instrument: Instrument | None, fill_price: float | None = None) -> float:
|
|
minimum = max(0.0, self.settings.min_position_usdt)
|
|
if instrument:
|
|
exchange_notional = max(0.0, instrument.min_notional_value)
|
|
if fill_price and fill_price > 0:
|
|
minimum_qty = max(0.0, instrument.min_order_qty)
|
|
if exchange_notional > 0:
|
|
minimum_qty = max(
|
|
minimum_qty,
|
|
_round_step_up(exchange_notional / fill_price, instrument.qty_step),
|
|
)
|
|
if minimum_qty > 0:
|
|
exchange_notional = max(exchange_notional, minimum_qty * fill_price)
|
|
if exchange_notional > 0:
|
|
exchange_minimum = exchange_notional * (1 + self.settings.taker_fee_rate) * 1.002 + 0.01
|
|
minimum = max(minimum, exchange_minimum)
|
|
return minimum
|
|
|
|
def _raise_qty_to_exchange_minimum(
|
|
self,
|
|
qty: float,
|
|
fill_price: float,
|
|
instrument: Instrument,
|
|
budget: float,
|
|
) -> float:
|
|
minimum_qty = max(0.0, instrument.min_order_qty)
|
|
if instrument.min_notional_value > 0 and fill_price > 0:
|
|
minimum_qty = max(
|
|
minimum_qty,
|
|
_round_step_up(instrument.min_notional_value / fill_price, instrument.qty_step),
|
|
)
|
|
if minimum_qty <= qty:
|
|
return qty
|
|
minimum_cost = minimum_qty * fill_price * (1 + self.settings.taker_fee_rate)
|
|
if minimum_cost <= budget + 1e-9:
|
|
return minimum_qty
|
|
return qty
|
|
|
|
def _entry_budget(
|
|
self,
|
|
signal: Signal,
|
|
ticker: Ticker,
|
|
extra_cap: float | None = None,
|
|
minimum_notional: float = 0.0,
|
|
) -> float:
|
|
available = max(0.0, self.cash - self.settings.min_cash_reserve_usdt)
|
|
rules = signal.diagnostics.get("adaptive_rules") or {}
|
|
target_total = self._adaptive_cap(rules, "target_total_exposure_usdt", self.settings.max_total_exposure_usdt)
|
|
target_symbol = self._adaptive_cap(rules, "target_symbol_exposure_usdt", self.settings.max_symbol_exposure_usdt)
|
|
exposure_room = max(0.0, target_total - self.exposure())
|
|
symbol_room = max(0.0, target_symbol - self.symbol_exposure(ticker.symbol))
|
|
requested = min(
|
|
max(self._signal_notional(signal), minimum_notional),
|
|
max(0.0, self.settings.max_position_usdt),
|
|
)
|
|
caps = [requested, available, exposure_room, symbol_room]
|
|
if extra_cap is not None:
|
|
caps.append(max(0.0, extra_cap))
|
|
return max(0.0, min(caps))
|
|
|
|
def _adaptive_cap(self, rules: object, key: str, default: float) -> float:
|
|
if not isinstance(rules, dict):
|
|
return default
|
|
try:
|
|
value = float(rules.get(key, default))
|
|
except (TypeError, ValueError):
|
|
value = default
|
|
return max(0.0, min(default, value))
|
|
|
|
def _entry_rate_limit_allows(self) -> bool:
|
|
limit = self.settings.max_entries_per_minute
|
|
if limit <= 0:
|
|
return True
|
|
now = utc_now()
|
|
cutoff = now - timedelta(seconds=60)
|
|
while self._entry_timestamps and self._entry_timestamps[0] < cutoff:
|
|
self._entry_timestamps.popleft()
|
|
return len(self._entry_timestamps) < limit
|
|
|
|
def _record_entry_timestamp(self) -> None:
|
|
if self.settings.max_entries_per_minute <= 0:
|
|
return
|
|
self._entry_timestamps.append(utc_now())
|
|
|
|
|
|
class LiveBroker(PaperBroker):
|
|
TERMINAL_ORDER_STATUSES = {
|
|
"Filled",
|
|
"Cancelled",
|
|
"Rejected",
|
|
"PartiallyFilledCanceled",
|
|
"PartillyFilledCancelled",
|
|
"Deactivated",
|
|
}
|
|
|
|
def __init__(self, settings: Settings, storage: Storage, client: BybitClient):
|
|
super().__init__(settings, storage)
|
|
if not settings.live_ready:
|
|
raise BrokerError("Live mode is not unlocked by settings")
|
|
self.client = client
|
|
self.reconciliation_state: dict[str, Any] = {
|
|
"status": "unknown",
|
|
"blocking": True,
|
|
"discrepancies": ["live account has not been reconciled"],
|
|
}
|
|
|
|
def can_open(
|
|
self,
|
|
symbol: str,
|
|
prices: dict[str, float],
|
|
requested_notional: float | None = None,
|
|
) -> tuple[bool, str]:
|
|
if self.reconciliation_state.get("blocking", True):
|
|
return False, "live reconciliation is not clean"
|
|
return super().can_open(symbol, prices, requested_notional)
|
|
|
|
def reconcile(self, instruments: dict[str, Instrument]) -> dict[str, Any]:
|
|
coins = {"USDT"}
|
|
for symbol in self.settings.symbols:
|
|
instrument = instruments.get(symbol)
|
|
if instrument and instrument.base_coin:
|
|
coins.add(instrument.base_coin.upper())
|
|
wallet = self.client.wallet_balance(coin=",".join(sorted(coins)))
|
|
balances = _wallet_balances(wallet)
|
|
usdt = balances.get("USDT", {})
|
|
self.cash = max(0.0, float(usdt.get("wallet_balance", 0.0)) - float(usdt.get("locked", 0.0)))
|
|
|
|
local_by_coin: dict[str, float] = {}
|
|
discrepancies: list[dict[str, Any]] = []
|
|
for position in self.positions:
|
|
instrument = instruments.get(position.symbol)
|
|
coin = instrument.base_coin.upper() if instrument and instrument.base_coin else position.symbol.removesuffix("USDT")
|
|
local_by_coin[coin] = local_by_coin.get(coin, 0.0) + position.qty
|
|
for coin, local_qty in local_by_coin.items():
|
|
remote_qty = float((balances.get(coin) or {}).get("wallet_balance", 0.0))
|
|
tolerance = max(1e-8, local_qty * 0.002)
|
|
if remote_qty + tolerance < local_qty:
|
|
discrepancies.append(
|
|
{
|
|
"severity": "error",
|
|
"code": "remote_balance_below_local_position",
|
|
"coin": coin,
|
|
"local_qty": round(local_qty, 12),
|
|
"remote_qty": round(remote_qty, 12),
|
|
}
|
|
)
|
|
for coin, row in balances.items():
|
|
if coin == "USDT" or coin not in coins:
|
|
continue
|
|
remote_qty = float(row.get("wallet_balance", 0.0))
|
|
local_qty = local_by_coin.get(coin, 0.0)
|
|
tolerance = max(1e-8, local_qty * 0.002)
|
|
if remote_qty > local_qty + tolerance:
|
|
discrepancies.append(
|
|
{
|
|
"severity": "error",
|
|
"code": "remote_asset_without_matching_local_position",
|
|
"coin": coin,
|
|
"local_qty": round(local_qty, 12),
|
|
"remote_qty": round(remote_qty, 12),
|
|
}
|
|
)
|
|
|
|
normal_orders = self.client.realtime_orders(
|
|
category="spot",
|
|
open_only=0,
|
|
limit=50,
|
|
order_filter="Order",
|
|
)
|
|
unresolved_orders = [
|
|
row
|
|
for row in normal_orders.get("list", [])
|
|
if isinstance(row, dict)
|
|
and str(row.get("orderStatus", "")) not in self.TERMINAL_ORDER_STATUSES
|
|
]
|
|
if unresolved_orders:
|
|
discrepancies.append(
|
|
{
|
|
"severity": "error",
|
|
"code": "unresolved_exchange_orders",
|
|
"count": len(unresolved_orders),
|
|
"order_ids": [str(row.get("orderId", "")) for row in unresolved_orders[:10]],
|
|
}
|
|
)
|
|
|
|
protection_rows = self.client.realtime_orders(
|
|
category="spot",
|
|
open_only=0,
|
|
limit=50,
|
|
order_filter="tpslOrder",
|
|
)
|
|
active_protection = {
|
|
str(row.get("orderId", ""))
|
|
for row in protection_rows.get("list", [])
|
|
if isinstance(row, dict)
|
|
and str(row.get("orderStatus", "")) not in self.TERMINAL_ORDER_STATUSES
|
|
}
|
|
if self.settings.live_protective_stop_enabled:
|
|
for position in self.positions:
|
|
if not position.protective_order_id or position.protective_order_id not in active_protection:
|
|
discrepancies.append(
|
|
{
|
|
"severity": "error",
|
|
"code": "missing_exchange_protective_stop",
|
|
"position_id": position.id,
|
|
"symbol": position.symbol,
|
|
}
|
|
)
|
|
|
|
blocking = any(row.get("severity") == "error" for row in discrepancies)
|
|
self.reconciliation_state = {
|
|
"status": "error" if blocking else ("warn" if discrepancies else "ok"),
|
|
"blocking": blocking,
|
|
"discrepancies": discrepancies,
|
|
"cash_usdt": round(self.cash, 8),
|
|
"checked_at": utc_now().isoformat(),
|
|
}
|
|
self.storage.set_runtime("live_reconciliation", self.reconciliation_state)
|
|
return dict(self.reconciliation_state)
|
|
|
|
def buy(
|
|
self,
|
|
signal: Signal,
|
|
ticker: Ticker,
|
|
instrument: Instrument | None,
|
|
prices: dict[str, float],
|
|
) -> Position | None:
|
|
fill_price = self._buy_price(ticker)
|
|
minimum_budget = self._minimum_entry_budget(instrument, fill_price)
|
|
requested_notional = min(
|
|
max(self._signal_notional(signal), minimum_budget),
|
|
self.settings.live_order_max_usdt,
|
|
)
|
|
allowed, reason = self.can_open(ticker.symbol, prices, requested_notional)
|
|
if not allowed:
|
|
self.storage.event(f"{ticker.symbol}: live BUY пропущен, {reason}", "WARN")
|
|
return None
|
|
budget = self._entry_budget(
|
|
signal,
|
|
ticker,
|
|
self.settings.live_order_max_usdt,
|
|
minimum_notional=minimum_budget,
|
|
)
|
|
if budget < max(self.settings.min_position_usdt, minimum_budget):
|
|
self.storage.event(f"{ticker.symbol}: live BUY skipped, adjusted budget below minimum", "WARN")
|
|
return None
|
|
|
|
signal.diagnostics["position_notional_usdt"] = budget
|
|
requested_quote = budget / (1 + self.settings.taker_fee_rate)
|
|
client_order_id = f"tb-buy-{uuid4().hex[:18]}"
|
|
self.storage.upsert_order(
|
|
client_order_id=client_order_id,
|
|
symbol=ticker.symbol,
|
|
side="Buy",
|
|
order_kind="MARKET",
|
|
status="PENDING_SUBMIT",
|
|
requested_notional=requested_quote,
|
|
raw={"signal": signal.as_dict()},
|
|
)
|
|
try:
|
|
response = self.client.place_spot_market_order(
|
|
symbol=ticker.symbol,
|
|
side="Buy",
|
|
qty=requested_quote,
|
|
market_unit="quoteCoin",
|
|
order_link_id=client_order_id,
|
|
)
|
|
order_id = str(response.get("orderId", ""))
|
|
if not order_id:
|
|
raise BrokerError("Bybit did not return orderId for live BUY")
|
|
self.storage.upsert_order(
|
|
client_order_id=client_order_id,
|
|
exchange_order_id=order_id,
|
|
symbol=ticker.symbol,
|
|
side="Buy",
|
|
order_kind="MARKET",
|
|
status="ACCEPTED",
|
|
requested_notional=requested_quote,
|
|
raw=response,
|
|
)
|
|
result = self.client.wait_for_spot_order(
|
|
order_id=order_id,
|
|
symbol=ticker.symbol,
|
|
timeout_seconds=self.settings.live_order_fill_timeout_seconds,
|
|
)
|
|
fill = _execution_fill(result, side="Buy", instrument=instrument)
|
|
self._save_order_fill(client_order_id, order_id, ticker.symbol, "Buy", requested_quote, result, fill)
|
|
if fill["qty"] <= 0 or fill["value"] <= 0:
|
|
raise BrokerError(f"live BUY was not filled, status={fill['status']}")
|
|
position = self._record_live_buy(signal, ticker, fill)
|
|
if self.settings.live_protective_stop_enabled:
|
|
try:
|
|
self._place_protective_stop(position)
|
|
except Exception as exc:
|
|
self.storage.event(
|
|
f"{ticker.symbol}: protective stop placement failed, closing position: {exc}",
|
|
"ERROR",
|
|
)
|
|
self.sell(position, ticker, "protective stop placement failed")
|
|
raise BrokerError("live BUY was unwound because protective stop failed") from exc
|
|
return position
|
|
except Exception as exc:
|
|
self.reconciliation_state["blocking"] = True
|
|
self.reconciliation_state["status"] = "error"
|
|
self.storage.event(f"{ticker.symbol}: live BUY failed: {exc}", "ERROR")
|
|
raise
|
|
|
|
def sell(self, position: Position, ticker: Ticker, reason: str) -> Trade:
|
|
if position.protective_order_id or position.protective_order_link_id:
|
|
self.client.cancel_spot_order(
|
|
symbol=position.symbol,
|
|
order_id=position.protective_order_id or None,
|
|
order_link_id=position.protective_order_link_id or None,
|
|
order_filter="tpslOrder",
|
|
)
|
|
if position.protective_order_id:
|
|
cancelled = self.client.wait_for_spot_order(
|
|
order_id=position.protective_order_id,
|
|
symbol=position.symbol,
|
|
timeout_seconds=min(10.0, self.settings.live_order_fill_timeout_seconds),
|
|
)
|
|
status = str((cancelled.get("order") or {}).get("orderStatus", ""))
|
|
if status and status != "Cancelled":
|
|
raise BrokerError(f"protective order was not cancelled, status={status}")
|
|
|
|
client_order_id = f"tb-sell-{uuid4().hex[:18]}"
|
|
self.storage.upsert_order(
|
|
client_order_id=client_order_id,
|
|
symbol=position.symbol,
|
|
side="Sell",
|
|
order_kind="MARKET",
|
|
status="PENDING_SUBMIT",
|
|
requested_qty=position.qty,
|
|
raw={"position_id": position.id, "reason": reason},
|
|
)
|
|
response = self.client.place_spot_market_order(
|
|
symbol=position.symbol,
|
|
side="Sell",
|
|
qty=position.qty,
|
|
market_unit="baseCoin",
|
|
order_link_id=client_order_id,
|
|
)
|
|
order_id = str(response.get("orderId", ""))
|
|
if not order_id:
|
|
raise BrokerError("Bybit did not return orderId for live SELL")
|
|
result = self.client.wait_for_spot_order(
|
|
order_id=order_id,
|
|
symbol=position.symbol,
|
|
timeout_seconds=self.settings.live_order_fill_timeout_seconds,
|
|
)
|
|
fill = _execution_fill(result, side="Sell", instrument=None)
|
|
self._save_order_fill(client_order_id, order_id, position.symbol, "Sell", position.qty, result, fill)
|
|
if fill["qty"] <= 0 or fill["value"] <= 0:
|
|
self.reconciliation_state["blocking"] = True
|
|
raise BrokerError(f"live SELL was not filled, status={fill['status']}")
|
|
return self._record_live_sell(position, reason, fill)
|
|
|
|
def _record_live_buy(self, signal: Signal, ticker: Ticker, fill: dict[str, Any]) -> Position:
|
|
qty = float(fill["net_qty"])
|
|
value = float(fill["value"])
|
|
price = value / max(float(fill["qty"]), 1e-12)
|
|
fee_usdt = float(fill["fee_usdt"])
|
|
stop_loss_percent = self._signal_percent(
|
|
signal, "stop_loss_percent", self.settings.stop_loss_percent, 0.003, 0.08
|
|
)
|
|
take_profit_percent = self._signal_percent(
|
|
signal, "take_profit_percent", self.settings.take_profit_percent, 0.003, 0.20
|
|
)
|
|
position = Position(
|
|
id=None,
|
|
symbol=ticker.symbol,
|
|
qty=qty,
|
|
entry_price=price,
|
|
notional_usdt=value,
|
|
entry_fee_usdt=fee_usdt,
|
|
stop_loss=price * (1 - stop_loss_percent),
|
|
take_profit=price * (1 + take_profit_percent),
|
|
highest_price=price,
|
|
entry_reason=signal.reason,
|
|
entry_confidence=signal.confidence,
|
|
entry_pattern=str(signal.diagnostics.get("pattern", {}).get("label", "")),
|
|
entry_diagnostics=signal.diagnostics,
|
|
mode="live",
|
|
)
|
|
position.id = self.storage.insert_position(position)
|
|
self.positions.append(position)
|
|
self._record_entry_timestamp()
|
|
self.cash = max(0.0, self.cash - value - float(fill["quote_fee"]))
|
|
self.storage.insert_trade(
|
|
Trade(
|
|
id=None,
|
|
symbol=ticker.symbol,
|
|
side="BUY",
|
|
qty=qty,
|
|
entry_price=price,
|
|
fee_usdt=fee_usdt,
|
|
net_pnl=-fee_usdt,
|
|
reason=signal.reason,
|
|
entry_pattern=position.entry_pattern,
|
|
entry_confidence=position.entry_confidence,
|
|
entry_diagnostics=position.entry_diagnostics,
|
|
opened_at=position.opened_at,
|
|
mode="live",
|
|
)
|
|
)
|
|
self.storage.event(
|
|
f"{ticker.symbol}: live BUY filled qty={qty:.8f} avg={price:.8f} value={value:.4f}"
|
|
)
|
|
return position
|
|
|
|
def _record_live_sell(self, position: Position, reason: str, fill: dict[str, Any]) -> Trade:
|
|
sold_qty = min(position.qty, float(fill["qty"]))
|
|
value = float(fill["value"])
|
|
price = value / max(float(fill["qty"]), 1e-12)
|
|
exit_fee = float(fill["fee_usdt"])
|
|
ratio = min(1.0, sold_qty / max(position.qty, 1e-12))
|
|
allocated_entry_fee = position.entry_fee_usdt * ratio
|
|
gross_pnl = (price - position.entry_price) * sold_qty
|
|
net_pnl = gross_pnl - allocated_entry_fee - exit_fee
|
|
self.cash += value - float(fill["quote_fee"])
|
|
remaining_qty = max(0.0, position.qty - sold_qty)
|
|
if remaining_qty <= max(1e-12, position.qty * 1e-6):
|
|
if position.id is not None:
|
|
self.storage.close_position(position.id)
|
|
self.positions = [item for item in self.positions if item.id != position.id]
|
|
else:
|
|
remaining_ratio = remaining_qty / position.qty
|
|
position.qty = remaining_qty
|
|
position.notional_usdt *= remaining_ratio
|
|
position.entry_fee_usdt *= remaining_ratio
|
|
position.protective_order_id = ""
|
|
position.protective_order_link_id = ""
|
|
if position.id is not None:
|
|
self.storage.update_position_after_partial_sell(
|
|
position.id,
|
|
qty=position.qty,
|
|
notional_usdt=position.notional_usdt,
|
|
entry_fee_usdt=position.entry_fee_usdt,
|
|
)
|
|
self.reconciliation_state["blocking"] = True
|
|
trade = Trade(
|
|
id=None,
|
|
symbol=position.symbol,
|
|
side="SELL",
|
|
qty=sold_qty,
|
|
entry_price=position.entry_price,
|
|
exit_price=price,
|
|
gross_pnl=gross_pnl,
|
|
fee_usdt=allocated_entry_fee + exit_fee,
|
|
net_pnl=net_pnl,
|
|
reason=reason,
|
|
entry_pattern=position.entry_pattern,
|
|
entry_confidence=position.entry_confidence,
|
|
entry_diagnostics=position.entry_diagnostics,
|
|
opened_at=position.opened_at,
|
|
closed_at=utc_now(),
|
|
mode="live",
|
|
)
|
|
trade.id = self.storage.insert_trade(trade)
|
|
self.storage.event(
|
|
f"{position.symbol}: live SELL filled qty={sold_qty:.8f} avg={price:.8f} pnl={net_pnl:.4f} reason={reason}"
|
|
)
|
|
return trade
|
|
|
|
def _place_protective_stop(self, position: Position) -> None:
|
|
link_id = f"tb-stop-{uuid4().hex[:17]}"
|
|
response = self.client.place_spot_protective_stop(
|
|
symbol=position.symbol,
|
|
qty=position.qty,
|
|
trigger_price=position.stop_loss,
|
|
order_link_id=link_id,
|
|
)
|
|
order_id = str(response.get("orderId", ""))
|
|
if not order_id:
|
|
raise BrokerError("Bybit did not return orderId for protective stop")
|
|
position.protective_order_id = order_id
|
|
position.protective_order_link_id = link_id
|
|
if position.id is not None:
|
|
self.storage.update_position_protective_order(position.id, order_id, link_id)
|
|
self.storage.upsert_order(
|
|
client_order_id=link_id,
|
|
exchange_order_id=order_id,
|
|
symbol=position.symbol,
|
|
side="Sell",
|
|
order_kind="PROTECTIVE_STOP",
|
|
status="ACCEPTED",
|
|
requested_qty=position.qty,
|
|
raw=response,
|
|
)
|
|
|
|
def _save_order_fill(
|
|
self,
|
|
client_order_id: str,
|
|
order_id: str,
|
|
symbol: str,
|
|
side: str,
|
|
requested: float,
|
|
result: dict[str, Any],
|
|
fill: dict[str, Any],
|
|
) -> None:
|
|
self.storage.upsert_order(
|
|
client_order_id=client_order_id,
|
|
exchange_order_id=order_id,
|
|
symbol=symbol,
|
|
side=side,
|
|
order_kind="MARKET",
|
|
status=str(fill["status"]),
|
|
requested_qty=requested if side == "Sell" else 0.0,
|
|
requested_notional=requested if side == "Buy" else 0.0,
|
|
executed_qty=float(fill["qty"]),
|
|
executed_value=float(fill["value"]),
|
|
fee_usdt=float(fill["fee_usdt"]),
|
|
raw=result,
|
|
)
|
|
|
|
|
|
def _wallet_balances(wallet: dict[str, Any]) -> dict[str, dict[str, float]]:
|
|
accounts = wallet.get("list")
|
|
if not isinstance(accounts, list) or not accounts:
|
|
return {}
|
|
coins = accounts[0].get("coin") if isinstance(accounts[0], dict) else None
|
|
if not isinstance(coins, list):
|
|
return {}
|
|
result: dict[str, dict[str, float]] = {}
|
|
for row in coins:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
coin = str(row.get("coin", "")).upper()
|
|
if not coin:
|
|
continue
|
|
result[coin] = {
|
|
"wallet_balance": _safe_float(row.get("walletBalance")),
|
|
"equity": _safe_float(row.get("equity")),
|
|
"locked": _safe_float(row.get("locked")),
|
|
}
|
|
return result
|
|
|
|
|
|
def _execution_fill(
|
|
result: dict[str, Any],
|
|
*,
|
|
side: str,
|
|
instrument: Instrument | None,
|
|
) -> dict[str, Any]:
|
|
order = result.get("order") if isinstance(result.get("order"), dict) else {}
|
|
executions = result.get("executions") if isinstance(result.get("executions"), list) else []
|
|
qty = 0.0
|
|
value = 0.0
|
|
quote_fee = 0.0
|
|
base_fee = 0.0
|
|
fee_usdt = 0.0
|
|
base_coin = instrument.base_coin.upper() if instrument and instrument.base_coin else ""
|
|
for row in executions:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
exec_qty = _safe_float(row.get("execQty"))
|
|
exec_value = _safe_float(row.get("execValue"))
|
|
exec_price = _safe_float(row.get("execPrice"))
|
|
fee = max(0.0, _safe_float(row.get("execFee")))
|
|
fee_currency = str(row.get("feeCurrency", "")).upper()
|
|
if not base_coin:
|
|
symbol = str(row.get("symbol", ""))
|
|
base_coin = symbol.removesuffix("USDT") if symbol.endswith("USDT") else ""
|
|
qty += exec_qty
|
|
value += exec_value or exec_qty * exec_price
|
|
if fee_currency == "USDT" or not fee_currency:
|
|
quote_fee += fee
|
|
fee_usdt += fee
|
|
elif fee_currency == base_coin:
|
|
base_fee += fee
|
|
fee_usdt += fee * exec_price
|
|
else:
|
|
fee_usdt += fee * exec_price
|
|
if qty <= 0:
|
|
qty = _safe_float(order.get("cumExecQty"))
|
|
if value <= 0:
|
|
value = _safe_float(order.get("cumExecValue"))
|
|
if value <= 0 and qty > 0:
|
|
value = qty * _safe_float(order.get("avgPrice"))
|
|
net_qty = max(0.0, qty - base_fee) if side == "Buy" else qty
|
|
return {
|
|
"status": str(order.get("orderStatus", "Unknown")),
|
|
"qty": qty,
|
|
"net_qty": net_qty,
|
|
"value": value,
|
|
"quote_fee": quote_fee,
|
|
"base_fee": base_fee,
|
|
"fee_usdt": fee_usdt,
|
|
}
|
|
|
|
|
|
def _safe_float(value: Any, default: float = 0.0) -> float:
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def prices_from_tickers(tickers: Iterable[Ticker]) -> dict[str, float]:
|
|
return {ticker.symbol: ticker.last_price for ticker in tickers}
|
|
|
|
|
|
def _symbol_position_limit(settings: Settings) -> int:
|
|
configured_limit = max(1, settings.max_positions_per_symbol)
|
|
exposure_based_limit = max(
|
|
1,
|
|
int(settings.max_symbol_exposure_usdt // max(settings.min_position_usdt, 0.01)),
|
|
)
|
|
return min(configured_limit, exposure_based_limit)
|