from __future__ import annotations import json from contextlib import asynccontextmanager from typing import Any from fastapi import FastAPI, HTTPException, Response from fastapi.responses import JSONResponse, PlainTextResponse from crypto_spot_bot.analytics import analytics_snapshot 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." 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) @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} @app.get("/api/status") async def status() -> 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(), } @app.get("/api/markets") async def markets() -> dict[str, Any]: return market.snapshot() @app.get("/api/trades") async def trades(limit: int = 80) -> dict[str, Any]: return {"items": storage.recent_trades(_limit(limit))} @app.get("/api/signals") async def signals(limit: int = 120) -> dict[str, Any]: return {"items": storage.recent_signals(_limit(limit))} @app.get("/api/events") async def events(limit: int = 120) -> dict[str, Any]: return {"items": storage.recent_events(_limit(limit))} @app.get("/api/analytics") async def analytics() -> dict[str, Any]: return analytics_snapshot(settings, storage) @app.get("/api/quality") async def quality() -> dict[str, Any]: return market.snapshot().get("quality", {}) @app.get("/api/reconciliation") async def reconciliation() -> dict[str, Any]: return reconciliation_snapshot( settings=settings, storage=storage, client=client, instruments=market.instruments, ) @app.get("/api/backtest") async def backtest() -> dict[str, Any]: return _runtime_json(settings, "torch_threshold_calibration.json") @app.get("/api/retrain") async def retrain() -> 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() -> dict[str, Any]: return training.status() @app.post("/api/training/retrain") async def training_retrain(payload: dict[str, Any] | None = None) -> dict[str, Any]: return training.request_retrain(payload) @app.post("/api/training/heartbeat") async def training_heartbeat(payload: dict[str, Any] | None = None) -> dict[str, Any]: return training.heartbeat(payload) @app.post("/api/training/claim") async def training_claim(payload: dict[str, Any] | None = None) -> 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]) -> 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) -> 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) -> dict[str, Any]: try: return training.complete(job_id, payload) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.get("/api/config") async def config() -> dict[str, Any]: return _safe_config(settings) @app.post("/api/config/fast-trading") async def set_fast_trading(payload: dict[str, Any]) -> 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() -> dict[str, Any]: await bot.start() return bot.status().as_dict() @app.post("/api/control/stop") async def stop() -> 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}", ] return PlainTextResponse("\n".join(lines) + "\n") @app.exception_handler(Exception) async def error_handler(_, exc: Exception) -> JSONResponse: storage.event(f"API error: {exc}", "ERROR") return JSONResponse({"error": str(exc)}, 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_model_artifact": _time_series_model_artifact(settings), "stop_loss_percent": settings.stop_loss_percent, "take_profit_percent": settings.take_profit_percent, "trailing_stop_percent": settings.trailing_stop_percent, "min_hold_seconds": settings.min_hold_seconds, "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, } 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