Skip to content

Test strategy.risk.max_drawdown at closing fills, as TradingView does - #814

Merged
luisleo526 merged 2 commits into
train/engine-1.11.1from
fix/drawdown-halt-at-fills
Nov 10, 2026
Merged

luisleo526 merged 2 commits into
train/engine-1.11.1from
fix/drawdown-halt-at-fills

Conversation

@luisleo526

Copy link
Copy Markdown
Collaborator

Refs #804

Limits of this change (put under the description of the change)

What it changes: strategy.risk.max_drawdown is now tested at the order fills that close units, on the marked equity at that fill's price (open positions included), against a peak that starts at the equity of the first fill and is raised by the marked equity at fills that close units. A breach closes every open position at that fill's price (exit text "Close Position (Max Drawdown)", empty exit id), cancels the pending orders and blocks later entries. Before, the drawdown was measured once per bar close against a peak made of bar-close equity, and a breach only refused new entries: it closed nothing, and it also refused entries that TradingView lets through. The bar-close measurement and the overflow check of an enormous percentage are kept as they were; only the latch that refused entries at the bar close is removed. The closing leg of a reversing entry is a closing fill, so the new position of the reversal is closed at once when its closing leg breaches the limit. A same-side entry fill is not a test.

What was measured against TradingView (NASDAQ:AAPL 15-minute bars of two sessions, fixed quantities, no commission, no process_orders_on_close, a limit of 0.5 percent of equity, initial capital 10000, pyramiding 3):

  1. No test between fills: a long held 1.77 percent under water for hours, closed by the script at a small loss, did not halt.
  2. A closing fill at a realised loss above the limit closes every other open position at that fill's price, and later entries never happen.
  3. The marked equity counts: a FIFO close whose own loss is below the limit, while the rest of the book is marked far below, closes the whole book at that fill.
  4. The closing leg of a reversing entry is tested: the new position is closed at once at the same price.
  5. A same-side entry fills while an untested breach exists, and a later same-side entry of the same session fills when it fits the account.
  6. A closing fill that realises a profit is tested.
  7. The peak is not made of bar-close marks (a drop of 148.75 from the highest bar-close mark at a closing fill did not halt) and not of the equity at an opening fill (a drop of 59.50 from the equity at an opening fill, at a closing fill that left the equity below the start by 2.98, did not halt).
  8. A closing fill that realises a profit raises the peak: a long closed at a profit of 94.20 (equity 10094.20 at that fill), then a closing fill 68.00 lower (the equity 10026.20, still 26.20 above the initial capital, the limit 50.47) closed the rest of the book at that fill, and later entries never happened. A reference that stays at the initial capital would not have halted there.
  9. The peak counts the open profit of the units still held: a long closed at a profit of 94.20 while a second long held an open profit of 75.36 (marked equity 10169.56); at the next closing fill the marked equity, 10093.06, stood 76.50 below it (the limit 50.85) and the rest of the book was closed at that fill. A reference made of realised equity only (10094.20) would not have halted there.
    Each point is a test scenario on synthetic bars in tests/test_adapter_drawdown_fill_halt.cpp, with the arithmetic in its comment and the digest of its tape; the two pinned scenarios of tests/test_adapter_risk_relower.cpp and the scenario of tests/test_engine_risk_l4a.cpp that this behaviour changes name the tape next to the edit. Each scenario fails on main; the scenarios of points 8 and 9 also fail when the peak is never raised, and the scenario of point 9 also fails when the peak is raised by realised equity only.

What it does not change and does not claim:

  1. Not measured: process_orders_on_close, the bar magnifier and calc_on_order_fills; margin-call fills as closing fills; stop and limit fills inside a bar; a short as the first position; the money (non-percent) form of the limit; a commission (the peak starts at the equity of the first fill, which a commission lowers); other symbols and timeframes; other limit values.
  2. The peak: whether a closing fill at a loss can raise it is not measured, and neither is the base of the percentage (the peak, the initial capital or the equity at the fill: the drops of the tapes are above all three limits).
  3. The cancel of pending orders is not observable on a tape (a later entry is blocked anyway); the test shows that a resting order of the scenario does not fill after the halt, which the entry block alone also explains.
  4. The block on later entries lasts to the end of the run in the engine; the tapes show it to the end of the next session only.
  5. In one tape a later same-side entry of the same session was absent where the account was short of funds (a twin with a smaller first position let it fill); the reason is not measured and no scenario asserts that entry.
  6. No scenario separates the cancel of pending orders from the entry block, pins the order of the latch against the forced close, or sits on the boundary of the comparison (a drawdown exactly equal to the limit).

Known items: this change adds lines to src/source/pine_adapter.cpp, so the documentation-anchor check (scripts/check_doc_anchors.py) reports the anchors below them on this pull request; they are re-anchored once, by one commit on the release branch after the last member pull request. The same commit corrects the statements that describe the previous rule: docs/design/native-feature-parity.md row 541 (a drawdown measured once per script bar at the close mark by update_risk_state against a peak of close marks; a halted strategy that still reverses) and its row 106 and docs/coverage.md line 264, which name update_risk_state as the enforcement point.

