From 48363a17fcfa60888ada8d3edc68574c93f66aed Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Thu, 9 Jul 2026 03:09:24 +0800 Subject: [PATCH] fix(engine): TV margin admission for frozen default-sized market orders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TradingView silently drops a default-sized market order whose margin requirement exceeds the funds free at the fill. The engine's own gate for this was unreachable for default sizing: it is guarded by !std::isnan(qty), and codegen passes qty=na whenever the script omits it. When it did run it compared against total equity rather than free funds. At the fill, for an order carrying a signal-bar sizing snapshot: same_dir = position open AND order direction matches it reversal = position open AND order direction opposes it held = same_dir ? |position_qty| * sizing_mark * pv * fx * margin/100 : 0 free_funds = sizing_equity - held admit_price = reversal ? slipped(fill_price) : sizing_price required = |qty| * admit_price * pointvalue * fx * margin_pct/100 drop iff required > free_funds + eps Admission price by position state: - FLAT open (incl. close-then-reenter): the SIZING notional. For percent-of-equity with pct <= 100 the floor in apply_qty_step guarantees qty*sizing_price*pv*fx <= sizing_equity, so a flat open is undeclinable no matter how the bar gaps. Pricing flat opens at the fill was refuted against TV exports. - TRUE REVERSAL: the fill price, slipped the way the fill kernel books it. An all-in flip's sizing notional sits within lot-floor slack of equity, so once the fill gap pushes the requirement past equity TV drops the flip. - SAME-direction add: the sizing notional against free margin. An all-in add sees free_funds ~= 0 and declines; a fractional add still fills. held is marked at the same bar close sizing_equity was (sizing_mark), not at cost basis. Marking it at cost basis leaves free_funds = cash + open_profit rather than free margin, so the threshold drifts with unrealized PnL in the wrong direction: an underwater add is dropped though affordable, while a profitable one is admitted and then immediately margin-called. It also keeps the gate consistent with process_margin_call, which marks required margin to the current price. eps absorbs double rounding AND one whole lot of notional: the quantity was floored to the lot step, so a decline whose margin is under one lot's worth of budget is decided by where the floor landed, not by affordability. Nearly half of all bars on a continuous feed gap by exactly one mintick, so without this term the reversal branch resolves those bars by lot-floor luck. Scope: percent_of_equity default sizing with pct <= 100 only — the one regime where the floor invariant exists. CASH sizing has no equity term (its notional is unbounded by equity), and pct > 100 breaks the invariant too; both keep the freeze and skip the re-check. Also: process_margin_call runs after on_bar, so a same-bar liquidation was not reflected in the frozen sizing snapshot. The engine then over-sized, the new position's notional exceeded equity, and the long_full_margin branch emitted a phantom LONG margin call TV does not have. Rather than moving process_margin_call (which would change what strategy.equity reads inside on_bar for every strategy), re-freeze any still-pending default-sized market order placed on this bar right after a liquidation actually trimmed something. A strict no-op on bars with no margin call. Gates: ctest 81/81. test_margin_admission_gate pins flat gap-up admitted, all-in same-dir add declined, fractional add admitted, reversal on adverse gap declined, reversal at exact tie admitted, CASH flat open admitted, slipped short reversal on a zero-gap bar admitted, reversal within one lot of notional REVISION 4 — scope tightened after three adversarial gate reviews: * The gate runs ONLY where its flat-open invariant actually holds: percent_of_equity, pct <= 100, margin <= 100, and sizing_equity > 0. apply_qty_step returns qty UNFLOORED for qty <= 0, so on a bankrupt account |qty|*sizing_price == |sizing_equity| while free_funds < 0 and the gate would decline every order forever, flat opens included (one harness candidate went 3659 entry rows -> zero trades). margin > 100 breaks the invariant the other way: required = equity*pct/100*margin/100 > equity. * A FRACTIONAL same-direction add is no longer gated. Whether TV measures free margin against cost basis or mark-to-market flips the verdict there, and no export discriminates the two; TV also demonstrably admits some over-allocated entries and trims them in-bar with a margin call, so a silent decline would not obviously be more faithful. Only the all-in add is gated -- unfundable under every convention, and the one case a real export pins. ctest 81/81; test_margin_admission_gate now carries 11 pins, of which the slipped-short-reversal, the one-lot epsilon and the bankrupt-account guard each FAIL without their respective fix. Corpus 252/252 unchanged. Sweep of 409 community strategies: 22 up, 0 down. REVISION 5 — the same-direction add's free-margin basis is now PINNED by a clean-room TradingView probe (data/probes/margin-basis-frac, pct=50, pyramiding=2). At pct=50 mark-to-market and cost-basis give OPPOSITE verdicts on the add; TV admitted 1535/1538 adds while the open lot was underwater and declined the in-profit ones -> mark-to-market. The engine reproduces that probe 100% (4706/4706 TV trades; the all-in control declines 3169/3169 adds). So the fractional add is no longer exempted: it is gated against mark-to-market free margin like the all-in add, which is what TV does. Pin I now asserts the in-profit fractional add is DECLINED (fails under a cost-basis rule). --- include/pineforge/engine.hpp | 78 +++++- src/engine_fills.cpp | 156 +++++++++++ src/engine_run.cpp | 23 +- src/engine_strategy_commands.cpp | 15 ++ tests/CMakeLists.txt | 1 + tests/test_margin_admission_gate.cpp | 389 +++++++++++++++++++++++++++ 6 files changed, 656 insertions(+), 6 deletions(-) create mode 100644 tests/test_margin_admission_gate.cpp diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 604be6e..090f7a7 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -213,6 +213,36 @@ struct PendingOrder { // flips every one of them. Keep ``qty`` NaN; read this field only where a // quantity is actually computed. double frozen_default_qty = std::numeric_limits::quiet_NaN(); + // TV margin-admission snapshot for a FROZEN default-sized market order + // (KI-54). Captured at the same placement point as frozen_default_qty: + // sizing_equity = current_equity() + open_profit(close(S)) [account ccy] + // sizing_price = close(S) + slippage*mintick*(+1 buy/-1 sell) + // At fill time the broker re-checks (see the gate in + // apply_filled_order_to_state for the full evidence trail) + // |qty| * admit_price * pointvalue * fx * margin_pct/100 + // <= free_funds = sizing_equity - (same-direction held margin) + // where admit_price is the SIZING price for flat opens and adds but the + // FILL price for a true reversal (opposite position still open when the + // order processes), and silently drops the order when it fails (no + // trade row). The floor in apply_qty_step guarantees + // qty*sizing_price*pv*fx <= sizing_equity ONLY for percent-of-equity + // sizing with pct <= 100, margin <= 100, and sizing_equity > 0 — under + // that invariant flat opens are undeclinable no matter how the bar gaps. + // It fails for CASH default sizing (no equity term), for pct > 100, for + // margin > 100 (required scales past equity), and on a bankrupt account + // (apply_qty_step returns qty UNFLOORED for qty <= 0, so |qty|*price == + // |sizing_equity| while free_funds < 0 — every order, flat opens + // included, would be declined forever). The re-check is restricted + // accordingly; orders outside it carry the snapshot and are admitted. + // NaN = no snapshot, no re-check. + double sizing_equity = std::numeric_limits::quiet_NaN(); + double sizing_price = std::numeric_limits::quiet_NaN(); + // The bar close sizing_equity was marked at. free_funds subtracts the + // margin the OPEN position ties up, and that must be marked at the same + // price the equity was, or the two sides of the comparison mix a + // mark-to-market total against a cost-basis deduction and the admission + // threshold drifts with unrealized PnL in the wrong direction. + double sizing_mark = std::numeric_limits::quiet_NaN(); std::string comment; // order comment for trade reporting bool requested_partial = false; // true iff caller passed qty_percent < 100 bool created_while_in_position = false; // true if position was open when order was placed @@ -930,12 +960,48 @@ class BacktestEngine { // Priced (limit/stop) entries are NOT frozen: TV's sizing basis for an // order armed one or more bars before its fill is not empirically // established, so they conservatively keep the legacy fill-time sizing. - double frozen_default_market_qty(bool is_buy) const { + // The sizing price of the frozen rule above, exposed separately so the + // placement sites can persist it on the order (PendingOrder::sizing_price) + // for the fill-time margin-admission re-check. + double frozen_sizing_price(bool is_buy) const { double sizing_price = current_bar_.close; if (slippage_ != 0 && syminfo_mintick_ > 0.0) { sizing_price += (is_buy ? 1.0 : -1.0) * slippage_ * syminfo_mintick_; } - return calc_qty(sizing_price); + return sizing_price; + } + + double frozen_default_market_qty(bool is_buy) const { + return calc_qty(frozen_sizing_price(is_buy)); + } + + // KI-54 defect fix: the frozen sizing snapshot must see POST-liquidation + // equity. TradingView liquidates intrabar, BEFORE the bar-close script + // body runs; the engine's process_margin_call runs at the END of + // dispatch_bar, AFTER on_bar placed (and froze) this bar's default-sized + // market orders. When a margin call fires on the placement bar, the + // frozen qty was computed on pre-liquidation equity — over-sized, so the + // next bar's fill opens a position whose notional exceeds equity and the + // long_full_margin branch of process_margin_call then emits a phantom + // LONG margin call TV does not have. Rather than moving process_margin_call + // (which would change what strategy.equity reads inside on_bar for every + // strategy), the dispatch loop calls this refresh right after a margin + // call actually liquidated something: every still-pending frozen + // default-sized market order placed on THIS bar is re-frozen on the + // post-liquidation state. Strict no-op on bars without a margin call + // (the caller checks), and bit-identical recompute for untouched state. + void refresh_frozen_default_sizing_after_margin_call() { + for (auto& o : pending_orders_) { + if (std::isnan(o.frozen_default_qty)) continue; + if (o.type != OrderType::MARKET && o.type != OrderType::RAW_ORDER) + continue; + if (o.created_bar != bar_index_) continue; + o.frozen_default_qty = calc_qty(o.sizing_price); + if (!std::isnan(o.sizing_equity)) { + o.sizing_equity = + current_equity() + open_profit(current_bar_.close); + } + } } // --- Strategy variable accessors --- @@ -997,8 +1063,12 @@ class BacktestEngine { // strategy.margin_liquidation_price — the price at which TradingView's // broker emulator force-liquidates the current open position. Returns na // when flat, when the instrument has no valid size/point-value, or when - // ``margin/100 - direction == 0`` (a long at 100% margin can never be - // liquidated). See compute_liquidation_price for the derivation. + // ``margin/100 - direction == 0`` (a 1x long has no leverage-derived + // liquidation PRICE — but it CAN still be force-liquidated when its + // notional exceeds equity: process_margin_call's long_full_margin + // branch deliberately bypasses the na bail for exactly that case, and + // TV emits such long margin calls too). See compute_liquidation_price + // for the derivation. double margin_liquidation_price() const { return compute_liquidation_price(); } // Shared liquidation-price formula (TradingView docs, validated against the diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index f800439..c66ed43 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -485,6 +485,162 @@ void BacktestEngine::apply_filled_order_to_state( } } + // KI-54: TradingView fill-time margin admission for FROZEN default-sized + // market orders (the snapshot fields are captured at placement — see + // PendingOrder::sizing_equity/sizing_price, engine.hpp): + // + // same_dir = position open AND order direction matches it + // reversal = position open AND order direction opposes it + // free_funds = same_dir ? sizing_equity - held_margin : sizing_equity + // admit_price = reversal ? slipped(fill_price) : sizing_price + // required = |qty| * admit_price * pointvalue * fx * margin_pct/100 + // drop iff required > free_funds + eps (silently: no trade row) + // + // eps absorbs double rounding AND one whole lot of notional: the quantity + // was floored to the lot step, so a decline whose margin is under one + // lot's worth of budget is decided by where the floor landed, not by + // affordability. + // + // Admission price, by position state at the fill: + // - FLAT open (incl. close-then-reenter, where the strategy.close leg + // filled earlier this tick): the SIZING notional. For percent-of- + // equity with pct <= 100, margin <= 100 and sizing_equity > 0 the + // floor in apply_qty_step guarantees + // qty*sizing_price*pv*fx <= sizing_equity, so a flat open is + // undeclinable no matter how the bar gaps. Outside that regime the + // invariant fails and the gate does not run at all. Pricing flat opens at + // the fill was refuted against TV exports: it drops razor-thin + // gap-up entries that exact-count close-then-reenter strategies + // demonstrably take. + // - TRUE REVERSAL (opposite position still open when the order + // processes): the FILL price, slipped the way the fill kernel + // will book it. Established independently by two from-the-feed + // replicas of all-in flip strategies: an all-in flip's sizing + // notional sits within lot-floor slack of equity, so once the + // fill gap pushes the requirement past equity TV silently drops + // the flip. Exports of such strategies contain no gap-up flip + // fill at all, on a feed where roughly half the bars gap; the + // ungated engine took every one. + // - SAME-direction add: the sizing notional, against free funds — + // the held position keeps its capital committed, so an all-in add + // sees free_funds ~= 0 and declines (TV performs no such adds even + // where pyramiding permits them), while a fractional add + // (pct=10, held ~= 0.1*equity) still fills. + // + // Scope: the re-check runs ONLY for percent_of_equity default sizing + // with pct <= 100 — the one regime where the floor invariant above + // exists AND TV ground truth pins the behavior. CASH default sizing + // has NO equity term (cash/(price*pv)), so required is unbounded by + // sizing_equity and the gate would decline perfectly ordinary flat + // opens whenever cash_value > equity (a real transpiled cash-20k-on- + // 10k-capital probe lost all 73 of its trades to it). The same + // applies to pct > 100 (leveraged sizing). Neither regime has TV + // ground truth; frozen CASH / pct>100 orders keep their freeze but + // are admitted unconditionally here. + // + // Frozen MARKET entries and frozen RAW market orders are checked; an + // opposite-direction RAW fill only CLOSES the position + // (apply_raw_order_fill's exit branch) and is never dropped. + // Explicit-qty entries keep the signal-time gate in strategy_entry; + // priced (limit/stop) entries carry no snapshot. Runs BEFORE the + // intraday-cap accounting below: a dropped order was never filled, so + // it must not consume a max_intraday_filled_orders slot. + // sizing_equity > 0 and frozen_default_qty > 0 are part of the invariant, + // not paranoia: apply_qty_step returns qty UNFLOORED for qty <= 0 + // (engine.hpp), so on a bankrupt account the frozen quantity is negative, + // |qty|*sizing_price == |sizing_equity|, and free_funds < 0 — every order, + // including a flat open, would be declined forever. There is no TV ground + // truth for what a negative-equity account may open; leave it to the + // legacy path. + if (!std::isnan(order.sizing_equity) && !std::isnan(order.sizing_price) + && !std::isnan(order.frozen_default_qty) + && order.sizing_equity > 0.0 && order.frozen_default_qty > 0.0 + && default_qty_type_ == QtyType::PERCENT_OF_EQUITY + && default_qty_value_ <= 100.0 + && (order.type == OrderType::MARKET + || order.type == OrderType::RAW_ORDER)) { + bool same_dir = position_side_ != PositionSide::FLAT + && ((position_side_ == PositionSide::LONG) == order.is_long); + bool reversal = position_side_ != PositionSide::FLAT && !same_dir; + bool raw_opposite_close = order.type == OrderType::RAW_ORDER && reversal; + double margin_pct = order.is_long ? margin_long_ : margin_short_; + // A same-direction add (fractional OR all-in) IS gated, against + // MARK-TO-MARKET free margin. This is pinned by a clean-room TV probe + // (data/probes/margin-basis-frac: pct=50, pyramiding=2). At pct=50 the + // two candidate rules give OPPOSITE verdicts on the add — mark-to- + // market admits it only when the open lot is UNDERWATER, cost basis + // only when it is IN PROFIT — and TV admitted 1535/1538 adds while + // underwater (2 in profit, float-noise), i.e. mark-to-market. The + // held side below uses that basis. (An earlier revision exempted the + // fractional add for lack of ground truth; the probe removes the + // ambiguity and TV declines the in-profit adds the exemption let + // through.) + // + // margin_pct > 100 breaks the flat-open invariant outright + // (required = equity * pct/100 * margin/100 > equity), which would + // silently drop every flat open. Leverage below 1x has no TV pin. + bool leverage_below_1x = margin_pct > 100.0; + if (!raw_opposite_close && !leverage_below_1x && margin_pct > 0.0) { + // The margin the OPEN position ties up, marked at the SAME price + // sizing_equity was marked at (the signal bar's close). Only the + // all-in add reaches this (see unpinned_fractional_add), where + // every convention agrees; marking it at cost basis instead — + // |qty * entry_price| — would leave + // free_funds = cash + open_profit rather than free margin, so the + // admission threshold would drift with unrealized PnL in the wrong + // direction: an underwater add gets declined while a profitable one + // gets admitted and then immediately margin-called. This also keeps + // the gate consistent with process_margin_call, which marks the + // required margin to the current price. Scaled by the same + // margin_pct/100 the required side carries; at margin 100 (every + // specimen we have) the scaling is a no-op. + double held = same_dir + ? std::abs(position_qty_) * order.sizing_mark + * syminfo_.pointvalue * account_currency_fx_ + * (margin_pct / 100.0) + : 0.0; + double free_funds = order.sizing_equity - held; + // Price the reversal at the price the fill kernel will actually + // book. ``fill_price`` here is still unslipped, while + // ``sizing_price`` already carries the slippage adjustment (see + // frozen_default_market_qty), so comparing the raw fill price + // against a slipped budget mixes two conventions and declines + // even a zero-gap reversal whenever slippage_ != 0. + double admit_price = reversal + ? apply_fill_slippage(fill_price, order.is_long) + : order.sizing_price; + double required_margin = std::abs(order.frozen_default_qty) + * admit_price + * syminfo_.pointvalue + * account_currency_fx_ + * (margin_pct / 100.0); + // The epsilon absorbs double rounding AND one whole lot of + // notional. + // + // The quantity was floored to the lot step, so the budget it left + // unspent is an unobservable remainder anywhere in + // [0, qty_step * price). A decline whose margin is smaller than + // that remainder is not a decision about affordability at all — + // it is a coin flip on where the floor happened to land. On a + // continuous feed nearly half of all bars gap by exactly one + // mintick, so without this term the reversal branch resolves such + // bars by lot-floor luck: deterministic at large equity, arbitrary + // at small. Widening by one lot keeps every decline that TV's + // exports actually confirm (their margins exceed a lot of + // notional) and drops the ones no ground truth supports. + double epsilon = + std::max(1e-9, std::abs(free_funds) * 1e-12); + epsilon = std::max(epsilon, qty_step_ * admit_price + * syminfo_.pointvalue + * account_currency_fx_ + * (margin_pct / 100.0)); + if (required_margin > free_funds + epsilon) { + filled_indices.push_back(order_index); + return; + } + } + } + // Check max_intraday_filled_orders limit. // // TV's broker emulator (LATCH-TILL-DAY-ROLLOVER semantics): diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 6f2f24d..878d4af 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -46,7 +46,19 @@ void BacktestEngine::dispatch_bar() { } // TradingView forced-liquidation check, once per script bar after all order // processing, using this bar's full adverse extreme (high/low). - process_margin_call(current_bar_); + // + // TV liquidates INTRABAR — before the close-time script body — so any + // default-sized market order frozen by this bar's on_bar was sized on + // pre-liquidation equity. When (and only when) the margin call actually + // liquidated something, re-freeze those orders on the post-liquidation + // state (see refresh_frozen_default_sizing_after_margin_call). + { + size_t trades_before_mc = trades_.size(); + process_margin_call(current_bar_); + if (trades_.size() != trades_before_mc) { + refresh_frozen_default_sizing_after_margin_call(); + } + } } @@ -316,7 +328,14 @@ void BacktestEngine::run_magnified_bar(const std::vector& sub_bars, int64_t // TradingView forced-liquidation check, once per script bar. By the final // sub-bar current_bar_.high/.low hold the full script-bar adverse extreme, // and current_bar_.timestamp was restored to the script-bar open ts above. - process_margin_call(current_bar_); + // Same post-liquidation re-freeze as the non-magnifier path (dispatch_bar). + { + size_t trades_before_mc = trades_.size(); + process_margin_call(current_bar_); + if (trades_.size() != trades_before_mc) { + refresh_frozen_default_sizing_after_margin_call(); + } + } finalize_bar(); } diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 0d96fdb..fb36888 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -238,6 +238,13 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, || default_qty_type_ == QtyType::CASH) && !std::isnan(current_bar_.close)) { order.frozen_default_qty = frozen_default_market_qty(/*is_buy=*/is_long); + // KI-54: persist the sizing basis for the fill-time TV margin + // admission re-check (see PendingOrder::sizing_equity in + // engine.hpp and the gate in apply_filled_order_to_state). + order.sizing_price = frozen_sizing_price(/*is_buy=*/is_long); + order.sizing_equity = + current_equity() + open_profit(current_bar_.close); + order.sizing_mark = current_bar_.close; } } else { order.type = OrderType::ENTRY; @@ -711,6 +718,14 @@ void BacktestEngine::strategy_order(const std::string& id, bool is_long, double || default_qty_type_ == QtyType::CASH) && !std::isnan(current_bar_.close)) { order.frozen_default_qty = frozen_default_market_qty(/*is_buy=*/is_long); + // KI-54: same admission snapshot as strategy_entry's MARKET + // branch. The fill-time gate skips opposite-direction RAW fills + // (they only close the position) — see + // apply_filled_order_to_state. + order.sizing_price = frozen_sizing_price(/*is_buy=*/is_long); + order.sizing_equity = + current_equity() + open_profit(current_bar_.close); + order.sizing_mark = current_bar_.close; } } else { order.type = OrderType::RAW_ORDER; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c0ef301..551cf08 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -63,6 +63,7 @@ set(TEST_SOURCES test_c_abi_setters test_fills_edge test_default_qty_signal_freeze + test_margin_admission_gate test_limit_fill_slippage test_strategy_commands_extra test_multi_tier_exit_precedence diff --git a/tests/test_margin_admission_gate.cpp b/tests/test_margin_admission_gate.cpp new file mode 100644 index 0000000..63a0951 --- /dev/null +++ b/tests/test_margin_admission_gate.cpp @@ -0,0 +1,389 @@ +/* + * test_margin_admission_gate.cpp — KI-54: TradingView's fill-time margin + * admission for FROZEN default-sized market orders. + * + * same_dir = position open AND order direction matches it + * reversal = position open AND order direction opposes it + * free_funds = same_dir ? sizing_equity - held_margin : sizing_equity + * admit_price = reversal ? fill_price : sizing_price + * required = |qty| * admit_price * pointvalue * fx * margin_pct/100 + * drop iff required > free_funds + eps (silent: no trade row) + * scope: percent_of_equity default sizing with pct <= 100 ONLY + * + * Pins (see the gate comment in engine_fills.cpp for the evidence trail): + * A. Flat open on a gap-UP bar is ADMITTED — flat opens price at the + * SIZING notional, which the floor guarantees affordable; the gap + * never enters the comparison. + * B. Same-direction add at pct=100 is DECLINED — the held position keeps + * its capital committed, free_funds ~= 0. (pyramiding=2, so the + * decline comes from the margin gate, not the pyramiding limit — + * pin C proves the same setup fills when funded.) + * C. Same-direction add at pct=10 with one prior lot is ADMITTED — + * held ~= 0.1*equity, free_funds ~= 0.9*equity >> required. + * D. A TRUE REVERSAL prices at the FILL: an adverse gap on the fill bar + * pushes required past sizing_equity and the flip is DECLINED — the + * old position stays. (TV ground truth: the all-in flip specimen has + * 0/1068 gap-up flip fills against a 50/50 gapping feed.) + * E. A reversal whose fill price EQUALS the sizing price is an exact + * required == free_funds tie at all-in — the epsilon must ADMIT it. + * F. CASH default sizing is exempt: a flat open with cash_value > + * equity (no floor invariant exists for CASH) must be ADMITTED — + * regression pin for the gate's percent_of_equity/pct<=100 scope. + */ + +#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_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; +} + +namespace { + +// Scripted probe: per-bar action, all prices on-tick (mintick 0.01) so the +// zero-slippage directional snap is an identity. +class Probe : public BacktestEngine { +public: + Probe(QtyType qty_type, double qty_value, int pyramiding) { + initial_capital_ = 10000.0; + default_qty_type_ = qty_type; + default_qty_value_ = qty_value; + commission_value_ = 0.0; + pyramiding_ = pyramiding; + // All-in probes hold fully-leveraged positions; forced liquidation is + // not the mechanism under test. + margin_call_enabled_ = false; + } + // 'L' = default long "L", 'A' = default long add "L2", + // 'S' = default short "S", '.' = nothing. + std::string script; + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ < 0 || bar_index_ >= (int)script.size()) return; + switch (script[bar_index_]) { + case 'L': strategy_entry("L", true); break; + case 'A': strategy_entry("L2", true); break; + case 'S': strategy_entry("S", false); break; + default: break; + } + } + using BacktestEngine::position_qty_; + using BacktestEngine::position_side_; + using BacktestEngine::qty_step_; + using BacktestEngine::slippage_; + using BacktestEngine::initial_capital_; + using BacktestEngine::margin_long_; + const std::vector& all_trades() const { return trades_; } +}; + +// A. Flat open, gap UP: admitted, qty stays frozen (10000/100 = 100). +void test_flat_gap_up_admitted() { + std::printf("-- A: flat open on gap-up bar admitted --\n"); + Probe eng(QtyType::PERCENT_OF_EQUITY, 100.0, 1); + eng.script = "L.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 102, 103, 101, 102), // gap up: fill 102 > close(S) 100 + mk_bar(3000, 102, 102, 102, 102), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); + CHECK_NEAR(eng.position_qty_, 100.0, 1e-9); +} + +// B. Same-direction add at pct=100: DECLINED (free_funds ~= 0). +void test_all_in_same_dir_add_declined() { + std::printf("-- B: all-in same-direction add declined --\n"); + Probe eng(QtyType::PERCENT_OF_EQUITY, 100.0, 2); + eng.script = "LA.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // L placed (frozen 100) + mk_bar(2000, 100, 100, 100, 100), // L fills: LONG 100 @100; L2 placed + mk_bar(3000, 100, 100, 100, 100), // L2: held=10000, free=0 -> DROP + mk_bar(4000, 100, 100, 100, 100), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); + CHECK_NEAR(eng.position_qty_, 100.0, 1e-9); // NOT 200 + CHECK(eng.trade_count() == 0); // no phantom fills/closes +} + +// C. Same-direction add at pct=10 with one prior lot: ADMITTED. +// held = 1000, free_funds = 9000, required = 1000. +void test_fractional_same_dir_add_admitted() { + std::printf("-- C: fractional same-direction add admitted --\n"); + Probe eng(QtyType::PERCENT_OF_EQUITY, 10.0, 2); + eng.script = "LA.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 100, 100, 100, 100), // L fills: LONG 10 @100; L2 placed + mk_bar(3000, 100, 100, 100, 100), // L2 fills: LONG 20 + mk_bar(4000, 100, 100, 100, 100), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); + CHECK_NEAR(eng.position_qty_, 20.0, 1e-9); +} + +// D. TRUE reversal on an adverse-gap fill bar: DECLINED. Frozen short: +// eq_S = 10000 + (110-100)*100 = 11000, qty = 11000/110 = 100, +// sizing_price 110. Fill gaps to 111: required = 100*111 = 11100 > +// 11000 -> the flip is silently dropped; the LONG stays open and no +// trade row is emitted. +void test_reversal_declined_on_adverse_gap() { + std::printf("-- D: reversal declined on adverse-gap fill bar --\n"); + Probe eng(QtyType::PERCENT_OF_EQUITY, 100.0, 1); + eng.script = "LS.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // L placed (frozen 100) + mk_bar(2000, 100, 112, 99, 110), // L fills @100; S placed + mk_bar(3000, 111, 112, 110, 111), // gap up: 100*111 > 11000 -> DROP + mk_bar(4000, 111, 111, 111, 111), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); // flip did NOT happen + CHECK_NEAR(eng.position_qty_, 100.0, 1e-9); + CHECK(eng.trade_count() == 0); +} + +// E. Reversal at the exact tie (fill_price == sizing_price): required == +// free_funds at all-in; the epsilon must ADMIT it and the flip happens +// with the frozen qty. +void test_reversal_admitted_at_exact_tie() { + std::printf("-- E: reversal admitted at exact required==free tie --\n"); + Probe eng(QtyType::PERCENT_OF_EQUITY, 100.0, 1); + eng.script = "LS.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 100, 112, 99, 110), // L fills @100; S placed (frozen + // 100 = 11000/110, sizing 110) + mk_bar(3000, 110, 111, 109, 110), // fill 110 == sizing_price: tie + mk_bar(4000, 110, 110, 110, 110), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::SHORT); + CHECK_NEAR(eng.position_qty_, 100.0, 1e-9); + CHECK(eng.trade_count() == 1); + if (eng.trade_count() == 1) { + const Trade& t0 = eng.all_trades()[0]; + CHECK(t0.is_long); + CHECK_NEAR(t0.exit_price, 110.0, 1e-9); + CHECK_NEAR(t0.pnl, 1000.0, 1e-9); + } +} + +// F. CASH default sizing is exempt from the re-check: cash 20000 on 10000 +// capital sizes 200 lots @100 — no floor invariant bounds it by equity, +// and no TV ground truth pins a decline, so the flat open must FILL. +// (Pre-fix the gate declined every such entry: 445/445 on a real +// transpiled cash probe, 73 trades -> 0.) +void test_cash_flat_open_admitted() { + std::printf("-- F: cash default sizing exempt, flat open admitted --\n"); + Probe eng(QtyType::CASH, 20000.0, 1); + eng.script = "L.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // frozen 20000/100 = 200 + mk_bar(2000, 100, 100, 100, 100), // must fill: LONG 200 + mk_bar(3000, 100, 100, 100, 100), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); + CHECK_NEAR(eng.position_qty_, 200.0, 1e-9); +} + +} // namespace + +// G. Slippage > 0, SHORT reversal on a bar that does not gap at all. +// The sell's real fill is close(S) - slip*mintick, which is exactly the +// sizing price the qty was frozen against, so this is the same tie as E. +// Comparing the RAW fill price against a slipped budget would decline it — +// and would decline every short reversal on the most common bar shape +// there is. +void test_slipped_short_reversal_zero_gap_admitted() { + std::printf("-- G: slipped short reversal, zero gap, admitted --\n"); + Probe eng(QtyType::PERCENT_OF_EQUITY, 100.0, 1); + eng.slippage_ = 2; // 2 ticks @ mintick 0.01 + eng.script = "LS.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), // L placed + mk_bar(2000, 100, 100, 100, 100), // L fills (buy @100.02); S placed + mk_bar(3000, 100, 100, 100, 100), // no gap: sell fills @99.98 + mk_bar(4000, 100, 100, 100, 100), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::SHORT); // the flip happened +} + +// H. Lot-step slack: with a real qty_step the frozen qty leaves an unspent +// remainder in [0, qty_step*price). A reversal whose adverse gap costs +// LESS than one lot of notional must be ADMITTED — the decline would be +// decided by where the floor landed, not by affordability. A gap that +// costs far more than one lot is still DECLINED. +void test_reversal_lot_step_slack() { + std::printf("-- H: reversal within one lot of notional admitted --\n"); + { + Probe eng(QtyType::PERCENT_OF_EQUITY, 100.0, 1); + eng.qty_step_ = 0.01; // one lot @ ~100 = ~$1.00 notional + eng.script = "LS.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 100, 100, 100, 100), // LONG 100 @100 + mk_bar(3000, 100.01, 100.01, 100.01, 100.01), // 1-tick gap: costs $1.00 < one lot + mk_bar(4000, 100.01, 100.01, 100.01, 100.01), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::SHORT); // admitted + } + { + Probe eng(QtyType::PERCENT_OF_EQUITY, 100.0, 1); + eng.qty_step_ = 0.01; + eng.script = "LS.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 100, 100, 100, 100), // LONG 100 @100 + mk_bar(3000, 101, 101, 101, 101), // $100 over budget >> one lot + mk_bar(4000, 101, 101, 101, 101), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); // declined + } +} + +// I. A FRACTIONAL same-direction add is gated against MARK-TO-MARKET free +// margin — pinned by data/probes/margin-basis-frac (pct=50, pyramiding=2): +// TV admitted 1535/1538 adds while UNDERWATER and declined the in-profit +// ones. At pct=50 the underwater add is admitted (free margin = cash = +// 0.5*equity >= required) and the profitable add is DECLINED (the position +// marked up shrinks free margin below required). A cost-basis rule would +// invert both, so this pin also refutes cost basis. +void test_fractional_add_marked_to_market() { + std::printf("-- I: fractional add gated mark-to-market (TV-pinned) --\n"); + { // UNDERWATER: price 100 -> 90. equity 9500, held 50*90=4500, + // free 5000, required 52.78*90=4750 -> ADMITTED. + std::printf(" I.1 underwater add admitted\n"); + Probe eng(QtyType::PERCENT_OF_EQUITY, 50.0, 2); + eng.script = "LA.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 100, 110, 90, 90), // L fills @100; down; L2 placed + mk_bar(3000, 90, 90, 90, 90), + mk_bar(4000, 90, 90, 90, 90), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); + CHECK(eng.position_qty_ > 50.0); // add went on + } + { // PROFITABLE: price 100 -> 110. equity 10500, held 50*110=5500, + // free 5000, required 47.73*110=5250 -> DECLINED (this is what TV does; + // a cost-basis rule would admit it). + std::printf(" I.2 profitable add declined\n"); + Probe eng(QtyType::PERCENT_OF_EQUITY, 50.0, 2); + eng.script = "LA.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 100, 110, 90, 110), // L fills @100; up; L2 placed + mk_bar(3000, 110, 110, 110, 110), + mk_bar(4000, 110, 110, 110, 110), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); + CHECK_NEAR(eng.position_qty_, 50.0, 1e-9); // add dropped + } +} + +// J. Bankrupt account: sizing_equity <= 0 makes the frozen qty NEGATIVE, and +// apply_qty_step returns it unfloored, so |qty|*sizing_price == +// |sizing_equity| while free_funds < 0. Without the guard the gate would +// decline every order forever — flat opens included — silently zeroing out +// a strategy that the pre-gate engine still traded. +// +// Pinned behaviour is the LEGACY path: the engine opens a negative-qty +// position. That is a separate, pre-existing pathology (an order sized off +// negative equity should arguably not be placed at all) and it is NOT this +// gate's job to hide it. If the guard is removed, position_qty_ becomes 0 +// and this pin fails. +void test_negative_equity_gate_does_not_run() { + std::printf("-- J: gate does not run on a bankrupt account --\n"); + Probe eng(QtyType::PERCENT_OF_EQUITY, 100.0, 1); + eng.initial_capital_ = -5000.0; + eng.script = "L.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 100, 100, 100, 100), + mk_bar(3000, 100, 100, 100, 100), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK_NEAR(eng.position_qty_, -50.0, 1e-9); // legacy path, gate inert +} + +// K. margin > 100 (sub-1x leverage) breaks the flat-open invariant: +// required = equity*pct/100*margin/100 > equity. The gate must not run. +void test_margin_above_100_flat_open_admitted() { + std::printf("-- K: flat open at margin > 100 admitted --\n"); + Probe eng(QtyType::PERCENT_OF_EQUITY, 100.0, 1); + eng.margin_long_ = 200.0; // 0.5x leverage + eng.script = "L.."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 100, 100, 100, 100), + mk_bar(3000, 100, 100, 100, 100), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::LONG); // admitted, not dropped + CHECK_NEAR(eng.position_qty_, 100.0, 1e-9); +} + +int main() { + std::printf("--- margin_admission_gate ---\n"); + test_flat_gap_up_admitted(); + test_all_in_same_dir_add_declined(); + test_fractional_same_dir_add_admitted(); + test_reversal_declined_on_adverse_gap(); + test_reversal_admitted_at_exact_tie(); + test_cash_flat_open_admitted(); + test_slipped_short_reversal_zero_gap_admitted(); + test_reversal_lot_step_slack(); + test_fractional_add_marked_to_market(); + test_negative_equity_gate_does_not_run(); + test_margin_above_100_flat_open_admitted(); + std::printf("\n=== Results: %d passed, %d failed ===\n", + tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +}