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)
- Iterate the schema, not the source. Only schema keys are ever emitted.
- 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.
- 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_popare GA metadata — 0 reads in every C# loader. They stay recoverable from_source_cli_hint, which is the provenance field ATAS does not parse. - Round-trip is the acceptance test. Write a settings file, load it with
--settings, and assert the values return unchanged. UnderGEX_RESEARCH_FROZEN=False(whatbacktest_options.pysets) 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
- Add the row to
SETTINGS_SCHEMA(correct type tag;enumif dual-form). - Add it to
GENOME_BOUNDS_PA/GENOME_BOUNDS_OPTif evolvable. - If evolvable, add a step to
PARAM_STEPS_*. - Do not touch
_SETTINGS_KEYS_ORDEREDor anySETTINGS_*_KEYSset — they are derived. - Re-gold catalog fixtures (
tests/fixtures/catalog_golden/), additive only. - Run
./Backtesting/run_tests.sh --alland--parity. - Bump
BACKTEST_VERSION+ header changelog (5/5 rule) andBacktesting/CONTEXT.md.