Files
TradeBot/crypto_spot_bot/dashboard.py
T

540 lines
23 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
from contextlib import asynccontextmanager
from typing import Any
from fastapi import Depends, FastAPI, HTTPException, Response
from fastapi.responses import JSONResponse, PlainTextResponse
from crypto_spot_bot.analytics import analytics_snapshot
from crypto_spot_bot.auth import ApiAuthorizer
from crypto_spot_bot.bot import CryptoSpotBot
from crypto_spot_bot.bybit import BybitClient
from crypto_spot_bot.config import Settings, load_settings, update_env_value
from crypto_spot_bot.execution import LiveBroker, PaperBroker
from crypto_spot_bot.learning import TradeLearner
from crypto_spot_bot.market_data import MarketData
from crypto_spot_bot.patterns import PatternAnalyzer
from crypto_spot_bot.reconciliation import reconciliation_snapshot
from crypto_spot_bot.storage import Storage
from crypto_spot_bot.strategy import SpotStrategy
from crypto_spot_bot.time_series import TimeSeriesForecaster
from crypto_spot_bot.training_coordination import TrainingCoordinator
WEB_UI_REMOVED_MESSAGE = "Web UI removed. Use the Android TradeBot AI app and /api/* endpoints."
logger = logging.getLogger(__name__)
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or load_settings()
storage = Storage(settings.database_path)
runtime_fast_trading = storage.get_runtime("fast_trading_enabled", None)
if isinstance(runtime_fast_trading, bool):
settings.fast_trading_enabled = runtime_fast_trading
client = BybitClient(settings)
market = MarketData(settings, client, storage)
broker: PaperBroker | LiveBroker
if settings.trading_mode == "live":
broker = LiveBroker(settings, storage, client)
else:
broker = PaperBroker(settings, storage)
strategy = SpotStrategy(settings)
pattern_analyzer = PatternAnalyzer()
learner = TradeLearner(settings, storage)
forecaster = TimeSeriesForecaster(settings)
bot = CryptoSpotBot(settings, storage, market, broker, strategy, pattern_analyzer, learner, forecaster)
training = TrainingCoordinator(settings.time_series_lstm_model_path.parent)
authorizer = ApiAuthorizer(settings)
@asynccontextmanager
async def lifespan(_: FastAPI):
await bot.start()
try:
yield
finally:
await bot.stop()
app = FastAPI(title="Крипто спот-бот", lifespan=lifespan)
app.state.settings = settings
app.state.storage = storage
app.state.bot = bot
app.state.market = market
app.state.training = training
@app.get("/", response_class=PlainTextResponse, status_code=410)
async def index() -> str:
return WEB_UI_REMOVED_MESSAGE
@app.get("/api/health")
async def health() -> dict[str, Any]:
return {
"ok": True,
"running": bot.running,
"mode": settings.trading_mode,
"auth_configured": authorizer.configured(),
}
@app.get("/api/ready")
async def ready() -> JSONResponse:
payload = bot.readiness_snapshot()
return JSONResponse(payload, status_code=200 if payload["ready"] else 503)
@app.get("/api/status")
async def status(_: None = Depends(authorizer.require)) -> dict[str, Any]:
return {
"status": bot.status().as_dict(),
"account": bot.account_snapshot(),
"positions": bot.positions_snapshot(),
"learning": bot.learning_snapshot(),
"latest_equity": storage.latest_equity(mode=settings.trading_mode),
"readiness": bot.readiness_snapshot(),
}
@app.get("/api/markets")
async def markets(_: None = Depends(authorizer.require)) -> dict[str, Any]:
return market.snapshot()
@app.get("/api/trades")
async def trades(limit: int = 80, _: None = Depends(authorizer.require)) -> dict[str, Any]:
row_limit = _limit(limit)
return {
"items": storage.recent_trades(row_limit, mode=settings.trading_mode),
"closed_items": storage.closed_trades(row_limit, mode=settings.trading_mode),
"closed_summary": storage.closed_trade_summary(mode=settings.trading_mode),
}
@app.get("/api/signals")
async def signals(limit: int = 120, _: None = Depends(authorizer.require)) -> dict[str, Any]:
return {"items": storage.recent_signals(_limit(limit))}
@app.get("/api/events")
async def events(limit: int = 120, _: None = Depends(authorizer.require)) -> dict[str, Any]:
return {"items": storage.recent_events(_limit(limit))}
@app.get("/api/analytics")
async def analytics(_: None = Depends(authorizer.require)) -> dict[str, Any]:
return analytics_snapshot(settings, storage)
@app.get("/api/quality")
async def quality(_: None = Depends(authorizer.require)) -> dict[str, Any]:
return market.snapshot().get("quality", {})
@app.get("/api/reconciliation")
async def reconciliation(_: None = Depends(authorizer.require)) -> dict[str, Any]:
return await asyncio.to_thread(
reconciliation_snapshot,
settings=settings,
storage=storage,
client=client,
instruments=market.instruments,
)
@app.get("/api/backtest")
async def backtest(_: None = Depends(authorizer.require)) -> dict[str, Any]:
return _runtime_json(settings, "torch_threshold_calibration.json")
@app.get("/api/retrain")
async def retrain(_: None = Depends(authorizer.require)) -> dict[str, Any]:
data = _runtime_json(settings, "torch_retrain_guard.json")
data["coordination"] = training.status()
return data
@app.get("/api/training/status")
async def training_status(_: None = Depends(authorizer.require)) -> dict[str, Any]:
return training.status()
@app.post("/api/training/retrain")
async def training_retrain(
payload: dict[str, Any] | None = None,
_: None = Depends(authorizer.require),
) -> dict[str, Any]:
return training.request_retrain(payload)
@app.post("/api/training/heartbeat")
async def training_heartbeat(
payload: dict[str, Any] | None = None,
_: None = Depends(authorizer.require_training),
) -> dict[str, Any]:
return training.heartbeat(payload)
@app.post("/api/training/claim")
async def training_claim(
payload: dict[str, Any] | None = None,
_: None = Depends(authorizer.require_training),
) -> dict[str, Any]:
return training.claim(payload)
@app.post("/api/training/jobs/{job_id}/artifacts/chunk")
async def training_artifact_chunk(
job_id: str,
payload: dict[str, Any],
_: None = Depends(authorizer.require_training),
) -> dict[str, Any]:
try:
return training.save_artifact_chunk(job_id, payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/training/jobs/{job_id}/progress")
async def training_progress(
job_id: str,
payload: dict[str, Any] | None = None,
_: None = Depends(authorizer.require_training),
) -> dict[str, Any]:
try:
return training.progress(job_id, payload)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post("/api/training/jobs/{job_id}/complete")
async def training_complete(
job_id: str,
payload: dict[str, Any] | None = None,
_: None = Depends(authorizer.require_training),
) -> dict[str, Any]:
try:
return training.complete(job_id, payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/config")
async def config(_: None = Depends(authorizer.require)) -> dict[str, Any]:
return _safe_config(settings)
@app.get("/api/mobile/snapshot")
async def mobile_snapshot(_: None = Depends(authorizer.require)) -> dict[str, Any]:
row_limit = 220
retrain_data = _runtime_json(settings, "torch_retrain_guard.json")
retrain_data["coordination"] = training.status()
return {
"health": {
"ok": True,
"running": bot.running,
"mode": settings.trading_mode,
},
"status": {
"status": bot.status().as_dict(),
"account": bot.account_snapshot(),
"positions": bot.positions_snapshot(),
"learning": bot.learning_snapshot(),
"latest_equity": storage.latest_equity(mode=settings.trading_mode),
"readiness": bot.readiness_snapshot(),
},
"markets": market.snapshot(),
"signals": {"items": storage.recent_signals(row_limit)},
"config": _safe_config(settings),
"trades": {
"items": storage.recent_trades(10, mode=settings.trading_mode),
"closed_items": storage.closed_trades(10, mode=settings.trading_mode),
"closed_summary": storage.closed_trade_summary(mode=settings.trading_mode),
},
"retrain": retrain_data,
"backtest": _runtime_json(settings, "torch_threshold_calibration.json"),
}
@app.post("/api/config/fast-trading")
async def set_fast_trading(
payload: dict[str, Any],
_: None = Depends(authorizer.require),
) -> dict[str, Any]:
enabled = _enabled_from_payload(payload)
env_persisted = _apply_fast_trading(settings, storage, enabled)
response = _safe_config(settings)
response["env_persisted"] = env_persisted
return response
@app.post("/api/control/start")
async def start(_: None = Depends(authorizer.require)) -> dict[str, Any]:
await bot.start()
return bot.status().as_dict()
@app.post("/api/control/stop")
async def stop(_: None = Depends(authorizer.require)) -> dict[str, Any]:
await bot.stop()
return bot.status().as_dict()
@app.get("/metrics")
async def metrics() -> Response:
account = bot.account_snapshot()
lines = [
"# HELP tradebot_equity_usdt Current account equity.",
"# TYPE tradebot_equity_usdt gauge",
f"tradebot_equity_usdt {account['equity']:.8f}",
"# HELP tradebot_cash_usdt Current free USDT cash.",
"# TYPE tradebot_cash_usdt gauge",
f"tradebot_cash_usdt {account['cash']:.8f}",
"# HELP tradebot_open_positions Open positions count.",
"# TYPE tradebot_open_positions gauge",
f"tradebot_open_positions {len(bot.positions_snapshot())}",
"# HELP tradebot_websocket_connected Bybit WebSocket connection status.",
"# TYPE tradebot_websocket_connected gauge",
f"tradebot_websocket_connected {1 if market.ws_connected else 0}",
"# HELP tradebot_fast_trading_enabled Fast trading mode status.",
"# TYPE tradebot_fast_trading_enabled gauge",
f"tradebot_fast_trading_enabled {1 if settings.fast_trading_enabled else 0}",
"# HELP tradebot_loop_interval_seconds Effective bot decision loop interval.",
"# TYPE tradebot_loop_interval_seconds gauge",
f"tradebot_loop_interval_seconds {settings.effective_loop_interval_seconds:.4f}",
"# HELP tradebot_ready Whether trading prerequisites are ready.",
"# TYPE tradebot_ready gauge",
f"tradebot_ready {1 if bot.readiness_snapshot()['ready'] else 0}",
"# HELP tradebot_rest_errors_total REST refresh errors observed by market data.",
"# TYPE tradebot_rest_errors_total counter",
f"tradebot_rest_errors_total {market.rest_error_count}",
]
return PlainTextResponse("\n".join(lines) + "\n")
@app.exception_handler(Exception)
async def error_handler(_, exc: Exception) -> JSONResponse:
try:
storage.event(f"API error: {exc}", "ERROR")
except Exception:
logger.exception("Could not persist API error event")
return JSONResponse({"error": "internal server error"}, status_code=500)
return app
def _limit(value: int) -> int:
return max(1, min(int(value), 500))
def _enabled_from_payload(payload: dict[str, Any]) -> bool:
value = payload.get("enabled")
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "y", "on", "вкл", "включено"}
return bool(value)
def _apply_fast_trading(settings: Settings, storage: Storage, enabled: bool) -> bool:
settings.fast_trading_enabled = enabled
storage.set_runtime("fast_trading_enabled", enabled)
env_persisted = True
try:
update_env_value(settings.env_file_path, "FAST_TRADING_ENABLED", "true" if enabled else "false")
except OSError as exc:
env_persisted = False
storage.event(f"Быстрая торговля изменена только в runtime, .env не записан: {exc}", "WARN")
state = "включена" if enabled else "выключена"
storage.event(f"Быстрая торговля {state}")
return env_persisted
def _safe_config(settings: Settings) -> dict[str, Any]:
return {
"trading_mode": settings.trading_mode,
"bybit_testnet": settings.bybit_testnet,
"starting_balance_usdt": settings.starting_balance_usdt,
"auto_select_symbols": settings.auto_select_symbols,
"top_symbols_count": settings.top_symbols_count,
"symbols": settings.symbols,
"strategy_mode": settings.strategy_mode,
"base_interval": settings.base_interval,
"kline_limit": settings.kline_limit,
"trend_interval": settings.trend_interval,
"trend_kline_limit": settings.trend_kline_limit,
"loop_interval_seconds": settings.loop_interval_seconds,
"fast_trading_enabled": settings.fast_trading_enabled,
"fast_loop_interval_seconds": settings.fast_loop_interval_seconds,
"effective_loop_interval_seconds": settings.effective_loop_interval_seconds,
"fast_entry_cooldown_seconds": settings.fast_entry_cooldown_seconds,
"effective_entry_cooldown_seconds": settings.effective_entry_cooldown_seconds,
"max_entries_per_minute": settings.max_entries_per_minute,
"websocket_enabled": settings.websocket_enabled,
"min_signal_confidence": settings.min_signal_confidence,
"max_spread_percent": settings.max_spread_percent,
"min_24h_turnover_usdt": settings.min_24h_turnover_usdt,
"pattern_analysis_enabled": settings.pattern_analysis_enabled,
"pattern_score_weight": settings.pattern_score_weight,
"learning_enabled": settings.learning_enabled,
"learning_lookback_trades": settings.learning_lookback_trades,
"learning_min_samples": settings.learning_min_samples,
"learning_max_adjustment": settings.learning_max_adjustment,
"learning_max_position_multiplier": settings.learning_max_position_multiplier,
"min_position_usdt": settings.min_position_usdt,
"max_position_usdt": settings.max_position_usdt,
"max_symbol_exposure_usdt": settings.max_symbol_exposure_usdt,
"max_total_exposure_usdt": settings.max_total_exposure_usdt,
"max_open_positions": settings.max_open_positions,
"max_positions_per_symbol": settings.max_positions_per_symbol,
"grid_trading_enabled": settings.grid_trading_enabled,
"grid_entry_confidence": settings.grid_entry_confidence,
"grid_buy_zone": settings.grid_buy_zone,
"grid_max_position_usdt": settings.grid_max_position_usdt,
"rebound_trading_enabled": settings.rebound_trading_enabled,
"rebound_entry_confidence": settings.rebound_entry_confidence,
"rebound_min_probability": settings.rebound_min_probability,
"rebound_max_position_usdt": settings.rebound_max_position_usdt,
"kelly_sizing_enabled": settings.kelly_sizing_enabled,
"kelly_fraction": settings.kelly_fraction,
"kelly_max_fraction": settings.kelly_max_fraction,
"risk_per_trade_percent": settings.risk_per_trade_percent,
"risk_guard_enabled": settings.risk_guard_enabled,
"risk_symbol_guard_enabled": settings.risk_symbol_guard_enabled,
"risk_recent_trade_window": settings.risk_recent_trade_window,
"risk_max_consecutive_losses": settings.risk_max_consecutive_losses,
"risk_min_recent_profit_factor": settings.risk_min_recent_profit_factor,
"risk_reduce_multiplier": settings.risk_reduce_multiplier,
"atr_trailing_multiplier": settings.atr_trailing_multiplier,
"trend_rsi_min": settings.trend_rsi_min,
"trend_rsi_max": settings.trend_rsi_max,
"time_series_forecast_enabled": settings.time_series_forecast_enabled,
"time_series_min_candles": settings.time_series_min_candles,
"time_series_forecast_horizon": settings.time_series_forecast_horizon,
"time_series_min_edge_percent": settings.time_series_min_edge_percent,
"time_series_min_probability_up": settings.time_series_min_probability_up,
"time_series_min_confidence": settings.time_series_min_confidence,
"time_series_max_adjustment": settings.time_series_max_adjustment,
"time_series_lstm_enabled": settings.time_series_lstm_enabled,
"time_series_lstm_model_path": str(settings.time_series_lstm_model_path),
"time_series_probe_enabled": settings.time_series_probe_enabled,
"time_series_probe_min_edge_percent": settings.time_series_probe_min_edge_percent,
"time_series_probe_min_probability_up": settings.time_series_probe_min_probability_up,
"time_series_probe_size_multiplier": settings.time_series_probe_size_multiplier,
"time_series_rebound_fallback_enabled": settings.time_series_rebound_fallback_enabled,
"time_series_require_quality_gate": settings.time_series_require_quality_gate,
"time_series_manual_quality_override": settings.time_series_manual_quality_override,
"time_series_require_fresh_model": settings.time_series_require_fresh_model,
"time_series_model_max_age_hours": settings.time_series_model_max_age_hours,
"market_ticker_max_age_seconds": settings.market_ticker_max_age_seconds,
"time_series_model_artifact": _time_series_model_artifact(settings),
"stop_loss_percent": settings.stop_loss_percent,
"stop_loss_exit_enabled": settings.stop_loss_exit_enabled,
"take_profit_percent": settings.take_profit_percent,
"trailing_stop_percent": settings.trailing_stop_percent,
"min_hold_seconds": settings.min_hold_seconds,
"min_exit_net_percent": settings.min_exit_net_percent,
"entry_cooldown_seconds": settings.entry_cooldown_seconds,
"max_daily_drawdown_usdt": settings.max_daily_drawdown_usdt,
"min_cash_reserve_usdt": settings.min_cash_reserve_usdt,
"taker_fee_rate": settings.taker_fee_rate,
"slippage_rate": settings.slippage_rate,
"live_ready": settings.live_ready,
"live_order_max_usdt": settings.live_order_max_usdt,
"live_order_fill_timeout_seconds": settings.live_order_fill_timeout_seconds,
"live_reconciliation_interval_seconds": settings.live_reconciliation_interval_seconds,
"live_protective_stop_enabled": settings.live_protective_stop_enabled,
"api_auth_configured": bool(
settings.api_auth_token
or settings.training_worker_token
or settings.trusted_proxy_user_header
),
}
def _runtime_json(settings: Settings, name: str) -> dict[str, Any]:
path = settings.time_series_lstm_model_path.parent / name
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"available": False, "path": str(path)}
if not isinstance(data, dict):
return {"available": False, "path": str(path)}
data["available"] = True
data["path"] = str(path)
return data
def _time_series_model_artifact(settings: Settings) -> dict[str, Any]:
path = settings.time_series_lstm_model_path
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {
"available": False,
"type": "missing",
"label": "нет файла модели",
"symbol_count": 0,
"models": [],
}
if not isinstance(data, dict):
return {
"available": False,
"type": "invalid",
"label": "файл модели не распознан",
"symbol_count": 0,
"models": [],
}
artifact_type = str(data.get("type", "")).strip()
symbols = data.get("symbols")
rows = list(symbols.values()) if isinstance(symbols, dict) else []
models = sorted(
{
_forecast_model_label(
str(row.get("model", row.get("architecture", "lstm"))),
torch_artifact=artifact_type == "pytorch_recurrent_forecaster",
)
for row in rows
if isinstance(row, dict)
}
)
if artifact_type != "pytorch_recurrent_forecaster":
return {
"available": False,
"type": artifact_type or "unknown",
"label": "устаревший файл модели не используется",
"created_at": data.get("created_at", ""),
"symbol_count": len(rows),
"models": models,
}
return {
"available": True,
"type": artifact_type,
"label": "PyTorch LSTM/GRU",
"created_at": data.get("created_at", ""),
"symbol_count": len(rows),
"models": models,
"feature_count": _artifact_feature_count(data, rows),
"target_horizon": _artifact_target_horizon(data, rows),
"direct_horizon": _artifact_direct_horizon(data, rows),
}
def _artifact_feature_count(data: dict[str, Any], rows: list[Any]) -> int:
feature_count = data.get("feature_count")
if isinstance(feature_count, int):
return feature_count
counts = [
int(row.get("input_size", 0))
for row in rows
if isinstance(row, dict) and isinstance(row.get("input_size"), int)
]
return max(counts) if counts else 1
def _artifact_target_horizon(data: dict[str, Any], rows: list[Any]) -> int:
horizon = data.get("target_horizon")
if isinstance(horizon, int):
return horizon
horizons = [
int(row.get("target_horizon", 0))
for row in rows
if isinstance(row, dict) and isinstance(row.get("target_horizon"), int)
]
return max(horizons) if horizons else 0
def _artifact_direct_horizon(data: dict[str, Any], rows: list[Any]) -> bool:
if bool(data.get("direct_horizon")):
return True
return any(isinstance(row, dict) and bool(row.get("direct_horizon")) for row in rows)
def _forecast_model_label(model: str, *, torch_artifact: bool = False) -> str:
normalized = model.strip().lower()
if normalized in {"torch_lstm", "lstm"} and torch_artifact:
return "PyTorch LSTM"
if normalized in {"torch_gru", "gru"} and torch_artifact:
return "PyTorch GRU"
if normalized == "lstm":
return "устаревший артефакт"
if normalized == "gru":
return "устаревший артефакт"
return model