716 lines
30 KiB
Python
716 lines
30 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
|
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
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_ROOT = Path(__file__).with_name("web")
|
|
WEB_INDEX = WEB_ROOT / "index.html"
|
|
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.mount("/assets", StaticFiles(directory=WEB_ROOT), name="dashboard-assets")
|
|
|
|
@app.middleware("http")
|
|
async def security_headers(request: Request, call_next) -> Response:
|
|
response = await call_next(request)
|
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
|
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
|
response.headers.setdefault(
|
|
"Content-Security-Policy",
|
|
"default-src 'self'; style-src 'self'; script-src 'self'; "
|
|
"img-src 'self' data:; connect-src 'self'; font-src 'self'; "
|
|
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
|
)
|
|
if request.url.path == "/":
|
|
response.headers.setdefault("Cache-Control", "no-store")
|
|
return response
|
|
|
|
@app.get("/", response_class=FileResponse)
|
|
async def index() -> FileResponse:
|
|
return FileResponse(WEB_INDEX, media_type="text/html")
|
|
|
|
@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/retrain/auto")
|
|
async def training_retrain_auto(
|
|
_: None = Depends(authorizer.require_training),
|
|
) -> dict[str, Any]:
|
|
return training.request_retrain(
|
|
{
|
|
"source": "windows-agent-auto",
|
|
"parameters": {"use_orderbook": True},
|
|
}
|
|
)
|
|
|
|
@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(),
|
|
"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.get("/api/dashboard/snapshot")
|
|
async def dashboard_snapshot(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
|
market_data = market.snapshot()
|
|
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 {
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"health": {
|
|
"ok": True,
|
|
"running": bot.running,
|
|
"mode": settings.trading_mode,
|
|
"version": __version__,
|
|
},
|
|
"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": _compact_markets(market_data),
|
|
"signals": {"items": storage.recent_signals(16)},
|
|
"trades": {
|
|
"items": storage.recent_trades(16, mode=settings.trading_mode),
|
|
"closed_items": storage.closed_trades(16, mode=settings.trading_mode),
|
|
"closed_summary": storage.closed_trade_summary(mode=settings.trading_mode),
|
|
},
|
|
"events": {"items": storage.recent_events(20)},
|
|
"retrain": retrain_data,
|
|
"config": _safe_config(settings),
|
|
}
|
|
|
|
@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 _compact_markets(snapshot: dict[str, Any]) -> dict[str, Any]:
|
|
markets: list[dict[str, Any]] = []
|
|
for market in snapshot.get("markets", []):
|
|
candles = market.get("candles") or []
|
|
markets.append(
|
|
{
|
|
"ticker": market.get("ticker"),
|
|
"sparkline": [
|
|
{
|
|
"timestamp": candle.get("timestamp"),
|
|
"close": candle.get("close"),
|
|
}
|
|
for candle in candles[-48:]
|
|
],
|
|
"forecast": market.get("forecast"),
|
|
"quality": market.get("quality"),
|
|
}
|
|
)
|
|
return {
|
|
"symbols": snapshot.get("symbols", []),
|
|
"ws_connected": snapshot.get("ws_connected", False),
|
|
"rest_error_count": snapshot.get("rest_error_count", 0),
|
|
"last_rest_error": snapshot.get("last_rest_error", ""),
|
|
"last_rest_refresh_at": snapshot.get("last_rest_refresh_at"),
|
|
"last_ws_message_at": snapshot.get("last_ws_message_at"),
|
|
"observation_collector": snapshot.get("observation_collector", {}),
|
|
"quality": snapshot.get("quality", {}),
|
|
"markets": markets,
|
|
}
|
|
|
|
|
|
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,
|
|
"profit_only_exit_enabled": settings.profit_only_exit_enabled,
|
|
"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
|