From 91a490881d656a46f275ae6d3f3cd650e8afbcee Mon Sep 17 00:00:00 2001 From: PineForge Factorial Date: Wed, 12 Aug 2026 18:50:33 +0000 Subject: [PATCH 1/3] factorial: extrema-positional-stoch-zerorange [engine] --- src/ta_extremes_volume.cpp | 43 ++++++++++++++++----------------- src/ta_oscillators.cpp | 10 ++++++-- tests/test_ta_extremes_edge.cpp | 11 +++++---- tests/test_ta_osc_edge.cpp | 19 +++++++++------ 4 files changed, 46 insertions(+), 37 deletions(-) diff --git a/src/ta_extremes_volume.cpp b/src/ta_extremes_volume.cpp index 2c99395..837cde2 100644 --- a/src/ta_extremes_volume.cpp +++ b/src/ta_extremes_volume.cpp @@ -28,10 +28,12 @@ Highest::Highest(int length) : length(length) {} double Highest::compute(double src) { - if (is_na(src)) { - return na(); - } - + // TV positional-window semantics (findings 331/332 oracle, KI-55 family): + // the lookback window covers the last `length` BARS — an na input advances + // the window as a gap instead of being dropped, the extremum is taken over + // the non-na members, and the result is na only while the bar window has + // not fully formed or contains no non-na member. On gap-free series this + // is byte-identical to the previous last-N-non-na buffer. buffer.push_back(src); while ((int)buffer.size() > length) { buffer.pop_front(); @@ -41,9 +43,9 @@ double Highest::compute(double src) { return na(); } - double hi = buffer[0]; - for (int i = 1; i < (int)buffer.size(); i++) { - if (buffer[i] > hi) hi = buffer[i]; + double hi = na(); + for (int i = 0; i < (int)buffer.size(); i++) { + if (!is_na(buffer[i]) && (is_na(hi) || buffer[i] > hi)) hi = buffer[i]; } return hi; } @@ -54,10 +56,7 @@ Lowest::Lowest(int length) : length(length) {} double Lowest::compute(double src) { - if (is_na(src)) { - return na(); - } - + // TV positional-window semantics — see Highest::compute above. buffer.push_back(src); while ((int)buffer.size() > length) { buffer.pop_front(); @@ -67,9 +66,9 @@ double Lowest::compute(double src) { return na(); } - double lo = buffer[0]; - for (int i = 1; i < (int)buffer.size(); i++) { - if (buffer[i] < lo) lo = buffer[i]; + double lo = na(); + for (int i = 0; i < (int)buffer.size(); i++) { + if (!is_na(buffer[i]) && (is_na(lo) || buffer[i] < lo)) lo = buffer[i]; } return lo; } @@ -533,12 +532,12 @@ double Highest::recompute(double src) { if (buffer.empty()) return compute(src); buffer.back() = src; - if (is_na(src)) return na(); if ((int)buffer.size() < length) return na(); - double hi = buffer[0]; - for (int i = 1; i < (int)buffer.size(); i++) { - if (buffer[i] > hi) hi = buffer[i]; + // Mirrors Highest::compute — positional window, na members skipped. + double hi = na(); + for (int i = 0; i < (int)buffer.size(); i++) { + if (!is_na(buffer[i]) && (is_na(hi) || buffer[i] > hi)) hi = buffer[i]; } return hi; } @@ -548,12 +547,12 @@ double Lowest::recompute(double src) { if (buffer.empty()) return compute(src); buffer.back() = src; - if (is_na(src)) return na(); if ((int)buffer.size() < length) return na(); - double lo = buffer[0]; - for (int i = 1; i < (int)buffer.size(); i++) { - if (buffer[i] < lo) lo = buffer[i]; + // Mirrors Lowest::compute — positional window, na members skipped. + double lo = na(); + for (int i = 0; i < (int)buffer.size(); i++) { + if (!is_na(buffer[i]) && (is_na(lo) || buffer[i] < lo)) lo = buffer[i]; } return lo; } diff --git a/src/ta_oscillators.cpp b/src/ta_oscillators.cpp index b69d77f..e154d29 100644 --- a/src/ta_oscillators.cpp +++ b/src/ta_oscillators.cpp @@ -143,7 +143,13 @@ double Stoch::compute(double src, double high, double low) { double range = hi - lo; if (range == 0.0) { - return 50.0; // Avoid division by zero; midpoint when flat + // Pine: 100 * (src - lowest) / (highest - lowest) divides by zero on a + // flat window; division by zero is na in Pine, and the finding-331 TV + // oracle pins exactly this at the first live stochRSI bar of a + // range-local security context (single-value window -> hi == lo -> na, + // NOT a 50 midpoint: a midpoint would wake %D one bar early and flip + // the 2025-04-19 readout). + return na(); } return (src - lo) / range * 100.0; @@ -618,7 +624,7 @@ double Stoch::recompute(double src, double high, double low) { } double range = hi - lo; - if (range == 0.0) return 50.0; + if (range == 0.0) return na(); // Pine division by zero (see compute) return (src - lo) / range * 100.0; } diff --git a/tests/test_ta_extremes_edge.cpp b/tests/test_ta_extremes_edge.cpp index cc05c33..a199def 100644 --- a/tests/test_ta_extremes_edge.cpp +++ b/tests/test_ta_extremes_edge.cpp @@ -64,9 +64,10 @@ static void test_highest_warmup_evict_na() { CHECK(near(hi.compute(1.0), 2.0)); // window {2,1,1} -> 9 evicted, max 2 CHECK(near(hi.compute(1.0), 1.0)); // window {1,1,1} -> all evicted, max 1 - // na input -> na out (covers is_na(src) guard) and must not perturb window. - CHECK(is_na(hi.compute(na()))); - CHECK(near(hi.compute(0.5), 1.0)); // window still {1,1,0.5} -> max 1 + // na input advances the positional window as a gap (TV semantics, pinned + // by the finding-331 oracle: extrema stay live over the non-na members). + CHECK(near(hi.compute(na()), 1.0)); // window {1,1,na} -> max 1 + CHECK(near(hi.compute(0.5), 1.0)); // window {1,na,0.5} -> max 1 } // --- Lowest: warmup na, full-window value, eviction, na-input --- @@ -86,8 +87,8 @@ static void test_lowest_warmup_evict_na() { CHECK(near(lo.compute(7.0), 7.0)); // {9,8,7} -> 1 evicted, min 7 CHECK(near(lo.compute(6.0), 6.0)); // {8,7,6} -> min 6 - CHECK(is_na(lo.compute(na()))); // na in -> na out - CHECK(near(lo.compute(10.0), 6.0)); // {7,6,10} -> min 6 + CHECK(near(lo.compute(na()), 6.0)); // {7,6,na} -> min 6 (gap) + CHECK(near(lo.compute(10.0), 6.0)); // {6,na,10} -> min 6 } // --- HighestBars: warmup na, offset semantics, eviction --- diff --git a/tests/test_ta_osc_edge.cpp b/tests/test_ta_osc_edge.cpp index e1158fd..4cecb47 100644 --- a/tests/test_ta_osc_edge.cpp +++ b/tests/test_ta_osc_edge.cpp @@ -143,14 +143,15 @@ static void test_stoch_flat_and_na() { CHECK(is_na(stoch.compute(10.0, 11.0, 9.0))); CHECK(is_na(stoch.compute(10.0, 12.0, 8.0))); - // Flat high/low across the whole window -> hi == lo -> range 0 -> 50.0 - // midpoint. Feed a constant high/low (range zero) for `length` bars. + // Flat high/low across the whole window -> hi == lo -> range 0 -> na + // (Pine division by zero; pinned by the finding-331 TV deep-backtest + // oracle at the first live stochRSI bar of a range-local D context). ta::Stoch flat(3); flat.compute(5.0, 5.0, 5.0); flat.compute(5.0, 5.0, 5.0); - CHECK(near(flat.compute(5.0, 5.0, 5.0), 50.0)); - // recompute on the same flat bar also takes the range==0 -> 50.0 arm. - CHECK(near(flat.recompute(5.0, 5.0, 5.0), 50.0)); + CHECK(is_na(flat.compute(5.0, 5.0, 5.0))); + // recompute on the same flat bar also takes the range==0 -> na arm. + CHECK(is_na(flat.recompute(5.0, 5.0, 5.0))); // na source with a valid window -> na (the is_na(src) guard). ta::Stoch s2(2); @@ -406,12 +407,14 @@ static void test_cog_degenerate_is_na() { static void test_degenerate_arms_negative_control() { std::printf("test_degenerate_arms_negative_control\n"); - // ta.stoch on a flat window -> 50.0 (NOT na). Known-wrong, unresolved. + // ta.stoch on a flat window -> na (Pine division by zero). RESOLVED by the + // finding-331 TV oracle: a 50.0 midpoint wakes %D one D bar early and + // flips the 2025-04-19 range-local readout; TV reads na. ta::Stoch stoch(3); stoch.compute(5.0, 5.0, 5.0); stoch.compute(5.0, 5.0, 5.0); - CHECK(near(stoch.compute(5.0, 5.0, 5.0), 50.0)); - CHECK(near(stoch.recompute(5.0, 5.0, 5.0), 50.0)); + CHECK(is_na(stoch.compute(5.0, 5.0, 5.0))); + CHECK(is_na(stoch.recompute(5.0, 5.0, 5.0))); // ta.cci on a constant source -> exactly 0.0 (NOT na). SPLIT/undecided: // TradingView really does return bitwise 0.0 in this construction. From 5c605c289cc8f7d09d4ee7ff916ab730536253e0 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Thu, 13 Aug 2026 04:22:19 +0800 Subject: [PATCH 2/3] fix(engine): exit-bracket lifecycle across declined in-position reversals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A declined in-position opposite MARKET reversal (the KI-54/KI-72 fill re-check declines — the tradeless reversal) cancels the live position's standing PRICED strategy.exit brackets on TradingView. The engine kept the original bracket live and stop-filled early, forking position state for days on every tradeless opposite signal. - KILL: the decline marks the position's standing priced brackets dormant — never the "__close__" close/close_all family, never stale exits bound to a not-yet-filled entry id (the #147 stale-exit family). - DORMANT: a dormant bracket stays in the book but never matches a fill (classify_order_eligibility Skip in the ordinary kernel; an apply-time mirror in the KI-60 COOF kernel, whose candidate set is pre-classified before any candidate is applied, so a mid-segment kill is invisible to classify there). - REVIVE-A: a fresh same-(id,from_entry) strategy.exit re-issue replaces the dormant bracket wholesale and arms the new call's prices (the ordinary replacement path — no new code). - REVIVE-B: a margin-call partial re-registers the surviving position's dormant brackets at their last-armed prices. - MC-CASCADE: when the margin-call event price already makes a revived full-percent default stop marketable, the entire remainder closes at that event price through the bracket's id on the slice bar. Derivation: 162/162 alive-at-breach episodes, 118/118 TV stop-skips, 19/19 revive-B exact-stop fills, 18/18 cascade fills at the event extreme; tape validation collapses the count mismatch to 0 with the whole suite green and the corpus byte-identical. Co-Authored-By: Claude Fable 5 --- include/pineforge/engine.hpp | 17 + src/engine_fills.cpp | 113 +++++ tests/CMakeLists.txt | 1 + ...st_bracket_lifecycle_declined_reversal.cpp | 399 ++++++++++++++++++ 4 files changed, 530 insertions(+) create mode 100644 tests/test_bracket_lifecycle_declined_reversal.cpp diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 323e48c..bc23294 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -551,6 +551,14 @@ struct PendingOrder { // flag is never set, so the fix is inert. See suppress_declined_reversal_ // close_legs (engine_fills.cpp). bool suppress_as_declined_reversal_close = false; + // finding-311 (bracket lifecycle on declined reversal): a standing exit + // bracket of the live position goes DORMANT when an in-position opposite + // entry is declined at its fill re-check (the tradeless reversal). A + // dormant bracket never matches a fill. It revives with ORIGINAL prices + // when a margin-call partial re-registers the surviving position's exits, + // or is replaced wholesale by a fresh same-(id,from_entry) strategy.exit + // call (which arms the NEW call's prices, the ordinary re-issue path). + bool dormant_bracket = false; // Qty this deferred close debited from id_unclosed_qty_[] in // compute_close_target_qty's default-FIFO branch at strategy.close CALL // time. On the false->true suppression transition it is re-credited to that @@ -2437,6 +2445,15 @@ class BacktestEngine { // side (see PendingOrder::suppress_as_declined_reversal_close), re-crediting // each flagged close's consumed id-ledger exactly once. void suppress_declined_reversal_close_legs(const PendingOrder& declined_entry); + // finding-311: mark the live position's standing strategy.exit brackets + // dormant when an in-position reversal entry is declined at fill. + void mark_position_brackets_dormant_on_declined_reversal(); + // finding-311: a margin-call partial re-registers the surviving + // position's exit brackets (revive with original prices). When the + // margin-call event price makes a revived bracket marketable, the whole + // remaining position closes at that price through the bracket's id. + void revive_position_brackets_after_margin_call_partial( + double margin_call_event_price); // Per-OrderType fill kernels. Called only after risk + intraday // gates pass; each updates the engine's position/trade state and // any per-type out-parameters the post-fill bookkeeping needs. diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 9a34714..cf1d9ac 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -1060,6 +1060,16 @@ void BacktestEngine::process_margin_call(const Bar& bar) { trades_[ti].exit_comment = "Margin call"; trades_[ti].exit_id = "__margin_call__"; } + // finding-311 REVIVE-B: a margin-call partial re-registers the surviving + // position's dormant brackets (original prices). If the margin-call event + // price already makes a revived bracket marketable, the WHOLE remaining + // position closes at that event price through the bracket's id — TV books + // the slice ("Margin call") and the residual full close ("Exit …") at the + // same adverse-extreme price on the same bar. + if (trades_.size() != trades_before + && position_side_ != PositionSide::FLAT) { + revive_position_brackets_after_margin_call_partial(raw_exit_fill_base); + } // A commissioned all-in close-then-short has two broker checkpoints on its // fill bar: fill-price opening affordability (which may be a no-op), then // the ordinary adverse-high check over the surviving short. The one-shot @@ -1067,6 +1077,59 @@ void BacktestEngine::process_margin_call(const Bar& bar) { run_default_short_adverse_retry(); } +void BacktestEngine::revive_position_brackets_after_margin_call_partial( + double margin_call_event_price) { + const double mc_price = margin_call_event_price; + if (position_side_ == PositionSide::FLAT) return; + static const std::string kClosePrefix = "__close__"; + PendingOrder* marketable = nullptr; + for (PendingOrder& o : pending_orders_) { + if (o.type != OrderType::EXIT) continue; + if (o.suppress_as_declined_reversal_close) continue; + if (o.id.size() >= kClosePrefix.size() + && o.id.compare(0, kClosePrefix.size(), kClosePrefix) == 0) continue; + if (!o.dormant_bracket) continue; + bool bound = o.from_entry.empty(); + if (!bound) { + for (const auto& pe : pyramid_entries_) { + if (pe.entry_id == o.from_entry) { bound = true; break; } + } + } + if (!bound) continue; + o.dormant_bracket = false; + // Marketable at the margin-call event price? (Full-percent default + // brackets only — the TV-pinned shape.) + const bool full_pct = std::isnan(o.qty) + && o.qty_percent >= 100.0 - internal::kFullPercentEps; + if (!full_pct || std::isnan(o.stop_price) + || !std::isfinite(mc_price)) continue; + const bool mk = (position_side_ == PositionSide::SHORT) + ? (o.stop_price <= mc_price) + : (o.stop_price >= mc_price); + if (mk && marketable == nullptr) marketable = &o; + } + if (marketable == nullptr) return; + const std::string exit_id = marketable->id; + const std::string exit_comment = marketable->comment; + const uint64_t exit_incarnation = marketable->incarnation; + const size_t trades_before = trades_.size(); + execute_market_exit(mc_price); + if (trades_.size() != trades_before) { + ++broker_fill_event_seq_; + for (size_t ti = trades_before; ti < trades_.size(); ++ti) { + trades_[ti].exit_comment = exit_comment; + trades_[ti].exit_id = exit_id; + } + // The bracket filled: consume the pending order object. + pending_orders_.erase( + std::remove_if(pending_orders_.begin(), pending_orders_.end(), + [&](const PendingOrder& o) { + return o.incarnation == exit_incarnation; + }), + pending_orders_.end()); + } +} + // finding-308 (margin-call intrabar chronology). TradingView places the // forced-liquidation event chronologically on the synthesized intrabar path. // When a priced exit of the live position fills on a bar whose adverse @@ -2955,6 +3018,16 @@ void BacktestEngine::apply_filled_order_to_state( decline_and_cancel(); return; } + // finding-311 (KI-60 COOF kernel mirror of classify's dormant Skip): the + // COOF kernel pre-classifies its whole candidate set BEFORE any candidate + // is applied, so a bracket marked dormant mid-segment by an earlier + // candidate's declined reversal still reaches apply. No-op the fill + // WITHOUT consuming the order — unlike the suppressed close leg above, a + // dormant bracket must SURVIVE in the book (a later margin-call partial + // revives it; a fresh same-(id,from_entry) strategy.exit replaces it). + if (order.dormant_bracket) { + return; + } // Fill-local proof that KI-54 admitted this order as a flat open on its // frozen sizing price. Merely carrying a snapshot is insufficient: true // reversals are admitted on their actual fill, and paired reentries may @@ -3157,6 +3230,7 @@ void BacktestEngine::apply_filled_order_to_state( const bool reversal = position_side_ != PositionSide::FLAT && !same_dir; if (reversal && order.type == OrderType::MARKET) { suppress_declined_reversal_close_legs(order); + mark_position_brackets_dormant_on_declined_reversal(); } decline_and_cancel(); return; @@ -3361,6 +3435,7 @@ void BacktestEngine::apply_filled_order_to_state( // excluded — see suppress_declined_reversal_close_legs. if (reversal && order.type == OrderType::MARKET) { suppress_declined_reversal_close_legs(order); + mark_position_brackets_dormant_on_declined_reversal(); } decline_and_cancel(); return; @@ -4801,6 +4876,38 @@ void BacktestEngine::suppress_declined_reversal_close_legs( } } +void BacktestEngine::mark_position_brackets_dormant_on_declined_reversal() { + static const std::string kClosePrefix = "__close__"; + if (position_side_ == PositionSide::FLAT) return; + // The live position's entry ids (pyramid lots) — a bracket is "standing" + // when its from_entry names one of them, or when it is a global + // (from_entry-less) exit. Stale exits bound to a not-yet-filled entry id + // (e.g. the declined reversal's own strategy.exit) are NOT standing + // brackets and stay untouched (the #147 stale-exit family). + for (PendingOrder& o : pending_orders_) { + if (o.type != OrderType::EXIT) continue; + if (o.suppress_as_declined_reversal_close) continue; + // strategy.close instructions (targeted "__close__X" AND the bare + // "__close__" close_all) are NOT brackets — never dormant. + if (o.id.size() >= kClosePrefix.size() + && o.id.compare(0, kClosePrefix.size(), kClosePrefix) == 0) continue; + // Only priced strategy.exit brackets (stop/limit/trail legs) die. + const bool priced = !std::isnan(o.stop_price) + || !std::isnan(o.limit_price) + || !std::isnan(o.trail_points) + || !std::isnan(o.trail_price); + if (!priced) continue; + bool bound = o.from_entry.empty(); + if (!bound) { + for (const auto& pe : pyramid_entries_) { + if (pe.entry_id == o.from_entry) { bound = true; break; } + } + } + if (!bound) continue; + o.dormant_bracket = true; + } +} + // ── Inner-loop phase 1: order eligibility ───────────────────────────── // Returns whether the given pending order should be processed this // iteration. Walks the chain of TV-empirical "skip" / "cancel" rules @@ -4819,6 +4926,12 @@ BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility( if (order.suppress_as_declined_reversal_close) { return OrderEligibility::Remove; } + // finding-311: a dormant bracket stays in the book (a later margin-call + // partial revives it; a fresh same-id strategy.exit replaces it) but + // never matches a fill while dormant. + if (order.dormant_bracket) { + return OrderEligibility::Skip; + } if (opposing_pass == 1) { if (!pass0_opposing_skip_ids.count(order.id)) { return OrderEligibility::Skip; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b55f165..8f254ad 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -93,6 +93,7 @@ set(TEST_SOURCES test_close_all_coqueued_entry test_same_bar_add_exit_coverage test_declined_reversal_close_leg + test_bracket_lifecycle_declined_reversal test_dual_entry_placement_sizing test_default_flat_market_gross_admission test_live_position_market_gross_admission diff --git a/tests/test_bracket_lifecycle_declined_reversal.cpp b/tests/test_bracket_lifecycle_declined_reversal.cpp new file mode 100644 index 0000000..2eb8845 --- /dev/null +++ b/tests/test_bracket_lifecycle_declined_reversal.cpp @@ -0,0 +1,399 @@ +/* + * test_bracket_lifecycle_declined_reversal.cpp — finding-311: exit-bracket + * LIFECYCLE across declined in-position reversal signals. + * + * TV rule set (stevenygabbyperez derivation, 162/162 episodes): + * KILL — a declined in-position opposite MARKET reversal (the KI-54/ + * KI-72 decline arms, the "tradeless reversal") cancels the live + * position's standing PRICED strategy.exit brackets. Not the + * "__close__" family, not stale exits bound to unfilled entries. + * DORMANT — a killed bracket never matches a fill (118/118 TV stop-skips), + * but stays in the book. + * REVIVE-A — a fresh same-(id,from_entry) strategy.exit re-issue replaces + * the dormant bracket wholesale and arms the NEW call's prices. + * REVIVE-B — a margin-call PARTIAL re-registers the surviving position's + * dormant brackets at their LAST-ARMED (original) prices. + * CASCADE — if the margin-call event price already makes a revived stop + * marketable, the WHOLE remaining position closes at that event + * price through the bracket's id (TV books the "Margin call" + * slice and the residual close at the same adverse extreme). + * + * Harness: modelled on test_declined_reversal_close_leg.cpp (Probe subclass, + * scripted per-bar actions; initial_capital 10000, PERCENT_OF_EQUITY pct=100, + * zero commission, qty_step 0). The canonical decline fixture is the same + * +1-gap open: LONG 100 @100, signal close 110 (eq 11000, frozen opposite qty + * 100), fill bar opens 111 -> required 11100 > 11000 -> KI-54 DECLINE. + * + * Matrix: + * KILL declined reversal kills the bracket; a same-bar stop touch does + * not fill (RED pre-fix: the stop filled early). + * DORMANT later-bar touches never fill either; position held. + * REVIVE-A same-(id,from_entry) re-issue arms fresh prices and fills. + * ADMITTED admitted reversal unchanged (fix inert; flip books the trade). + * REVIVE-B margin-call partial revives the bracket at its original price; + * it fills normally on a later bar. + * CASCADE revived stop marketable at the margin-call event price closes + * the entire remainder at that price under the bracket's id. + * R5 close_all co-queued with the declined reversal still fires + * (the "__close__" family is excluded from the kill). + * COOF KI-60 kernel mirror: the dormant flag set mid-segment by an + * earlier candidate's decline is caught at apply time (the COOF + * kernel pre-classifies its candidates, so classify's Skip alone + * cannot see it). RED without the apply-time mirror. + */ + +#include +#include +#include +#include +#include + +#include +#include + +using namespace pineforge; + +static int tests_passed = 0; +static int tests_failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +#define CHECK_NEAR(a, b, tol) \ + do { \ + double _a = (a), _b = (b); \ + if (!(std::fabs(_a - _b) <= (tol))) { \ + std::printf(" FAIL %s:%d %s == %.10f, expected %.10f\n", \ + __FILE__, __LINE__, #a, _a, _b); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +static constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +static Bar mk(int64_t ts, double o, double h, double l, double c) { + Bar b; + b.open = o; b.high = h; b.low = l; b.close = c; + b.volume = 1.0; b.timestamp = ts; + return b; +} + +namespace { + +// Scripted per-bar actions; creation order within a bar is preserved. +enum class Op { EnterLong, EnterShort, ExitStop90, ExitStop95, ExitStop105, + CloseAll }; +struct Action { Op op; }; + +class Probe : public BacktestEngine { +public: + Probe() { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_value_ = 0.0; + pyramiding_ = 1; + margin_call_enabled_ = false; + syminfo_mintick_ = 0.01; + } + std::vector> plan; // plan[bar_index] = actions + void on_bar(const Bar&) override { + if (bar_index_ < 0 || bar_index_ >= (int)plan.size()) return; + for (const auto& a : plan[bar_index_]) { + switch (a.op) { + case Op::EnterLong: strategy_entry("L", true); break; + case Op::EnterShort: strategy_entry("S", false); break; + case Op::ExitStop90: + strategy_exit("X", "L", kNaN, 90.0, kNaN, kNaN, kNaN, + 100.0, ""); + break; + case Op::ExitStop95: + strategy_exit("X", "L", kNaN, 95.0, kNaN, kNaN, kNaN, + 100.0, ""); + break; + case Op::ExitStop105: + strategy_exit("X", "L", kNaN, 105.0, kNaN, kNaN, kNaN, + 100.0, ""); + break; + case Op::CloseAll: strategy_close_all(); break; + } + } + } + std::string x_comment(int i) const { return closed_trade_exit_comment(i); } + std::string x_id(int i) const { return closed_trade_exit_id(i); } + double x_price(int i) const { return closed_trade_exit_price(i); } + double t_size(int i) const { return closed_trade_size(i); } + int x_bar(int i) const { return closed_trade_exit_bar_index(i); } + using BacktestEngine::position_qty_; + using BacktestEngine::position_side_; + using BacktestEngine::calc_on_order_fills_; + using BacktestEngine::margin_call_enabled_; + using BacktestEngine::margin_short_; +}; + +// Canonical LONG-then-declined-reversal bars. LONG fills 100 @100 (bar1), +// bar1 closes 110 (eq 11000, frozen short qty 100 @110), and bar2 opens +1 +// at 111 -> the short reversal DECLINES (11100 > 11000). `low2` shapes bar2's +// low so a fixture can touch (or avoid) the 90 stop on the decline bar. +static std::vector decline_bars(double low2) { + return { + mk(1000, 100, 100, 100, 100), // bar0: place L + mk(2000, 100, 112, 99, 110), // bar1: L fills @100; arm + mk(3000, 111, 112, low2, 111), // bar2: S declines @111 + mk(4000, 111, 112, low2, 111), // bar3 + mk(5000, 111, 112, low2, 111), // bar4 + mk(6000, 111, 111, 111, 111), // bar5 + }; +} + +} // namespace + +// KILL: the declined reversal kills the standing stop bracket. bar2 declines S +// at the 111 open and its low 89 crosses the 90 stop — a live bracket would +// fill @90. Post-fix the bracket is dormant: NO fill, LONG held. +static void test_kill_bracket_on_declined_reversal() { + std::printf("-- KILL: declined reversal kills the standing bracket --\n"); + Probe p; + p.plan = { + {{Op::EnterLong}}, // bar0 + {{Op::ExitStop90}, {Op::EnterShort}}, // bar1: arm X; queue S + {}, {}, {}, {}, + }; + auto bars = decline_bars(/*low2=*/89); + p.run(bars.data(), (int)bars.size()); + CHECK(p.position_side_ == PositionSide::LONG); // RED pre-fix: FLAT @90 + CHECK_NEAR(p.position_qty_, 100.0, 1e-9); + CHECK(p.trade_count() == 0); +} + +// DORMANT: repeated later-bar touches of the killed stop never fill either — +// the bracket stays in the book but never matches (118/118 TV stop-skips). +static void test_dormant_touches_never_fill() { + std::printf("-- DORMANT: later-bar touches never fill --\n"); + Probe p; + p.plan = { + {{Op::EnterLong}}, + {{Op::ExitStop90}, {Op::EnterShort}}, + {}, {}, {}, {}, + }; + auto bars = decline_bars(/*low2=*/89); + bars[3] = mk(4000, 100, 100, 88, 100); // bar3: touch again + bars[4] = mk(5000, 100, 100, 87, 100); // bar4: and again + p.run(bars.data(), (int)bars.size()); + CHECK(p.position_side_ == PositionSide::LONG); + CHECK_NEAR(p.position_qty_, 100.0, 1e-9); + CHECK(p.trade_count() == 0); +} + +// REVIVE-A: a fresh same-(id,from_entry) strategy.exit re-issue replaces the +// dormant bracket wholesale and arms the NEW prices. The re-issued stop 95 +// fills on the next touch bar at 95 (not at the original 90). +static void test_revive_A_reissue_arms_fresh_prices() { + std::printf("-- REVIVE-A: same-(id,from_entry) re-issue arms fresh prices --\n"); + Probe p; + p.plan = { + {{Op::EnterLong}}, // bar0 + {{Op::ExitStop90}, {Op::EnterShort}}, // bar1: arm X@90; queue S + {}, // bar2: S declines; kill + {{Op::ExitStop95}}, // bar3: re-issue X@95 + {}, // bar4: touch -> fill @95 + {}, + }; + auto bars = decline_bars(/*low2=*/110); // no touch on bar2 + bars[3] = mk(4000, 110, 110, 110, 110); // bar3: quiet re-issue bar + bars[4] = mk(5000, 96, 97, 89, 95); // bar4: crosses 95 (and 90) + p.run(bars.data(), (int)bars.size()); + CHECK(p.position_side_ == PositionSide::FLAT); + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) { + CHECK_NEAR(p.x_price(0), 95.0, 1e-9); // NEW price, not 90 + CHECK(p.x_id(0) == std::string("X")); + CHECK(p.x_bar(0) == 4); + } +} + +// ADMITTED: an admitted reversal is untouched by the kill machinery — the tie +// fill (open 110 == frozen sizing price) flips the position and books the L +// round-trip exactly as before. +static void test_admitted_reversal_unchanged() { + std::printf("-- ADMITTED: admitted reversal unchanged (fix inert) --\n"); + Probe p; + p.plan = { + {{Op::EnterLong}}, + {{Op::ExitStop90}, {Op::EnterShort}}, + {}, {}, {}, {}, + }; + auto bars = decline_bars(/*low2=*/110); + bars[2] = mk(3000, 110, 112, 110, 110); // tie open -> ADMIT + bars[3] = mk(4000, 110, 110, 110, 110); + bars[4] = mk(5000, 110, 110, 110, 110); + p.run(bars.data(), (int)bars.size()); + CHECK(p.position_side_ == PositionSide::SHORT); // flip happened + CHECK_NEAR(p.position_qty_, 100.0, 1e-9); + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) CHECK_NEAR(p.x_price(0), 110.0, 1e-9); +} + +namespace { + +// SHORT-side margin-call fixtures (REVIVE-B / CASCADE). A 5x short (margin_ +// short=20) opens 100 @100; bar1 closes 90 (eq 11000, frozen long qty +// 122.22 @90); bar2 opens 91 -> the LONG reversal DECLINES (122.22*91 = +// 11122.2 > 11000; margin_long stays 100) and kills the short's bracket. +// bar3 spikes to an adverse high 170: equity 3000 < required 3400 -> +// q_min = 100 - 3000/34 = 11.7647..., slice 4x = 47.0588... (a PARTIAL), +// booked "Margin call" @170; the slice then revives the bracket at its +// original stop. The bracket is armed on the ENTRY's signal bar (the tape's +// entry-bound shape): it defers with qty=NaN and the fill side executes a +// FULL remaining close — the same full-percent default shape the cascade's +// marketability rule is pinned on. +class ShortMcProbe : public Probe { +public: + explicit ShortMcProbe(double stop_price) : stop_price_(stop_price) { + margin_call_enabled_ = true; + margin_short_ = 20.0; // 5x short + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("S", false); + strategy_exit("X", "S", kNaN, stop_price_, kNaN, kNaN, kNaN, + 100.0, ""); + } + if (bar_index_ == 1) { + strategy_entry("L", true); // the reversal-to-decline + } + } +private: + double stop_price_; +}; + +static std::vector short_mc_bars(const Bar& post_event_bar) { + return { + mk(1000, 100, 100, 100, 100), // bar0: place S + mk(2000, 100, 101, 99, 90), // bar1: S fills @100; arm + mk(3000, 91, 91, 91, 91), // bar2: L declines; kill + mk(4000, 165, 170, 160, 168), // bar3: MC partial @170 + post_event_bar, // bar4 + }; +} + +} // namespace + +// REVIVE-B: the margin-call PARTIAL revives the dormant bracket at its +// original price. Stop 180 is NOT marketable at the 170 event price (no +// cascade); the revived stop then fills normally on bar4's 180 touch. +static void test_revive_B_margin_call_partial_revives() { + std::printf("-- REVIVE-B: margin-call partial revives at original price --\n"); + ShortMcProbe p(/*stop=*/180.0); + auto bars = short_mc_bars(mk(5000, 175, 185, 170, 180)); + p.run(bars.data(), (int)bars.size()); + CHECK(p.trade_count() == 2); + if (p.trade_count() == 2) { + CHECK(p.x_comment(0) == std::string("Margin call")); + CHECK_NEAR(p.t_size(0), 47.0588235294, 1e-6); + CHECK_NEAR(p.x_price(0), 170.0, 1e-9); + CHECK(p.x_bar(0) == 3); + CHECK(p.x_id(1) == std::string("X")); // revived bracket fill + CHECK_NEAR(p.t_size(1), 52.9411764706, 1e-6); + CHECK_NEAR(p.x_price(1), 180.0, 1e-9); // ORIGINAL armed price + CHECK(p.x_bar(1) == 4); + } + CHECK(p.position_side_ == PositionSide::FLAT); +} + +// CASCADE: the revived stop 150 is already marketable at the 170 event price +// (short stop <= event price), so the ENTIRE remainder closes at the event +// price through the bracket's id on the same bar as the slice. Pre-revive the +// dormant stop must NOT have filled at bar3's open 165 (dormancy proof). +static void test_cascade_marketable_revived_stop() { + std::printf("-- CASCADE: revived stop marketable at MC price closes remainder --\n"); + ShortMcProbe p(/*stop=*/150.0); + auto bars = short_mc_bars(mk(5000, 168, 168, 168, 168)); + p.run(bars.data(), (int)bars.size()); + CHECK(p.trade_count() == 2); + if (p.trade_count() == 2) { + CHECK(p.x_comment(0) == std::string("Margin call")); + CHECK_NEAR(p.t_size(0), 47.0588235294, 1e-6); + CHECK_NEAR(p.x_price(0), 170.0, 1e-9); // NOT the 165 open + CHECK(p.x_bar(0) == 3); + CHECK(p.x_id(1) == std::string("X")); + CHECK(p.x_comment(1) != std::string("Margin call")); + CHECK_NEAR(p.t_size(1), 52.9411764706, 1e-6); + CHECK_NEAR(p.x_price(1), 170.0, 1e-9); // MC event price + CHECK(p.x_bar(1) == 3); // same bar as the slice + } + CHECK(p.position_side_ == PositionSide::FLAT); +} + +// R5 non-regression: a close_all co-queued with the declined reversal still +// fires — the "__close__" family (targeted AND bare) is excluded from the +// kill, exactly like it is excluded from close-leg suppression. +static void test_R5_close_all_still_fires() { + std::printf("-- R5: close_all co-queued with declined reversal still fires --\n"); + Probe p; + p.plan = { + {{Op::EnterLong}}, + {{Op::ExitStop90}, {Op::EnterShort}, {Op::CloseAll}}, + {}, {}, {}, {}, + }; + auto bars = decline_bars(/*low2=*/110); + p.run(bars.data(), (int)bars.size()); + CHECK(p.position_side_ == PositionSide::FLAT); // close_all flattened + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) CHECK_NEAR(p.x_price(0), 111.0, 1e-9); +} + +// COOF mirror: under calc_on_order_fills the KI-60 kernel pre-classifies its +// whole candidate set before applying any candidate, so the dormant flag set +// by the reversal's decline mid-segment is invisible to classify — the shared +// apply-time guard must catch it. bar3 declines S at the 111 open and its low +// 104 crosses the 105 stop pre-classified in the same candidate set. RED +// without the apply-time mirror (the stop fills @105 -> FLAT). +static void test_coof_kernel_mirror() { + std::printf("-- COOF: KI-60 kernel apply-time mirror --\n"); + Probe p; + p.calc_on_order_fills_ = true; + p.plan = { + {{Op::EnterLong}}, // bar0: place L + {}, // bar1: L fills @100 + {{Op::ExitStop105}, {Op::EnterShort}}, // bar2: signal @110 + {}, {}, {}, + }; + std::vector bars = { + mk(1000, 100, 100, 100, 100), + mk(2000, 100, 100, 100, 100), // L fills @100 + mk(3000, 100, 112, 99, 110), // profit; X + S queued + mk(4000, 111, 112, 104, 111), // +1 gap declines S; low + // crosses the 105 stop + mk(5000, 111, 111, 111, 111), + mk(6000, 111, 111, 111, 111), + }; + p.run(bars.data(), (int)bars.size()); + CHECK(p.position_side_ == PositionSide::LONG); // RED pre-mirror: FLAT + CHECK_NEAR(p.position_qty_, 100.0, 1e-9); + CHECK(p.trade_count() == 0); +} + +int main() { + std::printf("--- bracket_lifecycle_declined_reversal ---\n"); + test_kill_bracket_on_declined_reversal(); + test_dormant_touches_never_fill(); + test_revive_A_reissue_arms_fresh_prices(); + test_admitted_reversal_unchanged(); + test_revive_B_margin_call_partial_revives(); + test_cascade_marketable_revived_stop(); + test_R5_close_all_still_fires(); + test_coof_kernel_mirror(); + std::printf("\n=== Results: %d passed, %d failed ===\n", + tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +} From 932c7788b41c05f9af1691407d1b04663a9e8f01 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Thu, 13 Aug 2026 04:22:28 +0800 Subject: [PATCH 3/3] fix(engine): 1x-long opening-affordability check runs at the entry fill, before same-bar intrabar exits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TradingView evaluates the margin_long=100 opening-affordability check AT THE ENTRY FILL, chronologically before the same bar's intrabar exits. The #149 chronology hook (margin_call_slice_before_priced_exit) deliberately excluded 1x longs — compute_liquidation_price() is na there — so a same-bar priced exit filled the FULL position first and the end-of-bar one-shot opening event found the position already reduced or gone: the "Margin call" trim row (ordinary floor-before-4x restore, including the sub-lot one-contract fallback, filled at the RAW matched entry base — a pnl-0 row) was lost, and the equity fork cascaded through every later default-sized fill. The hook now routes the 1x-long class to an opening-slice helper that books the trim with process_margin_call's opening-affordability LONG branch arithmetic verbatim, then lets the triggering exit close the reduced remainder. The one-shot event is consumed only when a slice is actually booked. Unchanged byte-for-byte: bars where no same-bar priced exit fills (end-of-bar placement), POOC close fills, the scoped SHORT opening event (end-of-bar plus adverse-retry), the leveraged adverse-extreme chronology class, and every no-deficit evaluation. The deficit class: a lot-floored percent=100 reversal admitted on an uptick inside its floor remainder overshoots post-close equity by tick*(qty+closed) - remainder (sub-lot -> one-contract fallback); a commissioned explicit open reaches the same discontinuity through its entry fee. Tape validation collapses the count mismatch (the pure cascade family) to 0 with the whole suite green and the corpus byte-identical. Co-Authored-By: Claude Fable 5 --- include/pineforge/engine.hpp | 11 + src/engine_fills.cpp | 164 +++++++- tests/CMakeLists.txt | 1 + tests/test_margin_call_1x_long_entry_fill.cpp | 364 ++++++++++++++++++ 4 files changed, 535 insertions(+), 5 deletions(-) create mode 100644 tests/test_margin_call_1x_long_entry_fill.cpp diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index bc23294..016867f 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -1332,6 +1332,17 @@ class BacktestEngine { // then fills the reduced remainder. bool margin_call_slice_before_priced_exit(const Bar& bar, double exit_fill_price); + // finding-325 (1x-long entry-fill affordability chronology): TV runs the + // 1x-long (margin_long=100) opening-affordability check AT THE ENTRY + // FILL, chronologically before the same bar's intrabar exits. When a + // priced exit of a just-opened 1x long is about to fill on the entry's + // own bar and the floor-sized opening cost exceeds post-close equity, + // the one-shot opening event books its trim FIRST — the ordinary + // floor-before-4x quantity (including the sub-lot one-contract + // fallback), filled at the RAW matched entry base, tagged "Margin call" + // — and the exit then closes the reduced remainder. Consumes the + // pending opening event; returns true when a slice was booked. + bool margin_call_1x_long_opening_slice_before_priced_exit(const Bar& bar); // A timestamped FX rollover is a broker-open event, not an end-of-bar // adverse-price check. Cell A1 supports carried 1x full-margin long and // short in ordinary historical dispatch; leveraged shapes stay fail-closed. diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index cf1d9ac..f29a166 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -1165,16 +1165,19 @@ bool BacktestEngine::margin_call_slice_before_priced_exit( if (bar_magnifier_enabled_ || coof_scheduler_active_) return false; // Eligibility gates, mirroring process_margin_call's finite-price path. - // A 1x long has no adverse-price liquidation (its only broker action is - // the one-shot post-fill affordability event, which stays end-of-bar); - // a POOC position filled at this bar's close has no post-fill adverse - // path on the bar. + // A 1x long has no adverse-price liquidation; its only broker action is + // the one-shot post-fill affordability event, whose TV placement is the + // ENTRY FILL itself (finding-325) — route it to the opening-slice hook + // below instead of the adverse-extreme arithmetic. A POOC position + // filled at this bar's close has no post-fill adverse path on the bar. const bool opened_this_bar = position_open_bar_ == bar_index_; const bool long_full_margin = (position_side_ == PositionSide::LONG) && std::isfinite(margin_long_) && std::abs(margin_long_ / 100.0 - 1.0) < 1e-12; - if (long_full_margin) return false; + if (long_full_margin) { + return margin_call_1x_long_opening_slice_before_priced_exit(bar); + } if (process_orders_on_close_ && opened_this_bar) return false; const double liq = compute_liquidation_price(); if (std::isnan(liq)) return false; @@ -1289,6 +1292,157 @@ bool BacktestEngine::margin_call_slice_before_priced_exit( return true; } +// finding-325 (1x-long entry-fill affordability chronology). The hook above +// deliberately excluded 1x longs: compute_liquidation_price() is na there and +// the only broker action is the one-shot opening-affordability event, which +// used to stay end-of-bar (process_margin_call). The rhyme17 exemplar +// (2026-01-09 14:30) pins the TV chronology: the opening check runs AT THE +// ENTRY FILL — a same-bar priced exit closes only the remainder left after +// the trim, and the trim itself fills at the RAW matched entry base (the +// pnl-0 "Margin call" row), never at an adverse extreme. The arithmetic below +// is process_margin_call's opening-affordability LONG branch verbatim +// (opening budget on the position's snapped entry basis, floor-before-4x, +// the sub-lot one-contract fallback); only its PLACEMENT moves, and only +// when a priced exit would otherwise fill first on the entry's own bar. +// Bars where no same-bar priced exit fills keep the established end-of-bar +// event untouched, as do POOC close fills (no intrabar chronology exists +// there) and the scoped SHORT opening event (its end-of-bar placement plus +// adverse-retry pass is separately pinned). +// +// The event is consumed ONLY when a slice is actually booked: a no-deficit +// evaluation leaves the pending event for process_margin_call exactly as +// before (where the post-exit state decides, as it always did). +bool BacktestEngine::margin_call_1x_long_opening_slice_before_priced_exit( + const Bar& bar) { + (void)bar; + if (position_side_ != PositionSide::LONG) return false; + // POOC fills at the close carry no later same-bar intrabar exit + // chronology; the opening check keeps its end-of-bar placement there. + if (process_orders_on_close_) return false; + // The one-shot event queued by this bar's successful opening/add fill. + if (!opening_affordability_pending_ || !opening_affordability_eligible_) { + return false; + } + const double raw_fill_base = opening_affordability_raw_fill_base_; + if (!std::isfinite(raw_fill_base) || !(raw_fill_base > 0.0)) return false; + + const double pv = syminfo_.pointvalue; + const double qty = position_qty_; + const double m = margin_long_ / 100.0; + if (!(m > 0.0)) return false; + if (!std::isfinite(qty) || !(qty > 0.0) + || !std::isfinite(position_entry_price_) + || !std::isfinite(pv) || !std::isfinite(initial_capital_) + || !std::isfinite(net_profit_sum_)) { + return false; + } + const double fx = active_account_currency_fx(); + if (!std::isfinite(fx) || !(fx > 0.0)) return false; + const double margin_per_unit = position_entry_price_ * pv * fx * m; + double entry_commission = 0.0; + for (const auto& pe : pyramid_entries_) { + // A requested add can floor to zero yet leave a bookkeeping row — + // not an accepted fill, so no CASH_PER_ORDER fixed fee (same rule + // as the end-of-bar opening branch). + if (pe.qty <= kQtyEpsilon) continue; + const double lot_commission = open_entry_commission(pe); + if (!std::isfinite(lot_commission)) return false; + entry_commission += lot_commission; + } + const double opening_equity = + initial_capital_ + net_profit_sum_ - entry_commission; + if (!std::isfinite(margin_per_unit) || !(margin_per_unit > 0.0) + || !std::isfinite(entry_commission) + || !std::isfinite(opening_equity)) { + return false; + } + const double required_margin = qty * margin_per_unit; + // Cent-rounded converted-ledger affordability tolerance — identical to + // the end-of-bar opening branch (identically zero for same-currency + // strategies). + const double converted_ledger_guard = + account_currency_fx_timestamps_.empty() + ? 0.0 + : std::max(0.005, std::abs(opening_equity) * 1e-12); + if (opening_equity >= required_margin - converted_ledger_guard) { + return false; + } + double q_min = qty - opening_equity / margin_per_unit; + if (!std::isfinite(q_min) || q_min <= kQtyEpsilon) return false; + + // Slice quantity: floor-before-4x plus the opening-event sub-lot + // one-contract fallback — process_margin_call's opening path verbatim + // (see the fitted evidence recorded there). + const double raw_q_min = q_min; + if (qty_step_ > 0.0) { + double step_count = q_min / qty_step_; + if (margin_zero_cover_full_liquidation_) { + const double nearest_step = std::round(step_count); + if (std::abs(step_count - nearest_step) < 1e-6) { + step_count = nearest_step; + } + } + q_min = std::floor(step_count) * qty_step_; + } + double opening_floor_zero_fallback = + std::numeric_limits::quiet_NaN(); + if (q_min <= kQtyEpsilon) { + if (qty_step_ > 0.0 + && qty_step_ <= 1.0 + && raw_q_min > kQtyEpsilon + && raw_q_min < 1.0) { + const double candidate = std::min(1.0, qty); + const bool full_position_cap = candidate >= qty - kQtyEpsilon; + const double gridded = apply_exit_qty_step(candidate); + const double grid_guard = std::max( + 1e-12, std::abs(candidate) * 1e-12); + if (full_position_cap + || std::abs(gridded - candidate) <= grid_guard) { + opening_floor_zero_fallback = candidate; + } + } + if (!std::isfinite(opening_floor_zero_fallback)) return false; + } + double qty_liq = std::isfinite(opening_floor_zero_fallback) + ? opening_floor_zero_fallback + : 4.0 * q_min; + if (qty_step_ > 0.0) { + const double floored = + std::floor(qty_liq / qty_step_ + 1e-6) * qty_step_; + if (floored <= kQtyEpsilon) return false; + qty_liq = floored; + } + if (qty_liq >= qty - kQtyEpsilon) qty_liq = qty; + if (!std::isfinite(qty_liq) || qty_liq <= kQtyEpsilon) return false; + + const size_t trades_before = trades_.size(); + if (qty_liq >= qty - kQtyEpsilon) { + execute_market_exit(raw_fill_base); + } else { + execute_partial_exit_qty( + raw_fill_base, qty_liq, PositionReductionCause::MARGIN_CALL); + } + if (trades_.size() == trades_before) return false; + + ++broker_fill_event_seq_; + for (size_t ti = trades_before; ti < trades_.size(); ++ti) { + trades_[ti].exit_comment = "Margin call"; + trades_[ti].exit_id = "__margin_call__"; + } + last_margin_call_event_bar_ = bar_index_; + intrabar_exit_margin_call_bar_ = bar_index_; + // The one-shot event is consumed by this chronological slice; the + // end-of-bar process_margin_call must not replay it. + opening_affordability_pending_ = false; + opening_affordability_eligible_ = false; + commissioned_all_in_market_long_opening_affordability_ = false; + opening_affordability_default_long_reversal_ = false; + close_then_short_opening_requires_adverse_retry_ = false; + opening_affordability_raw_fill_base_ = + std::numeric_limits::quiet_NaN(); + return true; +} + // ──────────────────────────────────────────────────────────────────── // process_pending_orders helpers // ──────────────────────────────────────────────────────────────────── diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8f254ad..7df8268 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -108,6 +108,7 @@ set(TEST_SOURCES test_drawing test_margin_call test_margin_call_intrabar_chronology + test_margin_call_1x_long_entry_fill test_percent_equity_open_entry_fee test_streaming test_calc_on_order_fills diff --git a/tests/test_margin_call_1x_long_entry_fill.cpp b/tests/test_margin_call_1x_long_entry_fill.cpp new file mode 100644 index 0000000..9f219df --- /dev/null +++ b/tests/test_margin_call_1x_long_entry_fill.cpp @@ -0,0 +1,364 @@ +/* + * test_margin_call_1x_long_entry_fill.cpp — finding-325: 1x-long entry-fill + * affordability chronology. + * + * TV evaluates the 1x-long (margin_long=100) opening-affordability check AT + * THE ENTRY FILL, chronologically before the same bar's intrabar exits. The + * #149 hook (margin_call_slice_before_priced_exit) deliberately excluded 1x + * longs — compute_liquidation_price() is na there — so a same-bar full exit + * hid the deficit (the end-of-bar event found the position already gone) and + * the engine filled the exit on the FULL position. The rhyme17 exemplar + * (2026-01-09 14:30, long reversal 3.5168 @3094.06, sub-lot deficit 0.0302 + * USD): TV books a 1.0-contract pnl-0 "Margin call" row at the raw entry + * fill price FIRST, then the stop closes only the 2.5168 remainder. + * + * The deficit class this reproduces: an omitted-qty percent-of-equity=100 + * reversal is frozen against the SIGNAL close C (lot-floored, leaving a + * sub-lot budget remainder r), then fills at a one-mintick UPTICK O=C+tick. + * KI-54 admits while the uptick notional on the frozen lot stays inside r, + * but the opening cost q*O against POST-CLOSE equity overshoots by + * tick*(q+s) - r — a positive, sub-lot deficit whose restore quantity floors + * to zero and takes the one-contract fallback. A commissioned explicit-qty + * open reaches the same discontinuity through its entry fee instead. + * + * Reversal scaffold (percent=100, commission 0, qty_step 0.0001): SHORT + * 3.3333 @3000; signal close 2997.49 -> eq 10008.3666, frozen long qty + * 3.3389 (remainder r=0.0472); fill @2997.50 -> admit (0.0334 <= r), realized + * eq 10008.3333, cost 10008.3527 -> deficit 0.0195 -> one-contract fallback. + * Commissioned scaffold (explicit qty 99.95 @100, 0.1% fee): opening budget + * 9990.005 < 9995 -> raw restore 0.04995 floors to 0.0499 -> 4x = 0.1996. + * + * A. Reversal + same-bar stop: slice 1.0 @2997.50 (pnl 0, "Margin call") + * BEFORE the stop, which closes the 2.3389 remainder. (The exemplar + * shape; RED pre-fix: the stop fills the full 3.3389.) + * B. Commissioned long + same-bar stop: ordinary floor-before-4x nibble + * 0.1996 @100 first, stop closes 99.7504. (RED pre-fix.) + * C. Zero-tick reversal fill (O == C): no deficit -> no Margin-call row. + * D. Reversal, stop never touched -> the event keeps its established + * END-OF-BAR placement (identical rows pre/post fix), survivor held. + * E. Commissioned SHORT mirror is untouched (LONG-only extension): the + * stop still closes the full position, no Margin-call row. + * F. POOC: the opening check keeps its end-of-bar placement. + * G. Emulator off -> nothing fires (full stop close). + * H. Handle reuse: a rerun reproduces the same rows. + */ + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace pineforge; + +static int tests_passed = 0; +static int tests_failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +static bool near(double a, double b, double tol = 1e-9) { + return std::fabs(a - b) < tol; +} + +namespace { + +static constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +static Bar mk_bar(int64_t ts, double o, double h, double l, double c) { + Bar b; + b.open = o; b.high = h; b.low = l; b.close = c; + b.volume = 1.0; b.timestamp = ts; + return b; +} + +class MCEngine : public BacktestEngine { +public: + std::string exit_comment(int i) const { return closed_trade_exit_comment(i); } + std::string exit_id(int i) const { return closed_trade_exit_id(i); } + double exit_price(int i) const { return closed_trade_exit_price(i); } + double entry_price(int i) const { return closed_trade_entry_price(i); } + double trade_size(int i) const { return closed_trade_size(i); } + double trade_pnl(int i) const { return closed_trade_profit(i); } + int exit_bar(int i) const { return closed_trade_exit_bar_index(i); } + double position_size() const { return signed_position_size(); } +}; + +static int margin_call_rows(const MCEngine& eng) { + int count = 0; + for (int i = 0; i < eng.trade_count(); ++i) { + if (eng.exit_comment(i) == std::string("Margin call")) ++count; + } + return count; +} + +// The exemplar shape: default-sized (percent=100) SHORT opened @3000, then a +// default-sized LONG reversal signalled on bar2 (close C) together with its +// stop bracket, filling at bar3's open. Commission 0, qty_step 0.0001. +class ReversalProbe : public MCEngine { +public: + explicit ReversalProbe(double stop_level, bool disable_mc = false) + : stop_level_(stop_level) { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.0; + margin_long_ = 100.0; + margin_short_ = 100.0; + process_orders_on_close_ = false; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + if (disable_mc) set_margin_call_enabled(false); + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) strategy_entry("S", false); + if (bar_index_ == 2) { + strategy_entry("L", true); + strategy_exit("X", "L", kNaN, stop_level_, kNaN, kNaN, kNaN, + 100.0, ""); + } + } + +private: + double stop_level_; +}; + +// bar3 opens one mintick ABOVE the bar2 signal close 2997.49 -> the frozen +// 3.3389 long admits inside the lot-floor remainder but overshoots the +// post-close equity by 0.0195 (sub-lot -> one-contract fallback). +static std::vector reversal_bars(double o3, double l3, double c3) { + return { + mk_bar(1000, 3000, 3000, 3000, 3000), // 0: short signal + mk_bar(2000, 3000, 3000, 2995, 3000), // 1: S fills @3000 + mk_bar(3000, 3000, 3000, 2996, 2997.49), // 2: reversal signal + mk_bar(4000, o3, o3, l3, c3), // 3: fill + same-bar stop + mk_bar(5000, c3, c3, c3, c3), // 4 + }; +} + +// Commissioned explicit-qty scaffold: MARKET qty 99.95 @100 with a 0.1% fee +// (opening budget 9990.005 < notional 9995 -> restore 0.04995 -> 4x 0.1996). +class CommissionedProbe : public MCEngine { +public: + CommissionedProbe(bool is_long, double stop_level, bool pooc = false) + : is_long_(is_long), stop_level_(stop_level) { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::FIXED; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.1; + margin_long_ = 100.0; + margin_short_ = 100.0; + process_orders_on_close_ = pooc; + qty_step_ = 0.0001; + syminfo_mintick_ = 0.01; + } + + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + strategy_entry("L", is_long_, kNaN, kNaN, /*qty=*/99.95); + strategy_exit("X", "L", kNaN, stop_level_, kNaN, kNaN, kNaN, + 100.0, ""); + } + } + +private: + bool is_long_; + double stop_level_; +}; + +} // namespace + +// ---- A: the exemplar — sub-lot deficit, one-contract fallback FIRST -------- + +static void test_one_contract_slice_before_same_bar_stop() { + std::printf("test_one_contract_slice_before_same_bar_stop\n"); + ReversalProbe eng(/*stop=*/2967.51); + auto bars = reversal_bars(2997.50, 2960.0, 2965.0); + eng.run(bars.data(), (int)bars.size()); + + // Row order is the TV chronology: the short's reversal close, the pnl-0 + // one-contract "Margin call" trim at the RAW entry fill base, then the + // stop closing the reduced remainder. + CHECK(eng.trade_count() == 3); + CHECK(margin_call_rows(eng) == 1); + CHECK(near(eng.trade_size(0), 3.3333)); + CHECK(near(eng.exit_price(0), 2997.50)); + CHECK(eng.exit_comment(1) == std::string("Margin call")); + CHECK(near(eng.trade_size(1), 1.0)); + CHECK(near(eng.entry_price(1), 2997.50)); + CHECK(near(eng.exit_price(1), 2997.50)); // RAW entry fill base + CHECK(near(eng.trade_pnl(1), 0.0)); // the TV pnl-0 row + CHECK(eng.exit_bar(1) == 3); + CHECK(eng.exit_comment(2) != std::string("Margin call")); + CHECK(eng.exit_id(2) == std::string("X")); + CHECK(near(eng.trade_size(2), 2.3389)); // the reduced remainder + CHECK(near(eng.exit_price(2), 2967.51)); + CHECK(eng.exit_bar(2) == 3); + CHECK(near(eng.position_size(), 0.0)); +} + +// ---- B: a lot-expressible deficit uses the ordinary floor-before-4x -------- + +static void test_four_x_nibble_slice_before_same_bar_stop() { + std::printf("test_four_x_nibble_slice_before_same_bar_stop\n"); + CommissionedProbe eng(/*is_long=*/true, /*stop=*/95.0); + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // 0: signal + mk_bar(2000, 100, 100, 94, 94), // 1: fill + same-bar stop + mk_bar(3000, 94, 94, 94, 94), // 2 + }; + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 2); + CHECK(margin_call_rows(eng) == 1); + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(near(eng.trade_size(0), 0.1996)); + CHECK(near(eng.exit_price(0), 100.0)); + CHECK(eng.exit_bar(0) == 1); + CHECK(eng.exit_id(1) == std::string("X")); + CHECK(near(eng.trade_size(1), 99.7504)); + CHECK(near(eng.exit_price(1), 95.0)); + CHECK(near(eng.position_size(), 0.0)); +} + +// ---- C: a zero-tick reversal fill has no deficit -> quiet ------------------ + +static void test_zero_tick_fill_stays_quiet() { + std::printf("test_zero_tick_fill_stays_quiet\n"); + ReversalProbe eng(/*stop=*/2967.51); + auto bars = reversal_bars(2997.49, 2960.0, 2965.0); // O == C + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 2); + CHECK(margin_call_rows(eng) == 0); + CHECK(near(eng.trade_size(1), 3.3389)); // full position + CHECK(near(eng.exit_price(1), 2967.51)); + CHECK(near(eng.position_size(), 0.0)); +} + +// ---- D: no same-bar priced exit -> end-of-bar placement preserved ---------- + +static void test_no_same_bar_exit_keeps_end_of_bar_event() { + std::printf("test_no_same_bar_exit_keeps_end_of_bar_event\n"); + // Stop far below the bar: the one-shot event books its trim at the + // established end-of-bar point exactly as before the fix. + ReversalProbe eng(/*stop=*/2000.0); + auto bars = reversal_bars(2997.50, 2990.0, 2995.0); + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 2); + CHECK(margin_call_rows(eng) == 1); + CHECK(eng.exit_comment(1) == std::string("Margin call")); + CHECK(near(eng.trade_size(1), 1.0)); + CHECK(near(eng.exit_price(1), 2997.50)); + CHECK(eng.exit_bar(1) == 3); + CHECK(near(eng.position_size(), 2.3389)); // survivor held +} + +// ---- E: the commissioned SHORT mirror is untouched (LONG-only) ------------- + +static void test_short_one_x_mirror_untouched() { + std::printf("test_short_one_x_mirror_untouched\n"); + // Same fee-created opening deficit on the short side; the stop above the + // open still fills the FULL position first (the established behavior on + // the short side) and no Margin-call row appears. + CommissionedProbe eng(/*is_long=*/false, /*stop=*/105.0); + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // 0: signal + mk_bar(2000, 100, 106, 100, 105), // 1: fill + same-bar stop + mk_bar(3000, 105, 105, 105, 105), // 2 + }; + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 1); + CHECK(margin_call_rows(eng) == 0); + CHECK(near(eng.trade_size(0), 99.95)); + CHECK(near(eng.exit_price(0), 105.0)); + CHECK(near(eng.position_size(), 0.0)); +} + +// ---- F: POOC keeps the end-of-bar placement -------------------------------- + +static void test_pooc_keeps_end_of_bar_event() { + std::printf("test_pooc_keeps_end_of_bar_event\n"); + // Under process_orders_on_close the entry fills at the bar-0 close; the + // opening check still runs end-of-bar (unchanged) and trims 0.1996 @100. + // The far stop never fills, pinning only the event placement. + CommissionedProbe eng(/*is_long=*/true, /*stop=*/80.0, /*pooc=*/true); + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // 0: signal + close fill + mk_bar(2000, 100, 100, 100, 100), // 1 + }; + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 1); + CHECK(margin_call_rows(eng) == 1); + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(near(eng.trade_size(0), 0.1996)); + CHECK(near(eng.exit_price(0), 100.0)); + CHECK(eng.exit_bar(0) == 0); // end-of-bar on the fill bar + CHECK(near(eng.position_size(), 99.7504)); +} + +// ---- G: emulator off -> nothing fires -------------------------------------- + +static void test_disabled_emulator_stays_quiet() { + std::printf("test_disabled_emulator_stays_quiet\n"); + ReversalProbe eng(/*stop=*/2967.51, /*disable_mc=*/true); + auto bars = reversal_bars(2997.50, 2960.0, 2965.0); + eng.run(bars.data(), (int)bars.size()); + + CHECK(eng.trade_count() == 2); + CHECK(margin_call_rows(eng) == 0); + CHECK(near(eng.trade_size(1), 3.3389)); // full stop close + CHECK(near(eng.exit_price(1), 2967.51)); +} + +// ---- H: handle reuse reproduces the same rows ------------------------------ + +static void test_rerun_reproduces_slice() { + std::printf("test_rerun_reproduces_slice\n"); + ReversalProbe eng(/*stop=*/2967.51); + auto bars = reversal_bars(2997.50, 2960.0, 2965.0); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() == 3); + CHECK(margin_call_rows(eng) == 1); + + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() == 3); + CHECK(margin_call_rows(eng) == 1); + CHECK(near(eng.trade_size(1), 1.0)); + CHECK(near(eng.exit_price(1), 2997.50)); + CHECK(near(eng.trade_size(2), 2.3389)); + CHECK(near(eng.exit_price(2), 2967.51)); +} + +int main() { + std::printf("=== test_margin_call_1x_long_entry_fill ===\n"); + + test_one_contract_slice_before_same_bar_stop(); + test_four_x_nibble_slice_before_same_bar_stop(); + test_zero_tick_fill_stays_quiet(); + test_no_same_bar_exit_keeps_end_of_bar_event(); + test_short_one_x_mirror_untouched(); + test_pooc_keeps_end_of_bar_event(); + test_disabled_emulator_stays_quiet(); + test_rerun_reproduces_slice(); + + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return (tests_failed > 0) ? 1 : 0; +}