Skip to content

fix(backtest): don't append symbol_suffix to symbols the broker carries bare, + two operator scripts - #18

Open
Marinski wants to merge 3 commits into
psyb0t:masterfrom
Marinski:fix/symbol-suffix-and-operator-scripts
Open

fix(backtest): don't append symbol_suffix to symbols the broker carries bare, + two operator scripts#18
Marinski wants to merge 3 commits into
psyb0t:masterfrom
Marinski:fix/symbol-suffix-and-operator-scripts

Conversation

@Marinski

Copy link
Copy Markdown
Contributor

Two independent changes. Happy to split them if you'd rather review separately — say the word.


1. symbol_suffix was appended to symbols the broker carries bare

_normalize_symbol appended symbol_suffix to every [Tester].Symbol that did not already end with it. Brokers rarely suffix their whole book: Eightcap Global carries 56 suffixed FX pairs (EURUSD.i) alongside 785 bare metals, indices and crypto (XAUUSD, BTCUSD, ASX200). Every non-FX backtest there asked the tester for a symbol that does not exist and came back empty.

The suffix is now skipped when the broker's symbol list has the bare name and lacks the suffixed one. Brokers that carry both forms — BlackBull lists AUDUSD and AUDUSDp — still get the suffix, so symbol_suffix: p keeps meaning "use the prime variant".

That check needs a symbol list, and there is no way to get one at INI-build time: a mode: backtest terminal never attaches the SDK, and Bases/<server>/symbols/*.dat is encrypted. So GET /symbols (unfiltered only) persists what it saw to <terminal>/mt5api-symbols.json, and the INI builder reads that back.

With no cache present the previous append-always behaviour is used unchanged, so a terminal that has never been primed cannot regress. Priming is one GET /<broker>/<account>/symbols per terminal.

Covered by tests/test_symbol_suffix_remap.py: bare-only, suffixed-only, both-forms, and the no-cache fallback.

2. Two operator scripts

Neither is imported by the API; both are run by hand.

prune-terminal-logs.shterminal64.exe and metatester64.exe write UTF-16 logs inside each terminal directory. MT5 has no retention setting and nothing prunes them: rotate-logs.sh only covers the API's own logs, and the log-rotator sidecar only ever saw data/shared/logs. They grow without bound.

measure-broker-offsets.pyconfig.yaml's per-terminal utc_offset is a static number subtracted from every broker timestamp, and nothing in the stack is DST-aware, so a value that is right in August is an hour wrong after the autumn rollover. This reports the measured offset per terminal.

It carries a prominent warning: hitting an SDK route on a mode: backtest terminal launches terminal64.exe, and POST /terminal/shutdown only detaches the SDK client — so the terminal is left holding MT5's single-instance lock, and the next backtest there returns an empty Bars=0 Ticks=0 Symbols=0 report. It must be followed by docker compose down && ./run.sh.


make test-unit: 433 passed, 2 skipped. make verify-binaries: OK.

@psyb0t

psyb0t commented Aug 25, 2026

Copy link
Copy Markdown
Owner

I reproduced a stale-cache failure in the current head.

symbol_cache.save() records updated, and age_seconds() can calculate the age, but production never calls age_seconds(). load() returns cached symbols regardless of whether updated is missing, malformed, or years old. I wrote a cache containing XAUUSD, changed its timestamp to epoch, and load() still returned {XAUUSD} with an age of over 1.7 billion seconds.

That makes the stale cache authoritative in _normalize_symbol, so a broker symbol that changes from bare to suffixed, or vice versa, can remain normalized incorrectly until somebody happens to refresh /symbols. This contradicts the module's stated stale-cache fallback behavior.

Please give the cache a finite configured maximum age, have load() treat absent or invalid timestamps as stale, and add an integration-level normalization test proving a stale bare-symbol cache falls back to the suffix behavior.

I also ran scripts/prune-terminal-logs.sh in dry-run mode against old fixture logs. It selected Tester/logs/tester.log, but did not select the documented root terminal/logs/terminal.log. find_logs() only matches Tester/logs and Tester/Agent-*/logs. Please include the root terminal logs path and add coverage for root, Tester, and Agent logs.

@Marinski
Marinski force-pushed the fix/symbol-suffix-and-operator-scripts branch from 3e8c680 to 6e8e203 Compare August 27, 2026 06:23
Marinski added a commit to Marinski/mt5-httpapi that referenced this pull request Aug 27, 2026
…detail to callers

