Skip to content

ATAS Settings File Contract — Architecture Reference


status: ACTIVE tier: 2 (design reference) last-verified: 2026-09-29 verified-against: a48aee3c (backtest.py v3.32.0) canonical-implementation: Backtesting/backtest.py (SETTINGS_SCHEMA, build_settings_dict) consumers: Alpha Signal.cs, Options Signal.cs, TJ Alpha Strategy.cs, Options Strategy.cs


The rule

A settings/gene key is declared in exactly ONE place: Backtesting/backtest.py::SETTINGS_SCHEMA.

To add a gene, add one row. Do not add it to a second list. There is no second list.

SETTINGS_SCHEMA = [
    ('pattern', 'enum'),
    ('trend_n', 'int'),
    ...
]

Everything else is derived:

Symbol Meaning
_SETTINGS_KEYS_ORDERED export key ORDER (schema order)
SETTINGS_TYPES key → type tag
SETTINGS_INT_KEYS / _FLOAT_ / _TIME_ / _STR_ / _ENUM_ typed membership views
build_settings_dict(source, keys=None) the one coercion path every writer calls

Key order is load-bearing. The C# loaders take the first occurrence of a duplicate key, so reordering the schema changes precedence, not just cosmetics. catalog_champions therefore pins its historical order as a prefix and appends new genes at the end (_CATALOG_KEY_ORDER + _ordered_keys()) so re-gold diffs stay purely additive.

Type tags

Tag Coercion Keys
int _scalar_from_arg → JSON integer gene counts, modes, thresholds
float _scalar_from_arg → JSON float ratios, money, ATR multiples
bool as-is (reserved)
time verbatim, no coercion no_trade_start, no_trade_end
str always textual direction
enum name or number, both legitimate pattern, gex_tracking

time is separate because ':' is a clock separator, not a decimal point — "14:45:00" is not a float. Relying on _scalar_from_arg alone would be a silent-corruption bug.

enum exists so the schema never misdescribes a key. pattern is "BREAKOUT" when written by the backtester (it holds the resolved name) but 1 when reconstructed from a raw --pattern 1 dashboard CLI token. gex_tracking is 'dynamic'/'static' on Engine B and 0 on Engine A. Tagging these str claimed they were always strings — the exact class of mislabel the table exists to prevent. If you add a dual-form key, use enum, not str.

Engine membership is NOT stored

A key's engine (PA / OPT / both) is derived from GENOME_BOUNDS_PA / GENOME_BOUNDS_OPT in the engine shims, not duplicated in the schema — so the table cannot drift from the engine's own evolutionary contract. Keys in neither bounds dict are fixed operational keys (direction, cost, session window, weekday gates, no-trade clock).

tests/test_writer_completeness.py::test_settings_schema_covers_both_engines_and_fixed_keys fails loudly if a bounded gene is missing from the schema.

Emission rules (build_settings_dict)

  1. Iterate the schema, not the source. Only schema keys are ever emitted.
  2. Absent keys are OMITTED, not default-filled. A partial legacy CLI yields a partial payload. Do not inject a Python-side default — the C# clamps to neutral on read, so an absent key is already a neutral gene, and a filled default would pin whatever the driver happened to choose and mask a real divergence from ATAS.
  3. Evolve/ops keys are not settings. export_settings (the exporter's own output path), date_from, pool_max, profit_formula, exit_opt_gens, exit_opt_pop are GA metadata — 0 reads in every C# loader. They stay recoverable from _source_cli_hint, which is the provenance field ATAS does not parse.
  4. Round-trip is the acceptance test. Write a settings file, load it with --settings, and assert the values return unchanged. Under GEX_RESEARCH_FROZEN=False (what backtest_options.py sets) Engine-B genomes load exactly; under the Engine-A default they are intentionally clamped to neutral.

Why this exists

Four modules each hand-maintained their own key list, and each drifted independently. Two production defects were both instances of that single failure mode:

  • v3.13.2 OPTIONS PARITY — lossy writer dropped Engine-B keys.
  • v3.31.0 DI/zone lossy writer — dropped the 14 Engine-A DI/zone/swing genes. A genome using DI filters exported fine, but the C# fell back to UI defaults, desyncing ATAS from the backtester across exactly the genes added in the DI Integration phase.

It also produced a silent identity collision: catalog_champions zero-fills every key and re-hashes the emitted dict, so its stale list made two genomes differing only in a DI gene hash identically and share a _genome_uid (proof: di_div_threshold 35 vs 55 → both MNQ_M5_A02A65AA; the schema separates them → B1F71F46 / 80436CA8). Two distinct genomes must never share a UID.

Lesson: when N places must agree, store the truth once and derive the rest. A test that asserts each copy matches is strictly worse than removing the copies.

Consequence for catalog UIDs

The cataloger hashes the emitted, zero-filled payload, so adding a schema key changes catalogued UIDs. That is correct, not a regression — a genome that differs only in a newly-tracked gene genuinely is a different genome. Before changing schema, check whether live catalogued UIDs must migrate (Genomes/ + the genomes lineage table).

Adding a gene — checklist

  1. Add the row to SETTINGS_SCHEMA (correct type tag; enum if dual-form).
  2. Add it to GENOME_BOUNDS_PA / GENOME_BOUNDS_OPT if evolvable.
  3. If evolvable, add a step to PARAM_STEPS_*.
  4. Do not touch _SETTINGS_KEYS_ORDERED or any SETTINGS_*_KEYS set — they are derived.
  5. Re-gold catalog fixtures (tests/fixtures/catalog_golden/), additive only.
  6. Run ./Backtesting/run_tests.sh --all and --parity.
  7. Bump BACKTEST_VERSION + header changelog (5/5 rule) and Backtesting/CONTEXT.md.