Harden trading, training, and monitoring
This commit is contained in:
+125
-31
@@ -1,13 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Response
|
||||
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
|
||||
@@ -23,6 +26,7 @@ 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:
|
||||
@@ -44,6 +48,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
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):
|
||||
@@ -66,50 +71,62 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict[str, Any]:
|
||||
return {"ok": True, "running": bot.running, "mode": settings.trading_mode}
|
||||
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() -> dict[str, Any]:
|
||||
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(),
|
||||
"latest_equity": storage.latest_equity(mode=settings.trading_mode),
|
||||
"readiness": bot.readiness_snapshot(),
|
||||
}
|
||||
|
||||
@app.get("/api/markets")
|
||||
async def markets() -> dict[str, Any]:
|
||||
async def markets(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||
return market.snapshot()
|
||||
|
||||
@app.get("/api/trades")
|
||||
async def trades(limit: int = 80) -> dict[str, Any]:
|
||||
async def trades(limit: int = 80, _: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||
row_limit = _limit(limit)
|
||||
return {
|
||||
"items": storage.recent_trades(row_limit),
|
||||
"closed_items": storage.closed_trades(row_limit),
|
||||
"closed_summary": storage.closed_trade_summary(),
|
||||
"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) -> dict[str, Any]:
|
||||
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) -> dict[str, Any]:
|
||||
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() -> dict[str, Any]:
|
||||
async def analytics(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||
return analytics_snapshot(settings, storage)
|
||||
|
||||
@app.get("/api/quality")
|
||||
async def quality() -> dict[str, Any]:
|
||||
async def quality(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||
return market.snapshot().get("quality", {})
|
||||
|
||||
@app.get("/api/reconciliation")
|
||||
async def reconciliation() -> dict[str, Any]:
|
||||
return reconciliation_snapshot(
|
||||
async def reconciliation(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||
return await asyncio.to_thread(
|
||||
reconciliation_snapshot,
|
||||
settings=settings,
|
||||
storage=storage,
|
||||
client=client,
|
||||
@@ -117,58 +134,113 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
)
|
||||
|
||||
@app.get("/api/backtest")
|
||||
async def backtest() -> dict[str, Any]:
|
||||
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() -> dict[str, Any]:
|
||||
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() -> dict[str, Any]:
|
||||
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) -> dict[str, Any]:
|
||||
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) -> dict[str, Any]:
|
||||
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) -> dict[str, Any]:
|
||||
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]) -> dict[str, Any]:
|
||||
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) -> dict[str, Any]:
|
||||
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) -> dict[str, Any]:
|
||||
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=404, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.get("/api/config")
|
||||
async def config() -> dict[str, Any]:
|
||||
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]) -> dict[str, Any]:
|
||||
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)
|
||||
@@ -176,12 +248,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
return response
|
||||
|
||||
@app.post("/api/control/start")
|
||||
async def start() -> dict[str, Any]:
|
||||
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() -> dict[str, Any]:
|
||||
async def stop(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||
await bot.stop()
|
||||
return bot.status().as_dict()
|
||||
|
||||
@@ -207,13 +279,22 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
"# 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:
|
||||
storage.event(f"API error: {exc}", "ERROR")
|
||||
return JSONResponse({"error": str(exc)}, status_code=500)
|
||||
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
|
||||
|
||||
@@ -317,6 +398,11 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
|
||||
"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,
|
||||
@@ -331,6 +417,14 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
|
||||
"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
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user