#!/usr/bin/env bash set -euo pipefail # --------------------------------------------------------------------------- # train.sh - Training orchestrator: submits K8s Jobs to the GPU pool # --------------------------------------------------------------------------- # Usage: # ./infra/scripts/train.sh [--model MODEL] [--symbol SYMBOL] # [--trials N] [--max-steps N] [--timeout N] # [--commit SHA] [--poll-interval N] # # Presets: # quick-test - fast sanity check (max-steps=500, requires --model) # single-model - full training run for one model (requires --model) # full-ensemble - trains all 10 models sequentially (dqn ppo tft mamba2 tggn tlob liquid kan xlstm diffusion) # hyperopt - hyperparameter optimisation (requires --model, uses --trials) # ci-train - wait for Argo CI to pass, then auto-submit training (requires --model) # --------------------------------------------------------------------------- # --- Defaults ------------------------------------------------------------- SYMBOL="ES.FUT" TRIALS=20 MAX_STEPS=2000 TIMEOUT=3600 # default; hyperopt preset overrides to 6h below DATA_DIR="/data/futures-baseline" NAMESPACE="foxhunt" IMAGE="gitlab-registry.foxhunt.svc.cluster.local:5000/root/foxhunt/foxhunt-training-runtime:latest" MODEL="" PRESET="" COMMIT="" POLL_INTERVAL=30 TRAIN_PRESET="hyperopt" TIMESTAMP="$(date +%s)" S3_BUCKET="foxhunt-models" MINIO_ENDPOINT="http://minio.foxhunt.svc.cluster.local:9000" RUN_ID="$(date +%Y%m%d-%H%M%S)" ALL_MODELS=(dqn ppo tft mamba2 tggn tlob liquid kan xlstm diffusion) # --- Model-to-binary mapping ---------------------------------------------- declare -A MODEL_BINARY=( [dqn]=train_baseline_rl [ppo]=train_baseline_rl [tft]=train_baseline_supervised [mamba2]=train_baseline_supervised [tggn]=train_baseline_supervised [tlob]=train_baseline_supervised [liquid]=train_baseline_supervised [kan]=train_baseline_supervised [xlstm]=train_baseline_supervised [diffusion]=train_baseline_supervised ) # Hyperopt uses separate binaries declare -A HYPEROPT_BINARY=( [dqn]=hyperopt_baseline_rl [ppo]=hyperopt_baseline_rl [tft]=hyperopt_baseline_supervised [mamba2]=hyperopt_baseline_supervised [tggn]=hyperopt_baseline_supervised [tlob]=hyperopt_baseline_supervised [liquid]=hyperopt_baseline_supervised [kan]=hyperopt_baseline_supervised [xlstm]=hyperopt_baseline_supervised [diffusion]=hyperopt_baseline_supervised ) EVAL_BINARY="evaluate_baseline" # --- Helpers -------------------------------------------------------------- usage() { cat <<'USAGE' Usage: ./infra/scripts/train.sh [OPTIONS] Presets: quick-test Fast sanity check (requires --model, max-steps=500) single-model Full training run (requires --model) full-ensemble Trains all 10 models sequentially (dqn ppo tft mamba2 tggn tlob liquid kan xlstm diffusion) hyperopt Hyperparameter search (requires --model, uses --trials) evaluate Evaluate trained models (requires --run-id from prior training run) ci-train Wait for Argo CI pipeline, then auto-submit training (requires --model) Options: --model MODEL Model name: dqn | ppo | tft | mamba2 | tggn | tlob | liquid | kan | xlstm | diffusion --symbol SYMBOL Trading symbol (default: ES.FUT) --trials N Hyperopt trial count (default: 20) --max-steps N Max steps per epoch (default: 2000) --timeout N Job timeout in seconds (default: 3600) --commit SHA Git commit to track CI for (default: HEAD of main) --poll-interval N CI poll interval seconds (default: 30) --train-preset P Training preset after CI (default: hyperopt; for ci-train) -h, --help Show this help message USAGE exit 0 } die() { echo "ERROR: $*" >&2; exit 1; } validate_model() { local m="$1" [[ -n "${MODEL_BINARY[$m]+x}" ]] || die "Unknown model '${m}'. Valid: ${!MODEL_BINARY[*]}" } # --- Parse arguments ------------------------------------------------------- [[ $# -eq 0 ]] && usage [[ "$1" == "-h" || "$1" == "--help" ]] && usage PRESET="$1"; shift while [[ $# -gt 0 ]]; do case "$1" in --model) MODEL="$2"; shift 2 ;; --symbol) SYMBOL="$2"; shift 2 ;; --trials) TRIALS="$2"; shift 2 ;; --max-steps) MAX_STEPS="$2"; shift 2 ;; --timeout) TIMEOUT="$2"; shift 2 ;; --run-id) RUN_ID="$2"; shift 2 ;; --commit) COMMIT="$2"; shift 2 ;; --poll-interval) POLL_INTERVAL="$2"; shift 2 ;; --train-preset) TRAIN_PRESET="$2"; shift 2 ;; -h|--help) usage ;; *) die "Unknown option: $1" ;; esac done # --- Preset validation ----------------------------------------------------- case "$PRESET" in quick-test) [[ -z "$MODEL" ]] && die "quick-test preset requires --model" validate_model "$MODEL" MAX_STEPS=500 ;; single-model) [[ -z "$MODEL" ]] && die "single-model preset requires --model" validate_model "$MODEL" ;; full-ensemble) # Trains all models; --model is ignored if provided MODEL="" ;; hyperopt) [[ -z "$MODEL" ]] && die "hyperopt preset requires --model" validate_model "$MODEL" # Hyperopt runs 20 trials × 8 epochs — needs 6h unless user specified --timeout if [[ "$TIMEOUT" -eq 3600 ]]; then TIMEOUT=21600 fi ;; evaluate) [[ -z "$RUN_ID" ]] && die "evaluate preset requires --run-id (from a previous training run)" ;; ci-train) [[ -z "$MODEL" ]] && die "ci-train preset requires --model" validate_model "$MODEL" # Resolve commit SHA if not provided if [[ -z "$COMMIT" ]]; then COMMIT="$(git rev-parse HEAD 2>/dev/null || echo "")" [[ -z "$COMMIT" ]] && die "Could not determine commit SHA. Use --commit SHA" fi ;; *) die "Unknown preset '${PRESET}'. Valid: quick-test, single-model, full-ensemble, hyperopt, evaluate, ci-train" ;; esac # --- CI Monitoring -------------------------------------------------------- # Find the Argo Workflow for a given commit SHA. # Argo CI workflows are submitted with parameter commit-sha=. # We search for workflows matching the ci-pipeline template. find_ci_workflow() { local sha="$1" local short_sha="${sha:0:8}" # List recent ci-pipeline workflows and find the one matching our commit. # Argo Events labels workflows with events.argoproj.io/trigger (not # workflows.argoproj.io/workflow-template), so use that selector. kubectl -n "${NAMESPACE}" get workflows \ -l "events.argoproj.io/trigger=ci-pipeline" \ -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.phase}{"\t"}{range .spec.arguments.parameters[*]}{.name}={.value}{","}{end}{"\n"}{end}' \ 2>/dev/null \ | grep "commit-sha=${sha}\|commit-sha=${short_sha}" \ | head -1 \ | cut -f1 } # Get workflow phase: Pending, Running, Succeeded, Failed, Error get_workflow_phase() { local wf_name="$1" kubectl -n "${NAMESPACE}" get workflow "${wf_name}" \ -o jsonpath='{.status.phase}' 2>/dev/null } # Get workflow node statuses (one-liner summary) get_workflow_summary() { local wf_name="$1" kubectl -n "${NAMESPACE}" get workflow "${wf_name}" \ -o jsonpath='{range .status.nodes[*]}{.displayName}: {.phase} {end}' 2>/dev/null } # Get workflow start time and duration get_workflow_timing() { local wf_name="$1" local started finished started=$(kubectl -n "${NAMESPACE}" get workflow "${wf_name}" \ -o jsonpath='{.status.startedAt}' 2>/dev/null) finished=$(kubectl -n "${NAMESPACE}" get workflow "${wf_name}" \ -o jsonpath='{.status.finishedAt}' 2>/dev/null) if [[ -n "$finished" && "$finished" != "null" ]]; then echo "started=${started} finished=${finished}" elif [[ -n "$started" ]]; then echo "started=${started} (still running)" else echo "(not started yet)" fi } # Print Prometheus / log links for CI monitoring print_monitor_links() { local wf_name="$1" echo "" echo " Argo UI: kubectl -n ${NAMESPACE} get workflow ${wf_name} -o yaml" echo " Logs: kubectl -n ${NAMESPACE} logs -l workflows.argoproj.io/workflow=${wf_name} --prefix -f" echo " Prometheus: http://prometheus-stack-kube-prom-prometheus.foxhunt.svc.cluster.local:9090/graph?g0.expr=argo_workflow_status_phase" echo "" } # Wait for CI workflow to complete. Returns 0 on success, 1 on failure. wait_for_ci() { local sha="$1" local short_sha="${sha:0:8}" local wf_name="" local phase="" local attempt=0 local max_wait_for_wf=60 # max attempts to find the workflow (60 * poll = 30 min) echo "======================================================================" echo " Waiting for Argo CI pipeline to complete" echo " Commit : ${sha} (${short_sha})" echo " Poll : every ${POLL_INTERVAL}s" echo "======================================================================" echo "" # Phase 1: Find the workflow (it may not exist yet if webhook is delayed) echo "--- Looking for CI workflow for commit ${short_sha}..." while [[ -z "$wf_name" ]]; do wf_name=$(find_ci_workflow "$sha") if [[ -n "$wf_name" ]]; then echo " Found workflow: ${wf_name}" print_monitor_links "$wf_name" break fi attempt=$((attempt + 1)) if [[ $attempt -ge $max_wait_for_wf ]]; then echo "ERROR: No CI workflow found for commit ${short_sha} after $((attempt * POLL_INTERVAL))s" echo " Verify the GitLab webhook triggered. Check:" echo " kubectl -n ${NAMESPACE} get workflows -l workflows.argoproj.io/workflow-template=ci-pipeline" return 1 fi echo " Workflow not found yet (attempt ${attempt}/${max_wait_for_wf}), retrying in ${POLL_INTERVAL}s..." sleep "${POLL_INTERVAL}" done # Phase 2: Poll until terminal phase echo "--- Monitoring workflow ${wf_name}..." while true; do phase=$(get_workflow_phase "$wf_name") local timing timing=$(get_workflow_timing "$wf_name") case "$phase" in Succeeded) echo "" echo " CI PASSED (${timing})" echo "" return 0 ;; Failed|Error) echo "" echo " CI FAILED phase=${phase} (${timing})" echo "" echo " Workflow summary:" get_workflow_summary "$wf_name" | tr ' ' '\n' | sed 's/^/ /' echo "" echo " Logs: kubectl -n ${NAMESPACE} logs -l workflows.argoproj.io/workflow=${wf_name} --prefix" return 1 ;; Running|Pending|"") local step_info step_info=$(get_workflow_summary "$wf_name" 2>/dev/null | tr ' ' '\n' | grep -c "Running" || echo "0") printf " [%s] phase=%-8s running_steps=%s %s\n" \ "$(date +%H:%M:%S)" "${phase:-Pending}" "${step_info}" "${timing}" ;; *) echo " [$(date +%H:%M:%S)] phase=${phase} (unexpected)" ;; esac sleep "${POLL_INTERVAL}" done } # --- Generate K8s Job manifest -------------------------------------------- generate_job_manifest() { local model="$1" local job_name="train-${model}-${TIMESTAMP}" local binary if [[ "$PRESET" == "hyperopt" ]]; then binary="${HYPEROPT_BINARY[$model]}" else binary="${MODEL_BINARY[$model]}" fi # Build CLI args (excluding binary name) local cli_args=("--model" "$model" "--symbol" "$SYMBOL" "--data-dir" "/data/futures-baseline") cli_args+=("--mbp10-data-dir" "/data/futures-baseline-mbp10") cli_args+=("--trades-data-dir" "/data/futures-baseline-trades") if [[ "$PRESET" == "hyperopt" ]]; then # hyperopt binary: --output is a JSON file path, --trials count cli_args+=("--output" "/output/hyperopt_results.json") cli_args+=("--trials" "$TRIALS") else # training binary: --output-dir is a directory for checkpoints cli_args+=("--output-dir" "/output") fi # Convert args array to YAML list items local args_yaml="" for arg in "${cli_args[@]}"; do args_yaml="${args_yaml} - \"${arg}\"" done cat </dev/null || true env: - name: MINIO_ACCESS_KEY valueFrom: secretKeyRef: name: minio-credentials key: access-key - name: MINIO_SECRET_KEY valueFrom: secretKeyRef: name: minio-credentials key: secret-key volumeMounts: - name: training-data mountPath: /data resources: requests: cpu: "1" memory: 512Mi limits: cpu: "2" memory: 1Gi containers: - name: training image: ${IMAGE} command: ["/binaries/${binary}"] args:${args_yaml} env: - name: RUST_LOG value: info - name: SQLX_OFFLINE value: "true" - name: OTEL_EXPORTER_OTLP_ENDPOINT value: "http://tempo.foxhunt.svc.cluster.local:4317" resources: requests: nvidia.com/gpu: "1" cpu: "4" memory: "16Gi" limits: nvidia.com/gpu: "1" cpu: "8" memory: "32Gi" volumeMounts: - name: training-data mountPath: /data readOnly: true - name: output mountPath: /output - name: binaries mountPath: /binaries readOnly: true volumes: - name: training-data persistentVolumeClaim: claimName: training-data-pvc - name: output emptyDir: sizeLimit: 2Gi - name: binaries emptyDir: sizeLimit: 500Mi EOF } generate_eval_manifest() { local job_name="eval-${RUN_ID}-${TIMESTAMP}" cat </dev/null || true env: - name: MINIO_ACCESS_KEY valueFrom: secretKeyRef: name: minio-credentials key: access-key - name: MINIO_SECRET_KEY valueFrom: secretKeyRef: name: minio-credentials key: secret-key volumeMounts: - name: training-data mountPath: /data resources: requests: cpu: "1" memory: 512Mi limits: cpu: "2" memory: 1Gi containers: - name: evaluate image: ${IMAGE} command: ["/binaries/${EVAL_BINARY}"] args: - "--model=both" - "--data-dir=/data/futures-baseline" - "--models-dir=/output/models" env: - name: RUST_LOG value: info - name: SQLX_OFFLINE value: "true" resources: requests: nvidia.com/gpu: "1" cpu: "4" memory: "16Gi" limits: nvidia.com/gpu: "1" cpu: "8" memory: "32Gi" volumeMounts: - name: training-data mountPath: /data readOnly: true - name: output mountPath: /output - name: binaries mountPath: /binaries readOnly: true volumes: - name: training-data persistentVolumeClaim: claimName: training-data-pvc - name: output emptyDir: sizeLimit: 2Gi - name: binaries emptyDir: sizeLimit: 500Mi EOF } # --- Submit a single job and collect its name ------------------------------ submitted_jobs=() submit_job() { local model="$1" local job_name="train-${model}-${TIMESTAMP}" echo "--- Submitting job: ${job_name} (model=${model}, preset=${PRESET})" generate_job_manifest "$model" | kubectl apply -f - submitted_jobs+=("$job_name") } # --- Main dispatch --------------------------------------------------------- echo "======================================================================" echo " Foxhunt Training Orchestrator" echo " Preset : ${PRESET}" if [[ "$PRESET" == "ci-train" ]]; then echo " Commit : ${COMMIT:0:12}" echo " After : ${TRAIN_PRESET} --model ${MODEL}" echo " Poll : every ${POLL_INTERVAL}s" fi echo " Symbol : ${SYMBOL}" echo " Image : ${IMAGE}" echo " NS : ${NAMESPACE}" echo " Run ID : ${RUN_ID}" echo " MinIO : minio://${S3_BUCKET}/runs/${RUN_ID}/" echo "======================================================================" echo "" case "$PRESET" in quick-test|single-model|hyperopt) submit_job "$MODEL" ;; full-ensemble) for m in "${ALL_MODELS[@]}"; do submit_job "$m" echo " Waiting for ${m} to complete before next model..." kubectl -n "${NAMESPACE}" wait --for=condition=complete "job/train-${m}-${TIMESTAMP}" --timeout="${TIMEOUT}s" 2>/dev/null || true done ;; evaluate) echo "--- Submitting evaluation job for run ${RUN_ID}" generate_eval_manifest | kubectl apply -f - submitted_jobs+=("eval-${RUN_ID}-${TIMESTAMP}") ;; ci-train) echo "--- CI-Train: monitoring CI for commit ${COMMIT:0:8}, then auto-submitting ${TRAIN_PRESET} job" echo "" if wait_for_ci "$COMMIT"; then echo "======================================================================" echo " CI passed — submitting ${TRAIN_PRESET} training for model=${MODEL}" echo "======================================================================" echo "" # Switch to the training preset for job generation PRESET="$TRAIN_PRESET" # Apply hyperopt timeout if needed if [[ "$TRAIN_PRESET" == "hyperopt" && "$TIMEOUT" -eq 3600 ]]; then TIMEOUT=21600 fi submit_job "$MODEL" else echo "======================================================================" echo " CI failed — NOT submitting training job" echo "======================================================================" exit 1 fi ;; esac echo "" echo "======================================================================" echo " All jobs submitted." echo "======================================================================" echo "" echo "Monitor with:" echo "" for jn in "${submitted_jobs[@]}"; do echo " kubectl -n ${NAMESPACE} get job ${jn}" echo " kubectl -n ${NAMESPACE} logs job/${jn} -f" echo "" done echo " kubectl -n ${NAMESPACE} get jobs -l foxhunt/preset=${PRESET}" echo " kubectl -n ${NAMESPACE} get pods -l foxhunt/job-type=training --watch"