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
+33 -1
View File
@@ -26,6 +26,7 @@ from crypto_spot_bot.bybit import BybitClient
from crypto_spot_bot.config import load_settings
from crypto_spot_bot.indicators import add_indicators
from crypto_spot_bot.models import Candle
from crypto_spot_bot.orderbook_features import ORDERBOOK_FEATURES, load_orderbook_feature_map
from crypto_spot_bot.time_series import (
DEFAULT_TORCH_FEATURES,
_barrier_outcome,
@@ -97,6 +98,14 @@ def main() -> None:
context_symbols = sorted(set(symbols + _symbols(args.context_symbols, ())))
horizon = args.horizon if args.horizon > 0 else settings.time_series_forecast_horizon
round_trip_cost = _artifact_round_trip_cost(artifact, settings)
orderbook_features: dict[str, dict[int, dict[str, float]]] = {}
if args.orderbook_db:
orderbook_features, _orderbook_manifest = load_orderbook_feature_map(
args.orderbook_db,
interval=settings.base_interval,
symbols=symbols,
min_samples_per_bucket=args.orderbook_min_samples_per_bucket,
)
market_candles: dict[str, list[Candle]] = {}
for symbol in context_symbols:
@@ -125,6 +134,7 @@ def main() -> None:
min_candles=max(30, settings.time_series_min_candles),
calibration_window=args.calibration_window,
batch_size=args.batch_size,
orderbook_features=orderbook_features,
)
records.extend(symbol_records)
per_symbol_counts[symbol] = len(symbol_records)
@@ -304,6 +314,8 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument("--min-oos-folds-with-trades", type=int, default=2, help="Minimum walk-forward folds that must produce trades.")
parser.add_argument("--min-oos-profit-factor", type=float, default=1.10, help="Minimum out-of-sample profit factor.")
parser.add_argument("--min-benchmark-edge-percent", type=float, default=0.0, help="Required total-net percent advantage over the benchmark.")
parser.add_argument("--orderbook-db", default="", help="SQLite cache used by an artifact with L1 features.")
parser.add_argument("--orderbook-min-samples-per-bucket", type=int, default=20)
return parser.parse_args()
@@ -352,6 +364,7 @@ def _forecast_records(
min_candles: int,
calibration_window: int,
batch_size: int,
orderbook_features: dict[str, dict[int, dict[str, float]]] | None = None,
) -> list[ForecastRecord]:
entry = _torch_recurrent_entry(symbol, artifact)
model = _torch_recurrent_model_name(symbol, artifact)
@@ -364,6 +377,7 @@ def _forecast_records(
symbol=symbol,
market_candles=market_candles,
trend_candles=trend_candles,
orderbook_features=orderbook_features,
)
closes = [float(candle.close) for candle in candles]
decision_horizon = _calibration_horizon(entry, horizon, explicit=horizon_is_explicit)
@@ -376,6 +390,18 @@ def _forecast_records(
start += 1
if calibration_window > 0:
start = max(start, end - calibration_window)
lookback = max(1, int(float(entry.get("lookback", 64))))
requires_orderbook = any(name in ORDERBOOK_FEATURES for name in feature_names)
symbol_orderbook = (orderbook_features or {}).get(symbol.upper(), {})
valid_indices = {
index
for index in range(start, max(start, end))
if not requires_orderbook
or all(
candles[position].timestamp in symbol_orderbook
for position in range(index - lookback + 1, index + 1)
)
}
batched_records = _batch_forecast_records(
symbol=symbol,
candles=candles,
@@ -389,6 +415,7 @@ def _forecast_records(
start=start,
end=end,
batch_size=batch_size,
valid_indices=valid_indices,
)
if batched_records is not None:
return batched_records
@@ -398,6 +425,8 @@ def _forecast_records(
# belong exclusively to the final quality gate and cannot influence replay.
skill = _entry_validation_skill(entry)
for index in range(start, max(start, end)):
if index not in valid_indices:
continue
prediction = _torch_recurrent_predict(
_log_returns(closes[: index + 1]),
symbol,
@@ -476,6 +505,7 @@ def _batch_forecast_records(
start: int,
end: int,
batch_size: int,
valid_indices: set[int] | None = None,
) -> list[ForecastRecord] | None:
if torch is None or RecurrentReturnModel is None:
return None
@@ -494,7 +524,9 @@ def _batch_forecast_records(
indices = [
index
for index in range(start, max(start, end))
if index - lookback + 1 >= 0 and index + decision_horizon < len(closes)
if index - lookback + 1 >= 0
and index + decision_horizon < len(closes)
and (valid_indices is None or index in valid_indices)
]
if not indices:
return []
+43 -4
View File
@@ -22,6 +22,10 @@ param(
[int]$HoldoutWindow = 0,
[string]$Interval = "",
[string]$EnvFile = "",
[string]$OrderbookDb = "",
[int]$OrderbookMinSamplesPerBucket = 0,
[int]$OrderbookMinCoveredBuckets = 0,
[int]$OrderbookMinSymbols = 0,
[switch]$Pooled,
[switch]$SkipGuard,
[switch]$ResumeCandidate
@@ -124,6 +128,10 @@ if ($HoldoutWindow -le 0) { $HoldoutWindow = if ($env:TORCH_RETRAIN_HOLDOUT_WIND
if (-not $Interval -and $env:TORCH_RETRAIN_INTERVAL) { $Interval = $env:TORCH_RETRAIN_INTERVAL }
if (-not $EnvFile -and $env:TORCH_RETRAIN_ENV) { $EnvFile = $env:TORCH_RETRAIN_ENV }
if (-not $EnvFile -and (Test-Path (Join-Path $RepoRoot ".env"))) { $EnvFile = Join-Path $RepoRoot ".env" }
if (-not $OrderbookDb -and $env:TORCH_ORDERBOOK_DB) { $OrderbookDb = $env:TORCH_ORDERBOOK_DB }
if ($OrderbookMinSamplesPerBucket -le 0) { $OrderbookMinSamplesPerBucket = if ($env:TORCH_ORDERBOOK_MIN_SAMPLES_PER_BUCKET) { [int]$env:TORCH_ORDERBOOK_MIN_SAMPLES_PER_BUCKET } else { 20 } }
if ($OrderbookMinCoveredBuckets -le 0) { $OrderbookMinCoveredBuckets = if ($env:TORCH_ORDERBOOK_MIN_COVERED_BUCKETS) { [int]$env:TORCH_ORDERBOOK_MIN_COVERED_BUCKETS } else { 240 } }
if ($OrderbookMinSymbols -le 0) { $OrderbookMinSymbols = if ($env:TORCH_ORDERBOOK_MIN_SYMBOLS) { [int]$env:TORCH_ORDERBOOK_MIN_SYMBOLS } else { 2 } }
$ModelFile = if ($env:TIME_SERIES_LSTM_MODEL_PATH) { $env:TIME_SERIES_LSTM_MODEL_PATH } else { Join-Path $RuntimeDir "lstm_forecaster.json" }
if (-not [System.IO.Path]::IsPathRooted($ModelFile)) { $ModelFile = Join-Path $RepoRoot $ModelFile }
@@ -131,6 +139,10 @@ $CandidateFile = Join-Path $RuntimeDir "lstm_forecaster.candidate.json"
$CurrentCalibration = Join-Path $RuntimeDir "torch_guard_current.json"
$CandidateCalibration = Join-Path $RuntimeDir "torch_guard_candidate.json"
$GuardReport = Join-Path $RuntimeDir "torch_retrain_guard.json"
$ShadowModelFile = Join-Path $RuntimeDir "lstm_forecaster.shadow.json"
$ShadowCalibration = Join-Path $RuntimeDir "torch_shadow_calibration.json"
$ShadowGuard = Join-Path $RuntimeDir "torch_shadow_guard.json"
$ShadowMode = -not [string]::IsNullOrWhiteSpace($OrderbookDb)
$mutex = New-Object System.Threading.Mutex($false, "TradeBotTorchRecurrentRetrainer")
$hasLock = $false
@@ -177,6 +189,14 @@ try {
if ($Features) { $trainerArgs += @("--features", $Features) }
if ($ContextSymbols) { $trainerArgs += @("--context-symbols", $ContextSymbols) }
if ($Seed -gt 0) { $trainerArgs += @("--seed", $Seed.ToString()) }
if ($OrderbookDb) {
$trainerArgs += @(
"--orderbook-db", $OrderbookDb,
"--orderbook-min-samples-per-bucket", $OrderbookMinSamplesPerBucket.ToString(),
"--orderbook-min-covered-buckets", $OrderbookMinCoveredBuckets.ToString(),
"--orderbook-min-symbols", $OrderbookMinSymbols.ToString()
)
}
Push-Location $RepoRoot
$pushedLocation = $true
@@ -216,6 +236,12 @@ try {
)
if ($Symbols) { $calibrationBaseArgs += @("--symbols", $Symbols) }
if ($EnvFile) { $calibrationBaseArgs += @("--env", $EnvFile) }
if ($OrderbookDb) {
$calibrationBaseArgs += @(
"--orderbook-db", $OrderbookDb,
"--orderbook-min-samples-per-bucket", $OrderbookMinSamplesPerBucket.ToString()
)
}
if (Test-Path $ModelFile) {
Write-RetrainLog "Calibrating current artifact for guard."
@@ -243,13 +269,14 @@ try {
}
Write-RetrainLog "Running retrain guard."
$GuardTarget = if ($ShadowMode) { $ShadowModelFile } else { $ModelFile }
$guardArgs = @(
"-u",
"tools\accept_torch_candidate.py",
"--current-report", $CurrentCalibration,
"--candidate-report", $CandidateCalibration,
"--candidate-artifact", $CandidateFile,
"--target-artifact", $ModelFile,
"--target-artifact", $GuardTarget,
"--report", $GuardReport
)
$guardExitCode = Invoke-LoggedNativeCommand -FilePath $python -ArgumentList $guardArgs -LogPath $LogFile
@@ -261,10 +288,22 @@ try {
throw "Retrain guard failed with exit code $guardExitCode."
}
if (Test-Path $CandidateCalibration) {
Copy-Item -Force -LiteralPath $CandidateCalibration -Destination (Join-Path $RuntimeDir "torch_threshold_calibration.json")
Write-RetrainLog "Updated active threshold calibration: $(Join-Path $RuntimeDir "torch_threshold_calibration.json")"
if ($ShadowMode) {
Copy-Item -Force -LiteralPath $CandidateCalibration -Destination $ShadowCalibration
Copy-Item -Force -LiteralPath $GuardReport -Destination $ShadowGuard
Write-RetrainLog "Candidate passed offline gate and was staged for shadow only: $ShadowModelFile"
}
else {
Copy-Item -Force -LiteralPath $CandidateCalibration -Destination (Join-Path $RuntimeDir "torch_threshold_calibration.json")
Write-RetrainLog "Updated active threshold calibration: $(Join-Path $RuntimeDir "torch_threshold_calibration.json")"
}
}
if ($ShadowMode) {
Write-RetrainLog "Candidate accepted by offline guard. Active artifact was not changed: $ModelFile"
}
else {
Write-RetrainLog "Candidate accepted by guard. Active artifact: $ModelFile"
}
Write-RetrainLog "Candidate accepted by guard. Active artifact: $ModelFile"
}
catch {
Write-RetrainLog "ERROR: $($_.Exception.Message)"
+210
View File
@@ -0,0 +1,210 @@
from __future__ import annotations
import argparse
import base64
import json
import os
import sqlite3
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
def sync_orderbook_observations(
*,
api_base_url: str,
token: str,
database_path: str | Path,
timeout: int = 60,
page_limit: int = 5000,
) -> dict[str, Any]:
path = Path(database_path)
path.parent.mkdir(parents=True, exist_ok=True)
_init_schema(path)
manifest = _get_json(
api_base_url,
"/api/training/market-observations/manifest",
token=token,
timeout=timeout,
)
rows = manifest.get("items") if isinstance(manifest.get("items"), list) else []
downloaded = 0
symbol_results: list[dict[str, Any]] = []
for row in rows:
if not isinstance(row, dict):
continue
symbol = str(row.get("symbol") or "").strip().upper()
remote_max_id = int(row.get("max_id", 0) or 0)
if not symbol or remote_max_id <= 0:
continue
after_id = _local_max_id(path, symbol)
symbol_downloaded = 0
while after_id < remote_max_id:
query = urlencode(
{
"symbol": symbol,
"after_id": after_id,
"limit": max(1, min(5000, int(page_limit))),
}
)
payload = _get_json(
api_base_url,
f"/api/training/market-observations?{query}",
token=token,
timeout=timeout,
)
items = payload.get("items") if isinstance(payload.get("items"), list) else []
if not items:
break
inserted = _insert_rows(path, items)
symbol_downloaded += inserted
downloaded += inserted
next_after_id = int(payload.get("next_after_id", after_id) or after_id)
if next_after_id <= after_id:
break
after_id = next_after_id
symbol_results.append(
{
"symbol": symbol,
"downloaded": symbol_downloaded,
"local_max_id": _local_max_id(path, symbol),
"remote_max_id": remote_max_id,
}
)
return {
"database_path": str(path.resolve()),
"downloaded": downloaded,
"symbols": symbol_results,
"local_samples": _local_count(path),
}
def _init_schema(path: Path) -> None:
with sqlite3.connect(path) as connection:
connection.executescript(
"""
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS market_observations (
id INTEGER PRIMARY KEY,
symbol TEXT NOT NULL,
bid_price REAL NOT NULL,
bid_size REAL NOT NULL,
ask_price REAL NOT NULL,
ask_size REAL NOT NULL,
mid_price REAL NOT NULL,
microprice REAL NOT NULL,
spread_bps REAL NOT NULL,
imbalance REAL NOT NULL,
last_price REAL NOT NULL,
source_timestamp_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_local_market_observations_symbol_id
ON market_observations(symbol, id);
"""
)
def _insert_rows(path: Path, rows: list[Any]) -> int:
values = []
for row in rows:
if not isinstance(row, dict):
continue
values.append(
(
int(row.get("id", 0) or 0),
str(row.get("symbol") or "").upper(),
float(row.get("bid_price", 0.0) or 0.0),
float(row.get("bid_size", 0.0) or 0.0),
float(row.get("ask_price", 0.0) or 0.0),
float(row.get("ask_size", 0.0) or 0.0),
float(row.get("mid_price", 0.0) or 0.0),
float(row.get("microprice", 0.0) or 0.0),
float(row.get("spread_bps", 0.0) or 0.0),
float(row.get("imbalance", 0.0) or 0.0),
float(row.get("last_price", 0.0) or 0.0),
int(row.get("source_timestamp_ms", 0) or 0),
str(row.get("created_at") or ""),
)
)
if not values:
return 0
with sqlite3.connect(path) as connection:
before = connection.total_changes
connection.executemany(
"""
INSERT OR IGNORE INTO market_observations (
id, symbol, bid_price, bid_size, ask_price, ask_size,
mid_price, microprice, spread_bps, imbalance, last_price,
source_timestamp_ms, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
values,
)
return connection.total_changes - before
def _local_max_id(path: Path, symbol: str) -> int:
with sqlite3.connect(path) as connection:
row = connection.execute(
"SELECT MAX(id) FROM market_observations WHERE symbol = ?",
(symbol,),
).fetchone()
return int(row[0] or 0) if row else 0
def _local_count(path: Path) -> int:
with sqlite3.connect(path) as connection:
row = connection.execute("SELECT COUNT(*) FROM market_observations").fetchone()
return int(row[0] or 0) if row else 0
def _get_json(api_base_url: str, path: str, *, token: str, timeout: int) -> dict[str, Any]:
headers = {"Accept": "application/json"}
headers.update(_auth_headers(token))
request = Request(api_base_url.rstrip("/") + path, headers=headers, method="GET")
try:
with urlopen(request, timeout=timeout) as response:
text = response.read().decode("utf-8")
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {exc.code} {path}: {detail[:300]}") from exc
except URLError as exc:
raise RuntimeError(f"network error {path}: {exc.reason}") from exc
data = json.loads(text) if text.strip() else {}
return data if isinstance(data, dict) else {}
def _auth_headers(token: str) -> dict[str, str]:
value = token.strip()
if not value:
return {}
headers = {"X-TradeBot-Token": value}
if value.lower().startswith(("basic ", "bearer ")):
headers["Authorization"] = value
elif ":" in value:
encoded = base64.b64encode(value.encode("utf-8")).decode("ascii")
headers["Authorization"] = f"Basic {encoded}"
else:
headers["Authorization"] = f"Bearer {value}"
return headers
def main() -> None:
parser = argparse.ArgumentParser(description="Synchronize TradeBot L1 observations to a local SQLite cache.")
parser.add_argument("--api-base-url", default=os.environ.get("TRADEBOT_API_BASE_URL", "https://tb.kusoft.xyz"))
parser.add_argument("--api-auth", default=os.environ.get("TRADEBOT_API_AUTH", ""))
parser.add_argument("--database", default="runtime/orderbook_observations.sqlite3")
args = parser.parse_args()
result = sync_orderbook_observations(
api_base_url=args.api_base_url,
token=args.api_auth,
database_path=args.database,
)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
+52
View File
@@ -28,6 +28,7 @@ from crypto_spot_bot.bybit import BybitClient
from crypto_spot_bot.config import load_settings
from crypto_spot_bot.indicators import add_indicators
from crypto_spot_bot.models import Candle
from crypto_spot_bot.orderbook_features import ORDERBOOK_FEATURES, load_orderbook_feature_map
from crypto_spot_bot.time_series import (
DEFAULT_TORCH_FEATURES,
_barrier_outcome,
@@ -41,6 +42,8 @@ EVENT_OUTPUT_NAME = "logit_tp_first"
OUTPUT_LAYOUT = (*RETURN_OUTPUT_LAYOUT, EVENT_OUTPUT_NAME)
TARGET_TRANSFORM = "barrier_net_return"
QUANTILES = {"q10": 0.10, "q50": 0.50, "q90": 0.90}
_ORDERBOOK_FEATURES_BY_SYMBOL: dict[str, dict[int, dict[str, float]]] = {}
_ORDERBOOK_MANIFEST: dict[str, dict[str, Any]] = {}
@dataclass(slots=True)
@@ -161,6 +164,7 @@ class RecurrentReturnModel(nn.Module):
def main() -> None:
global _ORDERBOOK_FEATURES_BY_SYMBOL, _ORDERBOOK_MANIFEST
args = _parse_args()
if args.threads > 0:
torch.set_num_threads(args.threads)
@@ -175,6 +179,33 @@ def main() -> None:
decision_horizon = args.horizon if args.horizon > 0 else max(1, settings.time_series_forecast_horizon)
target_horizons = _horizons(args.horizons, decision_horizon)
feature_names = _feature_names_arg(args.features)
if args.orderbook_db:
_ORDERBOOK_FEATURES_BY_SYMBOL, _ORDERBOOK_MANIFEST = load_orderbook_feature_map(
args.orderbook_db,
interval=interval,
symbols=symbols,
min_samples_per_bucket=args.orderbook_min_samples_per_bucket,
)
eligible_symbols = [
symbol
for symbol in symbols
if int(_ORDERBOOK_MANIFEST.get(symbol, {}).get("covered_buckets", 0) or 0)
>= args.orderbook_min_covered_buckets
]
if len(eligible_symbols) < max(1, args.orderbook_min_symbols):
coverage = ", ".join(
f"{symbol}={int(_ORDERBOOK_MANIFEST.get(symbol, {}).get('covered_buckets', 0) or 0)}"
for symbol in symbols
)
raise SystemExit(
"Orderbook coverage is below the training minimum: "
f"need {args.orderbook_min_covered_buckets} buckets for "
f"{args.orderbook_min_symbols} symbols; got {coverage or 'no data'}"
)
symbols = eligible_symbols
for feature_name in ORDERBOOK_FEATURES:
if feature_name not in feature_names:
feature_names.append(feature_name)
if args.pooled:
feature_names.extend(f"symbol_is_{symbol}" for symbol in symbols)
ensemble_seeds = _ints(args.ensemble_seeds) or [args.seed]
@@ -214,6 +245,15 @@ def main() -> None:
"selection_folds": args.selection_folds,
"symbols": {},
}
if args.orderbook_db:
artifact["orderbook_features"] = {
"source": "forward_collected_bybit_l1",
"interval": interval,
"min_samples_per_bucket": args.orderbook_min_samples_per_bucket,
"min_covered_buckets": args.orderbook_min_covered_buckets,
"features": list(ORDERBOOK_FEATURES),
"coverage": {symbol: _ORDERBOOK_MANIFEST.get(symbol, {}) for symbol in symbols},
}
if args.pooled:
artifact["version"] = 7
@@ -584,6 +624,10 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument("--threads", type=int, default=0, help="Torch CPU threads; 0 keeps torch default.")
parser.add_argument("--device", default="auto", help="auto, cpu, cuda, or mps.")
parser.add_argument("--output", default="", help="Output JSON path. Defaults to TIME_SERIES_LSTM_MODEL_PATH.")
parser.add_argument("--orderbook-db", default="", help="SQLite cache containing forward-collected L1 observations.")
parser.add_argument("--orderbook-min-samples-per-bucket", type=int, default=20)
parser.add_argument("--orderbook-min-covered-buckets", type=int, default=240)
parser.add_argument("--orderbook-min-symbols", type=int, default=2)
return parser.parse_args()
@@ -802,6 +846,7 @@ def _prepare_data(
symbol=symbol,
market_candles=market_candles,
trend_candles=trend_candles,
orderbook_features=_ORDERBOOK_FEATURES_BY_SYMBOL,
)
max_horizon = max(target_horizons)
samples: list[TrainingSample] = []
@@ -812,6 +857,11 @@ def _prepare_data(
window = feature_rows[end_index - lookback + 1 : end_index + 1]
if len(window) != lookback:
continue
if any(name in ORDERBOOK_FEATURES for name in feature_names):
symbol_orderbook = _ORDERBOOK_FEATURES_BY_SYMBOL.get(symbol.upper(), {})
window_candles = candles[end_index - lookback + 1 : end_index + 1]
if any(row.timestamp not in symbol_orderbook for row in window_candles):
continue
raw_targets: list[float] = []
event_targets: list[float] = []
volatility_scales: list[float] = []
@@ -856,6 +906,8 @@ def _prepare_data(
validation_window = min(max(16, validation_window), max(16, validation_end // 3))
validation_start = validation_end - validation_window
train_end = validation_start - max_horizon
if validation_start < 0 or train_end <= 0:
return None
train_samples = samples[:train_end]
validation_samples = samples[validation_start:validation_end]
holdout_samples = samples[holdout_start:]
+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: