Add analytics risk guard and redesigned dashboard

This commit is contained in:
Codex
2026-06-23 17:20:44 +03:00
parent 13de641fe3
commit 4d02ff3806
20 changed files with 1825 additions and 855 deletions
+300
View File
@@ -0,0 +1,300 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
from crypto_spot_bot.config import Settings
from crypto_spot_bot.storage import Storage
def analytics_snapshot(settings: Settings, storage: Storage) -> dict[str, Any]:
closed = _closed_trades_with_diagnostics(storage, settings.learning_lookback_trades)
return {
"pnl": pnl_attribution(closed),
"probability_calibration": probability_calibration(closed),
"drift": drift_snapshot(settings, closed, storage.recent_signals(240)),
"risk_guard": risk_guard_snapshot(settings, closed, storage.latest_equity()),
}
def pnl_attribution(closed_trades: list[dict[str, Any]]) -> dict[str, Any]:
total = _trade_stats(closed_trades)
by_symbol = _group_stats(closed_trades, lambda trade: str(trade.get("symbol", "")))
by_exit = _group_stats(closed_trades, lambda trade: _exit_category(str(trade.get("reason", ""))))
by_model = _group_stats(closed_trades, lambda trade: _entry_model(trade))
recent = [_trade_summary(trade) for trade in closed_trades[:20]]
return {
"total": total,
"by_symbol": by_symbol,
"by_exit": by_exit,
"by_model": by_model,
"recent": recent,
}
def probability_calibration(closed_trades: list[dict[str, Any]]) -> dict[str, Any]:
buckets: dict[str, list[dict[str, Any]]] = {}
for trade in closed_trades:
probability = _entry_probability(trade)
if probability is None:
continue
low = max(0.0, min(0.95, int(probability * 20) / 20))
high = low + 0.05
key = f"{low:.2f}-{high:.2f}"
buckets.setdefault(key, []).append(trade)
rows = []
for key in sorted(buckets):
trades = buckets[key]
stats = _trade_stats(trades)
predicted = [_entry_probability(trade) for trade in trades]
avg_probability = sum(value for value in predicted if value is not None) / len(trades)
rows.append(
{
"bucket": key,
"trades": len(trades),
"avg_probability": round(avg_probability, 4),
"actual_win_rate": stats["win_rate"],
"calibration_error": round(stats["win_rate"] - avg_probability, 4),
"net_pnl": stats["net_pnl"],
"avg_net_percent": stats["avg_net_percent"],
}
)
status = "insufficient"
if sum(row["trades"] for row in rows) >= 12:
avg_abs_error = sum(abs(row["calibration_error"]) * row["trades"] for row in rows) / sum(row["trades"] for row in rows)
status = "ok" if avg_abs_error <= 0.12 else "warn"
else:
avg_abs_error = None
return {
"status": status,
"buckets": rows,
"samples": sum(row["trades"] for row in rows),
"avg_abs_error": round(avg_abs_error, 4) if avg_abs_error is not None else None,
}
def drift_snapshot(settings: Settings, closed_trades: list[dict[str, Any]], signals: list[dict[str, Any]]) -> dict[str, Any]:
window = max(4, settings.risk_recent_trade_window)
recent = closed_trades[:window]
previous = closed_trades[window : window * 2]
recent_stats = _trade_stats(recent)
previous_stats = _trade_stats(previous)
failed_checks = _failed_check_counts(signals)
status = "insufficient"
warnings: list[str] = []
if recent_stats["trades"] >= max(4, min(window, 8)):
status = "ok"
if recent_stats["profit_factor"] < settings.risk_min_recent_profit_factor:
warnings.append("recent_profit_factor_below_min")
if recent_stats["avg_net_percent"] <= 0:
warnings.append("recent_expectancy_non_positive")
if _consecutive_losses(closed_trades) >= settings.risk_max_consecutive_losses:
warnings.append("consecutive_losses")
if warnings:
status = "warn"
return {
"status": status,
"warnings": warnings,
"recent": recent_stats,
"previous": previous_stats,
"failed_checks": failed_checks,
}
def risk_guard_snapshot(
settings: Settings,
closed_trades: list[dict[str, Any]],
latest_equity: dict[str, Any] | None,
) -> dict[str, Any]:
if not settings.risk_guard_enabled:
return {
"enabled": False,
"block_new_entries": False,
"position_size_multiplier": 1.0,
"reasons": [],
}
reasons: list[str] = []
consecutive_losses = _consecutive_losses(closed_trades)
if consecutive_losses >= settings.risk_max_consecutive_losses:
reasons.append("consecutive_losses")
today_pnl = _today_pnl(closed_trades)
if today_pnl <= -abs(settings.max_daily_drawdown_usdt):
reasons.append("daily_loss_limit")
window = max(4, settings.risk_recent_trade_window)
recent_stats = _trade_stats(closed_trades[:window])
if recent_stats["trades"] >= max(4, min(window, 8)):
if recent_stats["profit_factor"] < settings.risk_min_recent_profit_factor:
reasons.append("recent_profit_factor_below_min")
if recent_stats["avg_net_percent"] <= 0:
reasons.append("recent_expectancy_non_positive")
latest_drawdown = float((latest_equity or {}).get("drawdown", 0.0) or 0.0)
if latest_drawdown >= abs(settings.max_daily_drawdown_usdt):
reasons.append("equity_drawdown_limit")
block = bool({"consecutive_losses", "daily_loss_limit", "equity_drawdown_limit"} & set(reasons))
multiplier = 0.0 if block else (settings.risk_reduce_multiplier if reasons else 1.0)
return {
"enabled": True,
"block_new_entries": block,
"position_size_multiplier": round(max(0.0, min(1.0, multiplier)), 4),
"reasons": reasons,
"consecutive_losses": consecutive_losses,
"today_pnl": round(today_pnl, 6),
"recent": recent_stats,
}
def _closed_trades_with_diagnostics(storage: Storage, limit: int) -> list[dict[str, Any]]:
rows = storage.closed_trades(limit)
for row in rows:
row["entry_diagnostics"] = _json_or_default(row.get("entry_diagnostics_json"), {})
return rows
def _trade_stats(trades: list[dict[str, Any]]) -> dict[str, Any]:
values = [float(trade.get("net_pnl", 0.0) or 0.0) for trade in trades]
wins = [value for value in values if value > 0]
losses = [value for value in values if value < 0]
gross_profit = sum(wins)
gross_loss = abs(sum(losses))
profit_factor = gross_profit / gross_loss if gross_loss > 0 else (999.0 if gross_profit > 0 else 0.0)
percents = [_trade_net_percent(trade) for trade in trades]
return {
"trades": len(trades),
"wins": len(wins),
"losses": len(losses),
"win_rate": round(len(wins) / len(trades), 4) if trades else 0.0,
"net_pnl": round(sum(values), 6),
"fees": round(sum(float(trade.get("fee_usdt", 0.0) or 0.0) for trade in trades), 6),
"avg_net_pnl": round(sum(values) / len(trades), 6) if trades else 0.0,
"avg_net_percent": round(sum(percents) / len(percents), 4) if percents else 0.0,
"profit_factor": round(profit_factor, 4),
"best": round(max(values), 6) if values else 0.0,
"worst": round(min(values), 6) if values else 0.0,
}
def _group_stats(trades: list[dict[str, Any]], key_fn) -> list[dict[str, Any]]:
groups: dict[str, list[dict[str, Any]]] = {}
for trade in trades:
key = key_fn(trade) or "unknown"
groups.setdefault(key, []).append(trade)
rows = [{"key": key, **_trade_stats(items)} for key, items in groups.items()]
return sorted(rows, key=lambda row: (row["net_pnl"], row["trades"]), reverse=True)
def _trade_summary(trade: dict[str, Any]) -> dict[str, Any]:
diagnostics = trade.get("entry_diagnostics") if isinstance(trade.get("entry_diagnostics"), dict) else {}
forecast = diagnostics.get("forecast") if isinstance(diagnostics.get("forecast"), dict) else {}
return {
"id": trade.get("id"),
"symbol": trade.get("symbol"),
"net_pnl": round(float(trade.get("net_pnl", 0.0) or 0.0), 6),
"net_percent": _trade_net_percent(trade),
"fee_usdt": round(float(trade.get("fee_usdt", 0.0) or 0.0), 6),
"entry_price": trade.get("entry_price"),
"exit_price": trade.get("exit_price"),
"closed_at": trade.get("closed_at"),
"exit_category": _exit_category(str(trade.get("reason", ""))),
"entry_probability": forecast.get("probability_up"),
"entry_expected_percent": forecast.get("expected_return_percent"),
"entry_confidence": trade.get("entry_confidence"),
"model": forecast.get("model"),
}
def _trade_net_percent(trade: dict[str, Any]) -> float:
qty = float(trade.get("qty", 0.0) or 0.0)
entry_price = float(trade.get("entry_price", 0.0) or 0.0)
notional = qty * entry_price
if notional <= 0:
return 0.0
return round(float(trade.get("net_pnl", 0.0) or 0.0) / notional * 100, 4)
def _exit_category(reason: str) -> str:
text = reason.lower()
if "stop" in text:
return "stop_loss"
if "trailing" in text:
return "trailing_stop"
if "negative" in text or "turned" in text:
return "forecast_negative"
if "weak" in text or "enough edge" in text:
return "forecast_weak"
if "take" in text:
return "take_profit"
return "other"
def _entry_model(trade: dict[str, Any]) -> str:
diagnostics = trade.get("entry_diagnostics") if isinstance(trade.get("entry_diagnostics"), dict) else {}
forecast = diagnostics.get("forecast") if isinstance(diagnostics.get("forecast"), dict) else {}
return str(forecast.get("model") or "unknown")
def _entry_probability(trade: dict[str, Any]) -> float | None:
diagnostics = trade.get("entry_diagnostics") if isinstance(trade.get("entry_diagnostics"), dict) else {}
forecast = diagnostics.get("forecast") if isinstance(diagnostics.get("forecast"), dict) else {}
value = forecast.get("probability_up")
try:
probability = float(value)
except (TypeError, ValueError):
return None
return max(0.0, min(1.0, probability))
def _failed_check_counts(signals: list[dict[str, Any]]) -> dict[str, int]:
counts: dict[str, int] = {}
for signal in signals:
diagnostics = _json_or_default(signal.get("diagnostics_json"), {})
checks = diagnostics.get("checks") if isinstance(diagnostics, dict) else {}
if not isinstance(checks, dict):
continue
for key, ok in checks.items():
if not ok:
counts[str(key)] = counts.get(str(key), 0) + 1
return dict(sorted(counts.items(), key=lambda item: item[1], reverse=True))
def _consecutive_losses(closed_trades: list[dict[str, Any]]) -> int:
count = 0
for trade in closed_trades:
if float(trade.get("net_pnl", 0.0) or 0.0) < 0:
count += 1
else:
break
return count
def _today_pnl(closed_trades: list[dict[str, Any]]) -> float:
today = datetime.now(timezone.utc).date()
total = 0.0
for trade in closed_trades:
closed_at = _parse_datetime(trade.get("closed_at"))
if closed_at and closed_at.date() == today:
total += float(trade.get("net_pnl", 0.0) or 0.0)
return total
def _json_or_default(value: Any, default: Any) -> Any:
if isinstance(value, (dict, list)):
return value
if not isinstance(value, str):
return default
try:
return json.loads(value)
except json.JSONDecodeError:
return default
def _parse_datetime(value: Any) -> datetime | None:
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
+22
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
from datetime import datetime
from crypto_spot_bot.analytics import risk_guard_snapshot
from crypto_spot_bot.config import Settings
from crypto_spot_bot.execution import LiveBroker, PaperBroker
from crypto_spot_bot.learning import TradeLearner
@@ -123,6 +124,11 @@ class CryptoSpotBot:
async def _process_entries(self) -> None:
prices = self.market.prices()
risk_guard = risk_guard_snapshot(
self.settings,
self.storage.closed_trades(self.settings.learning_lookback_trades),
self.storage.latest_equity(),
)
for symbol in self.market.symbols:
cooldown_since = self._entry_cooldown_until.get(symbol)
if cooldown_since:
@@ -149,6 +155,22 @@ class CryptoSpotBot:
learning = self.learner.adjustment_for(symbol, str(pattern.get("label", ""))).as_dict()
learning["adaptive_rules"] = self._with_exposure_context(learning.get("adaptive_rules") or {})
account = self.broker.account_state(prices)
account["risk_guard"] = risk_guard
if risk_guard.get("block_new_entries"):
self.storage.insert_signal(
Signal(
symbol,
"HOLD",
0.0,
"risk_guard: new entries blocked",
{
"strategy_mode": self.settings.strategy_mode,
"risk_guard": risk_guard,
"checks": {"risk_guard_ok": False},
},
)
)
continue
llm = {}
if (
self.settings.llm_advisor_enabled
+25
View File
@@ -63,6 +63,19 @@ class BybitClient:
response.raise_for_status()
return self._unwrap(response.json())
def private_get(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
query_params = sorted((key, value) for key, value in params.items() if value is not None)
query = urlencode(query_params)
headers = self._headers(query)
response = self.session.get(
f"{self.settings.rest_base_url}{path}",
params=query_params,
headers=headers,
timeout=15,
)
response.raise_for_status()
return self._unwrap(response.json())
def _headers(self, payload: str) -> dict[str, str]:
timestamp = str(int(time.time() * 1000))
recv_window = "5000"
@@ -204,6 +217,18 @@ class BybitClient:
}
return self.private_post("/v5/order/create", payload)
def wallet_balance(self, account_type: str = "UNIFIED", coin: str | None = None) -> dict[str, Any]:
return self.private_get(
"/v5/account/wallet-balance",
{"accountType": account_type, "coin": coin},
)
def realtime_orders(self, *, category: str = "spot", open_only: int = 0, limit: int = 50) -> dict[str, Any]:
return self.private_get(
"/v5/order/realtime",
{"category": category, "openOnly": open_only, "limit": max(1, min(limit, 50))},
)
def websocket_subscribe_message(symbols: list[str], interval: str = "1") -> str:
args: list[str] = []
+12 -2
View File
@@ -101,6 +101,11 @@ class Settings:
kelly_fraction: float
kelly_max_fraction: float
risk_per_trade_percent: float
risk_guard_enabled: bool
risk_recent_trade_window: int
risk_max_consecutive_losses: int
risk_min_recent_profit_factor: float
risk_reduce_multiplier: float
atr_trailing_multiplier: float
trend_rsi_min: float
trend_rsi_max: float
@@ -244,14 +249,19 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
kelly_fraction=_float_env("KELLY_FRACTION", 0.25),
kelly_max_fraction=_float_env("KELLY_MAX_FRACTION", 0.20),
risk_per_trade_percent=_float_env("RISK_PER_TRADE_PERCENT", 0.01),
risk_guard_enabled=_bool_env("RISK_GUARD_ENABLED", True),
risk_recent_trade_window=_int_env("RISK_RECENT_TRADE_WINDOW", 20),
risk_max_consecutive_losses=_int_env("RISK_MAX_CONSECUTIVE_LOSSES", 4),
risk_min_recent_profit_factor=_float_env("RISK_MIN_RECENT_PROFIT_FACTOR", 0.85),
risk_reduce_multiplier=_float_env("RISK_REDUCE_MULTIPLIER", 0.50),
atr_trailing_multiplier=_float_env("ATR_TRAILING_MULTIPLIER", 2.2),
trend_rsi_min=_float_env("TREND_RSI_MIN", 45.0),
trend_rsi_max=_float_env("TREND_RSI_MAX", 65.0),
time_series_forecast_enabled=_bool_env("TIME_SERIES_FORECAST_ENABLED", forecast_enabled_default),
time_series_min_candles=_int_env("TIME_SERIES_MIN_CANDLES", 120),
time_series_forecast_horizon=_int_env("TIME_SERIES_FORECAST_HORIZON", 3),
time_series_min_edge_percent=_float_env("TIME_SERIES_MIN_EDGE_PERCENT", 0.10),
time_series_min_probability_up=_float_env("TIME_SERIES_MIN_PROBABILITY_UP", 0.64),
time_series_min_edge_percent=_float_env("TIME_SERIES_MIN_EDGE_PERCENT", 0.08),
time_series_min_probability_up=_float_env("TIME_SERIES_MIN_PROBABILITY_UP", 0.58),
time_series_min_confidence=_float_env("TIME_SERIES_MIN_CONFIDENCE", 0.72),
time_series_max_adjustment=_float_env("TIME_SERIES_MAX_ADJUSTMENT", 0.08),
time_series_lstm_enabled=_bool_env("TIME_SERIES_LSTM_ENABLED", True),
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
from __future__ import annotations
from typing import Any
from crypto_spot_bot.models import Candle, Ticker, utc_now
def market_quality_snapshot(
*,
symbols: list[str],
candles_by_symbol: dict[str, list[Candle]],
tickers: dict[str, Ticker],
interval: str,
) -> dict[str, Any]:
rows = [
analyze_symbol_quality(
symbol=symbol,
candles=candles_by_symbol.get(symbol, []),
ticker=tickers.get(symbol),
interval=interval,
)
for symbol in symbols
]
worst_score = min((row["score"] for row in rows), default=0.0)
status = "ok"
if any(row["status"] == "error" for row in rows):
status = "error"
elif any(row["status"] == "warn" for row in rows):
status = "warn"
return {
"status": status,
"score": round(worst_score, 4),
"symbols": rows,
}
def analyze_symbol_quality(
*,
symbol: str,
candles: list[Candle],
ticker: Ticker | None,
interval: str,
) -> dict[str, Any]:
issues: list[dict[str, Any]] = []
score = 1.0
interval_ms = _interval_ms(interval)
if not candles:
return _row(symbol, "error", 0.0, [{"code": "no_candles", "severity": "error"}], 0, None, None)
timestamps = [candle.timestamp for candle in candles]
duplicates = len(timestamps) - len(set(timestamps))
if duplicates:
issues.append({"code": "duplicate_candles", "severity": "warn", "count": duplicates})
score -= min(0.25, duplicates * 0.03)
invalid_ohlc = 0
zero_volume = 0
for candle in candles:
prices = [candle.open, candle.high, candle.low, candle.close]
if any(price <= 0 for price in prices) or candle.high < max(candle.open, candle.close) or candle.low > min(candle.open, candle.close):
invalid_ohlc += 1
if candle.volume <= 0:
zero_volume += 1
if invalid_ohlc:
issues.append({"code": "invalid_ohlc", "severity": "error", "count": invalid_ohlc})
score -= min(0.45, invalid_ohlc * 0.08)
if zero_volume:
issues.append({"code": "zero_volume", "severity": "warn", "count": zero_volume})
score -= min(0.20, zero_volume * 0.02)
missing_gaps = 0
largest_gap_ms = 0
if interval_ms > 0:
ordered = sorted(set(timestamps))
for left, right in zip(ordered, ordered[1:]):
gap = right - left
largest_gap_ms = max(largest_gap_ms, gap)
if gap > interval_ms * 1.5:
missing_gaps += max(1, round(gap / interval_ms) - 1)
if missing_gaps:
issues.append({"code": "missing_candles", "severity": "warn", "count": missing_gaps})
score -= min(0.35, missing_gaps * 0.04)
age_seconds = max(0.0, (utc_now().timestamp() * 1000 - max(timestamps)) / 1000)
stale_after = interval_ms / 1000 * 2.5
if age_seconds > stale_after:
issues.append({"code": "stale_candles", "severity": "warn", "age_seconds": round(age_seconds, 1)})
score -= 0.20
else:
age_seconds = None
if ticker is None:
issues.append({"code": "no_ticker", "severity": "error"})
score -= 0.45
else:
if ticker.last_price <= 0:
issues.append({"code": "invalid_ticker_price", "severity": "error"})
score -= 0.35
if ticker.spread_percent > 0.35:
issues.append({"code": "wide_spread", "severity": "warn", "spread_percent": round(ticker.spread_percent, 4)})
score -= 0.15
score = max(0.0, min(1.0, score))
severity = {str(issue.get("severity")) for issue in issues}
status = "error" if "error" in severity else "warn" if "warn" in severity else "ok"
return _row(
symbol,
status,
score,
issues,
len(candles),
max(timestamps),
largest_gap_ms if interval_ms > 0 else None,
)
def _row(
symbol: str,
status: str,
score: float,
issues: list[dict[str, Any]],
candle_count: int,
last_candle_timestamp: int | None,
largest_gap_ms: int | None,
) -> dict[str, Any]:
return {
"symbol": symbol,
"status": status,
"score": round(score, 4),
"candle_count": candle_count,
"last_candle_timestamp": last_candle_timestamp,
"largest_gap_ms": largest_gap_ms,
"issues": issues,
}
def _interval_ms(interval: str) -> int:
normalized = str(interval).strip().upper()
if normalized == "D":
return 24 * 60 * 60 * 1000
if normalized == "W":
return 7 * 24 * 60 * 60 * 1000
if normalized.isdigit():
return int(normalized) * 60 * 1000
return 0
+3
View File
@@ -171,6 +171,7 @@ class PaperBroker:
entry_reason=signal.reason,
entry_confidence=signal.confidence,
entry_pattern=str(signal.diagnostics.get("pattern", {}).get("label", "")),
entry_diagnostics=signal.diagnostics,
)
position.id = self.storage.insert_position(position)
self.positions.append(position)
@@ -189,6 +190,7 @@ class PaperBroker:
reason=signal.reason,
entry_pattern=position.entry_pattern,
entry_confidence=position.entry_confidence,
entry_diagnostics=position.entry_diagnostics,
opened_at=position.opened_at,
)
)
@@ -230,6 +232,7 @@ class PaperBroker:
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(),
)
+13
View File
@@ -10,6 +10,7 @@ import websockets
from crypto_spot_bot.bybit import BybitClient, Instrument, websocket_subscribe_message
from crypto_spot_bot.config import Settings
from crypto_spot_bot.data_quality import analyze_symbol_quality, market_quality_snapshot
from crypto_spot_bot.indicators import add_indicators
from crypto_spot_bot.models import Candle, Ticker, utc_now
from crypto_spot_bot.storage import Storage
@@ -217,6 +218,12 @@ class MarketData:
return {
"symbols": self.symbols,
"ws_connected": self.ws_connected,
"quality": market_quality_snapshot(
symbols=self.symbols,
candles_by_symbol=self.candles,
tickers=self.tickers,
interval=self.settings.base_interval,
),
"last_rest_refresh_at": self.last_rest_refresh_at.isoformat()
if self.last_rest_refresh_at
else None,
@@ -230,6 +237,12 @@ class MarketData:
"trend_candles": [candle.as_dict() for candle in self.trend_candles.get(symbol, [])[-5:]],
"pattern": self.patterns.get(symbol),
"forecast": self.forecasts.get(symbol),
"quality": analyze_symbol_quality(
symbol=symbol,
candles=self.candles.get(symbol, []),
ticker=self.tickers.get(symbol),
interval=self.settings.base_interval,
),
"instrument": asdict(self.instruments[symbol]) if symbol in self.instruments else None,
}
for symbol in self.symbols
+2
View File
@@ -87,6 +87,7 @@ class Position:
entry_reason: str = ""
entry_confidence: float = 0.0
entry_pattern: str = ""
entry_diagnostics: dict[str, Any] = field(default_factory=dict)
def mark_price(self, price: float) -> float:
return self.qty * price
@@ -127,6 +128,7 @@ class Trade:
reason: str = ""
entry_pattern: str = ""
entry_confidence: float = 0.0
entry_diagnostics: dict[str, Any] = field(default_factory=dict)
opened_at: datetime | None = None
closed_at: datetime | None = None
+161
View File
@@ -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
+11 -4
View File
@@ -43,6 +43,7 @@ class Storage:
entry_reason TEXT NOT NULL DEFAULT '',
entry_confidence REAL NOT NULL DEFAULT 0,
entry_pattern TEXT NOT NULL DEFAULT '',
entry_diagnostics_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'OPEN'
);
CREATE TABLE IF NOT EXISTS trades (
@@ -58,6 +59,7 @@ class Storage:
reason TEXT NOT NULL DEFAULT '',
entry_pattern TEXT NOT NULL DEFAULT '',
entry_confidence REAL NOT NULL DEFAULT 0,
entry_diagnostics_json TEXT NOT NULL DEFAULT '{}',
opened_at TEXT,
closed_at TEXT
);
@@ -113,6 +115,7 @@ class Storage:
"entry_reason": "TEXT NOT NULL DEFAULT ''",
"entry_confidence": "REAL NOT NULL DEFAULT 0",
"entry_pattern": "TEXT NOT NULL DEFAULT ''",
"entry_diagnostics_json": "TEXT NOT NULL DEFAULT '{}'",
}.items():
if column not in columns:
conn.execute(f"ALTER TABLE positions ADD COLUMN {column} {definition}")
@@ -123,6 +126,7 @@ class Storage:
for column, definition in {
"entry_pattern": "TEXT NOT NULL DEFAULT ''",
"entry_confidence": "REAL NOT NULL DEFAULT 0",
"entry_diagnostics_json": "TEXT NOT NULL DEFAULT '{}'",
}.items():
if column not in trade_columns:
conn.execute(f"ALTER TABLE trades ADD COLUMN {column} {definition}")
@@ -134,8 +138,8 @@ class Storage:
INSERT INTO positions (
symbol, qty, entry_price, notional_usdt, entry_fee_usdt, stop_loss,
take_profit, highest_price, opened_at, entry_reason,
entry_confidence, entry_pattern, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'OPEN')
entry_confidence, entry_pattern, entry_diagnostics_json, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'OPEN')
""",
(
position.symbol,
@@ -150,6 +154,7 @@ class Storage:
position.entry_reason,
position.entry_confidence,
position.entry_pattern,
json.dumps(position.entry_diagnostics, ensure_ascii=False),
),
)
return int(cur.lastrowid)
@@ -185,6 +190,7 @@ class Storage:
entry_reason=row["entry_reason"],
entry_confidence=float(row["entry_confidence"]),
entry_pattern=row["entry_pattern"],
entry_diagnostics=_json_or_default(row["entry_diagnostics_json"], {}),
)
for row in rows
]
@@ -196,8 +202,8 @@ class Storage:
INSERT INTO trades (
symbol, side, qty, entry_price, exit_price, gross_pnl,
fee_usdt, net_pnl, reason, entry_pattern, entry_confidence,
opened_at, closed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
entry_diagnostics_json, opened_at, closed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
trade.symbol,
@@ -211,6 +217,7 @@ class Storage:
trade.reason,
trade.entry_pattern,
trade.entry_confidence,
json.dumps(trade.entry_diagnostics, ensure_ascii=False),
trade.opened_at.isoformat() if trade.opened_at else None,
trade.closed_at.isoformat() if trade.closed_at else None,
),
+15 -1
View File
@@ -852,6 +852,8 @@ def _trend_position_sizing(
if equity <= 0:
equity = settings.starting_balance_usdt
risk_fraction = _clamp(settings.risk_per_trade_percent, 0.0, 0.01)
guard_multiplier = _risk_guard_multiplier(account)
risk_fraction *= guard_multiplier
risk_usdt = equity * risk_fraction
raw_notional = risk_usdt / max(stop_loss_percent, 0.0001)
high = max(0.0, settings.max_position_usdt)
@@ -860,6 +862,7 @@ def _trend_position_sizing(
return {
"method": "fixed_fractional_risk",
"risk_per_trade_percent": round(risk_fraction * 100, 4),
"risk_guard_multiplier": round(guard_multiplier, 4),
"risk_usdt": round(risk_usdt, 4),
"stop_loss_percent": round(stop_loss_percent * 100, 4),
"raw_notional_usdt": round(raw_notional, 4),
@@ -908,7 +911,7 @@ def _position_sizing(
denominator = max(0.0001, 1.0 - settings.min_signal_confidence)
confidence_ratio = _clamp((final_score - settings.min_signal_confidence) / denominator, 0.0, 1.0)
confidence_notional = low + (high - low) * confidence_ratio
risk_multiplier = _position_risk_multiplier(forecast, adaptive)
risk_multiplier = _position_risk_multiplier(forecast, adaptive) * _risk_guard_multiplier(account)
method = "confidence"
raw = confidence_notional
kelly = _kelly_position(
@@ -952,6 +955,17 @@ def _position_risk_multiplier(forecast: dict | None, adaptive: dict | None) -> f
return multiplier
def _risk_guard_multiplier(account: dict | None) -> float:
guard = (account or {}).get("risk_guard")
if not isinstance(guard, dict):
return 1.0
try:
value = float(guard.get("position_size_multiplier", 1.0))
except (TypeError, ValueError):
value = 1.0
return _clamp(value, 0.0, 1.0)
def _kelly_position(
*,
settings: Settings,