Start here

How the App Works

The design decisions behind the GUI — why every stage is a subprocess, how output streams without freezing the window, how cancellation kills a whole process tree, and how the app knows a result is stale rather than merely present.

Module layout

The package is split so that everything interesting is testable without a display. Only app.py and pages/ touch Tk.

ModuleResponsibility
config.pySettings, repo discovery, path conventions, capability detection, interpreter resolution.
mt5env.pyFind MT5 installs on this machine, pair install ↔ data dir, detect the broker's symbol suffix.
runner.pyTask descriptions, JobManager (streaming, cancel), Chain, Presets.
mt5.pyCompile via MetaEditor, parse its log, deploy into the terminal's MQL5\Experts.
pyrunner.pyThe frozen stand-in for a Python interpreter (built as FxMathLRStudioRunner.exe).
state.pyParse the run's JSON/CSV artefacts, judge freshness, emit warnings.
theme.pyPalette and fonts, shared with the raw-Tk canvas charts.
widgets.pyCard, KPI, LogConsole, LineChart, BarChart.
app.pyThe window, navigation, output routing, dialogs.
pages/*.pyOne module per screen.
run.pyThe entry point PyInstaller freezes for the GUI.

The ten screens, in sidebar order, which is also pipeline order:

Dashboardoverview
Full pipelineone button
Data
Optimize
Portfolio
Trading hours
Holdout
Expert Advisor
Reports
Settings

Why every stage is a subprocess

This is the central architectural decision and it is not an accident of convenience. The pipeline scripts are imported nowhere. They are always executed as child processes. Three reasons, in order of importance:

They are multi-process
feature_search.py and holdout_tail.py use a ProcessPoolExecutor. Importing them into the Tk process would fork workers out of a GUI — and on Windows, where spawn is the only start method, each worker would re-import the entire application, including Tk. The result is either a hard crash or an unkillable mess.
stdout is the progress protocol
The scripts print structured progress lines and the GUI parses them. Putting them in a subprocess means that output arrives verbatim, unmodified — what you see in the log panel is exactly what a terminal would show. Nothing is intercepted, reformatted or summarised.
Crash isolation
A segfault inside numpy, or an out-of-memory during a grid search, kills the child and leaves the window alive to report it. In-process, that failure takes the whole application down with no traceback.
A useful side effect Because the scripts are never imported, editing one takes effect immediately — no rebuild, no restart. The GUI is a caller, not a container.

The task/manager/chain model

Task
A description

argv, working directory, environment, a label, a grouping tag and optional metadata. It knows nothing about execution.

JobManager
An executor

Owns the single running job, streams its output through a queue, and exposes cancellation and a bounded line history.

Chain
A sequencer

Runs a list of tasks in order, stopping at the first non-zero exit, and reports which step is current.

The window never blocks

JobManager runs the child on a background thread that reads the pipe line by line and pushes each line into a queue. The Tk event loop polls that queue on a timer (every 80 ms) and appends whatever arrived to the log console. The UI thread never waits on the child, so the window stays responsive and repaints smoothly during a search that runs for half an hour.

The line history is a bounded ring buffer of 6,000 lines, so a search that produces hundreds of thousands of output lines cannot grow memory without limit.

Why the completion callback needs marshalling

Tk is single-threaded Every widget call must happen on the thread that created the interpreter. The job's completion handler repaints widgets, but it is invoked from the pump thread. Without a hand-off back to the GUI thread, that is a cross-thread Tk call — which on Windows is an access violation inside the Python DLL and kills the app with no traceback at all.

The fix is set_marshal(): the app registers lambda fn, ms: self.after(ms, fn), and the manager routes every completion and every delayed callback through it. A headless caller can leave it unset and get synchronous behaviour.

The same applies to threading.Timer, which fires on a thread of its own. call_later() therefore prefers the marshalled path so that both the wait and the callback stay on the GUI thread.

The chain restores its handler

Chain works by swapping in its own advance callback as the manager's completion handler. The handler that was there before — the app's own _on_job_done, which clears the busy state and repaints — must be put back when the chain finishes.

Forgetting that produces a distinctive bug: after running the full pipeline, every subsequent single job would finish without ever telling the UI, leaving the window stuck showing "running" forever. The chain keeps the previous handler and restores it, and a _finished guard prevents a double completion when a cancel and an advance race.

Stopping on failure is deliberate

The chain stops at the first non-zero exit. Every later stage reads an earlier stage's artefacts, so continuing after a failed search would silently build a portfolio out of yesterday's configs — producing a plausible-looking result that has nothing to do with the data you just validated.

Cancellation

Cancelling has to kill the child and everything it spawned. Both script pools mean the immediate child is only the parent of a tree.

PlatformHow the tree is killed
POSIX The child starts with start_new_session=True and is killed with os.killpg(), so the whole process group dies together.
Windows Popen.terminate() is TerminateProcess on exactly one process, so the tree is killed with taskkill /T /PID <pid> instead — /F after a grace period.

Killing only the immediate child would leave every pool worker alive, holding every core and keeping the output pipe open — so the log would never end and the app would never believe the job had stopped.

Text encoding: why the pipe is pinned to UTF-8

The pipeline prints ·, and ->. Python's console codec on Windows is cp1252, not UTF-8, so both sides of the pipe are pinned explicitly:

SideSettingWhere
Child stdoutPYTHONIOENCODING=utf-8, PYTHONUTF8=1JobManager._env
Parent readerencoding="utf-8", errors="replace"the Popen call

This was a real defect before it was a policy. With text=True and no explicit encoding, the parent decoded using its own codec, so under a cp1252 launch the log showed replacement characters instead of the actual glyphs — and in the worst case raised inside the reader thread, which stalled the log silently. The parent also takes precedence over whatever the launching shell had set. Both variables are needed: PYTHONUTF8 switches the default codec, PYTHONIOENCODING pins the stdio streams.

The child also gets PYTHONUNBUFFERED=1, so progress lines arrive as they happen rather than in 4 KB blocks.

Freshness, not just presence

The most dangerous failure in this workflow is not a missing file. It is "the file exists but was produced from different data". The repository holds identically named EAs and tail folders from many earlier runs, sitting side by side, and an old .ex5 will happily sit next to a new .mq5 and look perfectly compiled.

So state.py derives every stage's status from artefact mtimes and flags stale when an input is newer than an output.

ConditionReported as
summary.json older than data/XAUUSD_M5.csvsearch stale
best_hours.json older than all9/summary.jsonhours stale
tail_report.json's n_bars ≠ the current CSV row countholdout stale
X.ex5 older than X.mq5EA stale — an error, not a warning
An .ex5 with the same name exists elsewhere at a different sizewarning naming both paths

The Dashboard colours stale stages amber, the EA page refuses to present a stale .ex5 as compiled, and --check prints the same list.

Progress without guessing

The Optimize page does not show a fake spinner. It parses the search's own structured output and reports a real fraction.

Pipeline outputGUI effect
=== set=base9 abbr=BAS features=9"step 1 / 11 — base9, 9 features"
[base9] stage1 64 combos …sub-progress 0.15
[base9] stage2 243 combos …sub-progress 0.60
[base9] verifying 40 candidates @ max_iter=2000sub-progress 0.90
[base9] done in 41s -> …set 1 of 11 complete
ALL SETS DONE -> …100%
The parser degrades gracefully If the wording of a pipeline print changes, the parser simply matches less and the progress bar becomes less precise. It never breaks the run — the job is a subprocess, so it continues regardless of whether the GUI understands its output.

Log lines are also colour-classified from their content:

ClassMatched when the line…
errstarts with error/traceback, or contains " error", failed, not found:
warncontains warn, skip or stale
okstarts with wrote or -> no problems; contains done, " ok", combined (, all sets done
stagestarts with ===, --- or [
datastarts with engine=, mode= or magic=

How the app chooses an interpreter

Settings.python_exe() resolves in a deliberate order:

The bundled runner
When this build has one — that is what makes a distributed exe need nothing installed. Only used if use bundled is enabled.
The configured interpreter
From Settings, for source runs or a specific virtual environment.
The interpreter running the app
Never wrong for a source run.
PATH
Last resort.

The runner is located by searching several plausible roots — the PyInstaller unpack directory, the directory of the frozen executable, the package directory, and one level above it — because both "one-file exe" and "drop the exe in the repo" are equally valid shipping layouts.

Whatever is selected must have numpy and pandas That is precisely what the Settings Test button checks — and it checks through the selected interpreter, not through PATH.

Output root and naming conventions

These come from the pipeline, and the GUI mirrors them exactly rather than inventing its own.

XAUUSD output root
strategies_lr_<tf> (timeframe lowercased)
Any other symbol
strategies_<symbol>_<tf>
Data file
data/<SYMBOL>_<TF>.csv
Data header
time,open,high,low,close,tick_volume,spread,real_volume
Timezone of time
Broker server time, naive, oldest → newest
24-hour EA build
…_ALL9_24X_M5.mq5 — tagged so it never overwrites the hours build …_ALL9_M5.mq5

MetaEditor's two traps

Both are handled in mt5.py, and both are worth knowing because they produce badly misleading failures if you compile by hand.

It returns exit code 1 even on a clean compile
Success is therefore decided from the artefact, not the code. The app requires that the .ex5 exists, is newer than the previous build, and that the log reports 0 errors.

The subtle part: MetaEditor writes a fresh 0 errors log next to the old .ex5 when a build fails outright. The log alone is therefore not sufficient — mtime is checked as well.
The log is UTF-16LE
Sometimes with a byte-order mark, sometimes without. It is decoded defensively, and the N errors, M warnings summary is taken from the last match in the file — because included headers can print their own summary before the final one.

The compile log is shown verbatim on the EA page, with real diagnostics also extracted into a table of severity / code / file / line / message.

Broker-specific handling

Two things vary per broker, and both are handled explicitly rather than assumed.

The symbol suffix

Brokers decorate instrument names — XAUUSD may be XAUUSDb, XAUUSD.a, GOLD. The pipeline works in the clean name for all filenames and output folders, and the suffix is applied only where the terminal is involved.

The downloader is the one place this must not be confused, because the API only answers to the broker's spelling while the output file must carry the clean name. It has a specific quirk: it names each file after the raw --symbol it was handed. So requesting XAUUSDb writes XAUUSDb_M5.csv — a name no later stage looks for. The GUI passes a folder, then renames the result to the clean name once it lands.

Why the rename is not cosmetic If the rename failed silently, the next stage would read whatever stale XAUUSD_M5.csv was already on disk — and the entire run would be built on the wrong data without a single error message. The app emits a loud warning rather than letting that happen.

Multiple terminals on one machine

Two installs of MetaTrader 5 keep entirely separate %APPDATA% data directories. The app pairs each install with its data directory by reading that directory's origin.txt, which records the install it belongs to. Orphaned data directories — a leftover pointing at a path that no longer exists — are filtered out.

The chosen terminal's data directory is then passed to every job as MT5_DATA_DIR and MT5_TERMINAL, so the downloader and exporter talk to the terminal you selected rather than whichever one happens to be running.

Threading and lifecycle summary

ConcernMechanism
UI responsivenessChild on a pump thread; Tk polls a queue every 80 ms.
Cross-thread safetyset_marshal routes completion callbacks onto the GUI thread via after().
Memory boundsLog history is a 6,000-line ring buffer.
CancellationProcess-group kill on POSIX; taskkill /T on Windows.
EncodingUTF-8 pinned on both ends of the pipe.
Thread lifetimePump threads are daemons, so closing the window kills the search rather than orphaning it.
Handler restorationChain saves and restores the manager's completion handler.