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 import __version__ 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.shadow import shadow_gate_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) runtime_dir = settings.time_series_lstm_model_path.parent shadow_forecaster = TimeSeriesForecaster( settings, model_path=runtime_dir / "lstm_forecaster.shadow.json", calibration_path=runtime_dir / "torch_shadow_calibration.json", ) bot = CryptoSpotBot( settings, storage, market, broker, strategy, pattern_analyzer, learner, forecaster, shadow_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="Крипто спот-бот", version=__version__, 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(), "version": __version__, } @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() data["shadow"] = shadow_gate_snapshot(storage, shadow_forecaster.artifact_sha256()) return data @app.get("/api/training/status") async def training_status(_: None = Depends(authorizer.require)) -> dict[str, Any]: return training.status() @app.get("/api/training/shadow") async def training_shadow_status( _: None = Depends(authorizer.require), ) -> dict[str, Any]: return shadow_gate_snapshot(storage, shadow_forecaster.artifact_sha256()) @app.post("/api/training/shadow/promote") async def training_shadow_promote( _: None = Depends(authorizer.require), ) -> dict[str, Any]: gate = shadow_gate_snapshot(storage, shadow_forecaster.artifact_sha256()) if not gate.get("passed"): raise HTTPException(status_code=409, detail={"message": "shadow forward gate has not passed", "gate": gate}) try: return training.promote_shadow(gate) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @app.get("/api/training/market-observations") async def training_market_observations( symbol: str, after_id: int = 0, limit: int = 5000, _: None = Depends(authorizer.require_training), ) -> dict[str, Any]: normalized_symbol = symbol.strip().upper() if not normalized_symbol: raise HTTPException(status_code=400, detail="symbol is required") items = storage.market_observations_after( symbol=normalized_symbol, after_id=max(0, after_id), limit=max(1, min(limit, 5000)), ) return { "symbol": normalized_symbol, "items": items, "next_after_id": int(items[-1]["id"]) if items else max(0, after_id), } @app.get("/api/training/market-observations/manifest") async def training_market_observation_manifest( _: None = Depends(authorizer.require_training), ) -> dict[str, Any]: items = storage.market_observation_manifest() return { "items": items, "total_samples": sum(int(item.get("samples", 0) or 0) for item in items), } @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() retrain_data["shadow"] = shadow_gate_snapshot( storage, shadow_forecaster.artifact_sha256(), ) 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_trend_fallback_enabled": settings.time_series_trend_fallback_enabled, "time_series_fallback_mode": settings.time_series_fallback_mode, "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, "market_observation_enabled": settings.market_observation_enabled, "market_observation_sample_seconds": settings.market_observation_sample_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