🤖 Generated with Claude Code

…tten at the breach

strategy.risk.max_drawdown was measured once per bar close, against a peak made
of bar-close equity, and a breach only refused later entries: it never closed a
position, and it kept refusing entries that TradingView lets through.

What TradingView does, measured on the tapes listed below (fixed quantities, no
commission, a limit of 0.5 percent of equity, initial capital 10000). The
drawdown is tested at an order fill that closes units, never between fills, on
the equity at that fill's price with the open positions marked in; a close that
realises a profit is tested too. The peak is the start equity raised by that
marked equity at fills that close units, so the open profit of the units still
held counts; the equity at an opening fill or at a bar close does not raise it.
A breach closes every open position at that fill's price with the exit text
"Close Position (Max Drawdown)" and an empty exit id, and later entries do not
happen (to the end of the next session on the tapes). The closing leg of a
reversing entry is such a fill: the new position is closed at once. A same-side
entry fill is not tested, so it fills after an untested breach, and so does a
later same-side entry of the same session.

What the engine does besides, which no tape shows: the breach also cancels the
pending orders (a later entry is refused anyway, so no tape separates the
cancel from the entry block), and the block on later entries lasts to the end
of the run.

TradingView tapes (sha256):
marked equity at a fill and no test between fills
5beeac719db4c7b6d84c31bf94f8bd9ac80cecaac8483e9900ec0b9532f36fd5,
a flatten of every open position
ee278ee1d5069aae226c93ae9287f1b1129a9e6ddb1d8e528f378e9b98260009,
the marked book of a FIFO close
5eb4683a5e0f5688d6a46520f577380b2c40c59d92b460617da6fe22f0154bb9,
a reversing entry
8a5bd165a195e74ecf72ce6336a369b7892828acef6ee5dccf662be38440dc4a,
a same-side entry
0acb1158cea715b0e6cb5c6a838d997c6baf358c79df2433ad21a12fece399ac,
the peak at bar closes
566c1d58849f9f1660274618e85c5d7f41dcee96e849c6ae411c0b27cca0ab09,
a later entry of the same session
43dbd0bbe9a12b4c7252879895e295c6d20902b1f27129da13fbbac1c62180ae,
a profit close
a61368e0a2277f8939d4e338dca17545be75cbc6c86dac4b9c17bbcbf18a2f04,
the peak at an opening fill
47ed90bb5e9edb0199e36bd3ef64165c93769ba36904dc7834b026991c29c701,
the peak raised by a closing fill
6c8004381906ce42c022be9b7214d6a583f0fd23e64bb73220be1888a5646ed9,
the open profit counted in the peak
8996c131d435ddb85b3dce0931a4fc720f6d81e341fbf07c34acfde6505f511e.

update_risk_state keeps the consecutive-loss-days latch and the bar-close
observation and the threshold overflow check as they were (the broker state
hash of a run without a drawdown limit is unchanged); only the drawdown latch is
removed. The new fill-time peak is folded into the broker state hash only under
a drawdown limit.

