diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 8d20d80..604be6e 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -199,6 +199,20 @@ struct PendingOrder { // probe 52 trade 113). Preserving the largest observed carry // across re-placements would over-extend chains. double tv_carry_qty = 0.0; + // Quantity frozen at PLACEMENT (signal) time for a DEFAULT-sized (qty=na) + // market order whose default sizing is price-dependent (percent_of_equity + // / cash) — see frozen_default_market_qty. NaN = not frozen. + // + // Deliberately NOT stored in ``qty``: that field doubles as the "an + // explicit qty was provided" flag, and several sites branch on + // ``std::isnan(o.qty)`` to recover "was this order default-sized?" — + // reduce_oca_group's default-sized cancel (engine_orders.cpp), the + // pending-reversal-entry binding (engine_strategy_commands.cpp), the OCA + // fully-filled heuristic and the partial-vs-full exit classification + // (engine_fills.cpp). Writing a frozen quantity into ``qty`` silently + // 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(); 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 @@ -861,6 +875,16 @@ class BacktestEngine { // closed-equity accessor used elsewhere, but size new default // percent orders from the live equity snapshot so pyramid adds // and same-bar/re-entry sizing see unrealized PnL. + // + // KNOWN RESIDUAL (unresolved, do not "fix" without a probe): + // for an entry sized while a position is open AND commission_ + // type=percent, TV appears to also deduct the entry fee already + // charged on the open lot (it debits at fill; we book both legs + // at close). Three independent reconstructions of TV's equity + // chain disagreed on whether that term belongs here — adding it + // made one strategy's predicted quantities exact and two others + // markedly worse, and it cut end-to-end coverage. Settle it + // with a clean-room TV probe (the KI-52 method), not algebra. double equity = current_equity() + open_profit(current_bar_.close); double cash = reserve_percent_commission(equity * (default_qty_value_ / 100.0)) / account_currency_fx_; // Reject (qty 0) on a non-finite / non-positive fill price — a @@ -875,6 +899,45 @@ class BacktestEngine { return apply_qty_step(default_qty_value_); } + // TradingView freezes DEFAULT (qty=na) market-order sizing at the SIGNAL + // bar — the bar whose on_bar issued the strategy.entry/strategy.order + // call — not at the fill: + // + // equity_S = initial_capital + realized net profit + // + open_profit(close(S)) // position may still be OPEN + // sizing_price = close(S) + slippage*mintick*(+1 buy / -1 sell) + // qty = floor_step( reserve_percent_commission(budget) + // / fx / (sizing_price * pointvalue) ) + // + // The market order then fills at the NEXT bar's open carrying this frozen + // quantity. calc_qty(price) implements exactly that shape when evaluated + // AT SIGNAL TIME (current_bar_ IS the signal bar: open_profit marks at + // close(S) and the divisor is the argument), so the freeze is simply + // calc_qty(slipped signal close) captured at placement. Evaluating the + // same expression at FILL time — the pre-freeze behavior — was wrong in + // three separable ways on a reversal/gap: it double-counted the just- + // closed position's PnL (current_equity() already realized the exit while + // position_* still held the stale lot for open_profit), it marked open + // profit at the FILL bar's close (a look-ahead: that close is unknown + // when the order fills at the open), and it divided by the fill price + // instead of the signal close. Freezing at placement removes all three. + // + // Only PERCENT_OF_EQUITY / CASH default sizing is price/equity-dependent; + // FIXED default sizing stays qty=NaN at placement (identical value at + // fill, and keeping NaN preserves the isnan(order.qty)-keyed semantics + // elsewhere, e.g. the OCA "fully filled" heuristic). + // + // 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 { + 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); + } + // --- Strategy variable accessors --- double signed_position_size() const { if (position_side_ == PositionSide::LONG) return position_qty_; diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index da8b465..f800439 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -761,7 +761,14 @@ void BacktestEngine::apply_market_order_fill(PendingOrder& order, double fill_pr const Bar& bar, double& trail_best_path_state, bool later_same_tick_entry) { - execute_market_entry(order.id, order.is_long, fill_price, order.qty, order.qty_type, + // A default-sized market order carries a quantity frozen at the signal + // bar's close; hand it through as fixed contracts (qty_type < 0) so the + // fill does not re-derive it from the fill price. Explicit-qty and + // FIXED-default orders keep their own (qty, qty_type) pair unchanged. + const bool frozen = !std::isnan(order.frozen_default_qty); + execute_market_entry(order.id, order.is_long, fill_price, + frozen ? order.frozen_default_qty : order.qty, + frozen ? -1 : order.qty_type, order.created_position_side, /*close_only_opposite=*/false, /*is_priced_entry=*/false, /*tv_carry_qty=*/0.0, order.created_bar, later_same_tick_entry); @@ -953,7 +960,9 @@ void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price bool& exit_closed_was_long) { if (position_side_ == PositionSide::FLAT) { fill_price = apply_fill_slippage(fill_price, order.is_long); - double qty = std::isnan(order.qty) ? calc_qty(fill_price) : order.qty; + // Prefer the signal-time frozen quantity when the order carries one. + double qty = !std::isnan(order.frozen_default_qty) ? order.frozen_default_qty + : (std::isnan(order.qty) ? calc_qty(fill_price) : order.qty); position_side_ = order.is_long ? PositionSide::LONG : PositionSide::SHORT; position_entry_price_ = fill_price; position_entry_time_ = current_bar_.timestamp; @@ -1005,7 +1014,9 @@ void BacktestEngine::apply_raw_order_fill(PendingOrder& order, double fill_price return; } fill_price = apply_fill_slippage(fill_price, order.is_long); - double new_qty = std::isnan(order.qty) ? calc_qty(fill_price) : order.qty; + // Prefer the signal-time frozen quantity when the order carries one. + double new_qty = !std::isnan(order.frozen_default_qty) ? order.frozen_default_qty + : (std::isnan(order.qty) ? calc_qty(fill_price) : order.qty); double total_qty = position_qty_ + new_qty; position_entry_price_ = (position_entry_price_ * position_qty_ + fill_price * new_qty) / total_qty; @@ -1134,6 +1145,9 @@ BacktestEngine::OrderEligibility BacktestEngine::classify_order_eligibility( // slippage-scale noise. bool flat_armed_opposite_close = flat_armed_opposite_same_bar; if (flat_armed_opposite_same_bar) { + // No frozen-qty lookup here: this branch is reached only for + // OrderType::ENTRY (priced entries), and frozen_default_qty is set + // solely on MARKET / RAW_ORDER placements, so it is always NaN. double approx_price = !std::isnan(order.stop_price) ? order.stop_price : (!std::isnan(order.limit_price) ? order.limit_price : bar.close); double approx_tx_qty = calc_qty_for_type(approx_price, order.qty, order.qty_type); diff --git a/src/engine_strategy_commands.cpp b/src/engine_strategy_commands.cpp index 29892ed..0d96fdb 100644 --- a/src/engine_strategy_commands.cpp +++ b/src/engine_strategy_commands.cpp @@ -122,6 +122,15 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, // non-deterministic — see corpus/parity-anomalies/README.md). // Only applied to MARKET entries (limit/stop entries have their // own price baked into the order itself). + // + // Scope: this signal-time gate covers EXPLICIT-qty market entries only + // (the empirical base above — parity-probe-04..06 — is all explicit + // ``qty = `` sizing). DEFAULT-sized (qty=na) market entries never + // reach it (qty is NaN here); their quantity is frozen at this bar's + // close further below (see frozen_default_market_qty), AFTER this gate, + // precisely so the freeze cannot accidentally activate a gate whose + // equity basis (realized-only current_equity()) was never validated + // for default sizing. if (!std::isnan(qty) && std::isnan(limit_price) && std::isnan(stop_price)) { double margin_pct = is_long ? margin_long_ : margin_short_; if (margin_pct > 0.0 && !std::isnan(current_bar_.close)) { @@ -213,6 +222,23 @@ void BacktestEngine::strategy_entry(const std::string& id, bool is_long, order.type = OrderType::MARKET; order.limit_price = std::numeric_limits::quiet_NaN(); order.stop_price = std::numeric_limits::quiet_NaN(); + // TradingView freezes DEFAULT (qty=na) percent_of_equity / cash + // market-order sizing at THIS (signal) bar's close — see + // frozen_default_market_qty (engine.hpp) for the rule and the + // empirical basis. current_bar_.close is close(S) right here, so + // placement is the one point where the frozen computation is + // naturally correct (no double count, no fill-bar look-ahead). + // FIXED default sizing needs no freeze: its fill-time value is + // identical. The frozen quantity goes in frozen_default_qty, NOT in + // order.qty — order.qty must stay NaN so every isnan(order.qty)-keyed + // "was this default-sized?" branch (OCA cancel, reversal binding, + // OCA fully-filled, partial-exit classification) keeps its meaning. + if (std::isnan(qty) + && (default_qty_type_ == QtyType::PERCENT_OF_EQUITY + || default_qty_type_ == QtyType::CASH) + && !std::isnan(current_bar_.close)) { + order.frozen_default_qty = frozen_default_market_qty(/*is_buy=*/is_long); + } } else { order.type = OrderType::ENTRY; order.limit_price = limit_price; @@ -675,6 +701,17 @@ void BacktestEngine::strategy_order(const std::string& id, bool is_long, double order.type = OrderType::RAW_ORDER; order.limit_price = std::numeric_limits::quiet_NaN(); order.stop_price = std::numeric_limits::quiet_NaN(); + // Same signal-time freeze as strategy_entry's MARKET branch: a + // default-sized strategy.order market order runs through the same + // TV default-sizing engine, so its quantity is frozen at this + // (signal) bar's close too. Stored off to the side (order.qty stays + // NaN) for the same reason as in strategy_entry. + if (std::isnan(qty) + && (default_qty_type_ == QtyType::PERCENT_OF_EQUITY + || default_qty_type_ == QtyType::CASH) + && !std::isnan(current_bar_.close)) { + order.frozen_default_qty = frozen_default_market_qty(/*is_buy=*/is_long); + } } else { order.type = OrderType::RAW_ORDER; order.limit_price = limit_price; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a4c27af..c0ef301 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -62,6 +62,7 @@ set(TEST_SOURCES test_timeframe_aggregator_extra test_c_abi_setters test_fills_edge + test_default_qty_signal_freeze test_limit_fill_slippage test_strategy_commands_extra test_multi_tier_exit_precedence diff --git a/tests/test_default_qty_signal_freeze.cpp b/tests/test_default_qty_signal_freeze.cpp new file mode 100644 index 0000000..697aa8c --- /dev/null +++ b/tests/test_default_qty_signal_freeze.cpp @@ -0,0 +1,375 @@ +/* + * test_default_qty_signal_freeze.cpp — TradingView freezes DEFAULT (qty=na) + * percent_of_equity / cash market-order sizing at the SIGNAL bar's close; + * the market order fills at the next bar's open carrying the frozen qty. + * + * Pins (see frozen_default_market_qty in engine.hpp for the rule): + * A. Reversal with close(S) != open(S+1): the new lot's qty equals + * equity_S / close(S) where equity_S = capital + realized + open mark at + * close(S) — computed with the OLD position still open. The pre-freeze + * fill-time evaluation was wrong three ways at once (double-counted the + * just-closed lot's PnL, marked open profit at the FILL bar's close, and + * divided by the fill price); a frozen qty of exactly equity_S/close(S) + * excludes all three. + * B. Flat entry with a close→open gap DOWN: qty = equity_S / close(S), not + * equity / open(S+1) — pins the divisor with no position in play. + * C. Flat entry with a close→open gap UP: still ADMITTED, and the qty stays + * the frozen equity_S / close(S) — a gap must neither re-size nor + * decline a flat, fully-affordable entry (the FLOOR in apply_qty_step + * guarantees qty*close(S) <= equity_S; the fill price plays no role in + * sizing). + * D. process_orders_on_close=true: signal bar == fill bar and fill price == + * close(S), so the frozen qty is identical to the legacy fill-time + * computation — POC sizing is unchanged. + * E. CASH default sizing freezes at close(S) too: qty = cash / close(S), + * not cash / open(S+1). + */ + +#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: runs a fixed action per bar_index. All prices in the tests +// are on-tick (mintick 0.01) so the zero-slippage directional snap is an +// identity and fills land exactly at the bar prices. +class Probe : public BacktestEngine { +public: + Probe(QtyType qty_type, double qty_value, bool poc) { + initial_capital_ = 10000.0; + default_qty_type_ = qty_type; + default_qty_value_ = qty_value; + commission_value_ = 0.0; + process_orders_on_close_ = poc; + // The all-in (100%) probes hold fully-leveraged positions whose + // liquidation price sits at the entry; disable forced liquidation so + // the sizing freeze is the only mechanism under test. + margin_call_enabled_ = false; + } + // action per bar: 'L' = default-sized long entry, 'S' = default-sized + // short entry, 'C' = close all, '.' = 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 'S': strategy_entry("S", false); break; + case 'C': strategy_close_all(); break; + default: break; + } + } + using BacktestEngine::position_qty_; + using BacktestEngine::position_side_; + const std::vector& all_trades() const { return trades_; } +}; + +// A. Percent-of-equity reversal, close(S) != open(S+1). +// +// bar0 100/100/100/100 on_bar: long entry (frozen: 10000/100 = 100) +// bar1 100/112/ 99/110 long fills @open 100 qty 100 +// on_bar: short entry — SIGNAL bar. equity_S = +// 10000 + (110-100)*100 = 11000 (long still OPEN, +// marked at close(S)=110); frozen qty = +// 11000/110 = 100 exactly. +// bar2 108/109/ 99/101 reversal fills @open 108: long closes (+800), +// short opens with the FROZEN qty 100. +// Pre-freeze fill-time sizing would have produced +// (10800 + 100)/108 = 100.9259... — the realized +// +800 double-counted via the stale open-profit +// mark at the FILL bar's close (101), divided by +// the fill price: all three defects at once. +// bar3 101/101/101/101 on_bar: close_all +// bar4 101/101/101/101 short closes @open 101 (+700) +void test_reversal_freeze() { + std::printf("-- A: percent_of_equity reversal freeze --\n"); + Probe eng(QtyType::PERCENT_OF_EQUITY, 100.0, /*poc=*/false); + eng.script = "LS.C."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 100, 112, 99, 110), + mk_bar(3000, 108, 109, 99, 101), + mk_bar(4000, 101, 101, 101, 101), + mk_bar(5000, 101, 101, 101, 101), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() == 2); + if (eng.trade_count() == 2) { + const Trade& t0 = eng.all_trades()[0]; + CHECK(t0.is_long); + CHECK_NEAR(t0.entry_price, 100.0, 1e-9); + CHECK_NEAR(t0.qty, 100.0, 1e-9); + CHECK_NEAR(t0.exit_price, 108.0, 1e-9); + CHECK_NEAR(t0.pnl, 800.0, 1e-9); + const Trade& t1 = eng.all_trades()[1]; + CHECK(!t1.is_long); + CHECK_NEAR(t1.entry_price, 108.0, 1e-9); + // THE pin: frozen at the signal bar (11000/110), NOT the fill-time + // double-count (100.9259...). + CHECK_NEAR(t1.qty, 100.0, 1e-9); + CHECK_NEAR(t1.exit_price, 101.0, 1e-9); + CHECK_NEAR(t1.pnl, 700.0, 1e-9); + } +} + +// B. Flat entry, gap DOWN: divisor is close(S), not the fill price. +// bar0 100/100/100/100 on_bar: long entry — frozen 10000/100 = 100 +// bar1 98/ 98/ 98/ 98 fills @98: qty must stay 100 (legacy fill-time +// sizing would give 10000/98 = 102.04...). +// Admission: 100*98 = 9800 <= 10000 -> admitted. +void test_flat_gap_down_divisor() { + std::printf("-- B: flat entry, divisor = close(S) --\n"); + Probe eng(QtyType::PERCENT_OF_EQUITY, 100.0, /*poc=*/false); + eng.script = "L.C."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 98, 98, 98, 98), + mk_bar(3000, 98, 98, 98, 98), + mk_bar(4000, 98, 98, 98, 98), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() == 1); + if (eng.trade_count() == 1) { + const Trade& t0 = eng.all_trades()[0]; + CHECK_NEAR(t0.entry_price, 98.0, 1e-9); + CHECK_NEAR(t0.qty, 100.0, 1e-9); + } +} + +// C. Flat entry, gap UP: still admitted, qty stays frozen at equity_S / +// close(S). TradingView demonstrably takes flat all-in entries on gap-up +// bars (the FLOOR guarantees qty*close(S) <= equity_S, and TV's admission +// is based on the sizing notional, not the fill price) — a close→open gap +// must never decline or re-size a flat entry. +// bar0 100/100/100/100 on_bar: long entry — frozen qty 10000/100 = 100 +// bar1 102/103/101/102 fills @102 with qty 100 (legacy fill-time sizing +// would give 10000/102 = 98.04...) +void test_flat_gap_up_admitted() { + std::printf("-- C: flat entry on a gap up stays admitted, qty frozen --\n"); + Probe eng(QtyType::PERCENT_OF_EQUITY, 100.0, /*poc=*/false); + eng.script = "L.C."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 102, 103, 101, 102), + mk_bar(3000, 102, 102, 102, 102), + mk_bar(4000, 102, 102, 102, 102), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() == 1); + if (eng.trade_count() == 1) { + const Trade& t0 = eng.all_trades()[0]; + CHECK_NEAR(t0.entry_price, 102.0, 1e-9); + CHECK_NEAR(t0.qty, 100.0, 1e-9); + } +} + +// D. process_orders_on_close=true: unchanged. Signal bar == fill bar, fill +// price == close(S) — frozen and legacy sizing coincide. +// bar0 100/100/100/100 on_bar: long entry; fills same bar @close 100 +// qty = 10000/100 = 100 (as before the freeze) +// bar1 105/105/105/105 on_bar: close_all; fills same bar @close 105 +void test_poc_unchanged() { + std::printf("-- D: process_orders_on_close unchanged --\n"); + Probe eng(QtyType::PERCENT_OF_EQUITY, 100.0, /*poc=*/true); + eng.script = "LC"; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 105, 105, 105, 105), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() == 1); + if (eng.trade_count() == 1) { + const Trade& t0 = eng.all_trades()[0]; + CHECK_NEAR(t0.entry_price, 100.0, 1e-9); + CHECK_NEAR(t0.qty, 100.0, 1e-9); + CHECK_NEAR(t0.exit_price, 105.0, 1e-9); + CHECK_NEAR(t0.pnl, 500.0, 1e-9); + } +} + +// E. CASH default sizing freezes at close(S) too. +// bar0 100/100/100/100 on_bar: long entry — frozen 1000/100 = 10 +// bar1 98/... fills @98: qty 10, not 1000/98 = 10.204... +void test_cash_freeze() { + std::printf("-- E: cash default sizing freeze --\n"); + Probe eng(QtyType::CASH, 1000.0, /*poc=*/false); + eng.script = "L.C."; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 98, 98, 98, 98), + mk_bar(3000, 98, 98, 98, 98), + mk_bar(4000, 98, 98, 98, 98), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() == 1); + if (eng.trade_count() == 1) { + const Trade& t0 = eng.all_trades()[0]; + CHECK_NEAR(t0.entry_price, 98.0, 1e-9); + CHECK_NEAR(t0.qty, 10.0, 1e-9); + } +} + +// F. isnan(order.qty) semantics survive the freeze — OCA reduce. +// reduce_oca_group cancels a DEFAULT-sized sibling outright on any group +// fill (engine_orders.cpp: "default-sized: cancel"). If the freeze wrote +// the frozen quantity into order.qty, the sibling would instead take +// ``qty -= filled_qty`` and SURVIVE — here B (frozen 100) would live on +// as 95 after A's 5-lot close leg and open a phantom 95-lot short. +// bar0 100 on_bar: explicit long qty=5 ("L") +// bar1 100 L fills @100 (LONG 5); on_bar: two default-sized RAW shorts +// A + B in OCA group "G" (strategy.oca.reduce), frozen qty 100 +// bar2 100 A fills first: opposite raw fill closes the LONG (5 lots, +// filled_qty=5) -> reduce_oca_group must CANCEL default-sized B +// end position FLAT, exactly 1 trade (the closed long) +class OcaProbe : public BacktestEngine { +public: + OcaProbe() { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_value_ = 0.0; + margin_call_enabled_ = false; + } + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + strategy_entry("L", true, kNaN, kNaN, 5.0); + } else if (bar_index_ == 1) { + strategy_order("A", false, kNaN, kNaN, kNaN, "G", /*oca_type=*/2); + strategy_order("B", false, kNaN, kNaN, kNaN, "G", /*oca_type=*/2); + } + } + using BacktestEngine::position_side_; + const std::vector& all_trades() const { return trades_; } +}; + +void test_oca_default_sibling_cancelled() { + std::printf("-- F: default-sized OCA sibling still cancelled --\n"); + OcaProbe eng; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 100, 100, 100, 100), + mk_bar(3000, 100, 100, 100, 100), + mk_bar(4000, 100, 100, 100, 100), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::FLAT); + CHECK(eng.trade_count() == 1); + if (eng.trade_count() >= 1) { + CHECK_NEAR(eng.all_trades()[0].qty, 5.0, 1e-9); + } +} + +// G. isnan(order.qty) semantics survive the freeze — reversal-bracket +// binding. strategy_exit defers its reservation (qty=NaN -> full exit of +// the eventual lot) when its from_entry is a PENDING DEFAULT-sized entry +// OPPOSITE the live position (engine_strategy_commands.cpp, +// bind_to_pending_reversal_entry). If the freeze wrote into order.qty the +// binding test would see an explicit qty, freeze the bracket at the OLD +// position's size (1), and strand a 99-lot dust short when it fires. +// bar0 100 on_bar: explicit long qty=1 ("L") +// bar1 100 L fills @100 (LONG 1); on_bar: default-sized short +// "S" (frozen 10000/100 = 100) + bracket +// strategy.exit("SX", from_entry="S", stop=105) +// bar2 100 S fills @100: flip -> close LONG 1, open SHORT 100 +// bar3 100/106/100 SX buy-stop fires @105 -> must close the FULL 100 +// end position FLAT; short trade qty 100, pnl -500 +class ReversalBindProbe : public BacktestEngine { +public: + ReversalBindProbe() { + initial_capital_ = 10000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_value_ = 0.0; + margin_call_enabled_ = false; + } + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) { + strategy_entry("L", true, kNaN, kNaN, 1.0); + } else if (bar_index_ == 1) { + strategy_entry("S", false); + strategy_exit("SX", "S", kNaN, /*stop_price=*/105.0); + } + } + using BacktestEngine::position_side_; + const std::vector& all_trades() const { return trades_; } +}; + +void test_reversal_bracket_binding_survives_freeze() { + std::printf("-- G: default-sized reversal-bracket binding survives --\n"); + ReversalBindProbe eng; + std::vector bars = { + mk_bar(1000, 100, 100, 100, 100), + mk_bar(2000, 100, 100, 100, 100), + mk_bar(3000, 100, 100, 100, 100), + mk_bar(4000, 100, 106, 100, 100), + mk_bar(5000, 100, 100, 100, 100), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::FLAT); + CHECK(eng.trade_count() == 2); + if (eng.trade_count() == 2) { + const Trade& t1 = eng.all_trades()[1]; + CHECK(!t1.is_long); + CHECK_NEAR(t1.qty, 100.0, 1e-9); // full frozen lot, no 99-lot dust + CHECK_NEAR(t1.entry_price, 100.0, 1e-9); + CHECK_NEAR(t1.exit_price, 105.0, 1e-9); + CHECK_NEAR(t1.pnl, -500.0, 1e-9); + } +} + +} // namespace + +int main() { + std::printf("--- default_qty_signal_freeze ---\n"); + test_reversal_freeze(); + test_flat_gap_down_divisor(); + test_flat_gap_up_admitted(); + test_poc_unchanged(); + test_cash_freeze(); + test_oca_default_sibling_cancelled(); + test_reversal_bracket_binding_survives_freeze(); + std::printf("\n=== Results: %d passed, %d failed ===\n", + tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +}