feat: add orderbook shadow training pipeline

This commit is contained in:
Курнат Андрей
2026-07-15 09:44:29 +03:00
parent f7a625586e
commit 5d8ad1437e
19 changed files with 1486 additions and 23 deletions
+120 -4
View File
@@ -20,12 +20,24 @@ from urllib.error import URLError
from urllib.request import Request
from urllib.request import urlopen
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from crypto_spot_bot.orderbook_features import load_orderbook_feature_map
from tools.sync_orderbook_observations import sync_orderbook_observations
ARTIFACT_NAMES = (
"lstm_forecaster.json",
"torch_retrain_guard.json",
"torch_threshold_calibration.json",
)
SHADOW_ARTIFACT_NAMES = (
"lstm_forecaster.shadow.json",
"torch_shadow_guard.json",
"torch_shadow_calibration.json",
)
def main() -> None:
@@ -65,7 +77,35 @@ def poll_once(args: argparse.Namespace, repo_root: Path, runtime_dir: Path, log_
message = ""
summary: dict[str, Any] = {}
try:
run_retrain(args, job_id, lease_token, job, repo_root, log_path)
parameters = job.get("parameters") if isinstance(job.get("parameters"), dict) else {}
use_orderbook = parameters.get("use_orderbook", True) is not False
orderbook_status: dict[str, Any] = {}
if use_orderbook:
report_progress(
args,
job_id,
lease_token,
"running",
"orderbook_sync",
4,
"Синхронизирую forward-наблюдения стакана",
)
orderbook_status = prepare_orderbook_data(args, repo_root, parameters, log_path)
if orderbook_status["state"] != "ready":
summary = orderbook_status
message = "forward orderbook coverage is still accumulating"
success = True
log(log_path, f"Job {job_id} remains in collecting state: {orderbook_status}")
return
run_retrain(
args,
job_id,
lease_token,
job,
repo_root,
log_path,
orderbook_db=(repo_root / "runtime" / "orderbook_observations.sqlite3") if use_orderbook else None,
)
summary = read_json(runtime_dir / "torch_retrain_guard.json")
accepted = summary.get("accepted") is True
if accepted:
@@ -78,12 +118,20 @@ def poll_once(args: argparse.Namespace, repo_root: Path, runtime_dir: Path, log_
72,
"Обучение завершено, загружаю артефакты",
)
for name in ARTIFACT_NAMES:
artifact_names = SHADOW_ARTIFACT_NAMES if use_orderbook else ARTIFACT_NAMES
if use_orderbook:
summary["deployment"] = "shadow"
summary["orderbook"] = orderbook_status
for name in artifact_names:
path = runtime_dir / name
if path.is_file():
upload_artifact(args, job_id, lease_token, path, log_path)
message = "training completed; candidate accepted"
log(log_path, f"Completed retrain job {job_id}; candidate accepted")
message = (
"training completed; candidate staged in shadow"
if use_orderbook
else "training completed; candidate accepted"
)
log(log_path, f"Completed retrain job {job_id}; {message}")
else:
reason = str(summary.get("reason") or "validation failed")
message = f"training completed; candidate rejected by quality gate: {reason}"
@@ -109,6 +157,7 @@ def run_retrain(
job: dict[str, Any],
repo_root: Path,
log_path: Path,
orderbook_db: Path | None = None,
) -> None:
script = repo_root / "tools" / "run_torch_retrain.ps1"
if not script.is_file():
@@ -153,6 +202,14 @@ def run_retrain(
cmd.append("-Pooled")
if parameters.get("resume_candidate") is True:
cmd.append("-ResumeCandidate")
if orderbook_db is not None:
cmd.extend(["-OrderbookDb", str(orderbook_db)])
for key, ps_arg, default in (
("orderbook_min_samples_per_bucket", "-OrderbookMinSamplesPerBucket", 20),
("orderbook_min_covered_buckets", "-OrderbookMinCoveredBuckets", 240),
("orderbook_min_symbols", "-OrderbookMinSymbols", 2),
):
cmd.extend([ps_arg, str(int(parameters.get(key, default) or default))])
log(log_path, "Running retrain: " + " ".join(quote_for_log(part) for part in cmd))
report_progress(
args,
@@ -236,6 +293,65 @@ def run_retrain(
)
def prepare_orderbook_data(
args: argparse.Namespace,
repo_root: Path,
parameters: dict[str, Any],
log_path: Path,
) -> dict[str, Any]:
database_path = repo_root / "runtime" / "orderbook_observations.sqlite3"
token = args.api_auth or os.environ.get("TRADEBOT_API_AUTH", "")
sync_result = sync_orderbook_observations(
api_base_url=args.api_base_url,
token=token,
database_path=database_path,
)
interval = str(parameters.get("interval") or os.environ.get("TORCH_RETRAIN_INTERVAL") or "60")
minimum_samples = int(parameters.get("orderbook_min_samples_per_bucket", 20) or 20)
minimum_buckets = int(parameters.get("orderbook_min_covered_buckets", 240) or 240)
minimum_symbols = int(parameters.get("orderbook_min_symbols", 2) or 2)
requested_symbols = {
item.strip().upper()
for item in str(parameters.get("symbols") or "").split(",")
if item.strip()
}
_features, manifest = load_orderbook_feature_map(
database_path,
interval=interval,
symbols=sorted(requested_symbols) if requested_symbols else None,
min_samples_per_bucket=minimum_samples,
)
eligible = sorted(
symbol
for symbol, row in manifest.items()
if int(row.get("covered_buckets", 0) or 0) >= minimum_buckets
)
state = "ready" if len(eligible) >= minimum_symbols else "collecting_orderbook"
coverage = {
symbol: int(row.get("covered_buckets", 0) or 0)
for symbol, row in sorted(manifest.items())
}
result = {
"accepted": False,
"state": state,
"reason": (
"orderbook coverage ready for training"
if state == "ready"
else "forward orderbook coverage is below the configured minimum"
),
"eligible_symbols": eligible,
"eligible_symbol_count": len(eligible),
"minimum_symbols": minimum_symbols,
"minimum_covered_buckets": minimum_buckets,
"minimum_samples_per_bucket": minimum_samples,
"covered_buckets_by_symbol": coverage,
"local_samples": int(sync_result.get("local_samples", 0) or 0),
"downloaded_samples": int(sync_result.get("downloaded", 0) or 0),
}
log(log_path, "Orderbook preparation: " + json.dumps(result, ensure_ascii=False, sort_keys=True))
return result
def friendly_training_message(message: str) -> str:
cleaned = message.strip()
if not cleaned: