From dff13a71eea2ac2bc26cc202ce28e484fc6e6241 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sat, 25 Jul 2026 21:50:01 +0800 Subject: [PATCH] fix(engine): widen pending-aware gross admission to live-position pairs TradingView costs the LATER of two opposite same-source-bar strategy.entry calls as a GROSS movement -- its own requested position plus the movement the earlier pending opposite call will make -- and silently declines it when that exceeds the margin budget, even though the desired residual position would be affordable. The shipped rule (finalize_default_flat_market_gross_admission, PR #113) only ran when the pair was queued from TRUE FLAT with an exact two-object book. Every real specimen of this idiom queues the pair while a LIVE position is held, and queues strategy.close legs alongside the entries, so the rule never fired on the board. Widened past exactly two of the controls the KI-65 pending-MARKET oracle carved out, and nothing else: * the pair may be queued while a live position is held (default_flat_market_gross_scope_is_live no longer requires FLAT; the eligible() predicate no longer requires created_position_side == FLAT or !created_after_position_close_in_bar; both calls must still agree on created_position_side AND created_position_cycle_seq) * the book may additionally contain that source bar's own unpriced __close__* EXIT legs (they transact no new margin and the qty/equity/mark triple both candidates carry was frozen before they existed). Any other member -- priced, raw, bracket, OCA, or ANY order carried in from an earlier bar -- abandons the adjudication. The created_seq/incarnation consecutiveness check is relaxed by exactly the number of intervening close legs, so a three-call book still cannot masquerade as a pair. One term the naive reading of the rule does not have, and needs: an earlier call that was already over the pyramiding cap at placement moves nothing, so it contributes ZERO to the later call's budget. Without it the rule would convert 12 currently-correct real-row admissions into declines. live side at placement | earlier "Long" | later "Short" | TradingView -----------------------|-------------------|----------------|------------ FLAT | opens (counts) | 100+100 > 100 | declined SHORT | reverses (counts) | 100+100 > 100 | declined LONG | over cap (zero) | 100 + 0 <= 100 | ADMITTED Relationship to the fill-time margin admission gate (48363a1, unchanged): that gate is a per-order NET test -- budget = sizing_equity minus the margin a SAME-DIRECTION open position ties up, cost = the order's own frozen notional at the booked fill price. On a reversal it charges nothing for the position being closed, so it has no term for a sibling queued on the same bar and cannot reach this class. The two are complementary and cannot double-count: this rule runs at the broker boundary and ERASES the rejected order, so the fill-time gate never sees it; anything this rule admits reaches it with an unmodified quantity. Evidence. TradingView oracle: data/probes/pf-probe-ki65-pending-market- affordability (3,128 native trades, 6 discriminating cells) stays at 100% / cAbs 0. Real-row confirmation at zero export cost, over every board row whose engine tape prints both sides on one bar and both tapes agree on the side held entering it: 84/84 flat, 159/159 live-short declined, 12/12 live-long admitted -- 255/255, zero counterexamples, across fluxchart-supply-and-demand-zones, market-logic-india-low-lag-strength-oscillator, chartprime-power-order-blocks and cntvxiao-smc-vsa-oi. Negative control for the ARITHMETIC rather than a shape match: twelve further rows do print both sides and TradingView prints both too -- every one of them sizes at percent_of_equity <= 10 or default FIXED, where own + earlier <= equity. Those rows stay out of scope via the unchanged default_qty_value == 100 guard; gb2wgkrtxs-liquidity-sweep-strategy-m2-swing (992 such bars, excellent, cAbs 0) is the sensitive sentinel for it. Still excluded, deliberately: priced entries, explicit qty (own signal-time and fill-time gates; the POOC+COOF explicit pair has its own oracle), raw strategy.order, same-direction pairs, cross-bar pairs, OCA siblings, pyramiding != 0, POOC/COOF, magnifier, non-zero commission or slippage, percent_of_equity != 100, margin != 100, any active risk policy. The mirrored call ordering (the Short call first) is a principled consequence of the same inequality but is NOT measured -- no board row writes it that way. Gates: ctest 122/122. New suite test_live_position_market_gross_admission (8 cases, 29 asserts) is a genuine RED against bddddac -- 17 passed / 12 failed, including both live-short position assertions. One corrected pin: test_margin_admission_gate M.5 asserted that the all-in live-short opposite pair fills BOTH legs, which was the pre-widening engine behavior rather than an independent TradingView pin; its stated purpose (proving the GB2 fixed/explicit gate does not reach percent_of_equity) is unchanged and still proven. All nine admission-family probes byte-identical against a clean bddddac worktree. Co-Authored-By: Claude Opus 5 --- src/engine_fills.cpp | 156 ++++++++- src/engine_strategy_commands.cpp | 25 +- tests/CMakeLists.txt | 1 + ...t_live_position_market_gross_admission.cpp | 302 ++++++++++++++++++ tests/test_margin_admission_gate.cpp | 22 +- 5 files changed, 480 insertions(+), 26 deletions(-) create mode 100644 tests/test_live_position_market_gross_admission.cpp diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index a8e223c..035b4c0 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -1133,7 +1133,15 @@ bool BacktestEngine::default_flat_market_gross_scope_is_live() && !calc_on_order_fills_ && !bar_magnifier_enabled_ && !coof_fill_recalc_active_ - && position_side_ == PositionSide::FLAT + // The account may be FLAT or already holding a position. The + // pending-aware gross-movement rule is a property of the two queued + // calls, not of the broker state they were queued from: chartprime / + // fluxchart / market-logic-india all queue the pair while a live + // position is held, and TradingView declines the later call there on + // exactly the same arithmetic as from flat (255/255 controlled real-row + // events, 0 counterexamples; see the widened rule below). Which of the + // two calls actually MOVES the broker is decided at the boundary from + // over_pyramiding_cap_at_placement, not by excluding the whole class. // Pine's default pyramiding=0 is represented by one admitted entry. // Keep this scope away from KI-65's independently pinned pyramiding=2 // transaction model. @@ -1153,13 +1161,34 @@ bool BacktestEngine::default_flat_market_gross_scope_is_live() && !risk_halted_; } -// Two omitted-qty MARKET strategy.entry calls from true flat each freeze one -// account-equity lot at the same signal close. The later opposite call is a -// gross reversal transaction (first frozen qty + later frozen qty); at 1x and -// PoE=100 that exceeds placement equity and is declined. Wait until the next -// ordinary broker boundary so the complete source-bar book is known. Only an -// exact two-object book of fresh, consecutive, distinct-id opposite entries -// reaches this arithmetic; the first order follows existing fill rules. +// Two omitted-qty MARKET strategy.entry calls placed on one source bar each +// freeze one account-equity lot at the same signal close. The later opposite +// call is costed as a GROSS movement (the earlier call's frozen qty plus its +// own); at 1x and PoE=100 that exceeds placement equity and is declined. Wait +// until the next ordinary broker boundary so the complete source-bar book is +// known. Only a book of fresh, consecutive, distinct-id opposite entries plus +// their own same-bar unpriced close legs reaches this arithmetic; the first +// order follows existing fill rules. +// +// WIDENED (2026-07-25) past two of the controls the KI-65 pending-MARKET oracle +// carved out: the pair may be queued while a LIVE position is held, and the +// same-bar deferred market close legs the specimen idiom queues alongside the +// entries no longer disqualify the book. Still excluded, unchanged: priced +// entries, explicit qty (that path has its own signal-time + fill-time gates), +// raw strategy.order, same-direction pairs, cross-bar pairs, OCA siblings, +// pyramiding != 0, POOC/COOF, magnifier, non-zero commission or slippage, +// percent_of_equity != 100, margin != 100, and any active risk policy. +// +// Relationship to the fill-time margin admission gate (48363a1, still live in +// apply_filled_order_to_state): that gate is a per-order NET test -- budget +// = sizing_equity minus the margin a SAME-DIRECTION open position ties up, cost +// = the order's OWN frozen notional at the price the fill books. On a reversal +// it charges nothing for the position being closed, so it has no term for a +// sibling order queued on the same bar and cannot see this class at all. The +// two gates are complementary and cannot double-count: this one runs at the +// broker boundary and ERASES the rejected order, so the fill-time gate never +// sees it; anything this one admits reaches the fill-time gate with its own +// unmodified quantity. void BacktestEngine::finalize_default_flat_market_gross_admission() { std::vector group; group.reserve(2); @@ -1201,7 +1230,6 @@ void BacktestEngine::finalize_default_flat_market_gross_admission() { }; if (!default_flat_market_gross_scope_is_live() - || pending_orders_.size() != 2 || group.size() != 2 || candidate_source_bars.size() != 1) { consume_source_tombstones(); @@ -1213,12 +1241,66 @@ void BacktestEngine::finalize_default_flat_market_gross_admission() { if (second->incarnation < first->incarnation) std::swap(first, second); const int source_bar = first->created_bar; + // Book shape. The pinned oracle book is the two candidate calls and nothing + // else; the live-position widening additionally admits the unpriced + // deferred MARKET close legs the same source bar queued alongside them, + // because that is how every real specimen is written: + // + // if bull if bull + // entry("Long", long) entry("Long", long) + // close("Short") if bear + // if bear entry("Short", short) + // entry("Short", short) if bear or breakdown + // close("Long") close("Long") + // + // A close leg cannot change the admission arithmetic: it transacts no new + // margin, and the qty/equity/mark triple both candidates carry was frozen + // before it existed. Anything else in the book -- a priced order, a raw + // order, a bracket, an OCA sibling, or ANY order carried in from an earlier + // bar -- leaves the pinned shape and the whole adjudication is abandoned. + std::unordered_set group_indices(group.begin(), group.end()); + int intervening_close_legs = 0; + for (size_t i = 0; i < pending_orders_.size(); ++i) { + if (group_indices.count(i) != 0) continue; + const PendingOrder& other = pending_orders_[i]; + const bool same_bar_market_close = + other.type == OrderType::EXIT + && other.created_bar == source_bar + && other.id.rfind("__close__", 0) == 0 + && other.oca_name.empty() + && std::isnan(other.limit_price) + && std::isnan(other.stop_price) + && std::isnan(other.trail_points) + && std::isnan(other.trail_price) + && std::isnan(other.trail_offset) + && !other.created_during_coof_recalc + && !other.coof_born_at_close_recalc + && !other.coof_born_mid_bar; + if (!same_bar_market_close) { + consume_source_tombstones(); + return; + } + if (other.incarnation > first->incarnation + && other.incarnation < second->incarnation) { + ++intervening_close_legs; + } + } + auto eligible = [&](const PendingOrder& order) { return order.type == OrderType::MARKET && std::isnan(order.qty) && std::isfinite(order.frozen_default_qty) && order.frozen_default_qty > kQtyEpsilon - && order.opening_affordability_exemption_candidate + // The position-state-independent half of + // opening_affordability_exemption_candidate. percent_of_equity at + // exactly 100 and both margins at exactly 100 are already asserted + // by default_flat_market_gross_scope_is_live(); what remains is a + // complete finite freeze. Deliberately NOT the exemption flag + // itself: that flag also requires true-flat creation, which is + // exactly the control this rule now widens past. + && std::isfinite(order.sizing_price) + && std::isfinite(order.sizing_fx) + && order.sizing_fx > 0.0 && !order.explicit_flat_admission_candidate && !order.paired_flat_market_candidate && order.paired_flat_market_peer_seq == 0 @@ -1227,8 +1309,6 @@ void BacktestEngine::finalize_default_flat_market_gross_admission() { && order.incarnation > 0 && order.created_seq > 0 && !order.created_by_same_id_replacement - && order.created_position_side == PositionSide::FLAT - && !order.created_after_position_close_in_bar && !order.created_during_coof_recalc && !order.coof_born_at_close_recalc && !order.coof_born_mid_bar @@ -1246,8 +1326,21 @@ void BacktestEngine::finalize_default_flat_market_gross_admission() { || !eligible(*second) || first->id == second->id || first->is_long == second->is_long - || second->incarnation != first->incarnation + 1 - || second->created_seq != first->created_seq + 1) { + // Both calls must have been queued from the SAME broker state. Nothing + // on the ordinary non-POOC path can fill between two calls of one + // on_bar, so a disagreement here is provenance the rule has no oracle + // for. + || first->created_position_side != second->created_position_side + || first->created_position_cycle_seq + != second->created_position_cycle_seq + // No order object other than the intervening close legs counted above + // may have been created between the two calls. Together with the + // mutation tombstones this still excludes three-call books reduced + // back to two by replacement/cancel-rearm. + || second->incarnation + != first->incarnation + 1 + intervening_close_legs + || second->created_seq + != first->created_seq + 1 + intervening_close_legs) { return; } @@ -1262,9 +1355,9 @@ void BacktestEngine::finalize_default_flat_market_gross_admission() { return; } - const double first_qty = std::abs(first->frozen_default_qty); + const double first_own_qty = std::abs(first->frozen_default_qty); const double second_qty = std::abs(second->frozen_default_qty); - if (calc_commission(signal_close, first_qty) != 0.0 + if (calc_commission(signal_close, first_own_qty) != 0.0 || calc_commission(signal_close, second_qty) != 0.0) { return; } @@ -1272,8 +1365,37 @@ void BacktestEngine::finalize_default_flat_market_gross_admission() { * active_account_currency_fx(); if (!std::isfinite(notional_k) || !(notional_k > 0.0)) return; + // The later call is costed as its OWN requested position plus the movement + // the earlier pending opposite call will make. An earlier call that was + // already over the pyramiding cap when it was placed moves nothing -- TV + // never queues broker movement for it -- so it contributes ZERO to the + // later call's budget. That term is not cosmetic: it is the whole + // difference between the two live-position cases, and both are measured. + // + // live side at placement | earlier "Long" | later "Short" | TV + // -----------------------|------------------|----------------|---------- + // FLAT | opens (counts) | 100+100 > 100 | declined + // SHORT | reverses (counts)| 100+100 > 100 | declined + // LONG | over cap (zero) | 100 + 0 <= 100 | ADMITTED + // + // Measured on the four real all-in rows whose two opposite default-sized + // entries can fire on one bar (fluxchart-supply-and-demand-zones, + // market-logic-india-low-lag-strength-oscillator, chartprime-power-order- + // blocks, cntvxiao-smc-vsa-oi), over every bar where the engine printed + // both sides and both tapes agreed on the side held entering the bar: + // 84/84 flat, 159/159 short, 12/12 long -- 255/255, zero counterexamples. + // Dropping the over-cap term would turn those 12 admissions into declines. + // + // Negative control for the ARITHMETIC (not just "reject the later call"): + // twelve further board rows do print both sides on one bar and TradingView + // prints both too -- every one of them sizes at percent_of_equity <= 10 or + // default FIXED, where own + earlier <= equity. They are out of scope + // anyway (default_qty_value == 100 is required above), but they are the + // reason the gate is an inequality rather than a shape match. + const double first_movement_qty = + first->over_pyramiding_cap_at_placement ? 0.0 : first_own_qty; const double gross_required = - (first_qty + second_qty) * signal_close * notional_k; + (first_movement_qty + second_qty) * signal_close * notional_k; if (!(gross_required > equity + equity_guard)) return; const uint64_t rejected_incarnation = second->incarnation; diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index c15ecce..2aec225 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -439,10 +439,15 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, // margin_short==100 qualifies exactly as a long at margin_long==100. const double affordability_margin = is_long ? margin_long_ : margin_short_; - order.opening_affordability_exemption_candidate = - order.created_position_side == PositionSide::FLAT - && !order.created_after_position_close_in_bar - && default_qty_type_ == QtyType::PERCENT_OF_EQUITY + // The position-state-INDEPENDENT half of the all-in snapshot: + // percent_of_equity at exactly 100, direction-appropriate margin at + // exactly 100, and a complete finite freeze. Extracted so the + // pending-aware gross-admission candidate can share it without + // inheriting the true-flat creation requirement the fill-time + // exemption flag needs (see the widened scope in + // finalize_default_flat_market_gross_admission). + const bool default_all_in_sizing_snapshot = + default_qty_type_ == QtyType::PERCENT_OF_EQUITY && std::abs(default_qty_value_ - 100.0) < 1e-12 && std::isfinite(affordability_margin) && std::abs(affordability_margin / 100.0 - 1.0) < 1e-12 @@ -452,9 +457,19 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, && std::isfinite(order.sizing_mark) && std::isfinite(order.sizing_fx) && order.sizing_fx > 0.0; + order.opening_affordability_exemption_candidate = + order.created_position_side == PositionSide::FLAT + && !order.created_after_position_close_in_bar + && default_all_in_sizing_snapshot; + // KI-65 pending-aware gross admission. The placement equity / + // signal-mark / frozen-qty triple is all the arithmetic needs, and + // it is identical whether the account was flat or already holding a + // position when the pair was queued. The creation position side is + // preserved on the order and consulted at the broker boundary, so + // the live-position case is decided there rather than excluded here. order.default_flat_market_gross_candidate = default_flat_market_gross_call - && order.opening_affordability_exemption_candidate + && default_all_in_sizing_snapshot && std::isfinite(order.frozen_default_qty) && order.frozen_default_qty > kQtyEpsilon && std::isfinite(order.sizing_equity) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 28fb955..e7c5787 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -93,6 +93,7 @@ set(TEST_SOURCES test_declined_reversal_close_leg test_dual_entry_placement_sizing test_default_flat_market_gross_admission + test_live_position_market_gross_admission test_lower_tf_parse_extra test_ta_ma_warmup_extra test_ta_osc_edge diff --git a/tests/test_live_position_market_gross_admission.cpp b/tests/test_live_position_market_gross_admission.cpp new file mode 100644 index 0000000..bd8eb3f --- /dev/null +++ b/tests/test_live_position_market_gross_admission.cpp @@ -0,0 +1,302 @@ +/* + * Pending-aware gross admission for a default-sized MARKET/MARKET pair queued + * while a LIVE position is held. + * + * The KI-65 pending-MARKET oracle pins TradingView's rule as: the later of two + * opposite same-source-bar entries is costed as its OWN requested position plus + * the movement the earlier pending opposite call will make. At + * percent_of_equity=100 / margin=100 that gross movement is ~200% of equity and + * the later call is silently declined. + * + * The shipped rule only ran when the pair was queued from true flat. These tests + * pin the two live-position cases, which differ ONLY in whether the earlier call + * was already over the pyramiding cap when it was placed: + * + * live SHORT: earlier "Long" reverses -> counts -> later "Short" DECLINED + * live LONG: earlier "Long" over cap -> zero -> later "Short" ADMITTED + * + * plus the specimen idiom's co-queued unpriced close legs, and the book-shape + * controls that must still abandon the adjudication. + */ + +#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 constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +static bool near(double a, double b, double tolerance = 1e-9) { + return std::abs(a - b) <= tolerance; +} + +static Bar flat_bar(double price, int64_t timestamp) { + Bar bar; + bar.open = price; + bar.high = price; + bar.low = price; + bar.close = price; + bar.volume = 1000.0; + bar.timestamp = timestamp; + return bar; +} + +// Which side the account holds when the pair is queued. +enum class Seed { LiveShort, LiveLong }; + +// What the dual-signal bar queues besides the two entries. +enum class Shape { + // if bull: entry Long; close Short / if bear: entry Short; close Long + // The chartprime / market-logic-india idiom. + ClosePairIdiom, + // if bull: entry Long / if bear: entry Short (fluxchart idiom) + BareEntryPair, + // Same-direction pair: never this rule. + SameDirection, + // A priced third order in the book abandons the adjudication. + PricedThird, + // A raw strategy.order in the book abandons the adjudication. + RawThird, + // A bracket armed on an EARLIER bar is still in the book: abandon. + CarriedBracket, +}; + +struct Probe : public BacktestEngine { + Probe(Seed seed, Shape shape) : seed_(seed), shape_(shape) { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + pyramiding_ = 1; + margin_long_ = 100.0; + margin_short_ = 100.0; + commission_value_ = 0.0; + slippage_ = 0; + qty_step_ = 0.0; + set_margin_call_enabled(false); + } + + Seed seed_; + Shape shape_; + + size_t book_after_signal = 0; + int candidates_after_signal = 0; + // over_pyramiding_cap_at_placement of the EARLIER entry call. + bool earlier_over_cap = false; + double signed_position_after_fill = 0.0; + int trades_after_fill = 0; + std::string entry_ids_after_fill; + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + if (shape_ == Shape::CarriedBracket) { + // A resting priced bracket armed a bar before the pair. + strategy_entry(seed_ == Seed::LiveShort ? "Short" : "Long", + seed_ == Seed::LiveShort ? false : true); + return; + } + strategy_entry(seed_ == Seed::LiveShort ? "Short" : "Long", + seed_ == Seed::LiveShort ? false : true); + return; + } + if (bar_index_ == 1 && shape_ == Shape::CarriedBracket) { + // Arm a resting long stop entry far above the market so it survives + // into the pair's bar. Argument order is (id, is_long, limit, stop). + strategy_entry("Rest", true, kNaN, 500.0); + return; + } + const int pair_bar = (shape_ == Shape::CarriedBracket) ? 2 : 1; + if (bar_index_ == pair_bar) { + switch (shape_) { + case Shape::ClosePairIdiom: + strategy_entry("Long", true); + strategy_close("Short"); + strategy_entry("Short", false); + strategy_close("Long"); + break; + case Shape::BareEntryPair: + case Shape::CarriedBracket: + strategy_entry("Long", true); + strategy_entry("Short", false); + break; + case Shape::SameDirection: + strategy_entry("Long-1", true); + strategy_entry("Long-2", true); + break; + case Shape::PricedThird: + strategy_entry("Long", true); + strategy_entry("Short", false); + strategy_entry("Priced", true, kNaN, 500.0); + break; + case Shape::RawThird: + strategy_entry("Long", true); + strategy_entry("Short", false); + strategy_order("Raw", true, 1.0); + break; + } + book_after_signal = pending_orders_.size(); + uint64_t earliest = 0; + for (const PendingOrder& order : pending_orders_) { + if (order.default_flat_market_gross_candidate) { + ++candidates_after_signal; + if (earliest == 0 || order.incarnation < earliest) { + earliest = order.incarnation; + earlier_over_cap = + order.over_pyramiding_cap_at_placement; + } + } + } + return; + } + if (bar_index_ == pair_bar + 1) { + signed_position_after_fill = signed_position_size(); + trades_after_fill = trade_count(); + std::ostringstream ids; + ids << "["; + for (size_t i = 0; i < trades_.size(); ++i) { + if (i != 0) ids << ","; + ids << (trades_[i].is_long ? "L" : "S") << ":" + << trades_[i].entry_id; + } + ids << "]"; + entry_ids_after_fill = ids.str(); + } + } +}; + +static void run_probe(Probe& probe) { + const Bar bars[] = { + flat_bar(100.0, 600'000), flat_bar(100.0, 1'200'000), + flat_bar(100.0, 1'800'000), flat_bar(100.0, 2'400'000), + }; + probe.run(bars, 4); +} + +// --------------------------------------------------------------------------- +// The two live-position cases the widening exists for. +// --------------------------------------------------------------------------- + +static void test_live_short_declines_the_later_call() { + std::printf("-- live SHORT + close-pair idiom: later call DECLINED --\n"); + Probe probe(Seed::LiveShort, Shape::ClosePairIdiom); + run_probe(probe); + // Long entry + __close__Short exit leg + Short entry. + CHECK(probe.book_after_signal == 3); + CHECK(probe.candidates_after_signal == 2); + // The earlier "Long" opposes the live short, so it moves the broker and + // must be charged against the later "Short". + CHECK(probe.earlier_over_cap == false); + // "Long" reverses the short and is the sole fill; the account ends LONG. + CHECK(near(probe.signed_position_after_fill, 10.0)); + CHECK(probe.trades_after_fill == 1); + CHECK(probe.entry_ids_after_fill == "[S:Short]"); +} + +static void test_live_short_bare_pair_declines() { + std::printf("-- live SHORT + bare entry pair: later call DECLINED --\n"); + Probe probe(Seed::LiveShort, Shape::BareEntryPair); + run_probe(probe); + CHECK(probe.book_after_signal == 2); + CHECK(probe.candidates_after_signal == 2); + CHECK(probe.earlier_over_cap == false); + CHECK(near(probe.signed_position_after_fill, 10.0)); + CHECK(probe.trades_after_fill == 1); + CHECK(probe.entry_ids_after_fill == "[S:Short]"); +} + +static void test_live_long_admits_the_later_call() { + std::printf("-- live LONG: earlier call is over cap, later ADMITTED --\n"); + Probe probe(Seed::LiveLong, Shape::ClosePairIdiom); + run_probe(probe); + // Long entry + Short entry + __close__Long exit leg. + CHECK(probe.book_after_signal == 3); + CHECK(probe.candidates_after_signal == 2); + // The earlier "Long" duplicates the live long at pyramiding=0: it moves + // nothing, so it contributes zero and the later "Short" fits on its own. + CHECK(probe.earlier_over_cap == true); + CHECK(near(probe.signed_position_after_fill, -10.0)); + CHECK(probe.trades_after_fill == 1); + CHECK(probe.entry_ids_after_fill == "[L:Long]"); +} + +static void test_live_long_bare_pair_admits() { + std::printf("-- live LONG + bare entry pair: later ADMITTED --\n"); + Probe probe(Seed::LiveLong, Shape::BareEntryPair); + run_probe(probe); + CHECK(probe.earlier_over_cap == true); + CHECK(near(probe.signed_position_after_fill, -10.0)); + CHECK(probe.trades_after_fill == 1); + CHECK(probe.entry_ids_after_fill == "[L:Long]"); +} + +// --------------------------------------------------------------------------- +// Controls: shapes that must NOT be adjudicated by this rule. +// --------------------------------------------------------------------------- + +static void test_same_direction_pair_is_not_this_rule() { + std::printf("-- control: same-direction pair while live --\n"); + Probe probe(Seed::LiveShort, Shape::SameDirection); + run_probe(probe); + CHECK(probe.candidates_after_signal == 2); + // First reverses the short, second duplicates it at the cap: no decline. + CHECK(near(probe.signed_position_after_fill, 10.0)); +} + +static void test_priced_third_abandons_adjudication() { + std::printf("-- control: a priced third order abandons the book --\n"); + Probe probe(Seed::LiveShort, Shape::PricedThird); + run_probe(probe); + // Both entries fill: Long reverses the short, Short reverses back. + CHECK(near(probe.signed_position_after_fill, -10.0)); + CHECK(probe.trades_after_fill == 2); +} + +static void test_raw_third_abandons_adjudication() { + std::printf("-- control: a raw strategy.order abandons the book --\n"); + Probe probe(Seed::LiveShort, Shape::RawThird); + run_probe(probe); + CHECK(near(probe.signed_position_after_fill, -10.0)); +} + +static void test_carried_bracket_abandons_adjudication() { + std::printf("-- control: an order carried in from an earlier bar --\n"); + Probe probe(Seed::LiveShort, Shape::CarriedBracket); + run_probe(probe); + // The resting stop entry is still in the book on the pair's bar, so the + // pinned shape is gone and both entries keep their ordinary fill rules. + CHECK(probe.book_after_signal == 3); + CHECK(near(probe.signed_position_after_fill, -10.0)); +} + +int main() { + std::printf("=== live-position default MARKET/MARKET gross admission ===\n"); + test_live_short_declines_the_later_call(); + test_live_short_bare_pair_declines(); + test_live_long_admits_the_later_call(); + test_live_long_bare_pair_admits(); + test_same_direction_pair_is_not_this_rule(); + test_priced_third_abandons_adjudication(); + test_raw_third_abandons_adjudication(); + test_carried_bracket_abandons_adjudication(); + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +} diff --git a/tests/test_margin_admission_gate.cpp b/tests/test_margin_admission_gate.cpp index 575d56b..071111e 100644 --- a/tests/test_margin_admission_gate.cpp +++ b/tests/test_margin_admission_gate.cpp @@ -452,7 +452,21 @@ void test_same_side_market_becomes_reversal_free_margin_gate() { // - explicit qty remains owned by strategy_entry's signal-time admission; // - both the held side and requested side must independently be 100% margin; // - an ordinary same-direction FIXED add never became a reversal; -// - PERCENT_OF_EQUITY=100 remains owned by KI-54's frozen-sizing gate. +// - PERCENT_OF_EQUITY=100 is not owned by THIS gate. +// +// M.5 CORRECTED 2026-07-25 (pending-aware gross admission widened to live +// positions). The all-in `B` fixture is a same-source-bar opposite default-sized +// MARKET pair queued while a SHORT is held, which is now adjudicated by +// finalize_default_flat_market_gross_admission: the earlier "L" reverses the +// short so it costs 3 lots, the later "S" costs 3 more, and 600 > 300 equity, so +// "S" is declined and "L" is the sole fill. The old expectation (both fill, +// account ends back SHORT) was the pre-widening behavior, not an independent TV +// pin -- the sub-case exists to prove the GB2 fixed/explicit gate does not reach +// percent-of-equity, and it still proves exactly that. The new expectation is +// what TradingView does: pinned by data/probes/pf-probe-ki65-pending-market- +// affordability (3,128 trades, 100%) and by 159/159 live-short real-row events +// on chartprime-power-order-blocks / fluxchart-supply-and-demand-zones / +// market-logic-india-low-lag-strength-oscillator. void test_same_side_role_change_scope_controls() { std::printf("-- M: same-side role-change scope controls --\n"); { @@ -496,12 +510,12 @@ void test_same_side_role_change_scope_controls() { CHECK_NEAR(eng.position_qty_, 2.0, 1e-9); } { - std::printf(" M.5 percent-of-equity role change stays in KI-54\n"); + std::printf(" M.5 percent-of-equity pair goes to gross admission\n"); Probe eng(QtyType::PERCENT_OF_EQUITY, 100.0, 1); eng.initial_capital_ = 300.0; run_constant_100_script(eng, "S.B.."); - CHECK(eng.trade_count() == 2); - CHECK(eng.position_side_ == PositionSide::SHORT); + CHECK(eng.trade_count() == 1); + CHECK(eng.position_side_ == PositionSide::LONG); CHECK_NEAR(eng.position_qty_, 3.0, 1e-9); } }