Step 2 of 7

Feature Search

The stage that costs CPU. Eleven feature sets, each put through a two-stage grid search, then the survivors re-verified at full budget and filtered down to five low-correlation slots. Everything downstream reads this stage's output.

What this stage does

feature_search.py --engine lr --symbol XAUUSD --tf M5 --set all --cores 14

For each of the eleven feature sets, in order:

Stage 1 — search the trade geometry
64 combinations of sl_mult × rr × lookahead, each simulated at the reduced budget max_iter = 600. Keeps a top-3 diverse list, de-duplicated on the whole parameter tuple.
Stage 2 — search the training behaviour
81 combinations of training_bars × retrain_interval × batch_lr × long_thresh, run for each of the three stage-1 survivors — 243 runs.
Merge and filter
Combine both result sets, keep only configurations that pass the acceptance test, sort by score.
Verify the top 40 at full budget
Re-run the best 40 candidates at max_iter = 2000 — the budget the EA will actually use — and keep those still passing.
Correlation filter — choose up to 5 slots
Greedily select slots from the verified list, rejecting any whose first-differenced equity correlates above 0.85 with one already chosen.

The search space in full

Stage 1 — 64 combinations

ParameterValuesWhat it controls
sl_mult1.5, 2.0, 2.5, 3.0Stop distance in ATR units.
rr1.0, 1.5, 2.0, 3.0Take-profit as a multiple of the stop distance.
lookahead2, 3, 5, 8Label horizon, and the online-learning lag.

These three are searched first because they change the shape of every trade, and therefore the whole equity curve. Getting them roughly right before tuning the model is far cheaper than exploring the full cross-product.

Why "top-3 diverse" The three survivors are de-duplicated on the entire (sl_mult, rr, lookahead) tuple. Without that, the top three would often be three near-identical points around one good region, and stage 2 would search the same neighbourhood three times — wasting two-thirds of its budget and missing genuinely different regimes.

Stage 2 — 81 per survivor

ParameterValuesWhat it controls
training_bars500, 1000, 1500Rolling training window length; also sets warmup.
retrain_interval250, 500, 1000How often the weights are re-fitted from scratch.
batch_lr0.005, 0.01, 0.02Gradient-descent step size for batch training.
long_thresh0.50, 0.55, 0.60Probability above which a long is taken.

81 × 3 survivors = 243 runs, all still at max_iter = 600.

The acceptance test and the score

ok    = trades >= 30  AND  profit_factor > 1.0
score = net_profit / (1 + abs(max_dd_pct) / 100)

Anything failing ok is scored -1e18 and effectively vanishes from the ranking. This is what stops the search selecting a spectacular net profit achieved in twelve trades, or a configuration that loses more often than it wins.

The score divides net profit by a drawdown penalty. It is a ranking device, not a risk model — a −50% drawdown only divides the score by 1.5.

Why verify at 2,000 iterations?

The search runs at 600 iterations to keep 307 configurations per set affordable. But the EA does not run at 600 — it runs at the full budget. A configuration that looked best under-trained may not hold up when the gradient descent actually converges, and one that looked mediocre may improve.

So the top 40 are re-simulated at max_iter = 2000 and filtered again. Only survivors of that pass go into the correlation filter. The results you see are full-budget results.

This is a genuine correctness step, not a formality Everything downstream — the portfolio build, the holdout — also runs at max_iter = 2000. If the search selected at 600 and the portfolio re-simulated at 2000, the reported per-slot metrics would not match the search's own ranking, and the two stages would be describing different models.

The correlation filter

This is the step that decides what a "portfolio" actually contains.

Slots per set
N_SLOTS = 5
Similarity measure
Pearson correlation of first-differenced equity curves
Rejection threshold
MAX_CORR = 0.85
Minimum overlap
30 shared points and a denominator above 1e-12, otherwise the pair is not compared at all
Fallback
If the filter cannot fill 5 slots, the remainder are filled by best score, ignoring correlation

Why differencing is essential

Raw equity curves for any two positions on the same instrument share the price trend of that instrument. Correlating the levels would make almost everything look correlated — the measure would be dominated by gold's own trajectory rather than by anything the model is doing.

Taking the first difference (equity[i] − equity[i−1]) removes that shared trend and leaves the per-bar behaviour: when each slot wins, loses, and sits flat. That is the thing worth diversifying.

The fallback can quietly degrade the book If there are not enough low-correlation survivors, the remaining slots are filled by score alone — which can produce five slots that are effectively the same trade repeated. When you review the portfolio, check the per-slot curves for shapes that move together. Step 3 shows exactly this.

Progress output

The script prints structured lines as it goes. These are the ones the Optimize page parses:

engine=lr  symbol=XAUUSD  tf=M5  data=data/XAUUSD_M5.csv  out=strategies_lr_m5  cores=14
  magic_offset(+0) -> M5-style base + offset for tf=M5
  calibration ok: EURUSD 0.01 lot -> $0.10 per pip (lot_mult=100000)

