From fc8e4d70ec1ab339b1f2718b138fdef2f09dcbe5 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 7 Sep 2026 10:00:55 +0800 Subject: [PATCH 1/2] fix: retain rounded margin checks for high-value fractional lots --- include/pineforge/engine.hpp | 10 +- src/engine_fills.cpp | 21 +++- tests/CMakeLists.txt | 1 + ...est_high_value_fractional_money_margin.cpp | 112 ++++++++++++++++++ 4 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 tests/test_high_value_fractional_money_margin.cpp diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 9304062..461d395 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -34,7 +34,7 @@ enum class PositionSide { FLAT, LONG, SHORT }; // (entry-leg admission), log-20260905t180249z-10358e84 (margin-call // trigger)): TradingView's broker carries MONEY at TEN SIGNIFICANT DIGITS, // half-up — 3 decimals at >= 1e6, 4 decimals below. Five consequences are -// pinned and implemented, all scoped by tv_money_scope (below): +// pinned and implemented, with the per-rule scopes described below: // 1. SIZING — a default percent_of_equity order is floored from the // ROUNDED equity (calc_qty): the lot count flips one lot early/late // when the exact equity is within half a money unit of a lot boundary @@ -62,7 +62,7 @@ enum class PositionSide { FLAT, LONG, SHORT }; // the every-bar sensors have E_s < round(cost), 0/3086 admissions do. // 3. MARGIN CALL — a margin-100 LONG is liquidated (one contract, the // broker's minimum) at the first bar path point where the exact equity -// is <= the position value ROUNDED to 10 significant digits +// is below the position value ROUNDED to 10 significant digits // (process_margin_call): revL L18..L25 16/16, taro 6/6 one-unit trims. // Where equity ~ 1e6 and a lot is worth ~0.011 (EURUSD: qty step 0.01 at // price ~1.1) the 0.0005 rounding crosses a lot boundary on ~9 % of all-in @@ -73,8 +73,10 @@ enum class PositionSide { FLAT, LONG, SHORT }; // 4583, the float-accumulated ledger's 4584.000000000001 or a 1e-6-nudged // floor gives 4584, one share the account cannot pay at the 238.78 fill), so // rule 1 sizes every lot-stepped instrument (tv_money_lot_sizing); rules -// 2, 3 and 5 stay scoped by tv_money_scope — outside it the exact fill-price -// admission already decides (1094521.681 -> Q 4584 dropped on AAPL). +// 2 and 5 stay scoped by tv_money_scope — outside it the exact fill-price +// admission already decides (1094521.681 -> Q 4584 dropped on AAPL). Rule 3 +// additionally covers ordinary, fee/slippage-free single-position fractional +// unit-pointvalue/same-currency books with lot value >=1 (R21 BTC/XAU pins). // 5. WHOLE-ORDER DROP (round 9 family R follow-up, campaign notes // log-20260905t205824z-af397c83 and log-20260905t210117z-ab914192; // the residual of log-20260905t180249z-4bd857ad): once the rounded-cost diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 00c6f94..e09be5b 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -1499,7 +1499,11 @@ void BacktestEngine::process_margin_call(const Bar& bar) { // C 1.17653, 09-17 09:45Z C 1.18457, 10-03 01:45Z H 1.17293, 11-25 06:45Z H // 1.15217, 12-30 06:15Z H 1.17798); 0 false fires over 612 every-bar sensor // longs (their equity sits below 1e6). One broker event per bar, plain -// close-calc dispatch only (the pinned tapes), scoped by tv_money_scope. +// close-calc dispatch only (the pinned tapes). R21 also pins fee/slippage-free +// single-lot fractional books whose minimum lot is worth >=1 account unit: +// BTC Q10.68387 at105380.96, cash0.0003048999 fires1 at low105355.26; +// +0.0001cash does not. BTC Q10.68388 and XAU Q300.01 with0.00001cash +// fire at the opening price itself. Other money/admission scopes stay fixed. // Shorts keep the finite-price cascade: the same rounding moves their // liquidation price by ~1e-10, below a tick. // @@ -1555,7 +1559,15 @@ bool BacktestEngine::tv_money_long_margin_call(const Bar& bar, || stream_warmup_mode_ || stream_phase_ != StreamPhase::IDLE) { return false; } - if (!std::isfinite(bar.close) || !tv_money_scope(bar.close)) return false; + const bool legacy_money_scope = tv_money_scope(bar.close); + const bool high_value_fractional_scope = !legacy_money_scope + && qty_step_ > 0.0 && qty_step_ < 1.0 + && !process_orders_on_close_ && commission_value_ == 0.0 && slippage_ == 0 + && syminfo_.pointvalue == 1.0 && active_account_currency_fx() == 1.0 + && pyramiding_ >= 0 && pyramiding_ <= 1 + && position_entry_count_ == 1 && pyramid_entries_.size() == 1; + if (!std::isfinite(bar.close) || bar.close <= 0.0 + || !(legacy_money_scope || high_value_fractional_scope)) return false; // Pinned on same-currency accounts only. A converted (quote -> account // FX series) ledger is cent-rounded in TradingView's export and already // carries its own sub-half-cent tolerance on the opening path; the @@ -1584,6 +1596,11 @@ bool BacktestEngine::tv_money_long_margin_call(const Bar& bar, int seg = static_cast(std::floor(fill_pos + internal::kPathPosEps)); if (seg < 0) seg = 0; start = seg + 1; + // The newly covered high-value fractional pins include an immediate + // post-entry valuation at O. Only a fill actually at O can inspect it; + // a priced entry later on the path still skips earlier waypoints. + if (high_value_fractional_scope && fill_pos == 0.0 + && position_entry_price_ == round_to_mintick(bar.open)) start = 0; } double fire_price = std::numeric_limits::quiet_NaN(); double deficit = 0.0; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9865ff8..1ff8169 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -159,6 +159,7 @@ set(TEST_SOURCES test_metrics test_drawing test_margin_call + test_high_value_fractional_money_margin test_margin_call_intrabar_chronology test_margin_call_trail_exit_chronology test_margin_call_1x_long_entry_fill diff --git a/tests/test_high_value_fractional_money_margin.cpp b/tests/test_high_value_fractional_money_margin.cpp new file mode 100644 index 0000000..2494516 --- /dev/null +++ b/tests/test_high_value_fractional_money_margin.cpp @@ -0,0 +1,112 @@ +// R21 covered TV capital controls: fractional lots worth more than one unit +// still receive rounded-money margin calls. Synthetic three-bar fixtures pin +// the BTC low waypoint and XAU opening valuation without any corpus execution. +#include +#include +#include +#include +#include + +using namespace pineforge; +namespace { +constexpr double qnan=std::numeric_limits::quiet_NaN(); +int failures=0,passed=0; +#define CHECK(x) do {if(x)++passed;else{++failures;std::printf("FAIL %d %s\n",__LINE__,#x);}}while(0) +bool near(double a,double b){return std::abs(a-b)<1e-7;} +class Probe : public BacktestEngine { +public: + double explicit_qty=qnan; + Probe(double capital,double step,double tick) { + initial_capital_=capital;default_qty_type_=QtyType::PERCENT_OF_EQUITY; + default_qty_value_=100;qty_step_=step;syminfo_mintick_=tick; + commission_value_=0;slippage_=0; + } + void fixed_default(){default_qty_type_=QtyType::FIXED;default_qty_value_=1;} + void small_fee(){commission_type_=CommissionType::CASH_PER_ORDER;commission_value_=0.000001;} + void one_tick_slippage(){slippage_=1;} + void constant_fx(){account_currency_fx_=2;} + void double_point_value(){syminfo_.pointvalue=2;} + void larger_pyramid_cap(){pyramiding_=2;} + void on_bar(const Bar&) override { + if(bar_index_==0)strategy_entry("L",true,qnan,qnan,explicit_qty); + if(bar_index_==1)strategy_close("L"); + } + int margin_count()const{ + int n=0;for(const auto&t:trades_)if(t.exit_comment=="Margin call")++n;return n; + } + const Trade* margin()const{ + for(const auto&t:trades_)if(t.exit_comment=="Margin call")return &t;return nullptr; + } + double final_position()const{return signed_position_size();} +}; +std::vector btc(){return { + {105157.56,105481.78,105157.56,105380.95,100,1000}, + {105380.96,105504.67,105355.26,105496.54,100,2000}, + {105496.54,105737.84,105494.72,105737.82,100,3000}};} +std::vector xau(){return { + {3145.45,3146.31,3130.63,3132.08,100,1000}, + {3132.085,3132.88,3126.665,3130.84,100,2000}, + {3130.83,3138.26,3130.15,3136.37,100,3000}};} +void run(Probe&p,const std::vector&bars){p.run(bars.data(),static_cast(bars.size()));} +void check_margin(Probe&p,double price,int expected){ + CHECK(p.margin_count()==expected);CHECK(near(p.final_position(),0)); + if(expected&&p.margin()){ + CHECK(near(p.margin()->qty,1));CHECK(near(p.margin()->exit_price,price)); + CHECK(p.margin()->exit_time==2000); + } +} +void test_btc_cash_boundary(){ + for(double offset : {-0.0001,0.0,0.0001,0.001}){ + Probe p(1125876.4774201+offset,0.00001,0.01); + run(p,btc());check_margin(p,105355.26,offset<=0?1:0); + } + Probe fixed(1125876.4774201,0.00001,0.01); + fixed.fixed_default();fixed.explicit_qty=10.68387; + run(fixed,btc());check_margin(fixed,105355.26,1); + run(fixed,btc());check_margin(fixed,105355.26,1); // reuse +} +void test_xau_opening_cash_boundary(){ + for(double cash : {0.00001,0.001}){ + Probe p(939656.82085+cash,0.01,0.001); + p.fixed_default();p.explicit_qty=300.01; + run(p,xau());check_margin(p,3132.085,cash<0.0001?1:0); + } +} +void test_btc_opening_valuation(){ + Probe p(1125877.5309348,0.00001,0.01); + p.fixed_default();p.explicit_qty=10.68388; + run(p,btc());check_margin(p,105380.96,1); +} +void test_continuous_and_integer_excluded(){ + Probe continuous(1125876.4774201,0,0.01); + continuous.fixed_default();continuous.explicit_qty=10.68387; + run(continuous,btc());check_margin(continuous,0,0); + Probe integer(1125876.4774201,1,0.01); + integer.fixed_default();integer.explicit_qty=10; + run(integer,btc());check_margin(integer,0,0); +} +void test_other_money_paths_preserved(){ + Probe fee(1125876.4774211,0.00001,0.01); + fee.fixed_default();fee.explicit_qty=10.68387;fee.small_fee(); + run(fee,btc());check_margin(fee,0,0); + Probe slipped(1125876.5842588,0.00001,0.01); + slipped.fixed_default();slipped.explicit_qty=10.68387;slipped.one_tick_slippage(); + run(slipped,btc());check_margin(slipped,0,0); + for(bool fx : {false,true}){ + Probe converted(2251752.9542404,0.00001,0.01); + converted.fixed_default();converted.explicit_qty=10.68387; + if(fx)converted.constant_fx();else converted.double_point_value(); + run(converted,btc());check_margin(converted,0,0); + } + Probe cap(1125876.4774201,0.00001,0.01); + cap.fixed_default();cap.explicit_qty=10.68387;cap.larger_pyramid_cap(); + run(cap,btc());check_margin(cap,0,0); +} +} +int main(){ + test_btc_cash_boundary();test_xau_opening_cash_boundary(); + test_btc_opening_valuation(); + test_continuous_and_integer_excluded(); + test_other_money_paths_preserved(); + std::printf("%d passed, %d failed\n",passed,failures);return failures?1:0; +} From fb97ebd5fb8bc2038c19b74c6e741dc422756403 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 7 Sep 2026 10:25:32 +0800 Subject: [PATCH 2/2] fix: bind fractional margin extension to actual market-open fills --- include/pineforge/engine.hpp | 7 +++-- src/engine_fills.cpp | 19 ++++++++++--- ...est_high_value_fractional_money_margin.cpp | 27 ++++++++++++++++++- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 461d395..42e0027 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -75,8 +75,8 @@ enum class PositionSide { FLAT, LONG, SHORT }; // rule 1 sizes every lot-stepped instrument (tv_money_lot_sizing); rules // 2 and 5 stay scoped by tv_money_scope — outside it the exact fill-price // admission already decides (1094521.681 -> Q 4584 dropped on AAPL). Rule 3 -// additionally covers ordinary, fee/slippage-free single-position fractional -// unit-pointvalue/same-currency books with lot value >=1 (R21 BTC/XAU pins). +// additionally covers ordinary MARKET-opened, fee/slippage-free single-position +// fractional unit-pointvalue/same-currency books with lot value >=1 (R21 pins). // 5. WHOLE-ORDER DROP (round 9 family R follow-up, campaign notes // log-20260905t205824z-af397c83 and log-20260905t210117z-ab914192; // the residual of log-20260905t180249z-4bd857ad): once the rounded-cost @@ -201,6 +201,9 @@ struct PyramidEntry { // physical lot by FIFO. Its logical slot cannot later be released merely // because an owner-bound bracket closes the last physical remainder. bool bracket_slot_shadowed = false; + // Exact ordinary MARKET fill at the next bar open. Priced/RAW entries + // cannot infer this provenance from an equal numeric entry price. + bool ordinary_market_open = false; }; struct Trade { diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index e09be5b..a04a896 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -1500,7 +1500,8 @@ void BacktestEngine::process_margin_call(const Bar& bar) { // 1.15217, 12-30 06:15Z H 1.17798); 0 false fires over 612 every-bar sensor // longs (their equity sits below 1e6). One broker event per bar, plain // close-calc dispatch only (the pinned tapes). R21 also pins fee/slippage-free -// single-lot fractional books whose minimum lot is worth >=1 account unit: +// single fractional lots opened by an ordinary MARKET entry, whose minimum +// lot is worth >=1 account unit: // BTC Q10.68387 at105380.96, cash0.0003048999 fires1 at low105355.26; // +0.0001cash does not. BTC Q10.68388 and XAU Q300.01 with0.00001cash // fire at the opening price itself. Other money/admission scopes stay fixed. @@ -1565,7 +1566,8 @@ bool BacktestEngine::tv_money_long_margin_call(const Bar& bar, && !process_orders_on_close_ && commission_value_ == 0.0 && slippage_ == 0 && syminfo_.pointvalue == 1.0 && active_account_currency_fx() == 1.0 && pyramiding_ >= 0 && pyramiding_ <= 1 - && position_entry_count_ == 1 && pyramid_entries_.size() == 1; + && position_entry_count_ == 1 && pyramid_entries_.size() == 1 + && pyramid_entries_.front().ordinary_market_open; if (!std::isfinite(bar.close) || bar.close <= 0.0 || !(legacy_money_scope || high_value_fractional_scope)) return false; // Pinned on same-currency accounts only. A converted (quote -> account @@ -1598,7 +1600,7 @@ bool BacktestEngine::tv_money_long_margin_call(const Bar& bar, start = seg + 1; // The newly covered high-value fractional pins include an immediate // post-entry valuation at O. Only a fill actually at O can inspect it; - // a priced entry later on the path still skips earlier waypoints. + // the exact MARKET-origin marker excludes priced and RAW entries. if (high_value_fractional_scope && fill_pos == 0.0 && position_entry_price_ == round_to_mintick(bar.open)) start = 0; } @@ -5741,6 +5743,17 @@ void BacktestEngine::apply_filled_order_to_state( } if (primary_fill_applied) { + if (order.type == OrderType::MARKET + && !process_orders_on_close_ && !calc_on_order_fills_ + && !bar_magnifier_enabled_ && !coof_scheduler_active_ + && !stream_warmup_mode_ && stream_phase_ == StreamPhase::IDLE + && order.created_bar == bar_index_ - 1 + && pyramid_entries_.size() == 1 + && pyramid_entries_.front().entry_incarnation == order.incarnation + && pyramid_entries_.front().entry_bar_index == bar_index_ + && position_entry_price_ == round_to_mintick(bar.open)) { + pyramid_entries_.front().ordinary_market_open = true; + } ++broker_fill_event_seq_; } diff --git a/tests/test_high_value_fractional_money_margin.cpp b/tests/test_high_value_fractional_money_margin.cpp index 2494516..d1d51d6 100644 --- a/tests/test_high_value_fractional_money_margin.cpp +++ b/tests/test_high_value_fractional_money_margin.cpp @@ -16,6 +16,8 @@ bool near(double a,double b){return std::abs(a-b)<1e-7;} class Probe : public BacktestEngine { public: double explicit_qty=qnan; + double entry_limit=qnan, entry_stop=qnan; + bool raw_order=false; Probe(double capital,double step,double tick) { initial_capital_=capital;default_qty_type_=QtyType::PERCENT_OF_EQUITY; default_qty_value_=100;qty_step_=step;syminfo_mintick_=tick; @@ -28,7 +30,10 @@ class Probe : public BacktestEngine { void double_point_value(){syminfo_.pointvalue=2;} void larger_pyramid_cap(){pyramiding_=2;} void on_bar(const Bar&) override { - if(bar_index_==0)strategy_entry("L",true,qnan,qnan,explicit_qty); + if(bar_index_==0){ + if(raw_order)strategy_order("L",true,explicit_qty,entry_limit,entry_stop); + else strategy_entry("L",true,entry_limit,entry_stop,explicit_qty); + } if(bar_index_==1)strategy_close("L"); } int margin_count()const{ @@ -102,11 +107,31 @@ void test_other_money_paths_preserved(){ cap.fixed_default();cap.explicit_qty=10.68387;cap.larger_pyramid_cap(); run(cap,btc());check_margin(cap,0,0); } +void test_priced_entries_do_not_enter_market_extension(){ + for(bool stop : {false,true}){ + Probe p(1125876.4774201,0.00001,0.01); + p.fixed_default();p.explicit_qty=10.68387; + if(stop)p.entry_stop=105380.95;else p.entry_limit=105380.97; + run(p,btc());check_margin(p,0,0); + } + Probe p(1129689.1229734,0.00001,0.01); + p.fixed_default();p.explicit_qty=10.68387; + p.entry_stop=105737.83;p.entry_limit=105737.82; + const std::vector bars={ + {105496.54,105737.84,105494.72,105737.82,100,1000}, + {105737.82,105737.83,105458.83,105476.19,100,2000}, + {105476.19,105551.68,105439.73,105490.68,100,3000}}; + run(p,bars);check_margin(p,0,0); + Probe raw(1125876.4774201,0.00001,0.01); + raw.fixed_default();raw.explicit_qty=10.68387;raw.raw_order=true; + run(raw,btc());check_margin(raw,0,0); +} } int main(){ test_btc_cash_boundary();test_xau_opening_cash_boundary(); test_btc_opening_valuation(); test_continuous_and_integer_excluded(); test_other_money_paths_preserved(); + test_priced_entries_do_not_enter_market_extension(); std::printf("%d passed, %d failed\n",passed,failures);return failures?1:0; }