diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3314c26..e116e5b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -62,6 +62,7 @@ set(TEST_SOURCES test_fills_edge test_limit_fill_slippage test_strategy_commands_extra + test_multi_tier_exit_precedence test_lower_tf_parse_extra test_ta_ma_warmup_extra test_ta_osc_edge diff --git a/tests/test_multi_tier_exit_precedence.cpp b/tests/test_multi_tier_exit_precedence.cpp new file mode 100644 index 0000000..80dc986 --- /dev/null +++ b/tests/test_multi_tier_exit_precedence.cpp @@ -0,0 +1,404 @@ +/* + * test_multi_tier_exit_precedence.cpp — pin fill behaviour for multiple + * partial strategy.exit(..., qty_percent=...) siblings sharing a from_entry + * when several of their stop/limit levels sit inside the same bar's range. + * + * Isolates the mechanism probed by probe-multi-tier-exit and the joat-caldera + * 3-tier TP/stop shape: + * - Three partial limit exits (TP1/TP2/TP3) with qty_percent cascade. + * - Three partial siblings that SHARE a stop but have distinct limits. + * - A bar whose OHLC range touches the shared stop AND >=1 limit. + * + * The engine resolves each sibling independently via resolve_exit_path_fill + * on the synthesised 4-waypoint OHLC path (O→H→L→C or O→L→H→C depending on + * which extreme is nearer the open). When the path visits a limit first, that + * sibling fills at its own limit; when it visits the shared stop first, every + * sibling whose limit was NOT already touched fills at the stop level. + * + * qty_percent cascade: each sibling's reserved_qty is clamped against the + * position remaining AFTER already-placed siblings, so a "full" (qp=100) + * sibling placed last reserves only the remainder — firing order among the + * siblings does not change the per-tier qty. Verified by pinning each tier's + * closed qty below. + */ + +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace pineforge; + +static int tests_passed = 0; +static int tests_failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +static bool near(double a, double b, double tol = 1e-6) { + return std::fabs(a - b) <= tol; +} + +static constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +static Bar mk(double o, double h, double l, double c, int64_t ts) { + Bar b; + b.open = o; b.high = h; b.low = l; b.close = c; + b.volume = 1000.0; b.timestamp = ts; + return b; +} + +// ───────────────────────────────────────────────────────────────────── +// Test 1: Three partial LIMIT exits (no shared stop), distinct limits +// all touched on one bar's UP leg. Each tier fills at its OWN limit +// price (not the bar high, not the open). qty_percent cascade: TP1=40, +// TP2=33, TP3=100(remainder). +// +// Position: long 10 contracts @100. Limits: TP1=102, TP2=104, TP3=106. +// Trigger bar: O=100, H=108, L=100, C=107. All three limits in [100,108]. +// Path: |H-O|=8, |O-L|=0 → high_first=false → O→L→H→C. +// Segment L(100)→H(108): TP1@102 (t=2/8=0.25), TP2@104 (0.5), TP3@106 (0.75). +// TP1 fills first at 102, TP2 at 104, TP3 at 106. +// ───────────────────────────────────────────────────────────────────── +static void test_three_partial_limits_each_at_own_price() { + std::printf("test_three_partial_limits_each_at_own_price\n"); + class Probe : public BacktestEngine { + public: + Probe() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 10.0; + slippage_ = 0; + commission_value_ = 0; + pyramiding_ = 1; + syminfo_mintick_ = 0.01; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("L", true, kNaN, kNaN, kNaN, "enter"); + } + if (bar_index_ == 1 && position_side_ == PositionSide::LONG) { + strategy_exit("TP1", "L", /*limit=*/102.0, /*stop=*/kNaN, + kNaN, kNaN, kNaN, /*qty_percent=*/40.0, "", kNaN, ""); + strategy_exit("TP2", "L", /*limit=*/104.0, /*stop=*/kNaN, + kNaN, kNaN, kNaN, /*qty_percent=*/33.0, "", kNaN, ""); + strategy_exit("TP3", "L", /*limit=*/106.0, /*stop=*/kNaN, + kNaN, kNaN, kNaN, /*qty_percent=*/100.0, "", kNaN, ""); + } + } + }; + Probe p; + Bar bars[4] = { + mk(100, 101, 99, 100, 900'000), // bar0: place market entry + mk(100, 101, 99, 100, 1'800'000), // bar1: entry fills @100, arm TPs + mk(100, 108, 100, 107, 2'700'000), // bar2: all three limits touched + mk(107, 108, 106, 107, 3'600'000), // bar3: settle + }; + p.run(bars, 4); + + CHECK(p.trade_count() == 3); + if (p.trade_count() != 3) return; + + // Trades are emitted in fill order: TP1 first, TP2 second, TP3 last. + // Each fills at its OWN limit price. + CHECK(near(p.get_trade(0).exit_price, 102.0)); // TP1 + CHECK(near(p.get_trade(1).exit_price, 104.0)); // TP2 + CHECK(near(p.get_trade(2).exit_price, 106.0)); // TP3 + + // qty cascade: TP1=40% of 10 = 4, TP2=33% of 10 = 3.3, TP3=remainder=2.7 + CHECK(near(p.get_trade(0).qty, 4.0)); + CHECK(near(p.get_trade(1).qty, 3.3, 1e-4)); + CHECK(near(p.get_trade(2).qty, 2.7, 1e-4)); + + // All three entries @100 + for (size_t i = 0; i < 3; ++i) { + CHECK(near(p.get_trade(i).entry_price, 100.0)); + } +} + +// ───────────────────────────────────────────────────────────────────── +// Test 2: Three partial siblings SHARING a stop, distinct limits. The +// bar's DOWN leg touches the shared stop BEFORE the UP leg reaches any +// limit. All three siblings fill at the STOP price (the OCO bracket +// cancels the limit when the stop fires first on the path). +// +// Position: long 10 @100. Stop=96 (shared). Limits: TP1=103, TP2=105, TP3=107. +// Trigger bar: O=100, H=102, L=94, C=96. Path: |H-O|=2, |O-L|=6 → high-first +// → O→H→L→C. Segment O(100)→H(102): no limit touched (all > 102). Segment +// H(102)→L(94): stop 96 in [94,102] → stop fires at 96. +// So all three siblings' stop fires at 96 (limit was not reached first). +// ───────────────────────────────────────────────────────────────────── +static void test_shared_stop_fires_all_siblings_at_stop_price() { + std::printf("test_shared_stop_fires_all_siblings_at_stop_price\n"); + class Probe : public BacktestEngine { + public: + Probe() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 10.0; + slippage_ = 0; + commission_value_ = 0; + pyramiding_ = 1; + syminfo_mintick_ = 0.01; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("L", true, kNaN, kNaN, kNaN, "enter"); + } + if (bar_index_ == 1 && position_side_ == PositionSide::LONG) { + strategy_exit("TP1", "L", /*limit=*/103.0, /*stop=*/96.0, + kNaN, kNaN, kNaN, /*qty_percent=*/35.0, "", kNaN, ""); + strategy_exit("TP2", "L", /*limit=*/105.0, /*stop=*/96.0, + kNaN, kNaN, kNaN, /*qty_percent=*/35.0, "", kNaN, ""); + strategy_exit("TP3", "L", /*limit=*/107.0, /*stop=*/96.0, + kNaN, kNaN, kNaN, /*qty_percent=*/100.0, "", kNaN, ""); + } + } + }; + Probe p; + Bar bars[4] = { + mk(100, 101, 99, 100, 900'000), + mk(100, 101, 99, 100, 1'800'000), + mk(100, 102, 94, 96, 2'700'000), // stop touched on down-leg, limits NOT reached + mk(96, 97, 95, 96, 3'600'000), + }; + p.run(bars, 4); + + CHECK(p.trade_count() == 3); + if (p.trade_count() != 3) return; + + // All three should fill at the shared stop price 96. + for (size_t i = 0; i < 3; ++i) { + CHECK(near(p.get_trade(i).exit_price, 96.0)); + } + + // qty: TP1=3.5, TP2=3.5, TP3=3.0 (remainder) + CHECK(near(p.get_trade(0).qty, 3.5)); + CHECK(near(p.get_trade(1).qty, 3.5)); + CHECK(near(p.get_trade(2).qty, 3.0, 1e-4)); + + // Position fully closed: sum of trade qty == original 10 + double total_qty = 0; + for (size_t i = 0; i < 3; ++i) total_qty += p.get_trade(i).qty; + CHECK(near(total_qty, 10.0)); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 3: Mixed precedence — bar's UP leg touches TP1's limit, then the +// DOWN leg touches the shared stop. TP1 fills at its limit; TP2 and TP3 +// fill at the stop. +// +// Position: long 10 @100. Stop=97 (shared). Limits: TP1=102, TP2=108, TP3=110. +// Trigger bar: O=100, H=103, L=96, C=97. Path: |H-O|=3 < |O-L|=4 → high-first +// → O→H→L→C. Segment O(100)→H(103): TP1@102 in [100,103] → fires at 102. +// TP2@108 and TP3@110 NOT in [100,103]. Then for TP2/TP3: segment H(103)→L(96): +// stop 97 in [96,103] → stop at 97. So TP1 fills at 102, TP2+TP3 at 97. +// ───────────────────────────────────────────────────────────────────── +static void test_mixed_limit_then_stop_precedence() { + std::printf("test_mixed_limit_then_stop_precedence\n"); + class Probe : public BacktestEngine { + public: + Probe() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 10.0; + slippage_ = 0; + commission_value_ = 0; + pyramiding_ = 1; + syminfo_mintick_ = 0.01; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("L", true, kNaN, kNaN, kNaN, "enter"); + } + if (bar_index_ == 1 && position_side_ == PositionSide::LONG) { + strategy_exit("TP1", "L", /*limit=*/102.0, /*stop=*/97.0, + kNaN, kNaN, kNaN, /*qty_percent=*/40.0, "", kNaN, ""); + strategy_exit("TP2", "L", /*limit=*/108.0, /*stop=*/97.0, + kNaN, kNaN, kNaN, /*qty_percent=*/30.0, "", kNaN, ""); + strategy_exit("TP3", "L", /*limit=*/110.0, /*stop=*/97.0, + kNaN, kNaN, kNaN, /*qty_percent=*/100.0, "", kNaN, ""); + } + } + }; + Probe p; + Bar bars[4] = { + mk(100, 101, 99, 100, 900'000), + mk(100, 101, 99, 100, 1'800'000), + mk(100, 103, 96, 97, 2'700'000), // high-first: TP1 limit on up-leg, stop on down-leg + mk(97, 98, 96, 97, 3'600'000), + }; + p.run(bars, 4); + + CHECK(p.trade_count() == 3); + if (p.trade_count() != 3) return; + + // TP1 fills at its limit 102, TP2+TP3 fill at stop 97. + // Fill order by path metric: TP1 (limit @ path_pos ~1.33 on O→H seg), + // then TP2 (stop @ path_pos ~2.14), then TP3 (stop @ path_pos ~2.14). + // TP2 and TP3 tie on the stop metric; TP3 (full) sorts before TP2 + // (partial) per the "full before partial" tiebreaker — but both fill + // at 97 regardless. + double prices[3] = {p.get_trade(0).exit_price, + p.get_trade(1).exit_price, + p.get_trade(2).exit_price}; + + // Exactly one trade should be at 102 (TP1), two at 97 (TP2+TP3 stop). + int at_102 = 0, at_97 = 0; + for (int i = 0; i < 3; ++i) { + if (near(prices[i], 102.0)) ++at_102; + else if (near(prices[i], 97.0)) ++at_97; + } + CHECK(at_102 == 1); + CHECK(at_97 == 2); + + // Total qty closed = 10 (full position). Individual: 4 + 3 + 3 = 10. + double total_qty = 0; + for (size_t i = 0; i < 3; ++i) total_qty += p.get_trade(i).qty; + CHECK(near(total_qty, 10.0)); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 4: Gap-through at open for partial limits. All three limits are +// BELOW the open. They gap-fill at the open price. Each gets the SAME +// fill price (the open), but the qty_percent cascade still splits +// correctly per-tier. +// +// Position: long 10 @100. Limits: TP1=98, TP2=96, TP3=94 (all below open). +// Trigger bar: O=99, H=100, L=94, C=97. Open 99 >= all limits → gap-fill +// at open=99 for all three. +// ───────────────────────────────────────────────────────────────────── +static void test_gap_through_open_all_partial_limits() { + std::printf("test_gap_through_open_all_partial_limits\n"); + class Probe : public BacktestEngine { + public: + Probe() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 10.0; + slippage_ = 0; + commission_value_ = 0; + pyramiding_ = 1; + syminfo_mintick_ = 0.01; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("L", true, kNaN, kNaN, kNaN, "enter"); + } + if (bar_index_ == 1 && position_side_ == PositionSide::LONG) { + // TP limits below entry: SELL limits below current price. + strategy_exit("TP1", "L", /*limit=*/98.0, /*stop=*/kNaN, + kNaN, kNaN, kNaN, /*qty_percent=*/40.0, "", kNaN, ""); + strategy_exit("TP2", "L", /*limit=*/96.0, /*stop=*/kNaN, + kNaN, kNaN, kNaN, /*qty_percent=*/33.0, "", kNaN, ""); + strategy_exit("TP3", "L", /*limit=*/94.0, /*stop=*/kNaN, + kNaN, kNaN, kNaN, /*qty_percent=*/100.0, "", kNaN, ""); + } + } + }; + Probe p; + Bar bars[4] = { + mk(100, 101, 99, 100, 900'000), + mk(100, 101, 99, 100, 1'800'000), + mk(99, 100, 94, 97, 2'700'000), // open above all limits → gap-fill + mk(97, 98, 96, 97, 3'600'000), + }; + p.run(bars, 4); + + CHECK(p.trade_count() == 3); + if (p.trade_count() != 3) return; + + // For LONG sell-limits with open >= limit: TV fills at the LIMIT price + // (limit-or-better), NOT at the open. The open is above the limit, so + // the limit is "already marketable" at the open — but TV fills limit + // orders at the limit price (no slippage on limits). The engine's + // try_exit_open_gap_fill returns bar.open for gap cases. This test + // pins the engine's current behaviour (fill at open for gap-through). + // All three should fill at the same price (open = 99). + for (size_t i = 0; i < 3; ++i) { + CHECK(near(p.get_trade(i).exit_price, 99.0)); + } + + // qty cascade still correct + CHECK(near(p.get_trade(0).qty, 4.0)); + CHECK(near(p.get_trade(1).qty, 3.3, 1e-4)); + CHECK(near(p.get_trade(2).qty, 2.7, 1e-4)); + + double total_qty = 0; + for (size_t i = 0; i < 3; ++i) total_qty += p.get_trade(i).qty; + CHECK(near(total_qty, 10.0)); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 5: Two limits and a stop, where only ONE limit is in range. The +// other limit and the stop are NOT touched. Only the in-range limit +// fires (partial close); the position stays open with the remainder. +// +// Position: long 10 @100. Limits: TP1=102, TP2=110 (out of range). Stop=90. +// Trigger bar: O=100, H=104, L=99, C=103. TP1@102 touched. TP2@110 and +// stop@90 NOT touched. Only TP1 fires. +// ───────────────────────────────────────────────────────────────────── +static void test_only_one_partial_fires_position_stays_open() { + std::printf("test_only_one_partial_fires_position_stays_open\n"); + class Probe : public BacktestEngine { + public: + Probe() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 10.0; + slippage_ = 0; + commission_value_ = 0; + pyramiding_ = 1; + syminfo_mintick_ = 0.01; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("L", true, kNaN, kNaN, kNaN, "enter"); + } + if (bar_index_ == 1 && position_side_ == PositionSide::LONG) { + strategy_exit("TP1", "L", /*limit=*/102.0, /*stop=*/90.0, + kNaN, kNaN, kNaN, /*qty_percent=*/40.0, "", kNaN, ""); + strategy_exit("TP2", "L", /*limit=*/110.0, /*stop=*/90.0, + kNaN, kNaN, kNaN, /*qty_percent=*/100.0, "", kNaN, ""); + } + } + }; + Probe p; + Bar bars[4] = { + mk(100, 101, 99, 100, 900'000), + mk(100, 101, 99, 100, 1'800'000), + mk(100, 104, 99, 103, 2'700'000), // TP1@102 touched, TP2@110 and stop@90 NOT + mk(103, 105, 102, 104, 3'600'000), + }; + p.run(bars, 4); + + // Only TP1 should fire (1 trade). Position stays open with 6 contracts. + CHECK(p.trade_count() == 1); + if (p.trade_count() < 1) return; + CHECK(near(p.get_trade(0).exit_price, 102.0)); + CHECK(near(p.get_trade(0).qty, 4.0)); + // Only 4 of 10 closed → 6 remain (position still open). + CHECK(near(p.get_trade(0).qty, 4.0)); // partial close only +} + +int main() { + test_three_partial_limits_each_at_own_price(); + test_shared_stop_fires_all_siblings_at_stop_price(); + test_mixed_limit_then_stop_precedence(); + test_gap_through_open_all_partial_limits(); + test_only_one_partial_fires_position_stays_open(); + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +}