162 lines
5.4 KiB
Python
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
|