Files
TradeBot/crypto_spot_bot/reconciliation.py
T

162 lines
5.4 KiB
Python

from __future__ import annotations
from dataclasses import asdict
from typing import Any
from crypto_spot_bot.bybit import BybitClient, Instrument
from crypto_spot_bot.config import Settings
from crypto_spot_bot.storage import Storage
def reconciliation_snapshot(
*,
settings: Settings,
storage: Storage,
client: BybitClient,
instruments: dict[str, Instrument],
) -> dict[str, Any]:
local_positions = storage.open_positions()
local = [
{
"id": position.id,
"symbol": position.symbol,
"qty": position.qty,
"entry_price": position.entry_price,
"notional_usdt": position.notional_usdt,
}
for position in local_positions
]
if not settings.live_ready:
return {
"status": "paper",
"live_ready": False,
"local_positions": local,
"remote_balances": [],
"open_orders": [],
"discrepancies": [],
"message": "reconciliation requires unlocked live Bybit credentials",
}
try:
coins = _coins_for_symbols(settings.symbols, instruments)
wallet = client.wallet_balance(coin=",".join(coins))
orders = client.realtime_orders(category="spot", open_only=0, limit=50)
except Exception as exc:
return {
"status": "error",
"live_ready": True,
"local_positions": local,
"remote_balances": [],
"open_orders": [],
"discrepancies": [{"severity": "error", "code": "bybit_read_failed", "message": str(exc)}],
}
balances = _balances(wallet)
open_orders = orders.get("list", []) if isinstance(orders.get("list"), list) else []
discrepancies = _discrepancies(local_positions, balances, instruments)
return {
"status": "warn" if discrepancies else "ok",
"live_ready": True,
"local_positions": local,
"remote_balances": balances,
"open_orders": open_orders,
"discrepancies": discrepancies,
"account": _account_summary(wallet),
"instruments": {symbol: asdict(info) for symbol, info in instruments.items() if symbol in settings.symbols},
}
def _coins_for_symbols(symbols: tuple[str, ...], instruments: dict[str, Instrument]) -> list[str]:
coins = {"USDT"}
for symbol in symbols:
info = instruments.get(symbol)
if info and info.base_coin:
coins.add(info.base_coin.upper())
elif symbol.endswith("USDT"):
coins.add(symbol[:-4].upper())
return sorted(coins)
def _balances(wallet: dict[str, Any]) -> list[dict[str, Any]]:
rows = wallet.get("list")
if not isinstance(rows, list) or not rows:
return []
coins = rows[0].get("coin")
if not isinstance(coins, list):
return []
output = []
for row in coins:
if not isinstance(row, dict):
continue
output.append(
{
"coin": str(row.get("coin", "")),
"equity": _float(row.get("equity")),
"wallet_balance": _float(row.get("walletBalance")),
"locked": _float(row.get("locked")),
"usd_value": _float(row.get("usdValue")),
}
)
return output
def _account_summary(wallet: dict[str, Any]) -> dict[str, Any]:
rows = wallet.get("list")
if not isinstance(rows, list) or not rows:
return {}
row = rows[0]
return {
"account_type": row.get("accountType"),
"total_equity": _float(row.get("totalEquity")),
"total_wallet_balance": _float(row.get("totalWalletBalance")),
"total_available_balance": _float(row.get("totalAvailableBalance")),
}
def _discrepancies(
positions: list[Any],
balances: list[dict[str, Any]],
instruments: dict[str, Instrument],
) -> list[dict[str, Any]]:
by_coin = {str(row.get("coin", "")).upper(): row for row in balances}
local_by_coin: dict[str, float] = {}
for position in positions:
info = instruments.get(position.symbol)
coin = info.base_coin.upper() if info and info.base_coin else position.symbol.removesuffix("USDT")
local_by_coin[coin] = local_by_coin.get(coin, 0.0) + float(position.qty)
issues = []
for coin, local_qty in local_by_coin.items():
remote_qty = _float((by_coin.get(coin) or {}).get("equity"))
tolerance = max(1e-8, local_qty * 0.002)
if remote_qty + tolerance < local_qty:
issues.append(
{
"severity": "error",
"code": "remote_balance_below_local_position",
"coin": coin,
"local_qty": round(local_qty, 10),
"remote_qty": round(remote_qty, 10),
}
)
for coin, row in by_coin.items():
if coin == "USDT":
continue
remote_qty = _float(row.get("equity"))
local_qty = local_by_coin.get(coin, 0.0)
if remote_qty > max(1e-8, local_qty * 1.05) and local_qty <= 0:
issues.append(
{
"severity": "warn",
"code": "remote_asset_without_local_position",
"coin": coin,
"remote_qty": round(remote_qty, 10),
}
)
return issues
def _float(value: Any) -> float:
try:
return float(value)
except (TypeError, ValueError):
return 0.0