Add analytics risk guard and redesigned dashboard
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
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
|
||||
Reference in New Issue
Block a user