diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index d6c6abb..64ec184 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -1627,6 +1627,12 @@ class BacktestEngine { // PendingOrder::dormant_trail_best). double trail_best_before_bar_ = std::numeric_limits::quiet_NaN(); int trail_best_before_bar_index_ = -1; + // The ordinary POOC close scan may revisit a retained trail with that + // same pre-bar extreme only while the carried position is unchanged. + // A new cycle, add, reduction or close-time trail restart keeps its own + // established path state instead of inheriting an earlier position's. + int64_t trail_best_before_bar_position_cycle_ = 0; + uint64_t trail_best_before_bar_fill_seq_ = 0; // --- Intraday fill counter --- // Counts every fill processed by ``apply_filled_order_to_state`` on @@ -3696,7 +3702,11 @@ class BacktestEngine { // round 8 family R / round 10 family AB: the 10-significant-digit // margin-call trigger on a margin-100 LONG (process_margin_call; rule // and pins on tv_money_long_margin_call in engine_fills.cpp). - bool tv_money_long_margin_call(const Bar& bar); + // The POOC extension is called only before the close-time script, with + // no pending broker orders. End-of-bar callers keep it disabled so a + // close/add cannot make earlier prices act on the post-close position. + bool tv_money_long_margin_call(const Bar& bar, + bool carried_pooc_pre_close = false); // finding-311: mark the live position's standing strategy.exit brackets // dormant when an in-position reversal entry is declined at fill. void mark_position_brackets_dormant_on_declined_reversal(const Bar& bar); diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 7e44b5b..04a18bd 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -1381,14 +1381,40 @@ void BacktestEngine::process_margin_call(const Bar& bar) { // pins in the workflow repo): cash 0.0001 / 0.0002 fire at 1606.17, 0.0003+ // never (the residual there is 0.00029); 07-21 Q 270.621 cash <= 0.0003 fires // at the fill bar's high 3734.89 (residual 0.00031), 0.0004+ never. -bool BacktestEngine::tv_money_long_margin_call(const Bar& bar) { +// Round 13 D: r12-d-residual exact/default+explicit and +/-0.0001 capital +// controls pin this same rule on a CARRIED POOC long. Q 878945.99 at 1.17987, +// C 1037042.0056329: next bar low 1.17905 produces a 0.0000789 deficit +// and TV closes 1. The Q 878945.98 matched close/stop pair proves a POOC close +// fill must not revisit the entry bar's earlier high. Fresh full/30% closes +// on the trigger bar read PS 878944.99 before sizing their close orders +// (log-20260906t091207z-83d4bea0), so dispatch_bar calls this BEFORE on_bar. +bool BacktestEngine::tv_money_long_margin_call(const Bar& bar, + bool carried_pooc_pre_close) { if (!margin_call_enabled_) return false; if (position_side_ != PositionSide::LONG) return false; if (!std::isfinite(margin_long_) || std::abs(margin_long_ / 100.0 - 1.0) >= 1e-12) return false; if (last_margin_call_event_bar_ == bar_index_) return false; if (intrabar_exit_margin_call_bar_ == bar_index_) return false; - if (process_orders_on_close_ || calc_on_order_fills_ + if (process_orders_on_close_) { + // This extension has no oracle for a pending order racing the money + // trigger, adds, fees/slippage, currency conversion or risk-forced + // exits. Keep their prior POOC behavior. End-of-bar calls stay out + // even when the script merely reduced an older position: its current + // quantity did not exist over this bar's already-traversed path. + if (!carried_pooc_pre_close || position_open_bar_ < 0 + || position_open_bar_ >= bar_index_ || !pending_orders_.empty() + || opening_affordability_pending_ || pyramiding_ != 0 + || position_entry_count_ != 1 || pyramid_entries_.size() != 1 + || pyramid_entries_.front().entry_bar_index >= bar_index_ + || commission_value_ != 0.0 || slippage_ != 0 + || account_currency_fx_ != 1.0 || max_intraday_filled_orders_ > 0 + || risk_max_intraday_loss_ != 0.0 || risk_max_drawdown_ != 0.0 + || risk_max_cons_loss_days_ > 0) { + return false; + } + } + if (calc_on_order_fills_ || bar_magnifier_enabled_ || coof_scheduler_active_ || stream_warmup_mode_ || stream_phase_ != StreamPhase::IDLE) { return false; @@ -1425,6 +1451,7 @@ bool BacktestEngine::tv_money_long_margin_call(const Bar& bar) { } double fire_price = std::numeric_limits::quiet_NaN(); double deficit = 0.0; + int fire_path_point = -1; for (int i = start; i < 4; ++i) { const double p = path[i]; if (!std::isfinite(p) || !(p > 0.0)) continue; @@ -1442,6 +1469,7 @@ bool BacktestEngine::tv_money_long_margin_call(const Bar& bar) { if (equity + 1e-7 >= value && equity + 1e-7 < rounded_value) { fire_price = p; deficit = rounded_value - equity; + fire_path_point = i; break; } } @@ -1467,6 +1495,22 @@ bool BacktestEngine::tv_money_long_margin_call(const Bar& bar) { const double raw_exit_fill_base = bar_fill_price(fire_price); const size_t trades_before = trades_.size(); + if (process_orders_on_close_) { + // The pre-script pass precedes the ordinary full-bar excursion + // sample. Sample only the traversed waypoint prefix for this slice: + // the low-trigger pin includes the preceding high (MFE 0.00015), + // while the open-trigger control must not inherit that future high. + // update_per_trade_extremes is an arithmetic-only, non-throwing walk. + const Bar script_bar = current_bar_; + current_bar_.high = current_bar_.low = path[0]; + for (int i = 1; i <= fire_path_point; ++i) { + current_bar_.high = std::max(current_bar_.high, path[i]); + current_bar_.low = std::min(current_bar_.low, path[i]); + } + current_bar_.close = fire_price; + update_per_trade_extremes(); + current_bar_ = script_bar; + } if (qty_liq >= qty - kQtyEpsilon) { execute_market_exit(raw_exit_fill_base); } else { @@ -2238,6 +2282,8 @@ void BacktestEngine::update_trail_best_for_bar_open(const Bar& bar) { if (first_fold_this_bar) { trail_best_before_bar_ = trail_best_price_; trail_best_before_bar_index_ = bar_index_; + trail_best_before_bar_position_cycle_ = position_cycle_seq_; + trail_best_before_bar_fill_seq_ = broker_fill_event_seq_; } if (position_side_ == PositionSide::LONG) { if (std::isnan(trail_best_price_) || bar.high > trail_best_price_) @@ -4318,6 +4364,9 @@ void BacktestEngine::apply_filled_order_to_state( // reversals are admitted on their actual fill, and paired reentries may // fill from flat despite having been placed from a live position. bool admitted_flat_on_frozen_sizing_price = false; + // This call only: a true-flat positive gap admitted on rounded price + // still needs its existing opening-margin checkpoint after the fill. + bool admitted_flat_on_price_gap_band = false; if (order.type == OrderType::MARKET || order.type == OrderType::ENTRY) { PositionSide requested = order.is_long ? PositionSide::LONG : PositionSide::SHORT; @@ -4778,6 +4827,45 @@ void BacktestEngine::apply_filled_order_to_state( std::isfinite(order.sizing_fx) && order.sizing_fx > 0.0 ? order.sizing_fx : active_account_currency_fx(); + // Round 13 taro BTC, also pinned on ETH: for ordinary zero-fee + // default 100% market orders, a positive close-to-open gap compares + // the fill price with sig10(sig10(E_s) / Q), not exact Q*fill with E_s. + // BTC offsets -.00030 admit / -.00032 drop distinguish BOTH rounds. + // Keep the existing cost decision outside this directly pinned scope; + // in particular this does not widen tv_money_scope for other rules. + const bool price_gap_scope = + order.type == OrderType::MARKET + && std::isnan(order.qty) + && std::abs(default_qty_value_ - 100.0) < 1e-12 + && std::isfinite(margin_pct) + && std::abs(margin_pct - 100.0) < 1e-12 + && qty_step_ > 0.0 && qty_step_ < 1.0 + && syminfo_.pointvalue == 1.0 && sizing_fx == 1.0 + && account_currency_fx_timestamps_.empty() + && commission_type_ == CommissionType::PERCENT + && commission_value_ == 0.0 && slippage_ == 0 + && !process_orders_on_close_ && !calc_on_order_fills_ + && !bar_magnifier_enabled_ && !coof_scheduler_active_ + && !stream_warmup_mode_ && stream_phase_ == StreamPhase::IDLE + && !order.created_during_coof_recalc + && !order.created_after_position_close_in_bar + && std::isfinite(order.sizing_equity) + && std::isfinite(order.frozen_default_qty) + && std::isfinite(order.sizing_price) + && std::isfinite(order.sizing_mark) + && std::isfinite(fill_price) && fill_price > order.sizing_price + && ((position_side_ == PositionSide::FLAT + && order.created_position_side == PositionSide::FLAT + && !pending_flat_market_pair_is_live(order)) + || (reversal && order.created_position_side == position_side_ + && order.created_position_cycle_seq == position_cycle_seq_ + && pyramid_entries_.size() == 1)); + const auto price_gap_affordable = [&]() { + const double affordable_price = tv_money_round( + tv_money_round(order.sizing_equity) / order.frozen_default_qty); + return std::isfinite(affordable_price) + && affordable_price >= apply_fill_slippage(fill_price, order.is_long); + }; // Gap-reject (design-cntvxiao-gap-reject, PANEL-CLEARED; widened to // commissioned entries by the round-7 family-H market-entry-admission // pin, below): a high-level strategy.entry with omitted qty, sized @@ -4884,8 +4972,12 @@ void BacktestEngine::apply_filled_order_to_state( const double float_guard = std::max(1e-9, std::abs(order.sizing_equity) * 1e-12); if (gap_notional > order.sizing_equity + float_guard) { - decline_and_cancel(); - return; + if (price_gap_scope && price_gap_affordable()) { + admitted_flat_on_price_gap_band = true; + } else { + decline_and_cancel(); + return; + } } } // A same-direction add (fractional OR all-in) IS gated, against @@ -5003,7 +5095,10 @@ void BacktestEngine::apply_filled_order_to_state( * sizing_fx * (margin_pct / 100.0)); } - if (required_margin > free_funds + epsilon) { + const bool price_band_admitted_reversal = + reversal && price_gap_scope && price_gap_affordable(); + if (required_margin > free_funds + epsilon + && !price_band_admitted_reversal) { // design-declined-reversal-close-leg: ONLY the reversal decline // triggers close-leg suppression (admit_price == slipped fill, // MARKET). The same_dir add decline (probe65 shape) and the @@ -5681,6 +5776,11 @@ void BacktestEngine::apply_filled_order_to_state( && !order.created_after_position_close_in_bar && position_side_before_fill == PositionSide::FLAT && admitted_flat_on_frozen_sizing_price + // Newly price-band-admitted positive gaps can have a real + // fill deficit on either side (BTC/ETH flat MC1 tapes). + // Use the existing event/quantizer; exact-affordable fills + // keep the historical exemption and no persistent flag. + && !admitted_flat_on_price_gap_band && std::isfinite(new_opening_commission) && new_opening_commission == 0.0; @@ -7414,6 +7514,27 @@ BacktestEngine::FillEvaluation BacktestEngine::evaluate_fill_price( path_start_position = earliest_parent; } } + // Ordinary POOC scans retained orders before and after on_bar. + // The second call's trail_best_path_state already contains this + // bar's favorable extreme. Replaying O/H/L with that value can + // retroactively gap-fill a trail that only activated later on the + // path (Nils AAPL 2025-03-31: H 220.58 first arms the long, but + // the earlier O 219.56 was incorrectly reused as its exit). + // Rewalk a retained trail from the SAME pre-bar best on both scans. + // New/reissued orders, entry bars, changed position state, close + // restarts and the dedicated dormant/COOF/magnifier paths retain + // their existing state and chronology. + if (has_trail && order.type == OrderType::EXIT + && process_orders_on_close_ && !calc_on_order_fills_ + && !bar_magnifier_enabled_ && !order.dormant_bracket + && !is_entry_bar && order.created_bar < bar_index_ + && trail_close_restart_bar_ != bar_index_ + && trail_best_before_bar_index_ == bar_index_ + && position_cycle_seq_ != 0 + && trail_best_before_bar_position_cycle_ == position_cycle_seq_ + && trail_best_before_bar_fill_seq_ == broker_fill_event_seq_) { + trail_best_path_state = trail_best_before_bar_; + } ExitPathFill exit_fill = resolve_exit_path_fill( bar, tick_bar, diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 37f426a..4cd1e5c 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -177,8 +177,20 @@ void BacktestEngine::dispatch_bar() { // inlines its own on_bar call and pushes there instead. _push_source_series(); if (process_orders_on_close_) { + const bool no_pending_broker_orders = pending_orders_.empty(); + const uint64_t fills_before_pending = broker_fill_event_seq_; process_pending_orders(current_bar_); // step 1: old stop/limit evaluate_max_intraday_loss_over_path(current_bar_); + // Round 13 D: the carried 1x-long rounded-money event belongs before + // the close-time script. TV's full/30% close pins read the already + // reduced position here; an end-of-bar check would see the script's + // flattened/reduced state instead. The helper refuses pending-order + // interactions and every fresh entry, so it cannot replay a close + // fill's past path or move an existing broker fill across the event. + if (no_pending_broker_orders + && broker_fill_event_seq_ == fills_before_pending) { + tv_money_long_margin_call(current_bar_, /*carried_pooc_pre_close=*/true); + } update_per_trade_extremes(); // step 2: update before strategy reads invoke_chart_on_bar(current_bar_); // step 3: strategy logic flush_same_bar_close(); // step 3b: surviving strategy.close fill diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bf87d1a..b03a690 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -142,6 +142,7 @@ set(TEST_SOURCES test_close_percent_calltime_basis test_famag_close_first_admission test_famag_opening_money + test_taro_price_gap_admission test_live_position_market_gross_admission test_lower_tf_parse_extra test_ta_ma_warmup_extra @@ -158,6 +159,7 @@ set(TEST_SOURCES test_margin_call_trail_exit_chronology test_margin_call_1x_long_entry_fill test_tv_money_long_margin_call_eth + test_tv_money_carried_pooc test_margin_call_gap_open test_entry_bar_margin_path test_m_admission_36 @@ -186,6 +188,7 @@ set(TEST_SOURCES test_zero_offset_trail_rides test_trail_ref_entry_bar_extreme test_trail_close_restart_no_fold + test_pooc_retained_trail_path test_famx_declined_reversal_trail_leg test_famae_lot_sizing_ten_digit_equity test_famae_declined_reversal_trail_gap diff --git a/tests/test_pooc_retained_trail_path.cpp b/tests/test_pooc_retained_trail_path.cpp new file mode 100644 index 0000000..959a8b7 --- /dev/null +++ b/tests/test_pooc_retained_trail_path.cpp @@ -0,0 +1,242 @@ +/* + * A retained POOC trail must start each scan of the bar with the same + * pre-bar best. Activating at the second extreme does not make the earlier + * open or adverse extreme a later retrace. + * + * TradingView ws-report-v1 pins (2026-09-06), NASDAQ:AAPL 15m, all covered: + * r13-nils-long-retained 03-31 14:00Z 218.87 -> 16:00Z 219.56 + * r13-nils-short-retained 03-31 15:45Z 220.34 -> 16:15Z 220.18 + * r13-nils-{long,short}-retrace -> 15:45Z 220.46 / 16:00Z 219.78 + * r13-nils-long-active-gap -> 15:15Z 219.30 + * r13-nils-long-nonpooc 14:00Z 217.13 -> 16:00Z 219.56 + * r13-nils-long-restart 14:00Z 218.87 -> 17:00Z 220.01 + * r13-nils-long-new-at-close / long-newcycle: new long at 15:45Z + * 220.38 -> 16:00Z 220.37 + * + * Sources, raw report provenance and all byte hashes are in + * $PINEFORGE_PARITY_STATE/r13-nils/{pins,tv,pin-panel.json}. + * These small synthetic fixtures retain the decisive OHLC waypoints while + * omitting uneventful historical bars. They do not replay the strategy. + */ + +#include +#include +#include +#include + +#include +#include + +using namespace pineforge; + +namespace { +int passed = 0; +int failed = 0; +constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +#define CHECK(expr) do { \ + if (expr) { ++passed; } else { \ + ++failed; std::printf("FAIL line %d: %s\n", __LINE__, #expr); \ + } \ +} while (0) + +Bar mk(double open, double high, double low, double close) { + Bar bar{}; + bar.open = open; bar.high = high; bar.low = low; bar.close = close; + bar.volume = 100000; + return bar; +} + +enum class Action { None, Restart, NewCycle }; + +struct Probe final : BacktestEngine { + bool is_long = true; + double points = 150; + double offset = 100; + Action action = Action::None; + int action_bar = 2; + + explicit Probe(bool pooc = true) { + initial_capital_ = 25000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 8; + commission_type_ = CommissionType::CASH_PER_CONTRACT; + commission_value_ = 1.2; + slippage_ = 2; + pyramiding_ = 1; + process_orders_on_close_ = pooc; + calc_on_order_fills_ = false; + margin_long_ = margin_short_ = 1; + syminfo_mintick_ = 0.01; + } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("E", is_long, kNaN, kNaN, 8); + strategy_exit("X", "E", kNaN, kNaN, points, offset); + } + if (bar_index_ != action_bar) return; + if (action == Action::Restart) { + strategy_exit("X", "E", kNaN, kNaN, 151, 100); + } else if (action == Action::NewCycle) { + strategy_close_all(); + strategy_entry("E2", true, kNaN, kNaN, 8); + strategy_exit("X2", "E2", kNaN, kNaN, 1, 10); + } + } + + void run_fixture(std::vector bars) { + for (size_t i = 0; i < bars.size(); ++i) { + bars[i].timestamp = 1743429600000LL + i * 900000; + } + run(bars.data(), static_cast(bars.size())); + CHECK(last_error().empty()); + } +}; + +std::vector long_path() { + return { + mk(217.11, 218.85, 217.01, 218.85), // close entry 218.87 + mk(219.27, 220.07, 219.27, 219.35), // prior best below activation + mk(219.56, 220.58, 219.53, 220.36), // low first, then activation + mk(220.355, 220.49, 219.56, 220.0), // actual retrace + }; +} + +std::vector short_path() { + return { + mk(219.56, 220.58, 219.53, 220.36), // close entry 220.34 + mk(220.355, 220.49, 219.56, 220.0), // high first, then activation + mk(220.01, 220.93, 219.87, 220.92), // actual retrace + }; +} + +void expect_trade(const Probe& p, int index, bool is_long, + int entry_bar, double entry_price, + int exit_bar, double exit_price, const char* exit_id = "X") { + CHECK(p.trade_count() > index); + if (p.trade_count() <= index) return; + const Trade& trade = p.get_trade(index); + std::printf("trade %d: %s %d @ %.8f -> %d @ %.8f [%s]\n", + index, trade.is_long ? "long" : "short", trade.entry_bar_index, + trade.entry_price, trade.exit_bar_index, trade.exit_price, + trade.exit_id.c_str()); + CHECK(trade.is_long == is_long); + CHECK(trade.entry_bar_index == entry_bar); + CHECK(std::abs(trade.entry_price - entry_price) < 1e-9); + CHECK(trade.exit_bar_index == exit_bar); + CHECK(std::abs(trade.exit_price - exit_price) < 1e-9); + CHECK(std::abs(trade.qty - 8) < 1e-9); + CHECK(trade.exit_id == exit_id); +} + +void test_long_does_not_replay_earlier_open() { + Probe p; + p.run_fixture(long_path()); + CHECK(p.trade_count() == 1); + expect_trade(p, 0, true, 0, 218.87, 3, 219.56); +} + +void test_short_does_not_replay_earlier_open() { + Probe p; + p.is_long = false; p.points = 50; p.offset = 60; + p.run_fixture(short_path()); + CHECK(p.trade_count() == 1); + expect_trade(p, 0, false, 0, 220.34, 2, 220.18); +} + +void test_retrace_after_activation_still_fills_same_bar() { + Probe long_probe; + long_probe.offset = 10; + long_probe.run_fixture(long_path()); + CHECK(long_probe.trade_count() == 1); + expect_trade(long_probe, 0, true, 0, 218.87, 2, 220.46); + Probe short_probe; + short_probe.is_long = false; short_probe.points = 50; short_probe.offset = 20; + short_probe.run_fixture(short_path()); + CHECK(short_probe.trade_count() == 1); + expect_trade(short_probe, 0, false, 0, 220.34, 1, 219.78); +} + +void test_previously_active_trail_keeps_open_gap() { + Probe p; + p.points = 100; p.offset = 73; + p.run_fixture({long_path()[0], long_path()[1], + mk(219.32, 219.84, 218.97, 219.13)}); + CHECK(p.trade_count() == 1); + expect_trade(p, 0, true, 0, 218.87, 2, 219.30); +} + +void test_non_pooc_keeps_one_walk() { + Probe p(false); + p.points = 300; + auto bars = long_path(); + bars.insert(bars.begin(), mk(217.97, 218.19, 216.84, 217.11)); + p.run_fixture(bars); + CHECK(p.trade_count() == 1); + expect_trade(p, 0, true, 1, 217.13, 4, 219.56); +} + +void test_reissued_activation_keeps_close_restart() { + Probe p; + p.action = Action::Restart; + auto bars = long_path(); + bars.push_back(mk(220.01, 220.93, 219.87, 220.92)); + bars.push_back(mk(220.91, 221.03, 220.47, 220.505)); + bars.push_back(mk(220.53, 220.77, 220.09, 220.10)); + bars.push_back(mk(220.08, 220.74, 219.93, 220.46)); + p.run_fixture(bars); + CHECK(p.trade_count() == 1); + expect_trade(p, 0, true, 0, 218.87, 7, 220.01); +} + +void test_new_close_entry_does_not_inherit_pre_entry_extreme() { + Probe p; + p.points = 1; p.offset = 10; + p.run_fixture({long_path()[2], long_path()[3]}); + CHECK(p.trade_count() == 1); + expect_trade(p, 0, true, 0, 220.38, 1, 220.37); +} + +void test_same_bar_close_reentry_starts_new_position_cycle() { + Probe p; + p.action = Action::NewCycle; + p.run_fixture(long_path()); + CHECK(p.trade_count() == 2); + expect_trade(p, 0, true, 0, 218.87, 2, 220.34, "__close__"); + expect_trade(p, 1, true, 2, 220.38, 3, 220.37, "X2"); +} + +// Realtime processing sees a sequence of observed price points, not two +// replays of one inferred historical bar. A best reached by an earlier tick +// on the SAME bar must remain active on a later adverse tick. +void test_realtime_ticks_keep_previously_observed_best() { + for (bool is_long : {true, false}) { + Probe p; + p.is_long = is_long; p.points = 10; p.offset = 5; + const Bar warmup = mk(100, 100, 100, 100); + CHECK(p.stream_begin(&warmup, 1, "1", "1")); + CHECK(p.stream_push_tick(TradeTick{60001, 1, is_long ? 101.0 : 99.0, 1})); + CHECK(p.trade_count() == 0); + CHECK(p.stream_push_tick(TradeTick{60002, 2, is_long ? 100.94 : 99.06, 1})); + CHECK(p.trade_count() == 1); + expect_trade(p, 0, is_long, 0, is_long ? 100.02 : 99.98, + 1, is_long ? 100.92 : 99.08); + CHECK(p.stream_end(false)); + } +} +} // namespace + +int main() { + test_long_does_not_replay_earlier_open(); + test_short_does_not_replay_earlier_open(); + test_retrace_after_activation_still_fills_same_bar(); + test_previously_active_trail_keeps_open_gap(); + test_non_pooc_keeps_one_walk(); + test_reissued_activation_keeps_close_restart(); + test_new_close_entry_does_not_inherit_pre_entry_extreme(); + test_same_bar_close_reentry_starts_new_position_cycle(); + test_realtime_ticks_keep_previously_observed_best(); + std::printf("pooc_retained_trail_path: %d passed, %d failed\n", passed, failed); + return failed ? 1 : 0; +} diff --git a/tests/test_taro_price_gap_admission.cpp b/tests/test_taro_price_gap_admission.cpp new file mode 100644 index 0000000..4337551 --- /dev/null +++ b/tests/test_taro_price_gap_admission.cpp @@ -0,0 +1,254 @@ +/* + * Round 13 taro BTC: nested price-scale admission at an actual gap fill. + * TV tapes under state/r13-taro-audit and state/r13-taro-btc: + * BTC July03 Es910872.3625532,Q8.31589,close109533.95,fill109533.96. + * Offsets +.0001,0,-.0001,-.00030 admit; -.00032,-.00036,-.001 drop. + * -.00032 distinguishes sig10(sig10(E)/Q) from sig10(E/Q). + * ETH Apr01 Q10,close1821.47,fill1821.48: C18214.799997 admits + * (MC1 at fill, remainder9 on either side); C18214.799994 drops. + * Small synthetic fixtures preserve those prices and source calls. No + * corpus/feed/strategy/verifier is loaded. Existing exact-affordable and + * non-scope contracts must keep their old behavior. + */ +#include +#include +#include +#include +#include +#include + +using namespace pineforge; +static int passed = 0, failed = 0; +#define CHECK(expr) do { if (expr) ++passed; else { \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #expr); ++failed; \ +} } while (0) + +namespace { +constexpr double kNaN = std::numeric_limits::quiet_NaN(); +bool near(double a, double b, double tol = 1e-7) { + return std::abs(a-b) < tol; +} +struct Config { + double capital = 910872.3625532; + double step = 0.00001; + double tick = 0.01; + bool flat = true; + bool seed_long = true; + double seed_qty = 8.31589; + bool is_long = false; + int signal_bar = 2; + bool explicit_qty = false; + bool raw = false; + double fee = 0.0; + bool provider = false; + bool pooc = false; + bool coof = false; + bool magnifier = false; + bool close_first = false; +}; +class Probe : public BacktestEngine { +public: + explicit Probe(Config config) : cfg_(config) { + initial_capital_ = config.capital; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100; + commission_type_ = CommissionType::PERCENT; + commission_value_ = config.fee; + margin_long_ = margin_short_ = 100; + pyramiding_ = 1; + slippage_ = 0; + qty_step_ = config.step; + syminfo_.pointvalue = 1; + set_syminfo_mintick(config.tick); + process_orders_on_close_ = config.pooc; + calc_on_order_fills_ = config.coof; + set_margin_call_enabled(true); + if (config.provider) { + const int64_t times[] = {1000}; + const double rates[] = {1}; + CHECK(set_account_currency_fx_series(times, rates, 1)); + } + } + void on_bar(const Bar&) override { + if (!cfg_.flat && bar_index_ == 0) + strategy_entry("Seed", cfg_.seed_long, kNaN, kNaN, + cfg_.seed_qty, "SEED"); + if (bar_index_ == cfg_.signal_bar) { + if (cfg_.close_first) strategy_close("Seed"); + if (cfg_.raw) + strategy_order("Next", cfg_.is_long, kNaN); + else + strategy_entry("Next", cfg_.is_long, kNaN, kNaN, + cfg_.explicit_qty ? 8.31589 : kNaN, "ENTRY"); + } + if (bar_index_ == cfg_.signal_bar+1) strategy_close_all(); + } + const std::vector& rows() const { return trades_; } + double position() const { return signed_position_size(); } + bool magnifier() const { return cfg_.magnifier; } +private: + Config cfg_; +}; +std::vector btc() { + return { + {109393.88,109393.88,109393.88,109393.88,1,1000}, + {109393.88,109547.32,109382.93,109547.17,1,2000}, + {109547.16,109580,109471.6,109533.95,1,3000}, + {109533.96,109533.96,109377.57,109377.57,1,4000}, + {109377.57,109377.57,109377.57,109377.57,1,5000}, + {109377.57,109377.57,109377.57,109377.57,1,6000}, + }; +} +std::vector eth() { + return { + {1821.47,1821.47,1821.47,1821.47,1,1000}, + {1821.48,1829.36,1820.11,1826.38,1,2000}, + {1826.37,1826.37,1826.37,1826.37,1,3000}, + {1826.37,1826.37,1826.37,1826.37,1,4000}, + }; +} +void run(Probe& engine, const std::vector& bars) { + if (engine.magnifier()) + engine.run(bars.data(), static_cast(bars.size()), "1", "1", true, 4, + MagnifierDistribution::ENDPOINTS); + else + engine.run(bars.data(), static_cast(bars.size())); + CHECK(engine.last_error().empty()); + CHECK(near(engine.position(),0)); +} +void reversal_offsets() { + struct Offset { double delta; bool admit; }; + const Offset cases[] = {{.0001,true},{0,true},{-.0001,true}, + {-.00030,true},{-.00032,false},{-.00036,false},{-.001,false}}; + for (const auto& c : cases) { + Config cfg; + cfg.flat = false; + cfg.capital = 909707.5558409+c.delta; + Probe engine(cfg); + run(engine,btc()); + const auto& rows=engine.rows(); + CHECK(rows.size() == (c.admit ? 2u : 1u)); + if (rows.empty()) continue; + CHECK(near(rows[0].qty,8.31589)); + CHECK(rows[0].exit_time == (c.admit ? 4000 : 5000)); + if (c.admit && rows.size()==2) { + CHECK(near(rows[1].qty,8.31589)); + CHECK(near(rows[1].entry_price,109533.96)); + CHECK(rows[1].entry_time==4000); + CHECK(rows[1].exit_time==5000); + } + } +} +void opening_trim(Config cfg, const std::vector& bars, + double fill, double qty) { + Probe engine(cfg); + // Reuse the handle: a rescued-gap event cannot survive reset or replay. + for (int repeat=0;repeat<2;++repeat) { + run(engine,bars); + const auto& rows=engine.rows(); + CHECK(rows.size()==2); + if (rows.size()!=2) continue; + CHECK(rows[0].exit_comment=="Margin call"); + CHECK(near(rows[0].qty,1)); + CHECK(near(rows[0].entry_price,fill)); + CHECK(near(rows[0].exit_price,fill)); + CHECK(rows[0].entry_time==rows[0].exit_time); + CHECK(rows[0].entry_time==(cfg.signal_bar+2)*1000); + CHECK(near(rows[1].qty,qty-1)); + CHECK(rows[1].exit_comment!="Margin call"); + CHECK(rows[1].exit_time==(cfg.signal_bar+3)*1000); + CHECK(near(rows[0].qty+rows[1].qty,qty)); + } +} +void flat_and_eth_controls() { + for (bool is_long : {false,true}) { + Config cfg; cfg.is_long=is_long; + opening_trim(cfg,btc(),109533.96,8.31589); + cfg.capital=18214.799997;cfg.step=.0001;cfg.signal_bar=0; + opening_trim(cfg,eth(),1821.48,10); + } + Config drop;drop.capital=18214.799994;drop.step=.0001;drop.signal_bar=0; + Probe rejected(drop);run(rejected,eth());CHECK(rejected.rows().empty()); + + // Exactly affordable flat Long stays exempt: no new fill trim. + Config affordable;affordable.capital=910872.3626532;affordable.is_long=true; + Probe covered(affordable);run(covered,btc());CHECK(covered.rows().size()==1); + if (!covered.rows().empty()) { + CHECK(covered.rows()[0].exit_comment!="Margin call"); + CHECK(near(covered.rows()[0].qty,8.31589)); + } +} +void opposite_reversal() { + for (bool admit : {true,false}) { + Config cfg;cfg.flat=false;cfg.seed_long=false;cfg.seed_qty=1; + cfg.is_long=true;cfg.capital=admit?911012.4325532:911012.4322332; + Probe engine(cfg);run(engine,btc());const auto& rows=engine.rows(); + CHECK(rows.size()==(admit?3u:1u)); + if (rows.empty()) continue; + CHECK(near(rows[0].qty,1)); + CHECK(rows[0].exit_time==(admit?4000:5000)); + if (admit && rows.size()==3) { + CHECK(rows[1].exit_comment=="Margin call"); + CHECK(near(rows[1].qty,1)); + CHECK(near(rows[1].exit_price,109533.96)); + CHECK(near(rows[2].qty,7.31589)); + } + } +} +void scope_controls() { + // These all miss the newly pinned scope and retain exact-cost decline. + Config explicit_qty;explicit_qty.explicit_qty=true; + Probe explicit_order(explicit_qty);run(explicit_order,btc()); + CHECK(explicit_order.rows().empty()); + Config provider;provider.provider=true; + Probe converted(provider);run(converted,btc());CHECK(converted.rows().empty()); + Config commissioned;commissioned.fee=.000001; + Probe fee(commissioned);run(fee,btc());CHECK(fee.rows().empty()); + Config continuous;continuous.step=0; + Probe no_lot(continuous);run(no_lot,btc());CHECK(no_lot.rows().empty()); + Config raw;raw.flat=false;raw.capital=909707.5558409;raw.raw=true; + Probe raw_close(raw);run(raw_close,btc());CHECK(raw_close.rows().size()==1); + if (!raw_close.rows().empty()) CHECK(raw_close.rows()[0].exit_time==4000); + Config coof; + coof.coof=true; + Probe recalc(coof);run(recalc,btc());CHECK(recalc.rows().empty()); + Config mag; + mag.magnifier=true; + Probe magnifier(mag);run(magnifier,btc());CHECK(magnifier.rows().empty()); + Config pooc; + pooc.pooc=true;pooc.is_long=true; + Probe at_close(pooc);run(at_close,btc());CHECK(at_close.rows().size()==1); + if (!at_close.rows().empty()) CHECK(at_close.rows()[0].exit_comment!="Margin call"); + // An explicit source-order close-first pair keeps its existing bypass + // of the reversal gap gate; it is not a price-band rescued reversal. + Config cf;cf.flat=false;cf.capital=909707.5555209;cf.close_first=true; + Probe close_first(cf);run(close_first,btc());CHECK(close_first.rows().size()==2); + if (close_first.rows().size()==2) { + CHECK(close_first.rows()[0].exit_time==4000); + CHECK(close_first.rows()[1].entry_time==4000); + } +} +void historical_eth_pins() { + // famr3e-Ex010-04010000: actual all-in +1-tick gap decline remains. + Config cfg;cfg.capital=999999.9634;cfg.is_long=true;cfg.step=.0001; + cfg.signal_bar=0; + Probe gap(cfg);run(gap,eth());CHECK(gap.rows().empty()); + + // famr3e-Eh-03312315: actual zero-gap admit548.5884 stays unchanged. + cfg.capital=999999.8514; + Probe flat(cfg); + const std::vector bars={ + {1822.86,1822.86,1822.86,1822.86,1,1000}, + {1822.86,1822.86,1822.86,1822.86,1,2000}, + {1824.93,1824.93,1824.93,1824.93,1,3000}, + {1824.93,1824.93,1824.93,1824.93,1,4000}}; + run(flat,bars);CHECK(flat.rows().size()==1); + if (!flat.rows().empty()) CHECK(near(flat.rows()[0].qty,548.5884)); +} +} // namespace +int main() { + reversal_offsets();flat_and_eth_controls();opposite_reversal(); + scope_controls();historical_eth_pins(); + std::printf("%d passed, %d failed\n",passed,failed); + return failed?1:0; +} diff --git a/tests/test_tv_money_carried_pooc.cpp b/tests/test_tv_money_carried_pooc.cpp new file mode 100644 index 0000000..bf2db53 --- /dev/null +++ b/tests/test_tv_money_carried_pooc.cpp @@ -0,0 +1,266 @@ +/* + * Round 13 D: a carried POOC 1x long uses the rounded-money broker check + * BEFORE the close-time script. A new close fill has no remaining bar path. + * + * Synthetic five-bar fixtures retain the quantities/prices/capital of the + * July 2 EURUSD TV sensors, without loading a feed, corpus or verifier. + * Prior pin: log-20260906t033048z-72b15543 (seven valid controls). New + * full/partial-close pin: log-20260906t091207z-83d4bea0. All windows covered. + * + * Exact/default and explicit CSV d95732d8e479e1806f1cba9b0dfce4dedce3d5cd6ecf9781da50bc3d6922d865: + * C1037042.0056329, Q878945.99 at1.17987, cash.0004116. The next bar's + * open residual.0003188 passes, low residual.0004905 calls 1 at1.17905. + * C+.0001 removes the call; C-.0001 moves it to next open1.17988. + * Non-POOC stop at the same entry price retains the same next-low call. + * + * Q878945.98/C1037041.9938226 (cash.0004): entry-bar high1.17996 has + * residual.0004392. POOC close has NO call (CSV963e70dd1af18e4cdf9167872f97e31ad3003e22740279b2ed13834998d3a5e5), + * while the earlier stop fill calls 1 at that high (CSV8fe09e6f0c5551a775605186a846d2173e79380e0ee63bdc6bc8843eaca15686). + * + * Closing on the next-low trigger bar must see PS878944.99/E1036558.5850684: + * full close CSV1ec1d2c4cd65984908b778dabb443c1ca2856e4c27439e59c650d484c7c1e286; + * 30% close CSV637350d51f562446a90bc35e83ed0afca1ffa1b2df80ed216843409f943434d5 + * takes263683.49 at1.17932, then615261.5 at1.17958 on the next bar. + */ +#include +#include +#include +#include +#include + +#include +#include + +using namespace pineforge; + +static int passed = 0; +static int failed = 0; +#define CHECK(expr) do { \ + if (expr) { ++passed; } else { \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #expr); ++failed; \ + } \ +} while (0) + +namespace { +constexpr double kNaN = std::numeric_limits::quiet_NaN(); +constexpr double kCapital = 1037042.0056329; +constexpr double kQty = 878945.99; +enum class Entry { DEFAULT_CLOSE, EXPLICIT_CLOSE, EXPLICIT_STOP }; +enum class Close { LATER, FULL_AT_TRIGGER, PARTIAL_AT_TRIGGER }; + +bool near(double a, double b, double tolerance = 1e-6) { + return std::abs(a-b) < tolerance; +} + +class MoneyProbe : public BacktestEngine { +public: + MoneyProbe(double capital = kCapital, double qty = kQty, + Entry entry = Entry::DEFAULT_CLOSE, Close close = Close::LATER, + int flatten = 3) + : qty_(qty), entry_(entry), close_(close), flatten_(flatten) { + initial_capital_ = capital; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.0; + slippage_ = 0; + margin_long_ = margin_short_ = 100.0; + pyramiding_ = 0; + qty_step_ = 0.01; + syminfo_.pointvalue = 1.0; + set_syminfo_mintick(0.00001); + process_orders_on_close_ = entry != Entry::EXPLICIT_STOP; + set_margin_call_enabled(true); + } + + void on_bar(const Bar& bar) override { + if ((entry_ == Entry::EXPLICIT_STOP && bar_index_ == 0) + || (entry_ != Entry::EXPLICIT_STOP && bar_index_ == 1)) { + strategy_entry("L", true, kNaN, + entry_ == Entry::EXPLICIT_STOP ? 1.17987 : kNaN, + entry_ == Entry::DEFAULT_CLOSE ? kNaN : qty_, "ENTRY"); + if (resting_stop_) { + strategy_exit("Resting", "L", kNaN, 1.0, + kNaN, kNaN, kNaN, 100.0, "RESTING"); + } + } + if (bar_index_ == 2) { + trigger_script_qty = signed_position_size(); + trigger_script_equity = current_equity() + open_profit(bar.close); + if (close_ == Close::FULL_AT_TRIGGER) { + strategy_close("", "TRIGGER_CLOSE"); + } else if (close_ == Close::PARTIAL_AT_TRIGGER) { + strategy_close("L", "TRIGGER_REDUCE", kNaN, 30.0); + } + } + if (bar_index_ == flatten_) strategy_close("", "END"); + } + + const std::vector& rows() const { return trades_; } + double physical_qty() const { return position_qty_; } + void resting_stop() { resting_stop_ = true; } + void commission(double value) { commission_value_ = value; } + void pyramiding(int value) { pyramiding_ = value; } + void scalar_fx(double value) { account_currency_fx_ = value; } + void lot_step(double value) { qty_step_ = value; } + void intraday_cap(int value) { max_intraday_filled_orders_ = value; } + double trigger_script_qty = kNaN; + double trigger_script_equity = kNaN; + +private: + double qty_; + Entry entry_; + Close close_; + int flatten_; + bool resting_stop_ = false; +}; + +std::vector bars() { + return { + {1.17867, 1.17898, 1.17858, 1.17885, 1, 1000}, + {1.17884, 1.17996, 1.17884, 1.17987, 1, 2000}, + {1.17988, 1.18002, 1.17905, 1.17932, 1, 3000}, + {1.17933, 1.17980, 1.17933, 1.17958, 1, 4000}, + {1.17958, 1.17992, 1.17946, 1.17956, 1, 5000}, + }; +} + +void run(MoneyProbe& engine) { + const auto input = bars(); + engine.run(input.data(), static_cast(input.size())); + CHECK(engine.last_error().empty()); + CHECK(near(engine.physical_qty(), 0.0)); +} + +int margin_rows(const MoneyProbe& engine) { + int count = 0; + for (const auto& row : engine.rows()) + if (row.exit_comment == "Margin call") ++count; + return count; +} + +void check_entries(const MoneyProbe& engine, double qty) { + double total = 0.0; + for (const auto& row : engine.rows()) { + CHECK(row.entry_id == "L"); + CHECK(row.entry_time == 2000); + CHECK(near(row.entry_price, 1.17987)); + total += row.qty; + } + CHECK(near(total, qty)); // every negative control must actually enter +} + +void check_margin(const Trade& row, int64_t time, double price) { + CHECK(row.exit_comment == "Margin call"); + CHECK(row.exit_id == "__margin_call__"); + CHECK(row.exit_time == time); + CHECK(near(row.exit_price, price)); + CHECK(near(row.qty, 1.0)); + CHECK(near(row.pnl, price-1.17987, 1e-9)); +} + +void carried(Entry entry, double capital, bool fire, double price = 1.17905) { + MoneyProbe engine(capital, kQty, entry); + run(engine); + check_entries(engine, kQty); + CHECK(margin_rows(engine) == (fire ? 1 : 0)); + CHECK(engine.rows().size() == (fire ? 2u : 1u)); + if (engine.rows().size() != (fire ? 2u : 1u)) return; + if (fire) { + check_margin(engine.rows()[0], 3000, price); + if (entry != Entry::EXPLICIT_STOP) { + const bool at_open = near(price, 1.17988, 1e-9); + CHECK(near(engine.rows()[0].max_runup, at_open ? .00001 : .00015, 1e-9)); + CHECK(near(engine.rows()[0].max_drawdown, at_open ? 0.0 : .00082, 1e-9)); + } + } + const auto& final = engine.rows().back(); + CHECK(final.exit_comment == "END"); + CHECK(final.exit_time == (entry == Entry::EXPLICIT_STOP ? 5000 : 4000)); + CHECK(near(final.exit_price, 1.17958)); + CHECK(near(final.qty, kQty-(fire ? 1.0 : 0.0))); + if (entry != Entry::EXPLICIT_STOP) + CHECK(near(engine.trigger_script_qty, kQty-(fire ? 1.0 : 0.0))); +} + +void close_fill_has_no_past_path(bool pooc) { + MoneyProbe engine(1037041.9938226, 878945.98, + pooc ? Entry::EXPLICIT_CLOSE : Entry::EXPLICIT_STOP, Close::LATER, 2); + run(engine); + check_entries(engine, 878945.98); + CHECK(engine.rows().size() == (pooc ? 1u : 2u)); + CHECK(margin_rows(engine) == (pooc ? 0 : 1)); + if (engine.rows().size() != (pooc ? 1u : 2u)) return; + if (!pooc) check_margin(engine.rows()[0], 2000, 1.17996); + const auto& final = engine.rows().back(); + CHECK(final.exit_time == (pooc ? 3000 : 4000)); + CHECK(near(final.exit_price, pooc ? 1.17932 : 1.17933)); + CHECK(near(final.qty, pooc ? 878945.98 : 878944.98)); +} + +void on_close_observes_margin_first(Close close) { + MoneyProbe engine(kCapital, kQty, Entry::DEFAULT_CLOSE, close); + run(engine); + check_entries(engine, kQty); + CHECK(near(engine.trigger_script_qty, 878944.99)); + CHECK(near(engine.trigger_script_equity, 1036558.5850684)); + CHECK(margin_rows(engine) == 1); + const bool partial = close == Close::PARTIAL_AT_TRIGGER; + CHECK(engine.rows().size() == (partial ? 3u : 2u)); + if (engine.rows().size() != (partial ? 3u : 2u)) return; + check_margin(engine.rows()[0], 3000, 1.17905); + CHECK(near(engine.rows()[0].max_runup, .00015, 1e-9)); + CHECK(near(engine.rows()[0].max_drawdown, .00082, 1e-9)); + const auto& close_row = engine.rows()[1]; + CHECK(close_row.exit_time == 3000); + CHECK(near(close_row.exit_price, 1.17932)); + CHECK(close_row.exit_comment == (partial ? "TRIGGER_REDUCE" : "TRIGGER_CLOSE")); + CHECK(near(close_row.qty, partial ? 263683.49 : 878944.99)); + if (partial) { + CHECK(engine.rows()[2].exit_time == 4000); + CHECK(engine.rows()[2].exit_comment == "END"); + CHECK(near(engine.rows()[2].exit_price, 1.17958)); + CHECK(near(engine.rows()[2].qty, 615261.5)); + } +} + +void preserved_scope() { + // These are compatibility controls, not new TV margin claims. The + // formerly excluded POOC scopes must not be pulled into this extension. + for (int scope = 0; scope < 6; ++scope) { + MoneyProbe engine(kCapital, kQty, Entry::EXPLICIT_CLOSE); + switch (scope) { + case 0: engine.resting_stop(); break; // no pending-order chronology pin + case 1: engine.commission(1e-11); break; // still affordable, same residual + case 2: engine.pyramiding(2); break; // adds remain on established paths + case 3: { + const int64_t times[] = {1000}; + const double rates[] = {1.0}; + CHECK(engine.set_account_currency_fx_series(times, rates, 1)); + break; + } + case 4: engine.set_margin_call_enabled(false); break; + case 5: engine.intraday_cap(100); break; + } + run(engine); + check_entries(engine, kQty); + CHECK(margin_rows(engine) == 0); + CHECK(engine.rows().size() == 1); + } +} +} // namespace + +int main() { + carried(Entry::DEFAULT_CLOSE, kCapital, true); + carried(Entry::EXPLICIT_CLOSE, kCapital, true); + carried(Entry::DEFAULT_CLOSE, kCapital+.0001, false); + carried(Entry::DEFAULT_CLOSE, kCapital-.0001, true, 1.17988); + carried(Entry::EXPLICIT_STOP, kCapital, true); + close_fill_has_no_past_path(true); + close_fill_has_no_past_path(false); + on_close_observes_margin_first(Close::FULL_AT_TRIGGER); + on_close_observes_margin_first(Close::PARTIAL_AT_TRIGGER); + preserved_scope(); + std::printf("%d passed, %d failed\n", passed, failed); + return failed == 0 ? 0 : 1; +}