Skip to content

The RUN_CONFIG contract (#754)

The backtest and live/dry-run launch paths each serialize one typed config blob to the RUN_CONFIG container env var; the container parses it exactly once. This replaced the ~14 per-field env vars each launcher hand-maintained and each container hand-parsed with extra="ignore" — a design where fields silently vanished (wrong stake/stoploss, un-disable-able protections, dropped training windows) and a delivered field could be read from a different source than it was written to.

Scope: the cutover covers backtest + live/dry-run today. Training and hyperopt are NOT cut over — they still emit per-field env; TrainingRunConfig is a reserved contract with no live writer or reader until #538 (see the writers/readers table). Even within the cut-over paths a few discrete env vars deliberately remain (see "What is deliberately NOT in the contract").

Module: libs/tradai-common/src/tradai/common/run_config.py.

The contracts

Three frozen, extra="forbid" Pydantic models, one per launch mode:

Model Mode(s) Carries
BacktestRunConfig backtest the submitted BacktestConfig
TrainingRunConfig train model/strategy/window/pairs/timeframe + v4 binding context (reserved — no live writer/reader until #538)
TradingRunConfig live, dry-run timeframe, pairs, allowlisted config_overrides, risk_limits

_MODE_CONTRACTS maps each mode string to its model. RunConfig is their union.

What is deliberately NOT in the contract

  • Run/trace metadata (JOB_ID/TRACE_ID/EXPERIMENT_NAME) — stays discrete platform env, so content_hash is a deterministic hash of intent and there is no second identity source a malformed projection could disagree with.
  • exchange (live) and platform locators (DYNAMODB_TABLE, ARCTIC_*, secrets, STRATEGY_ID slug, TRADAI_PERSIST_DIR, …) — discrete env; read by the platform, not run intent. (Backtest keeps EXCHANGE as a discrete orchestration projection too, because the DynamoDB create-on-missing seed reads it at RUNNING, before RUN_CONFIG is parsed.)
  • model_version_alias — dead on the authoritative v4 path (the ACTIVE Release pins the model); survives only as a discrete LOCAL_DEV_OVERRIDE env.
  • enable_protections — intentionally not modelled: freqtrade's live trade has no --enable-protections flag (it is backtest/hyperopt-only; live protections are always-on), so there is no clean per-deployment toggle to transport. Adding it would be the exact silent-drop the contract exists to prevent — it lands only once a real live mechanism does.

The codec

  • serialize(cfg) -> str — compact, field-order-stable JSON. Enforces MAX_RUN_CONFIG_CHARS (5000) and raises a domain ValidationError over budget (so every launcher's API boundary maps an over-budget config to 4xx uniformly — not a 500/503/poison-retry). The 5000 cap bounds only the single largest override contributor, leaving headroom under the ECS 8192 combined-overrides limit; it is NOT by itself a combined-budget guarantee.
  • parse(mode, blob) -> RunConfig — selects the mode's contract, validates strictly (extra="forbid"), and rejects a blob whose self-described mode disagrees with the selector (critical: live and dry-run share one contract, so this is the only thing stopping a dry-run blob from being accepted in a live container).
  • content_hash(cfg) -> str — 64-char sha256 via the one shared canonical_sha256 primitive.

schema_version policy

schema_version: Literal[N] is a trip-wire, not a migration mechanism. Cutovers use a dev downtime window with no dual-read, so a blob at any other version fails validation.

Versioning is per-mode. Each wrapper (BacktestRunConfig / TrainingRunConfig / TradingRunConfig) carries its own schema_version: Literal[N], and MODE_SCHEMA_VERSIONS is derived from those model defaults (live and dry-run share TradingRunConfig, hence one version). The model's Literal[N] is the single source of truth — bump only the mode whose contract changed and the map follows automatically. A backtest-only field change must not force a live-trading fleet re-cut or invalidate persisted/in-flight training blobs. The parity gate's test_schema_version_literal_matches_default fails CI if a model's Literal[N] and its default ever desync (e.g. Literal[2] = 1).

What counts as breaking. Nearly everything does. The wrappers are extra="forbid" and serialize uses model_dump_json() without exclude_unset, so a writer emits every field including defaults. Adding an "optional field with a default" is therefore breaking: the new writer emits the key and an old reader rejects the whole blob. Define compatibility in writer/reader terms, not "additive vs breaking":

  • Safe — changes no writer output and no reader expectation (docstrings, validator error text, internal helpers).
  • Breaking — any field added, removed, renamed, or retyped; any default changed; any validator tightened such that a previously-valid blob now fails. All of these need a bump.

Bump procedure (per mode, breaking field change): because there is no dual-read, treat a bump as a coordinated cutover for that mode's launchers/readers only — disable its producers, drain or abort in-flight executions on that path (an already-started execution can otherwise launch later against a newer-schema image), migrate any persisted blobs for that mode, publish the reader image, then re-enable. A bump does not touch a mode whose version was unchanged.

Where the version lives. One source of truth, several derivations — they do not need to be edited in lockstep:

Site Role
the mode wrapper's schema_version: Literal[N] the source of truth — edit only this
MODE_SCHEMA_VERSIONS (run_config.py) derived from the model defaults; follows automatically
runconfig_schema_version() a projection of the "live" entry for the #782 image marker
cookiecutter-.../Dockerfile build gate reads the installed tradai-common at build time
cli/.../strategy_utils.py derives the label value it injects
aws/image_labels.py holds only the label key, never a version
tradai.yaml RUNCONFIG_SCHEMA_VERSION an operator override, not a pin to keep in sync

Note: the image-capability marker (#782, "can this deployed image read RUN_CONFIG at all") is a separate, coarser concern than these per-mode wire versions — do not conflate them. It projects "live" deliberately: the gate exists to protect the live/dry-run cutover, the one path whose writer strips legacy env. A bump to a mode that is not yet cut over does not move the marker.

config_overrides allowlist

TradingRunConfig.config_overrides accepts only tuning-safe keys (ALLOWED_OVERRIDE_KEYS, homed in this module). Safety/identity keys (dry_run, exchange, pairlists, protections, …) are rejected at the contract boundary. Risk-envelope knobs (max_leverage/max_drawdown_pct) are NOT overrides — they travel first-class in risk_limits (max_open_trades stays an override: it is a real freqtrade concurrency knob).

Writers and readers

Mode Writer(s) Reader
backtest aws/step_functions.py (SFN input $.run_config), aws/ecs_executor.py (direct ECS), backend/infrastructure/local.py entrypoint/handlers.py::BacktestHandler
live/dry-run backend/core/strategy_operations.py::run_strategy (+ remove_env_keys to strip legacy env) entrypoint/trading.py::TradingHandler
train (#538 — not yet a live launcher) (per-field until #538)
hyperopt (native freqtrade) (no launcher — discrete FREQTRADE_HYPEROPT_* env, #806) entrypoint/hyperopt.py::HyperoptHandler
hyperopt (optuna) (#808 — HyperoptRunConfig; built, unmerged) discrete HYPEROPT_* env today

The two hyperopt sub-paths are not on the contract. entrypoint/base.py branches on HYPEROPT_MODE: optunaOptunaHyperoptHandler, freqtrade → the native HyperoptHandler.

  • The optuna sub-path is being cut over to a fourth wrapper class (HyperoptRunConfig, a fifth mode key — live and dry-run share TradingRunConfig) in #808. It reads its own discrete HYPEROPT_* env today — HYPEROPT_N_TRIALS, HYPEROPT_OBJECTIVE_METRIC, HYPEROPT_TIMEOUT_SECONDS, HYPEROPT_N_JOBS — a second live discrete-env surface, listed for the same reason as the native one below.
  • The native freqtrade sub-path reads six discrete FREQTRADE_HYPEROPT_* env vars (epochs, spaces, loss function, jobs, min-trades, timeout) added by #806. This is a deliberate stopgap, listed here so the contract doc does not omit a live discrete-env surface. Before #806 the path had no configuration surface at all and always ran HyperoptConfig's defaults; the settings ship now because #808 is unmerged. When the native sub-path is cut over these fold into the hyperopt contract and the env vars are deleted.

The SFN backtest path keeps orchestration fields top-level (JSONPath for ValidateStrategy/ EnsureData/task-def selection) and a separate $.run_config string — "one model, two projections."

The parity gate

tests/contract/test_config_field_parity.py enforces the invariants: - byte-stable round-trip (serialize(parse(blob)) == blob) — catches default_factory drift; - serialize raises over the size cap (real oversized model, not a circular assertion); - strict rejection of unknown top-level AND nested fields, future schema_version, unknown and cross-mode blobs; - no-orphan gate: every field of BacktestConfig/TradingRunConfig has a declared disposition (APPLIED / CARRIED / ORCHESTRATION / CONTRACT). A transported-but-unconsumed field fails the gate — "wire it or remove it, don't park it." (Structural; the semantic "field reaches its consumer" proof lives in tests/e2e/test_backtest_realism_parity.py.)

The cutover (cross-repo, one-time)

The reader is compiled into tradai-common, which is baked into each per-strategy image (tradai/strategies/<slug>, built in the separate tradai-strategies repo from CodeArtifact) — NOT the platform strategy-service microservice image. So an already-deployed strategy image on an old tradai-common cannot read RUN_CONFIG. Cutting over the writer (emitting RUN_CONFIG, dropping per-field env) breaks such images.

The cutover is therefore a one-time, cross-repo operation: publish the new tradai-common → re-release every strategy from tradai-strategies → migrate each running service atomically (new per-strategy image + RUN_CONFIG in one task-def revision). It is a cost of the breaking wire-contract change, not of ordinary tradai-common updates (only a breaking schema_version bump forces it again).

  • Migration: StrategyOperationsService.migrate_all_to_run_config(strategy_images) / remigrate_to_run_config(strategy_id, image_uri) — idempotent, tri-state (migrated/skipped/blocked), skips stopped services (never resumes an emergency-stopped trader), parses the blob for idempotency, continues-on-error.
  • Driver: scripts/ops/migrate_run_config.py --images <strategy_id→URI map> (exit-code gated).
  • Runbook: docs/runbooks/754-run-config-cutover.md (ordering + verify-by-re-run + rollback).

Where to look

  • Contract + codec: libs/tradai-common/src/tradai/common/run_config.py
  • Backtest reader: libs/tradai-common/src/tradai/common/entrypoint/handlers.py
  • Live reader: libs/tradai-common/src/tradai/common/entrypoint/trading.py
  • Live writer + migration: services/backend/src/tradai/backend/core/strategy_operations.py
  • Parity gate: tests/contract/test_config_field_parity.py