diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 64ec184..26d29a0 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -110,8 +110,10 @@ enum class PositionSide { FLAT, LONG, SHORT }; // + every-bar sensors 3086/3086 admits, 1631/1631 whole drops, 64/64 // close-only, and every one of the 52 famr-adm band tapes (revb b06.. // b28, revd00..03, revL L24..L33, S100..S103, S307..S317). NOT -// implemented: TV's 1-unit entry fill when the entry leg fails with -// Q == |position| + 1.00 exactly (revL L23, taro 2025-09-15 16:15Z). +// generalized: closing-transaction surplus outside the narrow round14 +// default100 rule-2 close-only shape. Its revL L23 / taro Sep15 residue +// now requires a same-signal close-point MC receipt on the same lot; +// Q-new minus live-position alone is explicitly refuted by TV controls. // Round 10 family AB (BINANCE:ETHUSDT.P@15 hard lane, the corpus probe // anomaly-equity-mirror-strategy-equity-01, campaign note // log-20260905t213120z-d5f9e282) met rule 3 on an EXPLICIT-qty 1x long on a @@ -764,6 +766,15 @@ struct PendingOrder { // (consumed, no broker effect) when the account is flat or same-side at // the fill. bool affordability_close_only = false; + // Round14: only rule-2's rounded signal-cost decline can consume the + // pending reversal's closing carry after a same-signal close-point MC. + // These are order-owned receipts, not a last-margin-call heuristic. + bool rounded_signal_cost_close_only = false; + int signal_close_mc_bar = -1; + uint64_t signal_close_mc_entry_incarnation = 0; + uint64_t signal_close_mc_fill_seq = 0; + double signal_close_mc_remaining_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 // Narrow POOC global-full-exit candidate. ``qty`` deliberately keeps the @@ -1824,12 +1835,21 @@ class BacktestEngine { bool filled = false; double fill_price = std::numeric_limits::quiet_NaN(); uint64_t fill_events = 0; + // A chart-extreme tick touch books at the tick print, but the path + // advances from the raw H/L it actually reached. Never walk back from + // an outward-rounded fill price into an already-consumed segment. + double chart_waypoint_price = std::numeric_limits::quiet_NaN(); + // A pinned group of resting stop siblings settles on one historical + // adverse leg before one script recalculation. Broker fills remain + // individually counted by fill_events and broker_fill_event_seq_. + bool grouped_stop_recalc = false; }; CoofFillResult process_next_pending_order(const Bar& bar, bool allow_market_orders, int& exit_closed_from_bar, uint64_t& exit_closed_from_incarnation, - bool& exit_closed_was_long); + bool& exit_closed_was_long, + const Bar* chart_bar = nullptr); // TradingView forced-liquidation (margin call). Finite-price liquidation // paths use the bar's adverse extreme. A 100%-margin long has no later @@ -4043,7 +4063,8 @@ class BacktestEngine { bool recalc_at_bar_open, uint64_t triggering_events, uint64_t max_events, - uint64_t events_already); + uint64_t events_already, + bool grouped_stop_recalc = false); void run_simple_bar_loop(const Bar* input_bars, int n_input); void run_aggregation_bar_loop(const Bar* input_bars, int n_input, bool bar_magnifier, int expected_script_bars); diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 04a18bd..442f81d 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -324,8 +324,9 @@ void BacktestEngine::process_pending_orders(const Bar& bar) { // Flag-gated KI-60 counterpart to process_pending_orders. It preserves the // established eligibility / price / application kernels, but returns after -// exactly one ACTUAL broker fill so the scheduler can restore script state and -// execute on_bar before any later order sees the remaining path. Orders that +// one ACTUAL broker fill so the scheduler can restore script state and execute +// on_bar before later orders see the path. The bounded resting-stop cohort +// below reports its real fill count and requests one recalculation. Orders that // are cancelled, rejected by risk/margin, or quantize to zero are compacted // without producing a fill event and scanning continues. BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( @@ -333,7 +334,8 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( bool allow_market_orders, int& exit_closed_from_bar, uint64_t& exit_closed_from_incarnation, - bool& exit_closed_was_long) { + bool& exit_closed_was_long, + const Bar* chart_bar) { CoofFillResult result; update_risk_state(); @@ -396,6 +398,7 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( double path_position; bool was_trail; int64_t created_seq; + double chart_waypoint_price; }; std::vector candidates; candidates.reserve(pending_orders_.size()); @@ -476,6 +479,59 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( order, i, bar, opposing_pass, trail_best_path_state, pass0_opposing_skip_ids); coof_cascade_force_wp_gap_ = false; + double chart_waypoint_price = std::numeric_limits::quiet_NaN(); + // Round 14 JOAT (log-20260906t110510z-84b72250): a carried + // plain exit can touch the chart's outward-rounded H/L even + // when the raw segment never reached its level. F15 May29: + // H10.255 -> tick10.26 reaches SL10.257194001727152; F1D Jan26: + // L13.3448 -> tick13.34 reaches SL/LIMIT13.342. The synthetic + // segment stays raw. Only its completed REAL chart extreme gets + // this extra trigger point, after all ordinary eligibility gates. + // Entry/cascade, multiple-order ranking, partials, trails, POOC, + // magnifier and realtime keep their established behavior. + if (fill.kind == FillEvaluation::Kind::NoFill && chart_bar + && calc_on_order_fills_ && coof_scheduler_active_ + && !coof_hist_is_segment_ && coof_at_extreme_waypoint_ + && !bar_magnifier_enabled_ && !process_orders_on_close_ + && !stream_warmup_mode_ && stream_phase_ == StreamPhase::IDLE + && slippage_ == 0 && pending_orders_.size() == 1 + && position_open_bar_ >= 0 && position_open_bar_ < bar_index_ + && position_entry_count_ == 1 && pyramid_entries_.size() == 1 + && pyramiding_ == 0 && order.type == OrderType::EXIT + && order.created_bar < bar_index_ && !order.requested_partial + && order.created_while_in_position && !order.dormant_bracket + && !order.from_entry.empty() + && order.from_entry == pyramid_entries_.front().entry_id + && std::isnan(order.trail_points) && std::isnan(order.trail_price) + && std::isnan(order.trail_offset) + && std::isfinite(bar.open) && bar.open == bar.high + && bar.open == bar.low && bar.open == bar.close) { + double chart_path[4]; + internal::fill_bar_path_points(*chart_bar, chart_path); + const int point = coof_hist_path_index_; + if ((point == 1 || point == 2) && bar.open == chart_path[point]) { + const double raw = bar.open; + const double tick = tick_grid_price(raw); + const bool upper = raw == chart_bar->high && tick > raw; + const bool lower = raw == chart_bar->low && tick < raw; + const bool long_position = position_side_ == PositionSide::LONG; + const bool stop_touch = std::isfinite(order.stop_price) + && ((!long_position && upper && raw < order.stop_price + && order.stop_price <= tick) + || (long_position && lower && tick <= order.stop_price + && order.stop_price < raw)); + const bool limit_touch = std::isfinite(order.limit_price) + && ((long_position && upper && raw < order.limit_price + && order.limit_price <= tick) + || (!long_position && lower && tick <= order.limit_price + && order.limit_price < raw)); + if (stop_touch || limit_touch) { + fill = {FillEvaluation::Kind::Fill, + bar_fill_price(raw), limit_touch}; + chart_waypoint_price = raw; + } + } + } if (fill.kind != FillEvaluation::Kind::Fill) continue; double path_position = 0.0; @@ -487,7 +543,7 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( internal::first_touch_position(bar, fill.fill_price, &path_position); candidates.push_back({ i, fill, path_position, last_exit_fill_was_trail_, - order.created_seq}); + order.created_seq, chart_waypoint_price}); } std::stable_sort( @@ -498,6 +554,60 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( return a.created_seq < b.created_seq; }); + // ES daily COOF pins: resting same-entry stop siblings reached on + // this adverse leg settle before the script observes their reduced + // position. This includes distinct stop prices on the same leg; + // an unconsumed later stop remains cancellable after the group, and + // a newly created stop/market exit still waits for its waypoint. + // Keep this exception on the pinned single-long-lot, ordinary + // historical book. Other order races and scheduler modes retain + // the existing one-fill/recalc path. + const bool group_resting_stops = [&] { + if (candidates.size() < 2 || !filled_indices.empty() + || !calc_on_order_fills_ || !coof_scheduler_active_ + || !coof_evaluating_path_segment_ || !coof_hist_is_segment_ + || process_orders_on_close_ || bar_magnifier_enabled_ + || stream_warmup_mode_ || stream_phase_ != StreamPhase::IDLE + || position_side_ != PositionSide::LONG + || position_open_bar_ >= bar_index_ || pyramiding_ != 0 + || pyramid_entries_.size() != 1 || close_entries_rule_any_ + || commission_value_ != 0 || slippage_ != 0 + || account_currency_fx_ != 1 + || !account_currency_fx_timestamps_.empty() + || max_intraday_filled_orders_ > 0 + || risk_max_intraday_loss_ != 0 || risk_max_drawdown_ != 0 + || risk_max_cons_loss_days_ > 0 + || margin_long_ != 100 || opening_affordability_pending_ + || !(bar.close < bar.open)) return false; + const std::string& entry_id = pyramid_entries_.front().entry_id; + double reserved = 0; + for (const PendingOrder& pending : pending_orders_) { + if (pending.type != OrderType::EXIT + || pending.from_entry != entry_id || entry_id.empty() + || pending.created_bar >= bar_index_ + || pending.dormant_bracket || !pending.oca_name.empty() + || !std::isfinite(pending.stop_price) + || !std::isnan(pending.trail_points) + || !std::isnan(pending.trail_price) + || !std::isfinite(pending.qty) || pending.qty <= 0) + return false; + reserved += pending.qty; + } + if (reserved > position_qty_ + kQtyEpsilon) return false; + for (const FillCandidate& candidate : candidates) { + const PendingOrder& pending = pending_orders_[candidate.order_index]; + if (candidate.was_trail || candidate.fill.is_limit_fill + || !candidate.fill.exit_path_fill + || pending.stop_price > bar.open + || pending.stop_price < bar.close + || std::abs(candidate.fill.fill_price - pending.stop_price) + > kSegmentDenomEps) return false; + } + return true; + }(); + uint64_t grouped_fill_events = 0; + size_t grouped_fills = 0; + for (const FillCandidate& candidate : candidates) { PendingOrder& order = pending_orders_[candidate.order_index]; last_exit_fill_was_trail_ = candidate.was_trail; @@ -532,6 +642,18 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( continue; } + grouped_fill_events += produced; + ++grouped_fills; + result.filled = true; + result.fill_price = candidate.fill.fill_price; + result.fill_events = grouped_fill_events; + result.chart_waypoint_price = candidate.chart_waypoint_price; + result.grouped_stop_recalc = group_resting_stops && grouped_fills > 1; + // No callbacks, new orders or OCA erasures can occur in the + // proven group. Keep indices stable until its last existing + // candidate has passed through the ordinary fill kernel. + if (group_resting_stops && position_side_ != PositionSide::FLAT) continue; + std::sort(filled_indices.begin(), filled_indices.end()); filled_indices.erase( std::unique(filled_indices.begin(), filled_indices.end()), @@ -553,9 +675,6 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( if (position_side_ == PositionSide::FLAT) { purge_exit_orders(/*retain_for_pending_entries=*/true); } - result.filled = true; - result.fill_price = candidate.fill.fill_price; - result.fill_events = produced; return result; } @@ -567,6 +686,10 @@ BacktestEngine::CoofFillResult BacktestEngine::process_next_pending_order( filled_indices, exit_closed_from_bar, exit_closed_from_incarnation, exit_closed_was_long); + if (result.filled) { + finish_intraday_loss_cancel(); + return result; + } } // No fill consumed this segment, so the broker reached its endpoint and @@ -1494,6 +1617,25 @@ bool BacktestEngine::tv_money_long_margin_call(const Bar& bar, if (!std::isfinite(qty_liq) || qty_liq <= kQtyEpsilon) return false; const double raw_exit_fill_base = bar_fill_price(fire_price); + const int64_t close_mc_cycle = position_cycle_seq_; + const uint64_t close_mc_incarnation = pyramid_entries_.size() == 1 + ? pyramid_entries_.front().entry_incarnation : 0; + // The inherited L23 and taro Sep15 pins fire at the signal CLOSE. + // A pre-existing pending reversal still owns its pre-MC closing carry. + // Limit this receipt to the selected ordinary default100 single-lot + // shape; other reductions, accepted new legs and POOC are separate. + const bool close_mc_receipt_scope = + !process_orders_on_close_ && fire_path_point == 3 + && qty_liq == 1.0 && qty > 1.0 + && close_mc_incarnation != 0 && pending_orders_.size() == 1 + && default_qty_type_ == QtyType::PERCENT_OF_EQUITY + && std::abs(default_qty_value_ - 100.0) < 1e-12 + && commission_type_ == CommissionType::PERCENT + && commission_value_ == 0.0 && slippage_ == 0 + && pv == 1.0 && 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; const size_t trades_before = trades_.size(); if (process_orders_on_close_) { // The pre-script pass precedes the ordinary full-bar excursion @@ -1520,6 +1662,27 @@ bool BacktestEngine::tv_money_long_margin_call(const Bar& bar, if (trades_.size() == trades_before) return false; ++broker_fill_event_seq_; last_margin_call_event_bar_ = bar_index_; + if (close_mc_receipt_scope && position_side_ == PositionSide::LONG + && position_cycle_seq_ == close_mc_cycle + && pyramid_entries_.size() == 1 + && pyramid_entries_.front().entry_incarnation == close_mc_incarnation + && std::abs(qty - position_qty_ - 1.0) < 1e-6) { + auto& pending = pending_orders_.front(); + if (pending.type == OrderType::MARKET && !pending.is_long + && std::isnan(pending.qty) && !pending.affordability_close_only + && pending.created_bar == bar_index_ + && pending.created_position_side == PositionSide::LONG + && pending.created_position_cycle_seq == close_mc_cycle + && !pending.created_after_position_close_in_bar + && !pending.created_during_coof_recalc + && pending.tv_carry_qty == qty + && std::isfinite(pending.frozen_default_qty)) { + pending.signal_close_mc_bar = bar_index_; + pending.signal_close_mc_entry_incarnation = close_mc_incarnation; + pending.signal_close_mc_fill_seq = broker_fill_event_seq_; + pending.signal_close_mc_remaining_qty = position_qty_; + } + } for (size_t ti = trades_before; ti < trades_.size(); ++ti) { trades_[ti].exit_comment = "Margin call"; trades_[ti].exit_id = "__margin_call__"; @@ -4698,8 +4861,9 @@ void BacktestEngine::apply_filled_order_to_state( // 0.000462 -> P rounds up to close + 1e-9 -> F7-311130 filled). 507/507 // famr3 sweep decisions, 3086/3086 taro + every-bar admissions, // 1631/1631 whole drops, 64/64 close-only, 52/52 famr-adm band tapes. - // NOT implemented: TV's 1-unit entry fill when the entry leg fails with - // Q == |position| + 1.00 exactly (revL L23, taro 2025-09-15 16:15Z). + // Round14's narrow closing-carry residue is consumed only after rule 2 + // fails, with an actual same-signal close-point MC receipt. A difference + // between the requested quantity and live position is not sufficient. if (order.type == OrderType::MARKET && !order.affordability_close_only && !std::isnan(order.sizing_equity) && !std::isnan(order.sizing_mark) @@ -4758,6 +4922,7 @@ void BacktestEngine::apply_filled_order_to_state( return; } order.affordability_close_only = true; + order.rounded_signal_cost_close_only = true; } else if (!close_first_flat_open) { // Rule 5: the price-scale margin check (comment above). const double notional_per_price = @@ -6044,15 +6209,43 @@ void BacktestEngine::apply_market_order_fill(PendingOrder& order, double fill_pr if (order.affordability_close_only) { const PositionSide requested = order.is_long ? PositionSide::LONG : PositionSide::SHORT; + // Rule 2 removed the new entry leg, not the old closing transaction. + // A proven signal-close MC reduced that same lot AFTER placement; + // close the live remainder and retain only the frozen close surplus. + // Never infer this from frozen_default_qty minus the live position. + const double close_surplus = order.tv_carry_qty - position_qty_; + const bool keep_mc_close_surplus = + order.rounded_signal_cost_close_only + && order.signal_close_mc_bar == order.created_bar + && order.created_bar == bar_index_ - 1 + && order.signal_close_mc_entry_incarnation != 0 + && order.signal_close_mc_fill_seq == broker_fill_event_seq_ + && !process_orders_on_close_ && !calc_on_order_fills_ + && !bar_magnifier_enabled_ && !coof_scheduler_active_ + && !stream_warmup_mode_ && stream_phase_ == StreamPhase::IDLE + && order.type == OrderType::MARKET && !order.is_long + && std::isnan(order.qty) && !order.created_after_position_close_in_bar + && position_side_ == PositionSide::LONG + && order.created_position_side == PositionSide::LONG + && order.created_position_cycle_seq == position_cycle_seq_ + && pyramid_entries_.size() == 1 + && pyramid_entries_.front().entry_incarnation + == order.signal_close_mc_entry_incarnation + && position_qty_ == order.signal_close_mc_remaining_qty + && std::isfinite(close_surplus) + && std::abs(close_surplus - 1.0) < 1e-6; if (position_side_ != PositionSide::FLAT && position_side_ != requested && std::isfinite(fill_price)) { flip_market_position_to( order.id, order.is_long, apply_fill_slippage(fill_price, order.is_long), - order.qty, order.qty_type, - /*explicit_qty_prequantized=*/false, - /*close_only=*/true, order.incarnation); + keep_mc_close_surplus ? 1.0 : order.qty, + keep_mc_close_surplus ? -1 : order.qty_type, + /*explicit_qty_prequantized=*/keep_mc_close_surplus, + /*close_only=*/!keep_mc_close_surplus, order.incarnation); + if (keep_mc_close_surplus && !pyramid_entries_.empty()) + pyramid_entries_.back().entry_comment = order.comment; } trail_best_path_state = trail_best_price_; return; diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 4cd1e5c..7ed6c19 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -328,9 +328,10 @@ uint64_t BacktestEngine::run_coof_recalc_chain( bool recalc_at_bar_open, uint64_t triggering_events, uint64_t max_events, - uint64_t events_already) { + uint64_t events_already, + bool grouped_stop_recalc) { uint64_t total_events = triggering_events; - uint64_t pending_recalcs = triggering_events; + uint64_t pending_recalcs = grouped_stop_recalc ? 1 : triggering_events; uint64_t handled = 0; while (pending_recalcs > 0 && events_already + handled < max_events) { --pending_recalcs; @@ -428,15 +429,17 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { bool cursor_is_close, bool filled_at_bar_open_point) { const uint64_t before = fill_events; - cursor = fill.fill_price; - cursor_is_bar_point = false; + const bool chart_tick_touch = std::isfinite(fill.chart_waypoint_price); + cursor = chart_tick_touch ? fill.chart_waypoint_price : fill.fill_price; + cursor_is_bar_point = chart_tick_touch; // The recalc chain receives O-point provenance, but only its first fill // event is classified as bar-open. A later fill at the same O is a // leg-0 cascade (PendingOrder::coof_born_mid_bar). fill_events += run_coof_recalc_chain( - script_bar, cursor, cursor_is_bar_point, cursor_is_close, + script_bar, fill.fill_price, /*cursor_is_bar_point=*/false, cursor_is_close, filled_at_bar_open_point, - fill.fill_events, kNoFillEventBudget, fill_events); + fill.fill_events, kNoFillEventBudget, fill_events, + fill.grouped_stop_recalc); // The carried order's open fill triggers one execution at O, and the // order born in that first execution may also fill at O. Every later // fill—including the first fill when it occurs inside a path segment— @@ -463,7 +466,7 @@ void BacktestEngine::dispatch_bar_calc_on_order_fills() { CoofFillResult fill = process_next_pending_order( point, /*allow_market_orders=*/true, exit_closed_from_bar, exit_closed_from_incarnation, - exit_closed_was_long); + exit_closed_was_long, &script_bar); if (fill.filled) { // A fill at this POINT (cursor == path[next_waypoint-1]) puts the // in-flight leg at path[next_waypoint-1] -> path[next_waypoint], diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b03a690..ac00535 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -115,6 +115,7 @@ set(TEST_SOURCES test_market_entry_affordability test_percent_equity_affordability test_stop_tick_rounding + test_coof_chart_tick_touch test_level_grid_snap test_zero_lot_entry_decline test_pooc_coof_reversal_gross_admission @@ -143,6 +144,7 @@ set(TEST_SOURCES test_famag_close_first_admission test_famag_opening_money test_taro_price_gap_admission + test_taro_mc_close_residue test_live_position_market_gross_admission test_lower_tf_parse_extra test_ta_ma_warmup_extra @@ -189,6 +191,7 @@ set(TEST_SOURCES test_trail_ref_entry_bar_extreme test_trail_close_restart_no_fold test_pooc_retained_trail_path + test_coof_resting_stop_siblings test_famx_declined_reversal_trail_leg test_famae_lot_sizing_ten_digit_equity test_famae_declined_reversal_trail_gap diff --git a/tests/test_coof_chart_tick_touch.cpp b/tests/test_coof_chart_tick_touch.cpp new file mode 100644 index 0000000..ce78694 --- /dev/null +++ b/tests/test_coof_chart_tick_touch.cpp @@ -0,0 +1,160 @@ +/* + * Round 14 JOAT: an older plain exit may touch the chart's outward-rounded + * H/L tick even though the raw COOF segment did not reach its level. + * Synthetic arrays preserve TV sensor prices; no feed/corpus is loaded. + * + * F15 May29: short979@10.22, SL10.257194001727152, rawH10.255 ->tick10.26. + * COOF and ordinary both exit13:45@10.26 (CSV0e7b9a63fa0efd4fefe504152a0b2859e06688dfe1c27d8a265fdfb3bc5cdc25). + * SL10.27 instead exits14:00 (CSV596d5d38f39a5fcba3fa23e2519e3dd74f531b4575194590c7430012c0a800a3). + * LongLIMIT at samelevel also exits13:45@10.26 (CSV9198b9f17b99b48dcd61201c72d9e0da162369be96dca8dbf95ff28e6c122b83). + * F1D Jan26: rawL13.3448 ->tick13.34 reaches13.342 longSTOP/shortLIMIT + * (CSV6b50fba7e9b743481319fa9a0d3d23f1c6fe3cf380305fc755dba9e96b76223f / + * f2342928abbcd2d8b613af4ba96a78e8dded5ca375b3ee3c94b6b94fe65c8373). + * LongSTOP13.33 waits for END nextopen13.64 (CSV6ad6b01ca8d512460ed77f6efbc4feeaf603d94aa2abe402baf57879b7691024). + * All seven tapes are covered and actual intended seed quantities checked. + */ +#include +#include +#include +#include +#include +#include + +using namespace pineforge; +static int passed = 0, failed = 0; +#define CHECK(x) do { if (x) ++passed; else { \ + ++failed; std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #x); \ +} } while (0) + +namespace { +constexpr double kNaN = std::numeric_limits::quiet_NaN(); +bool near(double a, double b) { return std::abs(a-b) < 1e-8; } + +class TickProbe : public BacktestEngine { +public: + TickProbe(bool is_long, double qty, double stop, double limit, + int end_bar, bool coof = true) + : long_(is_long), qty_(qty), stop_(stop), limit_(limit), end_(end_bar) { + initial_capital_ = 100000.0; + margin_long_ = margin_short_ = 100.0; + pyramiding_ = 0; + qty_step_ = 1.0; + syminfo_.pointvalue = 1.0; + set_syminfo_mintick(.01); + commission_type_ = CommissionType::PERCENT; + commission_value_ = .01; + slippage_ = 0; + calc_on_order_fills_ = coof; + process_orders_on_close_ = false; + } + void on_bar(const Bar& bar) override { + if (bar_index_ == 0 && position_side_ == PositionSide::FLAT) + strategy_entry("E", long_, kNaN, kNaN, qty_, "SEED"); + if (position_side_ != PositionSide::FLAT) { + strategy_exit("X", "E", limit_, stop_, kNaN, kNaN, kNaN, 100, "X"); + if (extra_order_) + strategy_order("Idle", true, 1, kNaN, 1000.0); + } + if (trade_count() == 1) { + ++observed_after_exit; + exit_seen_bar = bar_index_; + last_seen_raw_high = bar.high; + last_seen_raw_low = bar.low; + } + if (bar_index_ == end_) strategy_close("", "END"); + } + void extra_order() { extra_order_ = true; } + const std::vector& rows() const { return trades_; } + double remaining() const { return position_qty_; } + int observed_after_exit = 0; + int exit_seen_bar = -1; + double last_seen_raw_high = kNaN, last_seen_raw_low = kNaN; +private: + bool long_; + double qty_, stop_, limit_; + int end_; + bool extra_order_ = false; +}; + +std::vector may_bars() { + return { + {10.205,10.205,10.17,10.18,1,1000}, + {10.215,10.22,10.17,10.19,1,2000}, + {10.185,10.255,10.185,10.25,1,3000}, + {10.255,10.275,10.22,10.235,1,4000}, + {10.23,10.23,10.2,10.21,1,5000}, + }; +} +std::vector jan_bars() { + return { + {13.405,13.77,13.405,13.77,1,1000}, + {13.78,13.84,13.7,13.71,1,2000}, + {13.7,13.7,13.55,13.56,1,3000}, + {13.56,13.655,13.3448,13.44,1,4000}, + {13.64,13.945,13.51,13.93,1,5000}, + }; +} +void check(TickProbe& e, const std::vector& bars, double qty, + double entry, int64_t exit_time, double exit, const char* comment) { + e.run(bars.data(), static_cast(bars.size())); + CHECK(e.last_error().empty()); + CHECK(near(e.remaining(),0.0)); + CHECK(e.rows().size()==1); + if (e.rows().size()!=1) return; + const auto& t=e.rows()[0]; + CHECK(t.entry_time==2000); + CHECK(near(t.entry_price,entry)); + CHECK(near(t.qty,qty)); + CHECK(t.exit_time==exit_time); + CHECK(near(t.exit_price,exit)); + CHECK(t.exit_comment==comment); + const double direction=t.is_long?1:-1; + CHECK(near(t.pnl,direction*(exit-entry)*qty-(entry+exit)*qty*.0001)); +} + +void upper_controls() { + for (bool coof : {false,true}) { + TickProbe short_stop(false,979,10.257194001727152,kNaN,3,coof); + check(short_stop,may_bars(),979,10.22,3000,10.26,"X"); + } + TickProbe long_limit(true,979,kNaN,10.257194001727152,3); + check(long_limit,may_bars(),979,10.22,3000,10.26,"X"); + TickProbe next_tick(false,979,10.27,kNaN,3); + check(next_tick,may_bars(),979,10.22,4000,10.27,"X"); +} +void lower_controls() { + TickProbe stop(true,100,13.342,kNaN,3); + check(stop,jan_bars(),100,13.78,4000,13.34,"X"); + TickProbe limit(false,100,kNaN,13.342,3); + check(limit,jan_bars(),100,13.78,4000,13.34,"X"); + TickProbe next_tick(true,100,13.33,kNaN,3); + check(next_tick,jan_bars(),100,13.78,5000,13.64,"END"); +} +void narrow_scope_and_liveness() { + // Scope compatibility: a competing pending order does not gain a new + // ranking interaction through this single-exit fallback. + TickProbe competing(false,979,10.257194001727152,kNaN,3); + competing.extra_order(); + check(competing,may_bars(),979,10.22,4000,10.26,"X"); + + TickProbe e(false,979,10.257194001727152,kNaN,3); + auto input=may_bars(); + input.resize(3); // final bar is the raw extreme that causes the new fill + check(e,input,979,10.22,3000,10.26,"X"); + CHECK(e.observed_after_exit==2); // fill recalc then ordinary close + CHECK(e.exit_seen_bar==2); + CHECK(near(e.last_seen_raw_high,10.255)); + CHECK(near(e.last_seen_raw_low,10.185)); + // Reusing the handle has no persisted chart-boundary or cursor state. + e.observed_after_exit=0; + check(e,input,979,10.22,3000,10.26,"X"); + CHECK(e.observed_after_exit==2); +} +} +int main() { + upper_controls(); + lower_controls(); + narrow_scope_and_liveness(); + std::printf("%d passed, %d failed\n",passed,failed); + return failed==0?0:1; +} diff --git a/tests/test_coof_resting_stop_siblings.cpp b/tests/test_coof_resting_stop_siblings.cpp new file mode 100644 index 0000000..8af25bc --- /dev/null +++ b/tests/test_coof_resting_stop_siblings.cpp @@ -0,0 +1,139 @@ +// TV-derived synthetic ES daily controls. An already-resting group of exit +// stops reaches the adverse path leg before its fill recalculation can +// replace/cancel the still-filled siblings. Newly created stops/market exits +// retain the next-waypoint rule. State/r14-exit-audit and r14-es-siblings +// contain the covered TV tapes and explicit varip position-visibility pins. +#include +#include +#include +#include +#include +#include + +using namespace pineforge; +namespace { +int passed=0, failed=0; +constexpr double N=std::numeric_limits::quiet_NaN(); +#define CHECK(e) do { if(e) ++passed; else { ++failed; std::printf("FAIL %d: %s\n",__LINE__,#e); } } while(0) + +enum class Mode { Same, Once, Different, CancelSame, CancelDifferent, + MoveSame, Partial, NewStop, NewMarket, OriginalBracket, + AfterTwoCancel, AfterTwoClose }; +struct Probe final: BacktestEngine { + Mode mode; + bool armed=false; + int seen2=0,seen1=0; + uint64_t fills() const { return broker_fill_event_seq_; } + explicit Probe(Mode m,bool coof=true):mode(m) { + initial_capital_=1000000; + default_qty_type_=QtyType::FIXED; + default_qty_value_=3; + pyramiding_=0; + calc_on_order_fills_=coof; + process_orders_on_close_=false; + syminfo_mintick_=.25; + qty_step_=1; + margin_long_=margin_short_=100; + syminfo_.pointvalue=50; + } + void on_bar(const Bar&) override { + if(bar_index_==0 && position_side_==PositionSide::FLAT) + strategy_entry("L",true,N,N,3); + if(bar_index_==2) { + if(position_qty_==2) ++seen2; + if(position_qty_==1) ++seen1; + } + const bool once=mode==Mode::Once || mode==Mode::CancelSame + || mode==Mode::CancelDifferent || mode==Mode::MoveSame + || mode==Mode::Partial || mode==Mode::NewStop || mode==Mode::NewMarket + || mode==Mode::AfterTwoCancel || mode==Mode::AfterTwoClose; + if(mode==Mode::OriginalBracket && position_side_!=PositionSide::FLAT) { + if(position_qty_==3) + strategy_exit("X1","L",5745,5615,N,N,N,100,"",1); + if(position_qty_>1) + strategy_exit("X2","L",position_qty_==2?5750:N,5615,N,N,N,100,"",1); + strategy_exit("X3","L",N,position_qty_==1?5500:5615,N,N,N,100,"",1); + } else if(position_side_!=PositionSide::FLAT && (!once || !armed)) { + strategy_exit("X1","L",N,5615,N,N,N,100,"",1); + if(mode!=Mode::Partial && mode!=Mode::NewStop && mode!=Mode::NewMarket) { + const bool different=mode==Mode::Different || mode==Mode::CancelDifferent + || mode==Mode::AfterTwoCancel || mode==Mode::AfterTwoClose; + strategy_exit("X2","L",N,different?5610:5615,N,N,N,100,"",1); + strategy_exit("X3","L",N,different?5600:5615,N,N,N,100,"",1); + } + armed=true; + } + if(position_qty_==1 && (mode==Mode::AfterTwoCancel || mode==Mode::AfterTwoClose)) { + strategy_cancel("X3"); + if(mode==Mode::AfterTwoClose) strategy_close("L"); + } + if(position_qty_==2 && position_side_!=PositionSide::FLAT) { + if(mode==Mode::CancelSame || mode==Mode::CancelDifferent) { + strategy_cancel("X2");strategy_cancel("X3"); + } else if(mode==Mode::MoveSame) { + strategy_exit("X2","L",N,5610,N,N,N,100,"",1); + strategy_exit("X3","L",N,5600,N,N,N,100,"",1); + } else if(mode==Mode::NewStop) { + strategy_exit("NEW","L",N,5615,N,N,N,100,"",2); + } else if(mode==Mode::NewMarket) { + strategy_close("L"); + } + } + } + void fixture() { + const Bar bars[]={ + {5608.5,5724.75,5601,5709,1000,1746136800000LL}, + {5705,5706.25,5655.25,5671.75,1000,1746396000000LL}, + {5666.25,5673.25,5605,5625.75,1000,1746482400000LL}, + {5608.5,5689.75,5596,5652,1000,1746568800000LL}, + }; + run(bars,4); + CHECK(last_error().empty()); + } +}; +void trade(const Probe&p,int i,int bar,double price,double qty) { + CHECK(p.trade_count()>i);if(p.trade_count()<=i)return; + const Trade&t=p.get_trade(i); + std::printf(" trade%d %s bar%d @%.2f qty%.0f\n",i,t.exit_id.c_str(),t.exit_bar_index,t.exit_price,t.qty); + CHECK(t.entry_bar_index==1);CHECK(std::abs(t.entry_price-5705)<1e-9); + CHECK(t.exit_bar_index==bar);CHECK(std::abs(t.exit_price-price)<1e-9); + CHECK(std::abs(t.qty-qty)<1e-9); + CHECK(std::abs(t.pnl-(price-5705)*50*qty)<1e-7); + CHECK(std::abs(t.max_drawdown-(5705-price)*50*qty)<1e-7); +} +void same(Mode mode,bool coof=true) { + Probe p(mode,coof);p.fixture();CHECK(p.trade_count()==3); + for(int i=0;i<3;i++)trade(p,i,2,5615,1); + CHECK(p.seen2==0);CHECK(p.seen1==0); + CHECK(p.fills()==4); +} +void different(Mode mode) { + Probe p(mode);p.fixture();CHECK(p.trade_count()==3); + trade(p,0,2,5615,1);trade(p,1,2,5610,1);trade(p,2,3,5600,1); + CHECK(p.seen2==0);CHECK(p.seen1==2); + CHECK(p.fills()==4); +} +void single_and_new() { + Probe partial(Mode::Partial);partial.fixture();CHECK(partial.trade_count()==1); + trade(partial,0,2,5615,1);CHECK(partial.seen2==2); + for(Mode mode:{Mode::NewStop,Mode::NewMarket}) { + Probe p(mode);p.fixture();CHECK(p.trade_count()==2); + trade(p,0,2,5615,1);trade(p,1,2,5605,2);CHECK(p.seen2==1); + } +} +void after_two() { + Probe cancel(Mode::AfterTwoCancel);cancel.fixture();CHECK(cancel.trade_count()==2); + trade(cancel,0,2,5615,1);trade(cancel,1,2,5610,1); + CHECK(cancel.seen2==0);CHECK(cancel.seen1==2); + Probe close(Mode::AfterTwoClose);close.fixture();CHECK(close.trade_count()==3); + trade(close,0,2,5615,1);trade(close,1,2,5610,1);trade(close,2,2,5605,1); + CHECK(close.seen2==0);CHECK(close.seen1==1); +} +} +int main(){ + same(Mode::Same);same(Mode::Once);same(Mode::CancelSame);same(Mode::MoveSame); + same(Mode::Same,false);different(Mode::Different);different(Mode::CancelDifferent); + same(Mode::OriginalBracket);single_and_new();after_two(); + std::printf("coof_resting_stop_siblings: %d passed, %d failed\n",passed,failed); + return failed?1:0; +} diff --git a/tests/test_taro_mc_close_residue.cpp b/tests/test_taro_mc_close_residue.cpp new file mode 100644 index 0000000..09f4202 --- /dev/null +++ b/tests/test_taro_mc_close_residue.cpp @@ -0,0 +1,141 @@ +/* + * Round14 taro EUR: a pending default100 reversal keeps its closing + * transaction across a same-signal-bar one-contract money margin call. + * The new side fails rounded signal cost; the old closing carry still + * exceeds the reduced position by one. TV leaves Short1. + * + * Pinned inherited Sep15 exact/no-MC/headroom and historical revL-L23, + * plus r14 earlier-MC and actual ordinary-partial controls. The no-MC + * sameE/newQ-live=1 control REFUTES a quantity-gap-only implementation. + * These small synthetic fixtures load no feed, corpus or grader. + */ +#include +#include +#include +#include +#include +#include +using namespace pineforge; +static int passed=0,failed=0; +#define CHECK(x) do { if(x)++passed;else { \ + std::printf("FAIL %s:%d: %s\n",__FILE__,__LINE__,#x);++failed; } } while(0) +namespace { +constexpr double NaN=std::numeric_limits::quiet_NaN(); +bool near(double a,double b,double tol=1e-6){return std::abs(a-b)& rows()const{return trades_;} + // Consume the real queued order after a synthetic between-event state + // change. The initial two-bar run performs the actual margin call. + void consume(const Bar& b,int index){bar_index_=index;current_bar_=b;process_pending_orders(b);} + double position()const{return signed_position_size();} + bool has_receipt()const{return pending_orders_.size()==1 + &&pending_orders_[0].signal_close_mc_entry_incarnation!=0;} + void invalidate(int mode){ + if(mode==0)++position_cycle_seq_; + if(mode==1)++pyramid_entries_.front().entry_incarnation; + if(mode==2){strategy_cancel("S");strategy_entry("S",false);} + if(mode==3)strategy_close("L","INTERVENING",.01,NaN,true); + if(mode==4)pending_orders_.front().signal_close_mc_bar-=1; + if(mode==5)++broker_fill_event_seq_; + } + void run_fixture(std::vector bars){ + run(bars.data(),(int)bars.size()); + } +private:Config cfg; +}; +std::vector bars(){ + return {{1.17714,1.17714,1.17714,1.17714,1,1000}, + {1.17714,1.17746,1.17652,1.17653,1,2000}, + {1.17652,1.17669,1.17592,1.17594,1,3000}, + {1.17600,1.17633,1.17581,1.17632,1,4000}, + {1.17632,1.17694,1.17632,1.17682,1,5000}, + {1.17682,1.17682,1.17682,1.17682,1,6000}}; +} +void exact_and_controls(){ + for(Shape shape:{Shape::Exact,Shape::NoMc,Shape::Headroom,Shape::Partial,Shape::Explicit,Shape::Half,Shape::Cancel}){ + Probe p({shape});auto b=bars();p.run_fixture(b);CHECK(p.last_error().empty()); + const auto&r=p.rows(); + if(shape==Shape::Exact){ + CHECK(r.size()==3);if(r.size()!=3)continue; + CHECK(r[0].exit_comment=="Margin call");CHECK(near(r[0].qty,1)); + CHECK(near(r[0].exit_price,1.17653));CHECK(r[0].exit_time==2000); + CHECK(near(r[1].qty,888240.18));CHECK(r[1].exit_time==3000); + CHECK(near(r[2].qty,1));CHECK(r[2].entry_time==3000); + CHECK(near(r[2].entry_price,1.17652));CHECK(near(r[2].exit_price,1.17632)); + // A reset rerun cannot inherit the prior order's residue provenance. + p.run_fixture(b);CHECK(p.rows().size()==3); + if(p.rows().size()==3)CHECK(near(p.rows()[2].qty,1)); + }else if(shape==Shape::NoMc){ + CHECK(r.size()==1);if(!r.empty())CHECK(near(r[0].qty,888240.18)); + }else if(shape==Shape::Partial){ + CHECK(r.size()==2);if(r.size()==2){CHECK(r[0].exit_comment=="PARTIAL"); + CHECK(near(r[0].qty,1));CHECK(near(r[1].qty,888239.18));} + }else if(shape==Shape::Headroom){ + CHECK(r.size()==3);if(r.size()==3){CHECK(near(r[0].qty,888241.18)); + CHECK(near(r[1].qty,1026.6));CHECK(near(r[2].qty,887214.58));} + }else if(shape==Shape::Explicit){ + // Related TV surplus exists but root excluded placement-close-only. + // Preserve the current engine path; do not disguise this as TV parity. + CHECK(r.size()==2); + }else if(shape==Shape::Half){ + // Related accepted-leg surplus is separately recorded, not this patch. + CHECK(r.size()==3);if(r.size()==3)CHECK(near(r[2].qty,444120.59)); + }else if(shape==Shape::Cancel){ + CHECK(r.size()==1);if(!r.empty())CHECK(r[0].exit_comment=="Margin call"); + } + } +} +void earlier_margin_call(){ + Probe p({Shape::Earlier});auto b=bars(); + b[3]={1.17690,1.17690,1.17690,1.17690,1,4000}; + b[4]={1.17688,1.17688,1.17688,1.17688,1,5000}; + b[5]={1.17695,1.17695,1.17695,1.17695,1,6000}; + b.push_back({1.17695,1.17695,1.17695,1.17695,1,7000}); + p.run_fixture(b);const auto&r=p.rows();CHECK(r.size()==2); + if(r.size()==2){CHECK(r[0].exit_comment=="Margin call"); + CHECK(near(r[1].qty,888240.18));CHECK(r[1].exit_time==5000);} +} +void provider_exclusion(){ + Config cfg;cfg.provider=true;Probe p(cfg);p.run_fixture(bars()); + CHECK(p.rows().size()==1); +} +void receipt_lifecycle(){ + for(int mode=-1;mode<6;++mode){ + Probe p({});auto b=bars();p.run_fixture({b[0],b[1]}); + CHECK(p.has_receipt()); + if(mode>=0)p.invalidate(mode); + if(mode==2)CHECK(!p.has_receipt()); // replacement owns fresh provenance + p.consume(b[2],2); + CHECK(near(p.position(),mode==-1?-1.0:0.0)); + } +} +} // namespace +int main(){exact_and_controls();earlier_margin_call();provider_exclusion();receipt_lifecycle(); + std::printf("%d passed, %d failed\n",passed,failed);return failed?1:0;}