from __future__ import annotations import json from contextlib import asynccontextmanager from typing import Any from fastapi import FastAPI, Response from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse 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.storage import Storage from crypto_spot_bot.strategy import SpotStrategy from crypto_spot_bot.time_series import TimeSeriesForecaster 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) @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.get("/", response_class=HTMLResponse) async def index() -> str: return HTML @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/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, "base_interval": settings.base_interval, "kline_limit": settings.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, "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, "time_series_forecast_enabled": settings.time_series_forecast_enabled, "time_series_min_candles": settings.time_series_min_candles, "time_series_validation_window": settings.time_series_validation_window, "time_series_forecast_horizon": settings.time_series_forecast_horizon, "time_series_ewma_lambda": settings.time_series_ewma_lambda, "time_series_min_edge_percent": settings.time_series_min_edge_percent, "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_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 _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": [], } 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")))) for row in rows if isinstance(row, dict) } ) artifact_type = str(data.get("type", "")).strip() 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, } def _forecast_model_label(model: str) -> str: normalized = model.strip().lower() if normalized == "torch_lstm": return "PyTorch LSTM" if normalized == "torch_gru": return "PyTorch GRU" if normalized == "lstm": return "устаревший LSTM" if normalized == "gru": return "GRU" return model HTML = r""" Крипто спот-бот

Крипто спот-бот

Загрузка состояния...
Демо Реальная торговля заблокирована Обычный режим Поток данных
Баланс
-
Свободно
-
В позициях
-
Прибыль/убыток
-
Позиции
-
Просадка
-

Открытые позиции

ПараКол-воВходЦенаПрибыль/убытокСтопЦель

Сделки

ВремяПараСторонаКол-воВходВыходИтогПричина

Сигналы стратегии

ВремяПараДействиеРежимРазмерУверенностьБазаШаблонОбучениеПрогнозОтскокИтогПричина
"""