=== set=base9 abbr=BAS features=9 magic_base=401 ===
  [base9] stage1 64 combos ...
    ... 25/64 done
    ... 50/64 done
  [base9] stage1 ok=13  diverse_top3: sl=1.5 rr=1.5 la=8 NP=14 DD=-0.15% PF=1.09 | ...
  [base9] stage2 243 combos ...
  [base9] combined ok=57/307
  [base9] verifying 40 candidates @ max_iter=2000 ...
  [base9] slot magic=401 PF=1.062 NP=+9 T=142 WR=... DD=-0.12% S=...
  [base9] done in 41s  -> 5 verified slots
    wrote strategies_lr_m5/base9/  (configs.json slots.txt summary.json equity_*.csv)

=== set=momentum abbr=MOM features=7 magic_base=411 ===
  ...

ALL SETS DONE -> strategies_lr_m5

The ... N/M done line prints every 25 completions, not every one — which is why the progress bar moves in visible jumps rather than smoothly.

How the progress bar maps to these lines

LineSub-progress
=== set=<name> abbr=… features=…Step counter increments: "step N / 11"
[set] stage1 N combos0.15
[set] stage2 N combos0.60
[set] verifying N candidates0.90
[set] done in Xs ->Set complete
ALL SETS DONE1.00

These weights are approximations of where the time actually goes: stage 2 (243 runs) costs far more than stage 1 (64 runs), and verification of 40 candidates at triple the iteration count is a substantial fraction again.

The --set flag is stricter than you expect

Only a single set name, or the literal all --set all9 fails with Unknown set 'all9'. The script has no concept of the GUI's groupings — its loop handles all internally, and anything else must be exactly one set name.

This is why the GUI expands groups such as all9 or core6 into one process per set, and why all stays a single process. It is also why the progress bar has to cope with two shapes: a single process tracking its own per-set counter, or a queue of processes tracking group_index / group_total.
all is also the cheapest option One process looping eleven sets avoids eleven interpreter startups and eleven separate loads of the CSV. Prefer --set all unless you specifically want to resume a partial run.

Running a subset

# one concrete set
feature_search.py --engine lr --symbol XAUUSD --tf M5 --set base9 --cores 14

# everything
feature_search.py --engine lr --symbol XAUUSD --tf M5 --set all --cores 14

The Optimize page also offers skip sets already on disk, which runs only the missing sets. Because those may not be contiguous, the GUI passes an explicit list of set names on the command line rather than a group key.

Outputs

Per set, under strategies_lr_m5/<set>/:

FileContents
summary.json The machine-readable result. Header keys: engine, set, abbr, data, bars, magic_start, n_features, union_features, max_spread, generated. Then slots[], each with magic, set, features, params and metrics.
configs.json The parameter dicts for every slot, including its magic number — the input the EA generator consumes.
slots.txt A human-readable block per slot: metrics on one line, then every parameter, one per line.
equity_<magic>.csv One column, equity — the slot's equity curve, used for the correlation test and the charts.

Full schemas →

The Optimize page

Controls
Which sets to run (all, or a group, or individual sets), the data file, and the core count.
Progress
A real fraction parsed from the pipeline's stdout — never a simulated spinner.
Log
The raw output, colour-classified, verbatim. Nothing is summarised or hidden.
Results table
Read from summary.json once it lands: per-set metrics, feature counts and the in-sample caveat.
Stop
Kills the whole process tree, including every pool worker.
You can leave it running and close the window? No — closing the window kills the search. The pump threads are daemons and the child is in its own process group, so shutting the app terminates the job rather than orphaning a process pool holding every core. If you need unattended runs, run the script directly from a shell.

Cores

The search is the only stage where the core count materially changes wall-clock time. It uses a ProcessPoolExecutor, one worker per core.

The default is cpu_count − 2, leaving a core for the UI and the OS. On a 16-core machine, 14 is the default. Setting cores to the full count is usually a net loss — the GUI updates lag badly and there is no idle headroom for the OS.

Cancelling must kill the pool, not just the parent Killing only the immediate child leaves every worker alive holding a core and keeping the output pipe open — so the log never ends and the app never believes the job stopped. On Windows this is done with taskkill /T; on POSIX the child runs in its own session and the whole group is killed.

Checkpoint

Before moving to Step 3
  • ALL SETS DONE -> strategies_lr_m5 appeared.
  • Every set you intended to run wrote a summary.json.
  • Each set reported 5 verified slots. A set that found fewer produces a smaller portfolio later.
  • The per-set table shows plausible trade counts — double digits at minimum, since 30 is the acceptance floor.
SymptomCauseFix
Data file not found The CSV is not at data/<SYMBOL>_<TF>.csv. Go back to Step 1. Pass --data only if the file genuinely lives elsewhere.
Unknown set 'all9' --set was given a group name. Use a concrete set name or the literal all.
Zero trades / absurd results Bad data — most often a zero spread column or a shifted clock. Re-run the validator and read it properly.
A set reports fewer than 5 slots The acceptance test or the correlation filter was too tight for that set. Expected on some sets. Note it, because the portfolio will be smaller than 9.
Calibration SystemExit The EURUSD dollars-per-pip derivation drifted from $0.10 by more than 5%. A units regression in the code. Do not work around it — the money model is wrong.