Skip to content

fix(backtest): symbol-suffix remap, safe cache priming, bounded log tails - #18

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

fix(backtest): symbol-suffix remap, safe cache priming, bounded log tails#18
Marinski wants to merge 1 commit into
psyb0t:masterfrom
Marinski:fix/symbol-suffix-and-operator-scripts

Conversation

@Marinski

@Marinski Marinski commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Three related fixes to how a mode: backtest terminal resolves symbols and bounds its own logs, plus one operator script. Rebased on current master and squashed to a single commit.


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.

The cache is trusted for symbol_cache_max_age (default 7d — a top-level config key, or SYMBOL_CACHE_MAX_AGE in the environment). Past that, or with a missing/malformed updated stamp, it counts as absent and the append-always fallback applies, so a broker moving a symbol between bare and suffixed cannot be papered over by a years-old list.

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

2. Priming that cache used to wedge the terminal

GET /symbols is @with_mt5, so on a mode: backtest terminal — which never attaches the SDK at startup — it fell through ensure_initialized() into a full mt5.initialize(). That spawns terminal64.exe and holds the tester's single-instance data-dir lock for the rest of that terminal's life; every backtest submitted afterward exits clean with an empty report. The documented way to prime the cache was the thing that broke the terminal.

It is now refused with 409 there, before any SDK call — and before the MT5 lock. The MODE check runs undecorated and delegates the live path to a @with_mt5 _list_symbols_live, so a terminal already stuck behind an in-flight SDK request answers the refusal immediately instead of 503-ing after the full acquire timeout. A regression holds _mt5_lock from another thread and asserts the prompt 409; its counterpart asserts the live listing still waits, so the split can't later be "fixed" by dropping the decorator outright.

POST /symbols/import is the safe replacement: it writes a caller-supplied list straight to the cache and never calls mt5.* or takes the lock. Source the list from a live terminal on the same broker/account, or the broker's own docs.

curl -X POST -H "Authorization: Bearer $MT5_API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"symbols": ["EURUSD", "GBPUSD", "XAUUSD"]}' \
  "$MT5_API_URL/symbols/import"

config/config.yaml.example and docs/market-data.md document this flow for backtest terminals. The example is covered behaviorally rather than by a source-text assertion: the test walks the documented steps against the real Flask app and asserts the outcome the comment promises — that the INI builder stops appending the suffix to a symbol the broker carries bare.

POST /symbols/import is also registered in the MCP unifier's _ROUTE_CATALOG, which is a hand-maintained mirror of the Flask route table and the only route discovery an agent on the unified endpoint gets — the 409 tells agents to call a route the catalog did not list. A new parity test in tests/test_mcp_tool_parity.py pins that catalog against the real url_map so the next added route cannot drift out of it.

3. Every request body is bounded

