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, socontent_hashis 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_IDslug,TRADAI_PERSIST_DIR, …) — discrete env; read by the platform, not run intent. (Backtest keepsEXCHANGEas a discrete orchestration projection too, because the DynamoDB create-on-missing seed reads it at RUNNING, beforeRUN_CONFIGis parsed.)model_version_alias— dead on the authoritative v4 path (the ACTIVE Release pins the model); survives only as a discreteLOCAL_DEV_OVERRIDEenv.enable_protections— intentionally not modelled: freqtrade's livetradehas no--enable-protectionsflag (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. EnforcesMAX_RUN_CONFIG_CHARS(5000) and raises a domainValidationErrorover 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-describedmodedisagrees with the selector (critical:liveanddry-runshare 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 sharedcanonical_sha256primitive.
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: optuna → OptunaHyperoptHandler, freqtrade → the native HyperoptHandler.
- The optuna sub-path is being cut over to a fourth wrapper class (
HyperoptRunConfig, a fifth mode key —liveanddry-runshareTradingRunConfig) in #808. It reads its own discreteHYPEROPT_*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 ranHyperoptConfig'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