439 lines
19 KiB
Python
439 lines
19 KiB
Python
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
|