From review: /compile accepted an unbounded body, wrote the source to
disk, read the whole .ex5 back into memory, and base64-inflated it into
the response - so one authenticated request could consume unbounded
disk, memory, worker time and bandwidth. And the catch-all handler
echoed the exception class and message to the caller, which for an
OSError is an internal path.

Two documented per-request caps, both enforced before the resource they
bound is spent:

- COMPILE_MAX_SOURCE_BYTES (default 2 MB): an oversized body is refused
  with 413 straight from its declared Content-Length, before parsing;
  the decoded source is then checked against the cap itself, before
  anything reaches disk.
- COMPILE_MAX_EX5_BYTES (default 16 MB): the artifact is size-checked on
  disk, before it would be read or encoded. A refusal carries no binary
  at all, and is a 500, not a 422 - the caller's source compiled fine;
  the server is declining to return the result. The log names the knob.

Both settings clamp rather than raise on bad values, matching the other
numeric settings in config.py - it is imported by the whole API, so a
typo in an optional endpoint's tuning must not stop trading.

Unexpected errors now return a bare 'internal error'; the traceback goes
to the server log only. The two remaining detail leaks on the 500 path
(MetaEditor's absolute path, the OSError from launching it) are
genericized the same way.

Six new tests: the 413 fires before the compiler ever runs and before
the work dir exists, the declared-length refusal happens before parsing
(proven with a non-JSON payload - a 400 would mean the parser read it),
the artifact refusal carries no ex5_base64, within-cap requests are
unaffected both ways, and the 500 body contains neither the exception
class nor its message nor a path. The existing kaboom test permitted
the leak by asserting only that log was a string; the new one closes
that hole. All six fail against the previous handler.

Also evicted scripts/prune-terminal-logs.sh from this branch: the squash
had swept it in from unrelated local work. It is PR psyb0t#18's file (byte-
identical to that branch's copy, referenced by nothing here), and psyb0t#18's
own review round has since fixed a selection bug in it - keeping a stale
copy in this PR would both collide with psyb0t#18 on merge and reintroduce the
bug that fix removes.
algotradingspace-dev and others added 3 commits August 27, 2026 09:42
…ries bare

_normalize_symbol appended `symbol_suffix` to every [Tester].Symbol that did
not already end with it. Brokers rarely suffix their whole book: Eightcap
Global carries 56 suffixed FX pairs (EURUSD.i) alongside 785 bare metals,
indices and crypto (XAUUSD, BTCUSD, ASX200). Every non-FX backtest there asked
the tester for a symbol that does not exist and came back empty.

The suffix is now skipped when the broker's symbol list has the bare name and
lacks the suffixed one. Brokers that carry both forms -- BlackBull lists
AUDUSD and AUDUSDp -- still get the suffix, so `symbol_suffix: p` keeps meaning
"use the prime variant".

That check needs a symbol list, and a `mode: backtest` terminal never attaches
the MT5 SDK while Bases/<server>/symbols/*.dat is encrypted, so there is no way
to enumerate symbols at INI-build time. GET /symbols (unfiltered only) now
persists what it saw to <terminal>/mt5api-symbols.json and the INI builder
reads that back. With no cache present the previous append-always behaviour is
used unchanged, so a terminal that has never been primed cannot regress.

Tests: tests/test_symbol_suffix_remap.py covers the bare-only, suffixed-only,
both-forms and no-cache paths.
…ment

Two standalone operator utilities. Neither is imported by the API; both are
run by hand.

prune-terminal-logs.sh — terminal64.exe and metatester64.exe write UTF-16 logs
inside each terminal directory. MT5 has no retention setting and nothing prunes
them: rotate-logs.sh only covers the API's own logs and the log-rotator sidecar
only ever saw data/shared/logs, so these grow without bound.

measure-broker-offsets.py — config.yaml's per-terminal `utc_offset` is a static
number subtracted from every broker timestamp, and nothing in the stack is
DST-aware, so a value that is correct in August is an hour wrong after the
autumn rollover. This reports the measured offset per terminal so config.yaml
can be corrected. It carries a prominent warning: hitting an SDK route on a
`mode: backtest` terminal launches terminal64.exe, and POST /terminal/shutdown
only detaches the SDK client, so the terminal is left holding MT5's
single-instance lock and the next backtest there returns an empty
"Bars=0 Ticks=0 Symbols=0" report. It must be followed by
`docker compose down && ./run.sh`.
…ot terminal log too

Both from review.

1. The symbol cache was authoritative forever. save() recorded 'updated'
   and age_seconds() could compute it, but production never called it -
   load() served the cached set regardless of whether the stamp was
   missing, malformed, or years old. A broker moving a symbol between
   bare and suffixed stayed wrongly normalized until someone happened to
   call GET /symbols, contradicting the module's own stated stale-cache
   fallback.

   The age check now lives INSIDE load(), so no caller can forget it:
   past symbol_cache_max_age (default 7d, SYMBOL_CACHE_MAX_AGE in the
   environment, clamped not raised - config.py is imported by the whole
   API), or with a missing or malformed 'updated' stamp, the cache counts
   as absent and the conservative append-always fallback applies. Stale
   degrades to the behaviour that shipped before the cache existed,
   never to an old answer presented as a current one.

   Tests: stale/missing/malformed stamps (parametrized), the window
   respected both ways, and the integration-level case from review run
   through the real INI-builder path - a fresh bare-symbol cache
   suppresses the remap, then the same cache aged to epoch stops
   suppressing it and the suffix comes back. All fail against the
   previous code.

2. prune-terminal-logs.sh never selected the root <terminal>/logs.
   find_logs matched only Tester/logs and Tester/Agent-*/logs, so the
   terminal's own log - the biggest of the three documented locations -
   was invisible to both the age and the size pass and grew without
   bound while every pass reported clean. One '*/logs/*.log' pattern now
   covers all three, with MQL5/ excluded by name so expert logs stay
   untouched even on a case-insensitive mount.

   New behavioral suite (tests/test_prune_terminal_logs.py) runs the
   real script against a fixture tree: all three locations pruned by
   age, MQL5 trees untouched, recent files kept, the size cap truncating
   (not deleting) the root log, and dry-run naming the root log without
   touching anything.
@Marinski

Copy link
Copy Markdown
Contributor Author

Both fixed in 14394f7.

1. The cache is no longer authoritative forever

You named the design flaw precisely: age_seconds() existed and production never called it. The age check now lives inside load() itself, so no caller can forget it — enforcing it anywhere else is how this bug happened.

load() treats as stale, and returns None for: a cache older than symbol_cache_max_age (default 7 days; SYMBOL_CACHE_MAX_AGE in the environment; clamped rather than raised, since config.py is imported by the whole API), a missing updated stamp, and a malformed one — non-int, bool, zero, negative, "yesterday" are all covered by a parametrized matrix. Stale degrades to the conservative append-always fallback that shipped before the cache existed, never to an old answer presented as a current one. The warning names the remedy (GET /symbols on that terminal).

The integration-level test you asked for runs through the real INI-builder path (_normalize_symbol), both directions in one test so the staleness is provably what flips it: a fresh cache knowing XAUUSD as bare suppresses the remap; the same cache aged to epoch stops suppressing it and XAUUSD.i comes back. Your exact reproduction — epoch timestamp, ~1.7 billion seconds old — is the unit variant beside it. All fail against the previous code.

2. The root terminal log is pruned now

find_logs matched only Tester/logs and Tester/Agent-*/logs, so the documented <terminal>/logs — the terminal's own log, the biggest of the three — was invisible to both the age and the size pass, and grew without bound while every pass reported clean. One */logs/*.log pattern now covers all three locations (find's -path crosses /), with MQL5/ excluded by name: its expert logs already miss the case-sensitive pattern on Linux, but these trees sit on Windows-backed shares where a case-insensitive mount would match them, and truncating an EA's own log mid-run is not this script's call to make.

New behavioral suite (tests/test_prune_terminal_logs.py) runs the real script against a fixture tree rather than asserting on its source text: all three documented locations pruned by age, MQL5/Logs untouched (terminal-level and agent-level), a recent root log surviving, the size cap truncating — not deleting — an oversized root log, and dry-run naming the root log without touching anything. That last one is your reproduction: the previous script fails it by omission. Dockerfile.test now copies the script so the offline suite can execute it.

Merge order

#15#16#18#10, this third. #16 previously carried a stale copy of prune-terminal-logs.sh swept in by its squash — evicted from #16 in its own review round, so this branch is the file's single home and there is no add/add collision. I will rebase this onto master promptly once #16 lands.

Also rebased onto current master (post-#17); the one conflict was the Dockerfile.test COPY line, resolved as the union — this branch adds prune-terminal-logs.sh to it so the offline suite can execute the real script.

Full suite green, lint clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants