feat: production paper trading platform
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
"""Crypto spot trading bot package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "1.0.0"
|
||||
|
||||
@@ -169,17 +169,23 @@ class Settings:
|
||||
hold_signal_sample_seconds: int = 60
|
||||
storage_retention_days: int = 30
|
||||
storage_prune_interval_seconds: int = 3600
|
||||
bybit_rest_base_url_override: str = ""
|
||||
bybit_websocket_url_override: str = ""
|
||||
|
||||
@property
|
||||
def rest_base_url(self) -> str:
|
||||
return "https://api-testnet.bybit.com" if self.bybit_testnet else "https://api.bybit.com"
|
||||
if self.bybit_rest_base_url_override:
|
||||
return self.bybit_rest_base_url_override.rstrip("/")
|
||||
return "https://api-testnet.bybit.com" if self.bybit_testnet else "https://api.bybit.kz"
|
||||
|
||||
@property
|
||||
def websocket_url(self) -> str:
|
||||
if self.bybit_websocket_url_override:
|
||||
return self.bybit_websocket_url_override
|
||||
return (
|
||||
"wss://stream-testnet.bybit.com/v5/public/spot"
|
||||
if self.bybit_testnet
|
||||
else "wss://stream.bybit.com/v5/public/spot"
|
||||
else "wss://stream.bybit.kz/v5/public/spot"
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -220,7 +226,7 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
||||
strategy_mode = os.getenv("STRATEGY_MODE", "torch_forecast").strip().lower()
|
||||
if strategy_mode not in STRATEGY_MODES:
|
||||
raise ValueError("STRATEGY_MODE must be legacy, trend_macd or torch_forecast")
|
||||
auto_select_symbols = _bool_env("AUTO_SELECT_SYMBOLS", False)
|
||||
auto_select_symbols = _bool_env("AUTO_SELECT_SYMBOLS", True)
|
||||
top_symbols_count = _int_env("TOP_SYMBOLS_COUNT", len(FIXED_SPOT_SYMBOLS))
|
||||
requested_symbols = _symbols_env("SYMBOLS")
|
||||
symbols = requested_symbols if requested_symbols else (() if auto_select_symbols else FIXED_SPOT_SYMBOLS)
|
||||
@@ -343,6 +349,11 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
||||
hold_signal_sample_seconds=_int_env("HOLD_SIGNAL_SAMPLE_SECONDS", 60),
|
||||
storage_retention_days=_int_env("STORAGE_RETENTION_DAYS", 30),
|
||||
storage_prune_interval_seconds=_int_env("STORAGE_PRUNE_INTERVAL_SECONDS", 3600),
|
||||
bybit_rest_base_url_override=os.getenv(
|
||||
"BYBIT_REST_BASE_URL",
|
||||
"" if _bool_env("BYBIT_TESTNET", False) else "https://api.bybit.kz",
|
||||
).strip(),
|
||||
bybit_websocket_url_override=os.getenv("BYBIT_WEBSOCKET_URL", "").strip(),
|
||||
)
|
||||
_validate_settings(settings)
|
||||
if settings.trading_mode == "live" and not settings.live_ready:
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
@@ -58,7 +59,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
finally:
|
||||
await bot.stop()
|
||||
|
||||
app = FastAPI(title="Крипто спот-бот", lifespan=lifespan)
|
||||
app = FastAPI(title="Крипто спот-бот", version=__version__, lifespan=lifespan)
|
||||
app.state.settings = settings
|
||||
app.state.storage = storage
|
||||
app.state.bot = bot
|
||||
@@ -76,6 +77,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
"running": bot.running,
|
||||
"mode": settings.trading_mode,
|
||||
"auth_configured": authorizer.configured(),
|
||||
"version": __version__,
|
||||
}
|
||||
|
||||
@app.get("/api/ready")
|
||||
|
||||
@@ -2,9 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import UTC
|
||||
@@ -20,11 +22,11 @@ ALLOWED_TRAINING_ARTIFACTS = {
|
||||
"torch_retrain_guard.json",
|
||||
"torch_threshold_calibration.json",
|
||||
}
|
||||
RUNNING_TIMEOUT = timedelta(hours=12)
|
||||
RUNNING_LEASE_TIMEOUT = timedelta(minutes=10)
|
||||
ONLINE_WINDOW = timedelta(minutes=3)
|
||||
MAX_JOB_ATTEMPTS = 3
|
||||
MAX_ARTIFACT_CHUNK_BYTES = 1024 * 1024
|
||||
# Independent per-symbol ensembles are intentionally larger than pooled models.
|
||||
# Keep a bounded limit, but leave enough room for the supported 12-symbol bundle.
|
||||
# Keep uploads bounded while leaving room for explicitly requested per-symbol bundles.
|
||||
MAX_ARTIFACT_BYTES = 256 * 1024 * 1024
|
||||
MAX_ARTIFACT_CHUNKS = 1024
|
||||
REQUIRED_MODEL_BUNDLE = set(ALLOWED_TRAINING_ARTIFACTS)
|
||||
@@ -52,7 +54,12 @@ class TrainingCoordinator:
|
||||
existing = self._active_job(state)
|
||||
if existing is not None:
|
||||
self._save_state(state)
|
||||
return {"queued": False, "reason": "active_job_exists", "job": existing, "status": self._public_status(state)}
|
||||
return {
|
||||
"queued": False,
|
||||
"reason": "active_job_exists",
|
||||
"job": self._public_job(existing),
|
||||
"status": self._public_status(state),
|
||||
}
|
||||
|
||||
now = _now()
|
||||
job = {
|
||||
@@ -63,11 +70,16 @@ class TrainingCoordinator:
|
||||
"parameters": _safe_parameters(payload.get("parameters")),
|
||||
"message": "",
|
||||
"artifacts": [],
|
||||
"attempts": 0,
|
||||
}
|
||||
state.setdefault("jobs", []).append(job)
|
||||
self._trim_jobs(state)
|
||||
self._save_state(state)
|
||||
return {"queued": True, "job": job, "status": self._public_status(state)}
|
||||
return {
|
||||
"queued": True,
|
||||
"job": self._public_job(job),
|
||||
"status": self._public_status(state),
|
||||
}
|
||||
|
||||
def heartbeat(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
@@ -91,12 +103,21 @@ class TrainingCoordinator:
|
||||
return {"claimed": False, "job": None, "status": self._public_status(state)}
|
||||
|
||||
now = _now()
|
||||
lease_token = secrets.token_urlsafe(32)
|
||||
job["status"] = "running"
|
||||
job["claimed_at"] = now
|
||||
job["updated_at"] = now
|
||||
job["claimed_by"] = worker["id"]
|
||||
job["worker"] = worker
|
||||
job["lease_token"] = lease_token
|
||||
job["attempts"] = int(job.get("attempts", 0)) + 1
|
||||
self._save_state(state)
|
||||
return {"claimed": True, "job": job, "status": self._public_status(state)}
|
||||
return {
|
||||
"claimed": True,
|
||||
"job": self._public_job(job),
|
||||
"lease_token": lease_token,
|
||||
"status": self._public_status(state),
|
||||
}
|
||||
|
||||
def save_artifact_chunk(self, job_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
job_id = _valid_job_id(job_id)
|
||||
@@ -126,6 +147,7 @@ class TrainingCoordinator:
|
||||
raise ValueError(f"training job not found: {job_id}")
|
||||
if job.get("status") != "running" or not job.get("claimed_by"):
|
||||
raise ValueError("training job is not claimed and running")
|
||||
self._require_lease(job, payload)
|
||||
uploads = job.setdefault("uploads", {})
|
||||
upload = uploads.setdefault(name, {"sha256": sha256, "total": total})
|
||||
if upload.get("sha256") != sha256 or int(upload.get("total", 0)) != total:
|
||||
@@ -138,6 +160,7 @@ class TrainingCoordinator:
|
||||
received = sum(1 for part in range(total) if (chunk_dir / f"{part:06d}.part").is_file())
|
||||
if received < total:
|
||||
upload["received"] = received
|
||||
job["updated_at"] = _now()
|
||||
self._save_state(state)
|
||||
return {"complete": False, "received": received, "total": total}
|
||||
|
||||
@@ -169,6 +192,7 @@ class TrainingCoordinator:
|
||||
{"name": name, "sha256": sha256, "size": size, "staged_at": _now()}
|
||||
)
|
||||
job["artifacts"] = artifacts
|
||||
job["updated_at"] = _now()
|
||||
upload["received"] = total
|
||||
upload["complete"] = True
|
||||
self._save_state(state)
|
||||
@@ -184,6 +208,7 @@ class TrainingCoordinator:
|
||||
raise ValueError(f"training job not found: {job_id}")
|
||||
if job.get("status") != "running" or not job.get("claimed_by"):
|
||||
raise ValueError("training job is not claimed and running")
|
||||
self._require_lease(job, payload)
|
||||
if isinstance(payload.get("worker"), dict):
|
||||
state["worker"] = self._worker_from_payload(payload["worker"])
|
||||
job["status"] = "running"
|
||||
@@ -194,7 +219,11 @@ class TrainingCoordinator:
|
||||
if isinstance(payload.get("details"), dict):
|
||||
job["details"] = payload["details"]
|
||||
self._save_state(state)
|
||||
return {"ok": True, "job": job, "status": self._public_status(state)}
|
||||
return {
|
||||
"ok": True,
|
||||
"job": self._public_job(job),
|
||||
"status": self._public_status(state),
|
||||
}
|
||||
|
||||
def complete(self, job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
@@ -206,6 +235,7 @@ class TrainingCoordinator:
|
||||
raise ValueError(f"training job not found: {job_id}")
|
||||
if job.get("status") != "running" or not job.get("claimed_by"):
|
||||
raise ValueError("training job is not claimed and running")
|
||||
self._require_lease(job, payload)
|
||||
success = bool(payload.get("success", payload.get("status") == "completed"))
|
||||
if success and job.get("artifacts"):
|
||||
promoted = self._validate_and_promote(job_id, job)
|
||||
@@ -221,8 +251,13 @@ class TrainingCoordinator:
|
||||
job["model_decision"] = (
|
||||
"accepted" if payload["summary"]["accepted"] else "rejected"
|
||||
)
|
||||
job.pop("lease_token", None)
|
||||
self._save_state(state)
|
||||
return {"ok": True, "job": job, "status": self._public_status(state)}
|
||||
return {
|
||||
"ok": True,
|
||||
"job": self._public_job(job),
|
||||
"status": self._public_status(state),
|
||||
}
|
||||
|
||||
def _validate_and_promote(self, job_id: str, job: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
ready_dir = self.upload_root / job_id / "ready"
|
||||
@@ -327,11 +362,27 @@ class TrainingCoordinator:
|
||||
"agent_recently_seen": recently_seen,
|
||||
"agent_busy": agent_busy,
|
||||
"worker": worker,
|
||||
"active_job": active,
|
||||
"latest_job": latest,
|
||||
"active_job": self._public_job(active),
|
||||
"latest_job": self._public_job(latest),
|
||||
"pending_jobs": sum(1 for job in state.get("jobs", []) if job.get("status") == "pending"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _public_job(job: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if job is None:
|
||||
return None
|
||||
public = dict(job)
|
||||
public.pop("lease_token", None)
|
||||
public.pop("uploads", None)
|
||||
return public
|
||||
|
||||
@staticmethod
|
||||
def _require_lease(job: dict[str, Any], payload: dict[str, Any]) -> None:
|
||||
expected = str(job.get("lease_token") or "")
|
||||
supplied = str(payload.get("lease_token") or "")
|
||||
if not expected or not supplied or not hmac.compare_digest(expected, supplied):
|
||||
raise ValueError("training job lease is invalid or expired")
|
||||
|
||||
def _active_job(self, state: dict[str, Any]) -> dict[str, Any] | None:
|
||||
for job in reversed(state.get("jobs", [])):
|
||||
if job.get("status") in {"pending", "running"}:
|
||||
@@ -355,11 +406,30 @@ class TrainingCoordinator:
|
||||
for job in state.get("jobs", []):
|
||||
if job.get("status") != "running":
|
||||
continue
|
||||
claimed_at = _parse_time(str(job.get("claimed_at") or ""))
|
||||
if claimed_at and now - claimed_at > RUNNING_TIMEOUT:
|
||||
lease_updated_at = _parse_time(
|
||||
str(job.get("updated_at") or job.get("claimed_at") or "")
|
||||
)
|
||||
if not lease_updated_at or now - lease_updated_at <= RUNNING_LEASE_TIMEOUT:
|
||||
continue
|
||||
job_id = str(job.get("id") or "")
|
||||
if job_id:
|
||||
_remove_tree(self.upload_root / job_id)
|
||||
job.pop("lease_token", None)
|
||||
job.pop("uploads", None)
|
||||
attempts = int(job.get("attempts", 0))
|
||||
if attempts < MAX_JOB_ATTEMPTS:
|
||||
job["status"] = "pending"
|
||||
job["phase"] = "queued"
|
||||
job["progress_percent"] = 0
|
||||
job["message"] = "training worker lease expired; queued for retry"
|
||||
job["retry_queued_at"] = _now()
|
||||
for key in ("claimed_at", "claimed_by", "worker", "updated_at"):
|
||||
job.pop(key, None)
|
||||
else:
|
||||
job["status"] = "failed"
|
||||
job["phase"] = "failed"
|
||||
job["completed_at"] = _now()
|
||||
job["message"] = "training worker timeout"
|
||||
job["message"] = "training worker lease expired after maximum retries"
|
||||
|
||||
def _trim_jobs(self, state: dict[str, Any]) -> None:
|
||||
jobs = state.get("jobs", [])
|
||||
|
||||
Reference in New Issue
Block a user