POST /symbols/import above takes a caller-supplied list, and it originally took it unbounded: one symbols item of 2,097,153 bytes was accepted with a 200 and persisted into <terminal>/mt5api-symbols.json, a file the backtest INI builder parses on every run inside a fixed-disk Windows VM. Three per-request caps now apply, all configurable, all clamped rather than raising (config.py is imported by the whole API, so a typo in one endpoint's tuning value must not stop trading):

Setting Default Notes
symbol_import_max_body_bytes 2 MiB Checked against the declared Content-Length before request.get_json()
symbol_import_max_symbols 20000 Counted on the raw array before dedup — bounding the strip/dedupe/sort pass is the job. ~20× the widest book in this fleet
symbol_import_max_symbol_length 64 Measured on the normalized name, so padding can neither fail a legal name nor smuggle an illegal one. Double MT5's own 31-char limit

The ordering is the part worth checking, so it is pinned by a test that sends an over-cap Content-Length with a body that is not valid JSON — only a gate placed before the parser can answer 413; one placed after answers 400.

Capping that one endpoint left six others parsing whatever arrived, though — POST /orders, PUT /orders/<id>, PUT and DELETE /positions/<id>, POST /symbols/<symbol>/rates/ta, POST /backtest/build-ini and /backtest/build-set — and a per-endpoint check only bounds the endpoint someone remembered, so the next route added is the next hole. The cap therefore also lives once, in the before_request hook every route already passes through: max_request_body_bytes (4 MiB) for anything that is not a file upload, max_upload_body_bytes (25 MiB) for multipart, since POST /backtest carries a compiled .ex5 plus its .set and .ini. The upload cap matches the client_max_body_size nginx already enforces in front of the API, so reaching the port directly answers the same as coming through the proxy. It runs after the auth check, so limits cannot be probed anonymously, and an endpoint with a tighter cap of its own still reports that one.

Deliberately not covered, so it does not look like it is: /mcp is mounted through DispatcherMiddleware outside Flask's router, so before_request never runs for it. Separate transport, separate framing — happy to bound it as its own change.

4. Terminal journals are bounded by size, not just age

The retention window added in v4.13.1 deletes dated journals once they are RETAIN_DAYS old, which cannot reach the actual hazard: a high-frequency grid strategy logs every order placement, modification and cancellation, so one backtest writes tens of gigabytes into today's journal — the disk fills a week before that file is even eligible for the age pass.

rotate-logs.sh now truncates any in-window journal over MAX_LOG_BYTES (default 2 GiB) once it has been idle for IDLE_MINUTES (default 30), so a running backtest never loses its own diagnostics. Truncated in place, not deleted: terminal64.exe holds the journal open, so unlinking the inode would leave it writing to a deleted file with the space unreclaimed until it exited. Both knobs sit on the existing log-rotator service in both Compose files and are validated at startup like RETAIN_DAYS.

Two details that are easy to get wrong, both covered by tests:

  • Sizing uses stat, not wc -c — busybox wc reads the whole file to count bytes, ~18s per 3 GB journal in the alpine:3.20 image this actually runs in, repeated every INTERVAL on precisely the files the cap exists for. The test image ships GNU coreutils, where wc -c is already O(1), so that cost is invisible to the suite.
  • The journal's mtime is preserved across the truncation. _tail_dir_log picks the newest .log in a directory by mtime, so bumping a just-emptied journal to now would make it outrank the one a running job is writing, and GET /backtest/<id>/tail would answer with nothing until that job's next write — reintroducing, by another route, the stale-wrong-log failure item 5 fixes.

The docs are explicit about what this does not do: IDLE_MINUTES deliberately exempts a journal its own backtest is still writing, so the cap reclaims space after the run goes quiet rather than bounding a runaway mid-run. Such a journal is logged as over cap but still active, left alone on every pass rather than silently skipped.

5. Backtest log tails are bounded to the final 256 KB

GET /backtest/<id>/tail read a whole multi-gigabyte UTF-16 terminal log on every call: whole-file bytes, plus a decoded str copy, plus a splitlines() list, all under the GIL. Measured at 45-65s per call, which starves every other request in the process — /ping and the container healthcheck included — so a perfectly healthy terminal looks wedged from the outside.

Tailing is now O(tail): seek to the final window, aligned to a 2-byte boundary so UTF-16 code units stay intact, and drop the partial first line. Encoding is sniffed from the first two bytes, covering both MT5's BOM-marked UTF-16 logs and the BOM-less ones, with run.log decoding as UTF-8. run.log gets the same treatment: it is usually sparse, but "usually" is not a bound and this endpoint is polled once a minute per running job.

While in there, _tail_dir_log picked the newest log alphabetically, so metaeditor.log sorted after every <date>.log and a stale compile log was returned instead of the run being polled. It now picks by modification time and excludes metaeditor.log, which is the rule _tail_terminal_log already applied. 15 tests in tests/test_backtest_log_tail.py.

6. One operator script

scripts/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: 670 passed, 3 skipped, 80.60% coverage.
make test-integration: 25 passed.
make lint clean. make verify-binaries: OK.

Scope note: the terminal-log tail fix was briefly a separate PR (#19). It changes the same file as item 1's caller and is the same kind of terminal-log robustness, so it is folded in here and #19 is closed with the reasoning kept on it.

@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.
@Marinski
Marinski force-pushed the fix/symbol-suffix-and-operator-scripts branch from 6e8e203 to 14394f7 Compare August 27, 2026 06:42
@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.

@Marinski Marinski changed the title fix(backtest): don't append symbol_suffix to symbols the broker carries bare, + two operator scripts fix(backtest): symbol-suffix remap, bounded log tails, + two operator scripts Sep 10, 2026
@Marinski

Copy link
Copy Markdown
Contributor Author

Consolidated: #19 is folded into this one and closed. It changes the same file as item 1's caller and is the same kind of terminal-log robustness, so two PRs over mt5api/backtest/handler.py was one more than you needed.

One commit on top of what you last saw: b24399d, unchanged from #19, merged clean. It bounds every log tail to the final 256 KB. A terminal writing a multi-year backtest grows its Tester log to gigabytes while the run is being polled, and /tail was reading the whole file on every call — measured at 45-65s per call, which starves every other request in the process, /ping and the container healthcheck included. So a healthy terminal looks wedged from the outside.

It also fixes something I found in there: _tail_dir_log picked the newest log alphabetically, so metaeditor.log sorted after every <date>.log and a stale compile log came back instead of the run being polled.

make test-unit 464 passed, make lint clean. Branch is on current master.

Your two findings from 2026-08-25 remain fixed as described in 14394f7: the cache age check lives inside load() where no caller can forget it, and find_logs covers the root terminal log with coverage for all three locations.

Merge order for what is left: #15 → this → #16#10.

@psyb0t

psyb0t commented Sep 10, 2026

Copy link
Copy Markdown
Owner

I rechecked the current head locally. The two earlier findings are fixed: cache staleness is enforced inside load(), and the pruner covers root, Tester, and Agent logs. make test-unit passes: 464 passed, 2 skipped, 80.18% coverage.

One blocking issue remains in the suffix-cache workflow. The config says a backtest-mode terminal should be primed with GET /<broker>/<account>/symbols. That route is not safe for a backtest terminal:

  • list_symbols() calls ensure_initialized().
  • I executed the current branch's ensure_initialized() with a disconnected terminal stub and recorded terminal_info, initialize. It attempts a full SDK initialization.
  • mode: backtest only skips startup initialization. It does not stop this endpoint from initializing the SDK.
  • This PR's new measure-broker-offsets.py documents the consequence: an SDK route on a backtest terminal launches terminal64.exe, holds the portable-directory single-instance lock, and leaves the next backtest with an empty report.

So the documented cache-priming request triggers the exact state backtest mode is intended to avoid. Please redesign the cache lifecycle so a backtest terminal never needs an SDK request to populate it. For example, populate a broker/account cache from a live terminal or provide an explicit safe cache import. Add an HTTP-level regression test for a mode: backtest terminal proving the documented priming path does not call SDK initialization or acquire the terminal lock.

Separate gate issue: make lint exits 0 even though it prints Invoke-ScriptAnalyzer ... is not recognized twice, then says all lint categories passed. I cannot count the claimed clean lint until the image imports or checks for that command and the script fails when it is unavailable.

I cannot approve this revision yet.

Marinski added a commit to Marinski/mt5-httpapi that referenced this pull request Sep 11, 2026
Two defects surfaced during a deep manual QA pass on the prior commit
(553bb99, the mode:backtest symbol-cache-priming fix for PR psyb0t#18):

- POST /symbols/import crashed with an unhandled AttributeError (raw
  500) on a syntactically-valid-but-non-object JSON body (a bare
  array or scalar), since `request.get_json(silent=True) or {}`
  passes such values straight through to a `.get()` call. Now
  validated explicitly and rejected with a clean 400.

- Symbol names from this endpoint are operator/copy-paste input,
  unlike the SDK-sourced names GET /symbols persists, so stray
  whitespace could silently survive into the cache and defeat the
  exact-match lookup in backtest.handler._normalize_symbol. Now
  stripped before storage.

Also gitignore .deep-qa/, the local audit-trail scaffolding used for
that QA pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Marinski

Copy link
Copy Markdown
Contributor Author

Both fixed, 553bb99 + 88c6fbd.

1. The unsafe cache-priming path

You named it exactly: list_symbols()'s ensure_initialized() call attempts a full SDK init on a terminal that never attached one, and mode: backtest only skips startup init — it never guarded this endpoint.

GET /symbols is now refused with 409 on a mode: backtest terminal, before ensure_initialized() or any mt5.* call. The new POST /symbols/import is the safe replacement: it writes a caller-supplied symbol list straight to symbol_cache and calls no mt5.* function at all, so it's the priming path for a broker/account that has no live terminal to call GET /symbols on in the first place (most of this fleet's config.yaml entries are backtest-only — only darwinex has a live sibling).

HTTP-level regression test (tests/test_symbol_import.py) proves, against the real Flask app, that neither route reaches ensure_initialized() when MODE == "backtest".

I didn't stop at the unit suite for this one. I also ran the actual mt5api.main.main() process — real waitress, real socket, mode: backtest — and hit it with real HTTP: the 409 refusal, the auth gate, POST /symbols/import end to end, and the routing around the new path all behaved correctly against the live process, not just mocks.

That same pass turned up two more real bugs in the new endpoint, now also fixed in 88c6fbd:

  • POST /symbols/import crashed with an unhandled AttributeError (raw 500) on a syntactically-valid-but-non-object JSON body — e.g. posting a bare array. request.get_json(silent=True) or {} passed that straight into .get("symbols"), which doesn't exist on a list. Now explicitly rejected with a clean 400.
  • Symbol names here are operator/copy-paste input, unlike the SDK-sourced names GET /symbols persists — a stray space would silently survive into the cache and defeat the exact-match lookup in _normalize_symbol. Now stripped before storage.

make test-unit: 475 passed, 2 skipped, 80.26% coverage.

2. The lint gate

check_psscriptanalyzer's $results = Invoke-ScriptAnalyzer ... followed by if ($results) treated a missing command the same as zero findings — PowerShell's default $ErrorActionPreference turns "the term ... is not recognized" into a printed error with $results left unset, so it fell through to exit 0.

check_psscriptanalyzer now sets $ErrorActionPreference = 'Stop' in the per-file script and preflights Get-Command Invoke-ScriptAnalyzer once before the file loop, failing the category loudly if the module never loaded. Dockerfile.lint also verifies the module imports at build time, so a broken Install-Module fails the image build rather than a lint run months later.

I reproduced your exact symptom on the old script (uninstalled the module inside the built image, ran it: prints "is not recognized" twice, still reports "all lint categories passed", exit 0) and confirmed the new script fails loudly under the identical scenario (exit 1, named error). make lint: all 5 categories clean.

make test-unit and make lint both green. Branch is on current master.

@psyb0t

psyb0t commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Thanks for fixing the SDK initialization, stale-cache, root-log matching, and PSScriptAnalyzer issues. I re-reviewed current head 88c6fbdadd9a525c1e20d453498b92a9089ea540 locally. I cannot approve it yet.

1. The documented backtest priming flow is still broken

config/config.yaml.example lines 72-80 still tells a backtest-mode user to prime with GET /<broker>/<account>/symbols, then to refresh with that same GET. Current GET /symbols correctly returns 409 in backtest mode and tells the user to use POST /symbols/import. A user following the shipped config example therefore cannot populate the cache.

Replace that configuration guidance with the safe import workflow, including a concrete POST body and the live-terminal source option. Add a behavioral test for the documented backtest workflow, not a source-text assertion.

2. The backtest refusal still waits on the global MT5 lock

list_symbols remains decorated with @with_mt5. The decorator enters session() and acquires _mt5_lock before the handler reaches its MODE == "backtest" branch.

I reproduced this against the real Flask route on this head: I held _mt5_lock, shortened only the test acquire timeout to 50 ms, then requested GET /symbols in backtest mode. The result was 503 after 79.5 ms, not the expected immediate 409. With the production timeout, the same request can wait 60 seconds behind a stuck SDK request.

Split the endpoint so the backtest guard runs before @with_mt5, while the live path stays lock-protected. Add an HTTP regression that holds the lock and proves the backtest request returns 409 promptly without acquiring it.

3. The terminal log pruner is not deployed or runnable by users

The new script is only referenced by its own test, Dockerfile.test, and the changelog. docker compose -f docker-compose.yml.example config --services renders only:

log-rotator
mt5
mcpunifier
nginx
wickworks

There is no service or supervisor that invokes prune-terminal-logs.sh, no mount for its default /terminals, and no operations documentation telling an operator how to run it. The script itself works in its unit test, but no deployed stack will execute it.

Either wire it into the generated Compose deployment with the terminal-root mount and documented configurable environment, then add a rendered-Compose integration test, or document it as a deliberately manual command with a usable invocation. Do not claim automated pruning until one of those is true.

Merge and test status

GitHub currently reports this PR as CONFLICTING / DIRTY, so it needs a rebase before it can merge.

I ran make lint, which passed. I ran make test-unit, which passed: 475 passed, 2 skipped, 80.26% coverage. The full integration gate did not complete cleanly here: 13 integration tests passed, then the four existing Wickworks lifecycle cases failed while Docker runc could not open the owner network namespace. That failure happened before the test assertions and that integration file is not changed by this PR, so I am not attributing it to this branch. It still means I cannot count a full green gate for this head.

Please fix the three blockers, rebase, and post the focused regression output.

@Marinski
Marinski force-pushed the fix/symbol-suffix-and-operator-scripts branch from 88c6fbd to 36b24b6 Compare September 11, 2026 22:59
@Marinski Marinski changed the title fix(backtest): symbol-suffix remap, bounded log tails, + two operator scripts fix(backtest): symbol-suffix remap, safe cache priming, bounded log tails Sep 11, 2026
@Marinski

Copy link
Copy Markdown
Contributor Author

All three fixed, rebased onto current master, and squashed to a single commit so the branch carries no intermediate states.

1. The documented backtest priming flow. config/config.yaml.example now documents POST /symbols/import with a concrete body and where to source the list (a live terminal on the same broker/account, or the broker's docs). It also notes that symbol_cache_max_age is a top-level key, not a per-terminal one — it sat at the end of the per-terminal comment block and read as one.

Tested behaviorally, not by asserting on the file's text: test_documented_backtest_priming_workflow_primes_the_suffix_decision walks the documented steps against the real app — GET /symbols → 409, then the documented POST body — and asserts the outcome the comment promises, that the INI builder then stops appending the suffix to a symbol the broker carries bare.

2. The refusal waiting on the lock. Confirmed and fixed. list_symbols now runs the MODE check undecorated and delegates the live path to a @with_mt5 _list_symbols_live. test_backtest_refusal_does_not_wait_on_the_mt5_lock holds _mt5_lock from another thread, shortens SESSION_ACQUIRE_TIMEOUT to 2s, and asserts a 409 in under a second with the lock still held by the other thread. I verified it is red before green: restoring the decorator produces 503 after ~2001ms, matching what you measured. test_live_listing_still_waits_on_the_mt5_lock is the counterpart, so the split can't later be "fixed" by dropping the decorator entirely.

3. The pruner. Your premise changed under me during the rebase, so I went a different way than either option you offered — flagging it explicitly since it wasn't what you asked for.

v4.13.1 already added terminal-journal pruning into rotate-logs.sh, with /terminals mounted into the existing log-rotator sidecar. Wiring prune-terminal-logs.sh in as its own service would have put a second container on the same tree running a duplicate age pass. So instead: prune-terminal-logs.sh and its tests are deleted, and the one thing it did that your rotator cannot — the size cap — moves into rotate-logs.sh.

That capability is the part that mattered. Your pass deletes whole files whose YYYYMMDD.log name is older than the cutoff, so a 40 GB journal written today survives for RETAIN_DAYS; the disk fills long before it is eligible. Any in-window journal over MAX_LOG_BYTES (default 2 GiB) that has been idle IDLE_MINUTES (default 30) is now truncated in place. Both knobs sit on the existing service in both Compose files and are validated at startup like RETAIN_DAYS.

Three notes on it, all of which cost me a round of self-review to find:

  • Sizing uses stat, not wc -c. Busybox wc reads the entire file — ~18s per 3 GB journal in alpine:3.20, which I measured — repeated every INTERVAL on exactly the files the cap exists for. The test image has GNU coreutils where wc -c is already a stat, so that cost is invisible to the suite.
  • The journal's mtime is preserved across the truncation. _tail_dir_log picks the newest .log by mtime, so a just-emptied journal would otherwise outrank the one a running job is writing and GET /backtest/<id>/tail would answer with nothing until that job's next write — reintroducing the stale-wrong-log failure the mtime selection was added to fix, by a different route.
  • The docs state plainly what this does not do: IDLE_MINUTES exempts a journal its own backtest is still writing, so the cap reclaims space after a run goes quiet rather than bounding a runaway mid-run. It logs over cap but still active, left alone each pass rather than skipping silently.

I verified the script's behavior in a real alpine:3.20 container, not just the dash + GNU coreutils of the test image: expired journal deleted, oversized idle journal truncated to 0 with its inode and mtime intact, oversized active journal left alone, MQL5/Logs and metaeditor.log untouched.

Also dropped: my PSScriptAnalyzer commit. v4.13.0 landed your own fix, which covers the same gap plus a pinned-version check — I took yours and dropped mine entirely rather than carry a conflicting variant.

One thing I found on the way: POST /symbols/import was missing from the mcpunifier _ROUTE_CATALOG. That catalog is the only route discovery an agent on the unified endpoint gets, so the 409 from GET /symbols was telling agents to call a route they could not see. Added, plus a parity test pinning the catalog against the real url_map so the next added route cannot drift out of it.

On the integration gate: it is green here — 23 passed, including all four Wickworks lifecycle cases. Environment: docker 29.2.1, compose v5.1.0, runc 1.3.4, cgroup v2, overlayfs. Your runc/netns failure does not reproduce on this host, and I do not think it can come from this branch: those tests generate their own throwaway compose referencing only python:3.12-alpine and scripts/wickworks-healthcheck.py, neither of which this branch touches. Happy to dig further if you can share the runc error text.


make test-unit
  635 passed, 3 skipped, 80.29% coverage

make test-integration
  23 passed (8 mcpunifier, 5 nginx, 6 vm-watchdog, 4 wickworks)

focused regressions
  tests/test_symbol_import.py tests/test_rotate_logs.py
  tests/test_config_generation.py tests/test_mcp_tool_parity.py
  tests/test_symbol_suffix_remap.py
  74 passed

make lint
  non-ASCII / parse / PSScriptAnalyzer / shellcheck / shfmt — all clean

make verify-binaries
  OK (1 known-unsigned, pre-existing)

@psyb0t

psyb0t commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Thanks, the latest revision addresses the earlier issues around refusing backtest /symbols before SDK access, safe cache priming, and deployed log rotation. Two things still need fixing before I can approve.

  1. POST /symbols/import has no input bounds. I sent one symbols item containing 2,097,153 bytes to this head. The endpoint returned 200 and persisted it in the cache. There is no request-body limit before get_json(), no maximum item count, and no per-symbol length limit. Please add explicit configurable limits, reject oversized bodies before parsing, bound the count and each normalized symbol, return a clear 413 or 400, and cover max and max-plus-one cases.

  2. test_unified_route_catalog_matches_the_real_flask_routes reads and AST-parses mcpunifier/mcp_server.py to extract _ROUTE_CATALOG. That is an implementation-source assertion, not an MCP contract test. It can stay green while MCP tool registration, transport, or response serialization breaks. Replace it with a black-box test that invokes the public endpoints MCP tool through the protocol and compares its returned routes to the Flask router.

The rest of the focused branch checks passed, including the existing test and lint workflows. These two points are the remaining blockers.

@Marinski
Marinski force-pushed the fix/symbol-suffix-and-operator-scripts branch from 36b24b6 to 8631193 Compare September 12, 2026 07:18
@Marinski

Copy link
Copy Markdown
Contributor Author

Both fixed, head is now 8631193 (rebased and squashed, so the unbounded revision is not in history).

1. POST /symbols/import bounds. Three per-request caps, each configurable at the top level of config/config.yaml or under the same name uppercased in the environment, and each clamped to a minimum of 1 rather than raising — config.py is imported by the whole API, so a typo in one endpoint's tuning value must not stop trading, nor disable the only offline path a mode: backtest terminal has for priming its cache.

  • symbol_import_max_body_bytes (2 MiB) — checked against the declared Content-Length before request.get_json().
  • symbol_import_max_symbols (20000) — counted on the raw array before dedup, since bounding the strip/dedupe/sort pass is the job. ~20× the widest book in this fleet (Eightcap Global: 841), so no broker's full book is ever refused.
  • symbol_import_max_symbol_length (64) — measured on the normalized name, so whitespace can neither fail a legal name nor smuggle an illegal one. Double MT5's own 31-character limit.

413 for the body, 400 for count and length. Nothing reaches the cache when a request is refused.

The ordering is the part worth checking, so it is pinned by a test that sends an over-cap Content-Length with a body that is not valid JSON — only a gate placed before the parser can answer 413; one placed after answers 400. Your exact payload (one 2,097,153-byte symbol) is now 413 with an empty cache. Max and max-plus-one are covered for all three caps.

One decision worth surfacing: content_length is None in two different situations and I separated them. With a Transfer-Encoding header the body is streamed and cannot be bounded up front, so it is refused 411 rather than waved past the cap. With no transfer encoding there is simply no body, and it keeps the 400 it always returned. In production waitress de-chunks and supplies a length, so the 411 rarely fires there and the 413 does the work; the transport-level bound for genuinely streamed bodies is waitress's own max_request_body_size, outside this endpoint.

2. The MCP catalog test. You were right that it was an implementation-source assertion. Deleted, along with its helpers. Replaced with two black-box tests in tests/integration/test_mcpunifier.py that boot the shipped unifier image, call the public endpoints tool over MCP, and compare the routes it returns against the real mt5api.server.app.url_map.

Comparison choices, all commented in place: equality in both directions, because a missing entry hides a real route from every agent and a surplus entry sends them at a 404 — a subset check would pass an empty catalog. Werkzeug's converter prefix is stripped from the Flask side (<int:ticket> against the catalog's <ticket>) rather than the reverse, since the converter is a routing-layer detail with no presence on the wire while the parameter name is what an agent reads — so renaming <symbol> still fails. Every route in mt5api/server.py registers unconditionally today, so equality is exact; the comment records that if one ever becomes flag-gated the test must build the app under the configuration the catalog documents rather than be relaxed to a subset.

Verified load-bearing two different ways: removing POST /symbols/import from the catalog fails both new tests on the real container, and renaming the tool's response key fails both while the old AST test still passed.

Known and deliberately not done here: POST /symbols/<symbol>/rates/ta, /backtest/build-ini and /backtest/build-set take JSON bodies with no per-request cap either. Same class as what you found. I kept this change to the endpoint you reported rather than widening it mid-review — happy to sweep them in this PR or a follow-up, whichever you prefer.

make test-unit         652 passed, 3 skipped, 80.47% coverage
make test-integration   25 passed
make lint               all categories clean

…ails

Several related fixes to how a mode:backtest terminal resolves symbols
and bounds its own logs, plus an operator script for broker clock drift.

symbol_suffix was appended to symbols the broker carries bare.
_normalize_symbol appended it to every [Tester].Symbol that did not
already end with it, but 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), so 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 carrying both
forms (BlackBull lists AUDUSD and AUDUSDp) still get it.

That check needs a symbol list, which cannot be obtained at INI-build
time -- a backtest terminal never attaches the SDK and
Bases/<server>/symbols/*.dat is encrypted. GET /symbols (unfiltered
only) persists what it saw to <terminal>/mt5api-symbols.json and the INI
builder reads it back, trusted for symbol_cache_max_age (default 7d).
With no cache the previous append-always behaviour is unchanged.

Priming it no longer wedges the terminal. GET /symbols is @with_mt5, so
on a mode:backtest terminal it fell through ensure_initialized() into a
full mt5.initialize(), spawning terminal64.exe and holding the tester's
single-instance data-dir lock for that terminal's life -- every backtest
afterward exited clean with an empty report. It is now refused with 409
there, before any SDK call, and before the MT5 lock: the MODE check runs
undecorated and delegates the live path to a @with_mt5
_list_symbols_live, so a terminal already stuck behind an SDK request
answers immediately instead of 503-ing a minute later.

POST /symbols/import is the safe replacement, and it is bounded. It
previously accepted anything: a single 2,097,153-byte symbol name came
back 200 and was persisted into the cache file the INI builder parses on
every run, inside a fixed-disk Windows VM. Three per-request caps now
apply, all configurable and all clamped rather than raising, since
config.py is imported by the whole API and a typo in one endpoint's
tuning value must not stop trading:

  * SYMBOL_IMPORT_MAX_BODY_BYTES (2 MiB) is checked against the declared
    Content-Length BEFORE request.get_json() runs. Parsing first would
    already have paid the memory cost the cap exists to prevent, so the
    ordering is the point: it is covered by a test that sends an
    over-cap length with a body that is not valid JSON, which only a
    gate placed before the parser can answer 413. A body with no
    declared length but a transfer encoding cannot be bounded up front
    and is refused 411; a request with no body keeps its historical 400.
  * SYMBOL_IMPORT_MAX_SYMBOLS (20000, ~20x the widest book in this
    fleet) is counted on the raw array before dedup, because bounding
    the strip/dedupe/sort pass is the job.
  * SYMBOL_IMPORT_MAX_SYMBOL_LENGTH (64, double MT5's own 31-character
    limit) is measured on the NORMALIZED name, so padding can neither
    fail a legal name nor smuggle an illegal one.

Every request body is bounded, not just that one endpoint's. Capping the
endpoint that was reported left six others parsing whatever arrived --
POST /orders, PUT /orders/<id>, PUT and DELETE /positions/<id>,
POST /symbols/<symbol>/rates/ta, POST /backtest/build-ini and
/backtest/build-set -- and a per-endpoint check only bounds the endpoint
someone remembered, so the next route added is the next hole. The cap now
lives once in the before_request hook every route already passes through:
MAX_REQUEST_BODY_BYTES (4 MiB) for anything that is not a file upload,
MAX_UPLOAD_BODY_BYTES (25 MiB) for multipart, since POST /backtest carries
a compiled .ex5 plus its .set and .ini. The upload cap matches the
client_max_body_size nginx already enforces in front of the API, so
reaching the port directly answers the same as coming through the proxy.
An endpoint with a tighter cap of its own still reports that one, because
it is checked inside the handler.

Terminal journals are bounded by size, not just age. The retention
window only deletes journals once they are RETAIN_DAYS old, which cannot
reach the real hazard: one high-frequency backtest writes tens of
gigabytes into TODAY's journal and the disk fills a week before that
file is eligible. rotate-logs.sh now truncates any in-window journal
over MAX_LOG_BYTES once it has been idle for IDLE_MINUTES, in place
(terminal64.exe holds it open) and preserving the mtime, so a
just-emptied journal cannot outrank the one a running job is writing in
_tail_dir_log's newest-by-mtime selection. Sizing uses stat, not
`wc -c`: busybox wc reads the whole file, ~18s per 3 GB journal in the
alpine image this runs in.

Backtest log tails are bounded to the final 256 KB. Reading a
multi-gigabyte UTF-16 terminal log in full to show its tail took 45-65s
under the GIL, starving /ping and the container healthcheck with it.

The unified `endpoints` MCP tool is now tested through the protocol.
It was asserted by AST-parsing _ROUTE_CATALOG out of the unifier's
source -- an implementation-source check that stays green while tool
registration, transport or serialization breaks. It is replaced by
black-box tests that boot the shipped image, call the public tool over
MCP, and compare what it returns against the real Flask url_map.
POST /symbols/import was missing from that catalog, so the 409 above was
telling agents to call a route the unified endpoint never advertised.

Also: scripts/measure-broker-offsets.py reports each terminal's measured
broker clock offset, since config.yaml's utc_offset is static and
DST-unaware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Marinski
Marinski force-pushed the fix/symbol-suffix-and-operator-scripts branch from 8631193 to 46aa2f5 Compare September 12, 2026 07:31
@Marinski

Copy link
Copy Markdown
Contributor Author

Swept the rest rather than leaving them for a later round — head is now 46aa2f5.

Capping only the endpoint you reported left six others parsing whatever arrived: POST /orders, PUT /orders/<id>, PUT and DELETE /positions/<id>, POST /symbols/<symbol>/rates/ta, POST /backtest/build-ini and /backtest/build-set. A per-endpoint check only bounds the endpoint someone remembered to add one to, so the next route added is the next hole — which is how /symbols/import came to be the one you found.

The cap now lives once, in the before_request hook every route already passes through:

  • max_request_body_bytes (4 MiB) — anything that is not a file upload.
  • max_upload_body_bytes (25 MiB) — multipart, since POST /backtest carries a compiled .ex5 plus its .set and .ini. Matched to the client_max_body_size nginx already enforces in front of the API, so a caller reaching the port directly gets the same answer as one coming through the proxy rather than a larger one.

Both refuse with 413 from the declared Content-Length before the body is parsed. It runs after the auth check, so an unauthenticated caller cannot probe the limits. An endpoint with a tighter cap of its own still reports that one, because it is checked inside the handler — /symbols/import's 2 MiB fires first and keeps its specific message, which a test pins.

tests/test_request_body_cap.py covers every body-reading route: an over-cap body on each, and a pre-parse ordering test on each that sends an over-cap body which is deliberately not valid JSON, so only a gate placed before the parser can answer 413. Both caps' boundaries, the multipart split, and the endpoint-cap-wins case are covered too.

Honest note on that file: 15 of its 18 cases go red when I disable the guard. The other three assert that a cap does not fire (a body exactly at the limit, a multipart body above the JSON cap) or that the endpoint's own cap still wins — none of which can be made red by removing the guard. They pin off-by-one against a future tightening; I am not counting them as proof of the fix.

I also checked the guard does not refuse anything legitimate, which is the real risk of a global gate: all seven JSON routes accept a normal body, a realistic 2 MB multipart backtest submission passes, and both caps fire at exactly +1.

Deliberately not covered, so it does not look like it is: /mcp is mounted through DispatcherMiddleware outside Flask's router, so before_request never runs for it. That is a separate transport with its own framing rather than a REST body — say the word if you want it bounded too and I will do it as its own change.

make test-unit         670 passed, 3 skipped, 80.60% coverage
make test-integration   25 passed
make lint               all categories 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.

2 participants