feat: add orderbook shadow training pipeline
This commit is contained in:
@@ -17,11 +17,17 @@ from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
|
||||
ALLOWED_TRAINING_ARTIFACTS = {
|
||||
ACTIVE_TRAINING_ARTIFACTS = {
|
||||
"lstm_forecaster.json",
|
||||
"torch_retrain_guard.json",
|
||||
"torch_threshold_calibration.json",
|
||||
}
|
||||
SHADOW_TRAINING_ARTIFACTS = {
|
||||
"lstm_forecaster.shadow.json",
|
||||
"torch_shadow_guard.json",
|
||||
"torch_shadow_calibration.json",
|
||||
}
|
||||
ALLOWED_TRAINING_ARTIFACTS = ACTIVE_TRAINING_ARTIFACTS | SHADOW_TRAINING_ARTIFACTS
|
||||
RUNNING_LEASE_TIMEOUT = timedelta(minutes=10)
|
||||
ONLINE_WINDOW = timedelta(minutes=3)
|
||||
MAX_JOB_ATTEMPTS = 3
|
||||
@@ -29,7 +35,8 @@ MAX_ARTIFACT_CHUNK_BYTES = 1024 * 1024
|
||||
# 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)
|
||||
REQUIRED_MODEL_BUNDLE = set(ACTIVE_TRAINING_ARTIFACTS)
|
||||
REQUIRED_SHADOW_BUNDLE = set(SHADOW_TRAINING_ARTIFACTS)
|
||||
|
||||
|
||||
class TrainingCoordinator:
|
||||
@@ -46,6 +53,61 @@ class TrainingCoordinator:
|
||||
self._save_state(state)
|
||||
return self._public_status(state)
|
||||
|
||||
def promote_shadow(self, forward_gate: dict[str, Any]) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if not bool(forward_gate.get("passed")):
|
||||
raise ValueError("shadow forward gate has not passed")
|
||||
shadow_model = self.runtime_dir / "lstm_forecaster.shadow.json"
|
||||
shadow_calibration = self.runtime_dir / "torch_shadow_calibration.json"
|
||||
shadow_guard = self.runtime_dir / "torch_shadow_guard.json"
|
||||
missing = [
|
||||
path.name
|
||||
for path in (shadow_model, shadow_calibration, shadow_guard)
|
||||
if not path.is_file()
|
||||
]
|
||||
if missing:
|
||||
raise ValueError("shadow bundle is incomplete: " + ", ".join(missing))
|
||||
model_sha256 = hashlib.sha256(shadow_model.read_bytes()).hexdigest()
|
||||
if str(forward_gate.get("model_sha256") or "") != model_sha256:
|
||||
raise ValueError("shadow forward gate is bound to another model")
|
||||
calibration = _read_json(shadow_calibration)
|
||||
guard = _read_json(shadow_guard)
|
||||
if calibration.get("artifact_sha256") != model_sha256:
|
||||
raise ValueError("shadow calibration is not bound to the model")
|
||||
if not bool(guard.get("accepted")) or guard.get("candidate_artifact_sha256") != model_sha256:
|
||||
raise ValueError("shadow offline guard is invalid")
|
||||
|
||||
promotion_id = str(uuid.uuid4())
|
||||
backup_dir = self.runtime_dir / ".model_backups" / f"{_compact_now()}-shadow-{promotion_id}"
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
targets = {
|
||||
"lstm_forecaster.json": shadow_model,
|
||||
"torch_threshold_calibration.json": shadow_calibration,
|
||||
"torch_retrain_guard.json": shadow_guard,
|
||||
}
|
||||
for target_name in targets:
|
||||
current = self.runtime_dir / target_name
|
||||
if current.is_file():
|
||||
shutil.copy2(current, backup_dir / target_name)
|
||||
for target_name, source in targets.items():
|
||||
target_tmp = self.runtime_dir / f".{target_name}.{promotion_id}.promote"
|
||||
shutil.copy2(source, target_tmp)
|
||||
os.replace(target_tmp, self.runtime_dir / target_name)
|
||||
gate_path = self.runtime_dir / "torch_shadow_forward_gate.json"
|
||||
gate_tmp = gate_path.with_suffix(".tmp")
|
||||
gate_tmp.write_text(
|
||||
json.dumps(forward_gate, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(gate_tmp, gate_path)
|
||||
return {
|
||||
"promoted": True,
|
||||
"model_sha256": model_sha256,
|
||||
"promotion_id": promotion_id,
|
||||
"backup_dir": str(backup_dir),
|
||||
"promoted_at": _now(),
|
||||
}
|
||||
|
||||
def request_retrain(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
with self._lock:
|
||||
@@ -238,8 +300,17 @@ class TrainingCoordinator:
|
||||
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)
|
||||
job["promoted_artifacts"] = promoted
|
||||
artifact_names = {
|
||||
str(item.get("name"))
|
||||
for item in job.get("artifacts", [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
if artifact_names & REQUIRED_SHADOW_BUNDLE:
|
||||
staged = self._validate_and_stage_shadow(job_id, job)
|
||||
job["shadow_artifacts"] = staged
|
||||
else:
|
||||
promoted = self._validate_and_promote(job_id, job)
|
||||
job["promoted_artifacts"] = promoted
|
||||
job["status"] = "completed" if success else "failed"
|
||||
job["phase"] = "completed" if success else "failed"
|
||||
job["progress_percent"] = 100 if success else _coerce_percent(payload.get("progress_percent"), job.get("progress_percent", 0))
|
||||
@@ -247,7 +318,9 @@ class TrainingCoordinator:
|
||||
job["message"] = str(payload.get("message") or "")
|
||||
if isinstance(payload.get("summary"), dict):
|
||||
job["summary"] = payload["summary"]
|
||||
if isinstance(payload["summary"].get("accepted"), bool):
|
||||
if str(payload["summary"].get("state") or "").startswith("collecting"):
|
||||
job["model_decision"] = "collecting"
|
||||
elif isinstance(payload["summary"].get("accepted"), bool):
|
||||
job["model_decision"] = (
|
||||
"accepted" if payload["summary"]["accepted"] else "rejected"
|
||||
)
|
||||
@@ -318,6 +391,60 @@ class TrainingCoordinator:
|
||||
_remove_tree(self.upload_root / job_id)
|
||||
return promoted
|
||||
|
||||
def _validate_and_stage_shadow(self, job_id: str, job: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
ready_dir = self.upload_root / job_id / "ready"
|
||||
staged = {path.name for path in ready_dir.iterdir() if path.is_file()} if ready_dir.is_dir() else set()
|
||||
missing = REQUIRED_SHADOW_BUNDLE - staged
|
||||
if missing:
|
||||
raise ValueError("shadow training bundle is incomplete: " + ", ".join(sorted(missing)))
|
||||
|
||||
model_path = ready_dir / "lstm_forecaster.shadow.json"
|
||||
calibration_path = ready_dir / "torch_shadow_calibration.json"
|
||||
guard_path = ready_dir / "torch_shadow_guard.json"
|
||||
model = _read_json(model_path)
|
||||
calibration = _read_json(calibration_path)
|
||||
guard = _read_json(guard_path)
|
||||
if model.get("type") != "pytorch_recurrent_forecaster":
|
||||
raise ValueError("shadow candidate model type is invalid")
|
||||
symbols = model.get("symbols")
|
||||
if not isinstance(symbols, dict) or not symbols:
|
||||
raise ValueError("shadow candidate model has no symbol models")
|
||||
_validate_symbol_models(symbols)
|
||||
model_sha256 = hashlib.sha256(model_path.read_bytes()).hexdigest()
|
||||
if calibration.get("artifact_sha256") != model_sha256:
|
||||
raise ValueError("shadow calibration is not bound to the uploaded model")
|
||||
if not bool(guard.get("accepted")):
|
||||
raise ValueError("shadow candidate did not pass the offline guard")
|
||||
if guard.get("candidate_artifact_sha256") != model_sha256:
|
||||
raise ValueError("shadow guard is not bound to the uploaded model")
|
||||
validation = calibration.get("validation")
|
||||
if not isinstance(validation, dict) or not _validation_passed(validation):
|
||||
raise ValueError("shadow candidate offline quality gate did not pass")
|
||||
if validation.get("protocol") != "untouched_model_holdout_with_threshold_walk_forward":
|
||||
raise ValueError("shadow candidate validation protocol is not an untouched holdout")
|
||||
|
||||
self.runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
artifact_rows = {
|
||||
str(item.get("name")): item
|
||||
for item in job.get("artifacts", [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
installed: list[dict[str, Any]] = []
|
||||
for name in sorted(REQUIRED_SHADOW_BUNDLE):
|
||||
target_tmp = self.runtime_dir / f".{name}.{job_id}.stage"
|
||||
shutil.copy2(ready_dir / name, target_tmp)
|
||||
os.replace(target_tmp, self.runtime_dir / name)
|
||||
row = artifact_rows.get(name, {})
|
||||
installed.append(
|
||||
{
|
||||
"name": name,
|
||||
"sha256": row.get("sha256", ""),
|
||||
"staged_at": _now(),
|
||||
}
|
||||
)
|
||||
_remove_tree(self.upload_root / job_id)
|
||||
return installed
|
||||
|
||||
def _load_state(self) -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(self.state_path.read_text(encoding="utf-8"))
|
||||
@@ -464,6 +591,10 @@ def _safe_parameters(value: Any) -> dict[str, Any]:
|
||||
"interval",
|
||||
"pooled",
|
||||
"resume_candidate",
|
||||
"use_orderbook",
|
||||
"orderbook_min_samples_per_bucket",
|
||||
"orderbook_min_covered_buckets",
|
||||
"orderbook_min_symbols",
|
||||
}
|
||||
result = {key: value[key] for key in allowed if key in value}
|
||||
for key, low, high in (
|
||||
@@ -475,6 +606,9 @@ def _safe_parameters(value: Any) -> dict[str, Any]:
|
||||
("horizon", 1, 96),
|
||||
("patience", 1, 50),
|
||||
("seed", 1, 2_147_483_647),
|
||||
("orderbook_min_samples_per_bucket", 1, 5000),
|
||||
("orderbook_min_covered_buckets", 96, 20000),
|
||||
("orderbook_min_symbols", 1, 30),
|
||||
):
|
||||
if key not in result:
|
||||
continue
|
||||
@@ -523,6 +657,8 @@ def _safe_parameters(value: Any) -> dict[str, Any]:
|
||||
result["pooled"] = result["pooled"] is True
|
||||
if "resume_candidate" in result:
|
||||
result["resume_candidate"] = result["resume_candidate"] is True
|
||||
if "use_orderbook" in result:
|
||||
result["use_orderbook"] = result["use_orderbook"] is True
|
||||
return result
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user