tests/test_adapter_drawdown_fill_halt.cpp holds eleven scenarios derived from
the tapes. tests/test_adapter_risk_relower.cpp: the cadence scenario keeps its
result and its comment now names the tapes; the scenario of a halted strategy
that still reverses is re-pinned (the reversing entry's new position is closed
at once and the later entry is refused). tests/test_engine_risk_l4a.cpp: four
same-side lots now fill, the close_all closes four, and one entry after the
breaching close_all is refused; the scenario is renamed.
tests/twin_parity_ledger.md and tests/twin_parity_inventory.json follow the
lines and the assertion text of that file (the inventory pins the digest of
the rewritten twin's assertions), and the two sha256 pins of those files in
scripts/check_twin_parity.py are updated with them.

Refs #804.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
@luisleo526

Copy link
Copy Markdown
Collaborator Author

Not ready to merge: the x86_64 layout pins in tests/test_abi_layout_probe.cpp need updating for the new field; a follow-up commit will come on this branch.

…oves

The fill-time peak of the drawdown rule is one double in the risk state, so
PineExecutionAdapter and PineStrategyHost each grow by 8 bytes and the three
adapter members that follow that field move by 8 bytes. The layout probe pins
all of these on Linux x86_64 with GNU libstdc++ (two static assertions and
runtime pins), and the release profile's build checks them.

The new values are the ones the probe printed when built and run on Linux
x86_64: sizeof PineExecutionAdapter 90360 (was 90352), sizeof PineStrategyHost
109264 (was 109256), and the offsets of pine_view_ 89080 (was 89072),
retired_row_scratch_ 89088 (was 89080) and staged_scopes_ 89760 (was 89752).
No other pin of the probe moves.

Refs #804.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

No issues found by static checks — no coupling regressions or blocking issues in the code graph. That is not the same as safe to merge: see what was not checked.

Not checked: tests were not run; no formal proof of the changed code.


Graphify review — findings

Changes strategy.risk.max_drawdown so it is tested at order fills that close units, not once per bar close. Drawdown is measured as the peak equity at such fills minus the equity at the current fill (open positions included). A breach latches the halt, closes every open position at that fill's price with the exit text "Close Position (Max Drawdown)", cancels pending orders, and blocks later entries. The old bar-close check that only refused new entries is removed. The new fill-peak state is reset per run and hashed only when a drawdown limit is set, so runs without one keep their digests. The forced close is classified as a risk-limit close cause, and the MG10-b reversal expectation is rewritten to one reversal followed by the drawdown close, with a new test covering the fill-halt behaviour.

Review partial — this diff was larger than one review pass covers, so later files were not reviewed; some findings may be missing.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 2354 functions depend on the 2225 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: find() — 10 callers, 4 callees
  • new: main() — 0 callers, 11 callees

Verification — 2354 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2315 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

1401 of 1401 test file(s) selected (100%) via static blast radius.

Escalated to a full run for safety (noncode-change, uncovered-code) — the selection is not trustworthy on its own (see below). CI should run the whole suite.

  • benchmarks/tests/test_aggregate.py — full-run-safety
  • benchmarks/tests/test_check_provenance.py — full-run-safety
  • benchmarks/tests/test_compare.py — full-run-safety
  • benchmarks/tests/test_public_names.py — full-run-safety
  • benchmarks/tests/test_run_all.py — full-run-safety
  • benchmarks/tests/test_run_strategy_feeds.py — full-run-safety
  • docker/pineforge_sink_finalize_test.py — full-run-safety
  • docker/pineforge_sink_replay_rq_test.py — full-run-safety
  • docker/pineforge_sink_replay_test.py — full-run-safety
  • docker/pineforge_sink_to_json_test.py — full-run-safety
  • docker/quota_columns_test.py — full-run-safety
  • docker/quota_convert_test.py — full-run-safety
  • docker/quota_finalize_test.py — full-run-safety
  • docker/replay_rq_engine_test.py — full-run-safety
  • docker/run_json_artifact_window_test.py — full-run-safety
  • docker/run_json_bin_test.py — full-run-safety
  • docker/run_json_bounded_transport_test.py — full-run-safety
  • docker/run_json_bulk_equity_fuzz_test.py — full-run-safety
  • docker/run_json_bulk_equity_test.py — full-run-safety
  • docker/run_json_codes_test.py — full-run-safety
  • docker/run_json_converter_metadata_test.py — full-run-safety
  • docker/run_json_converter_session_test.py — full-run-safety
  • docker/run_json_diagnostics_test.py — full-run-safety
  • docker/run_json_equity_scan_test.py — full-run-safety
  • docker/run_json_fused_test.py — full-run-safety
  • docker/run_json_harnesses_test.py — full-run-safety
  • docker/run_json_metrics_only_test.py — full-run-safety
  • docker/run_json_misc2_test.py — full-run-safety
  • docker/run_json_misc_test.py — full-run-safety
  • docker/run_json_pfbar_test.py — full-run-safety
  • docker/run_json_provenance_test.py — full-run-safety
  • docker/run_json_quota_supervisor_test.py — full-run-safety
  • docker/run_json_replay_test.py — full-run-safety
  • docker/run_json_selected_sink_test.py — full-run-safety
  • docker/run_json_sink_lexical_test.py — full-run-safety
  • docker/run_json_sink_summary_test.py — full-run-safety
  • docker/run_json_sink_test.py — full-run-safety
  • docker/run_json_sink_vectors_test.py — full-run-safety
  • docker/run_json_spool_work_test.py — full-run-safety
  • docker/run_json_stringio_test.py — full-run-safety
  • docker/run_json_symbol_feeds_test.py — full-run-safety
  • docker/run_json_syminfo_test.py — full-run-safety
  • docker/run_json_trade_scan_test.py — full-run-safety
  • docker/run_json_window_budget_test.py — full-run-safety
  • docker/run_json_wrapper_work_test.py — full-run-safety
  • include/pineforge/native_run_spec.hpp — full-run-safety
  • scripts/derive_corpus_feeds_self_test.py — full-run-safety
  • scripts/fingerprint_self_test.py — full-run-safety
  • scripts/run_corpus_self_test.py — full-run-safety
  • scripts/test_abi_receipt_skips.py — full-run-safety
  • … and 1351 more

non-code file(s) changed (CHANGELOG.md, tests/CMakeLists.txt, tests/twin_parity_inventory.json, tests/twin_parity_ledger.md) → running the full suite for safety (a code graph can't see config/fixture/data deps)

changed code file(s) with no mapped test (CHANGELOG.md, src/source/pine_state_hash.cpp, src/source/pine_strategy_host.cpp, tests/adapter_risk_relower_expectations.inc, tests/twin_parity_ledger.md) — a coverage gap or a missing link — running the full suite rather than only the selected tests

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

· 1 grounded finding(s) anchored inline below; 1 more finding(s) on lines outside this diff (see the check run).


} // namespace

int main() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — main()

fans out to 11 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

@luisleo526
luisleo526 merged commit 1eab934 into train/engine-1.11.1 Nov 10, 2026
3 checks passed
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.

1 participant