diff --git a/CMakeLists.txt b/CMakeLists.txt index 4f3e33233..e29d59d85 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -88,6 +88,7 @@ add_library(pineforge STATIC src/engine_metrics.cpp src/engine_orders.cpp src/engine_execution.cpp + src/engine_execution_lifecycle.cpp src/engine_path_resolve.cpp src/engine_report.cpp src/engine_risk.cpp diff --git a/docs/native-settlement.md b/docs/native-settlement.md index 295947d0f..b5248fe1b 100644 --- a/docs/native-settlement.md +++ b/docs/native-settlement.md @@ -4,6 +4,9 @@ trusted matching adapters. It applies an already admitted, matched execution to the engine's existing physical lot book and emits the resulting close and open observations. It adds no second position ledger or persistent selector. +Callers that supply transient EXIT lifecycle effects use the separately named +`settle_execution_with_lifecycle` seam; the two-argument symbol is unchanged +and forwards empty effects. The caller supplies an `execution::Action` and `execution::Fill` from ``. These values describe immediate effects: @@ -69,9 +72,27 @@ ordinary floating-point rounding. A rebate increases it. ## Integration and limits -The kernel currently serves full market exits and selected frozen-transaction -and same-side materialization paths. Existing source scheduling and dust -decisions remain at their call sites. Migrated frozen transactions and the +The kernel currently serves full market exits, the close-opposite-then-enter +reversal family, and selected frozen-transaction and same-side materialization +paths. Existing source scheduling and dust decisions remain at their call +sites. The reversal helper consumes one already-resolved `Fill` and one current +fee; it does not slip again. `compat::pine` suspension selection stays at the +replacement caller. `settle_resolved_execution` remains the original +two-argument symbol and forwards empty effects to +`settle_execution_with_lifecycle`, the protected seam that consumes transient +lifecycle effects. Those effects name exact pending identities, revisions and +operations; `created_seq` 0 and `Target{0,0}` are literal expected values. +Source selection may preview the upcoming lifecycle frame without consuming it +and must supply a literal operation payload. They are not stored, hashed, or +reusable execution authority. Empty effects leave other settlement callers +unchanged. + +Authorized pre-close events run first, then close observations and the existing +flat unbind, then the listed pending removals, then `open_quoted_position`, +which still binds only remaining exits. False removal lists do not replace or +reallocate `pending_orders_`. Native settlement does not recognize source +cases, rewrite supplied window/barrier facts, or install a callback/plan. +Migrated frozen transactions and the final short-seed crossing settle their close/open effects in one native call. Other legacy close loops are not yet grouped into one parent execution; their per-row current ticket behavior is diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 30c76e0cc..83b5cba13 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include "na.hpp" #include "bar.hpp" #include "broker_events.hpp" @@ -1796,8 +1797,18 @@ class BacktestEngine { // order, perform admission/slippage, or provide cancellation/replay. The // owning run must abort on an exception; failed commits are not retryable // in place. A saved arithmetic plan is not execution authority. - execution::Result settle_resolved_execution(const execution::Action& action, - const execution::Fill& fill); + // Empty lifecycle effects. Member-pointer type is the original two-argument + // symbol; it forwards to settle_execution_with_lifecycle. + execution::Result settle_resolved_execution( + const execution::Action& action, const execution::Fill& fill); + // Trusted matching-adapter extension for one execution plus lifecycle effects. + // Pre-close operations, then close observations and old-cycle unbind, then + // the listed pending removals, then the quoted opening path which binds + // only remaining exits. Native settlement does not call compat::pine + // selectors; the effects value is not retained. + execution::Result settle_execution_with_lifecycle( + const execution::Action& action, const execution::Fill& fill, + const execution::LifecycleEffects& lifecycle); // Native account value: realized balance plus marked physical lots minus // their remaining paid entry costs, for every fee type. No Pine sizing or // end-of-range reporting convention participates in this value. @@ -3871,6 +3882,10 @@ class BacktestEngine { void execute_market_exit(double fill_price); void append_same_side_fill(PyramidEntry lot); void append_quoted_lot(PyramidEntry lot, double total_qty, double average_price); + // Allocates the new position cycle, lots and observations, then binds + // exits that still remain in pending_orders_. Settlement that authorized + // pending removals applies those erasures after old-cycle unbind and + // before this opening bind. void open_quoted_position(PositionSide requested, PyramidEntry lot); // Range-end accounting: record the rows that close a position still // open after the final script bar at that bar's close, the way @@ -4054,8 +4069,33 @@ class BacktestEngine { // done). Called right after every process_margin_call dispatch site. void settle_dormant_bracket_reissues(exit_legs::Domain domain); exit_legs::Frame next_leg_event(exit_legs::Phase phase = exit_legs::Phase::Observation); + exit_legs::Frame preview_next_leg_event( + exit_legs::Phase phase = exit_legs::Phase::Observation) const; void apply_leg_action(PendingOrder& order, exit_legs::Operation operation, std::optional cause = std::nullopt); + exit_legs::Domain current_exit_leg_domain() const; + enum class ExitLegTransitionResult { + Applied, Replay, StaleIdentity, BindRefused, ActionRefused, Exhausted, + RevisionExhausted + }; + ExitLegTransitionResult transition_exit_leg( + exit_legs::Lifecycle& legs, uint64_t order_incarnation, + exit_legs::Operation operation, std::optional supplied, + uint64_t& event_seq, int64_t position_cycle) const; + std::optional validate_lifecycle_effects( + const execution::LifecycleEffects& lifecycle) const; + std::optional preflight_settlement_lifecycle( + const execution::LifecycleEffects& lifecycle, + bool will_reset_to_flat, bool will_open_quoted); + void apply_pre_close_lifecycle_batch(const execution::LifecycleBatch& batch); + void apply_authorized_pending_removals( + const std::vector& removals); + std::vector snapshot_exit_pending_removals() const; + std::optional select_declined_reversal_pre_close( + const Bar& bar) const; + const PendingOrder* find_unique_pending( + uint64_t incarnation, int64_t created_seq) const; + PendingOrder* find_unique_pending(uint64_t incarnation, int64_t created_seq); // Per-OrderType fill kernels. Called only after risk + intraday // gates pass; each updates the engine's position/trade state and // any per-type out-parameters the post-fill bookkeeping needs. @@ -4276,12 +4316,21 @@ class BacktestEngine { PositionSide created_position_side, bool is_priced_entry, uint64_t entry_incarnation); + // `fill_price` is already resolved. Source sizing, direction and dust + // selection stay here; purge_pending_exits is translated into exact + // pending removals for the settlement coordinator. False does not + // touch pending_orders_ storage. void close_opposite_then_enter(const std::string& id, bool is_long, double fill_price, double explicit_qty, int explicit_qty_type, bool purge_pending_exits, bool explicit_qty_prequantized, uint64_t entry_incarnation); + void apply_resolved_close_opposite_then_enter( + const std::string& id, bool is_long, double fill_price, + double explicit_qty, int explicit_qty_type, + bool explicit_qty_prequantized, uint64_t entry_incarnation, + execution::LifecycleEffects lifecycle); void flip_market_position_to(const std::string& id, bool is_long, double fill_price, double explicit_qty, int explicit_qty_type, diff --git a/include/pineforge/execution.hpp b/include/pineforge/execution.hpp index 4623f36d1..6902963eb 100644 --- a/include/pineforge/execution.hpp +++ b/include/pineforge/execution.hpp @@ -1,10 +1,12 @@ #pragma once +#include "exit_leg_lifecycle.hpp" #include "order_action.hpp" #include #include #include #include +#include namespace pineforge::execution { @@ -27,7 +29,10 @@ struct Fill { enum class Status { Applied, NoEffect, InvalidPrice, InvalidQuantity, InvalidBook, - UnrepresentableQuantity, InvalidAccounting + UnrepresentableQuantity, InvalidAccounting, + // Caller-supplied lifecycle targets/revisions/operations that cannot be + // applied to the current pending book. Not durable engine state. + InvalidLifecycle }; struct Result { @@ -38,4 +43,40 @@ struct Result { double opened_units = 0.0; }; +// One pre-close operation on an exact pending identity. created_seq 0 and +// Target{0,0} are actual expected values, not wildcards. The coordinator +// allocates the batch cause and does not rewrite operation payloads. +struct LifecycleIntent { + uint64_t order_incarnation = 0; + int64_t created_seq = 0; + exit_legs::Target target{}; + uint64_t expected_revision = 0; + exit_legs::Operation operation{}; +}; + +// Engaging this batch allocates one observation frame on a successful +// commit even when operations is empty. Empty optional means no allocation. +// Phase must be a valid exit_legs::Phase even for an empty operations list. +struct LifecycleBatch { + exit_legs::Phase phase = exit_legs::Phase::Observation; + std::vector operations; +}; + +// Exact pending EXIT to remove after old-cycle unbind and before any +// quoted opening bind. Fields are the initial snapshot before this +// execution's own effects; incarnation 0 is refused. +struct PendingRemoval { + uint64_t incarnation = 0; + int64_t created_seq = 0; + exit_legs::Target target{}; + uint64_t expected_revision = 0; +}; + +// Transient, stack-local effects for one settle_execution_with_lifecycle +// call. Not stored, hashed, replayed, or reusable execution authority. +struct LifecycleEffects { + std::optional pre_close; + std::vector removals; +}; + } // namespace pineforge::execution diff --git a/src/engine_execution.cpp b/src/engine_execution.cpp index 5ac8d3556..6a361d432 100644 --- a/src/engine_execution.cpp +++ b/src/engine_execution.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -22,6 +23,12 @@ void reserve_effects(std::vector& values, size_t extra) { execution::Result BacktestEngine::settle_resolved_execution( const execution::Action& action, const execution::Fill& fill) { + return settle_execution_with_lifecycle(action, fill, {}); +} + +execution::Result BacktestEngine::settle_execution_with_lifecycle( + const execution::Action& action, const execution::Fill& fill, + const execution::LifecycleEffects& lifecycle) { using execution::Status; if (!std::isfinite(fill.price)) return {Status::InvalidPrice}; if (fill.commission_account && !std::isfinite(*fill.commission_account)) @@ -53,6 +60,8 @@ execution::Result BacktestEngine::settle_resolved_execution( held += lot.qty; if (!std::isfinite(held)) return {Status::InvalidBook}; } + if (auto invalid = validate_lifecycle_effects(lifecycle)) + return {*invalid}; const double signed_held = position_side_ == PositionSide::SHORT ? -held : held; if (!flatten && requested == 0.0) return no_effect(); if ((flatten || reduce) && pyramid_entries_.empty()) return no_effect(); @@ -182,6 +191,13 @@ execution::Result BacktestEngine::settle_resolved_execution( && position_entry_count_ == std::numeric_limits::max()) throw std::overflow_error("position entry counter exhausted"); + const bool will_reset = closed > 0.0 && survivors.empty(); + const bool will_open_quoted = opening > 0.0 + && (position_side_ == PositionSide::FLAT || survivors.empty()); + if (auto invalid = preflight_settlement_lifecycle( + lifecycle, will_reset, will_open_quoted)) + return {*invalid}; + const size_t first_trade = trades_.size(); const size_t first_action = stream_order_actions_.size(); const size_t events = closed_trades.size() + (opening > 0.0 ? 1 : 0); @@ -195,6 +211,9 @@ execution::Result BacktestEngine::settle_resolved_execution( // Commit through the existing accounting/observation sinks. Allocation or // lifecycle exceptions still abort the owning engine run; this internal // synchronous kernel does not promise recovery/replay of a failed commit. + // Order: authorized pre-close events, close observations and old-cycle + // unbind, authorized pending removals, then quoted opening bind. + if (lifecycle.pre_close) apply_pre_close_lifecycle_batch(*lifecycle.pre_close); for (auto& trade : closed_trades) record_close_trade(std::move(trade)); if (closed > 0.0) { if (survivors.empty()) { @@ -206,6 +225,7 @@ execution::Result BacktestEngine::settle_resolved_execution( position_entry_count_ = static_cast(pyramid_entries_.size()); } } + apply_authorized_pending_removals(lifecycle.removals); if (opening > 0.0) { PyramidEntry lot{fill.price, current_bar_.timestamp, opening, fill.id, bar_index_}; lot.entry_incarnation = fill.incarnation; diff --git a/src/engine_execution_lifecycle.cpp b/src/engine_execution_lifecycle.cpp new file mode 100644 index 000000000..afff6e3b1 --- /dev/null +++ b/src/engine_execution_lifecycle.cpp @@ -0,0 +1,321 @@ +#include "engine_internal.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pineforge { +namespace { + +bool valid_lifecycle_phase(exit_legs::Phase phase) { + return static_cast(phase) + <= static_cast(exit_legs::Phase::AfterMargin); +} + +bool same_target(exit_legs::Target a, exit_legs::Target b) { + return a.incarnation == b.incarnation && a.owner == b.owner; +} + +struct PendingLegCopy { + uint64_t incarnation = 0; + int64_t created_seq = 0; + OrderType type = OrderType::EXIT; + exit_legs::Lifecycle legs; + bool removed = false; +}; + +PendingLegCopy* find_leg_copy(std::vector& copies, + uint64_t incarnation, int64_t created_seq) { + PendingLegCopy* found = nullptr; + for (auto& copy : copies) { + if (copy.removed) continue; + if (copy.incarnation != incarnation || copy.created_seq != created_seq) + continue; + if (found) return nullptr; + found = © + } + return found; +} + +struct IdentityKey { + uint64_t incarnation = 0; + int64_t created_seq = 0; + bool operator==(const IdentityKey& other) const { + return incarnation == other.incarnation && created_seq == other.created_seq; + } +}; + +struct IdentityKeyHash { + size_t operator()(const IdentityKey& key) const { + return std::hash{}(key.incarnation) + ^ (std::hash{}(key.created_seq) << 1); + } +}; + +} // namespace + +exit_legs::Domain BacktestEngine::current_exit_leg_domain() const { + if (stream_phase_ != StreamPhase::IDLE) return exit_legs::Domain::RawTicks; + if (bar_magnifier_enabled_) { + return coof_scheduler_active_ ? exit_legs::Domain::MagnifierCoof + : exit_legs::Domain::Magnifier; + } + return coof_scheduler_active_ ? exit_legs::Domain::Coof + : exit_legs::Domain::Ordinary; +} + +exit_legs::Frame BacktestEngine::preview_next_leg_event(exit_legs::Phase phase) const { + if (exit_leg_event_seq_ == UINT64_MAX) + throw std::overflow_error("exit lifecycle event exhausted"); + return {exit_leg_event_seq_ + 1, bar_index_, current_exit_leg_domain(), phase}; +} + +const PendingOrder* BacktestEngine::find_unique_pending( + uint64_t incarnation, int64_t created_seq) const { + const PendingOrder* found = nullptr; + for (const auto& order : pending_orders_) { + if (order.incarnation != incarnation || order.created_seq != created_seq) + continue; + if (found) return nullptr; + found = ℴ + } + return found; +} + +PendingOrder* BacktestEngine::find_unique_pending( + uint64_t incarnation, int64_t created_seq) { + return const_cast( + static_cast(this)->find_unique_pending( + incarnation, created_seq)); +} + +BacktestEngine::ExitLegTransitionResult BacktestEngine::transition_exit_leg( + exit_legs::Lifecycle& legs, uint64_t order_incarnation, + exit_legs::Operation operation, std::optional supplied, + uint64_t& event_seq, int64_t position_cycle) const { + const auto receipt_phase = supplied ? supplied->phase + : exit_legs::Phase::Observation; + const auto mint = [&](exit_legs::Phase phase) -> std::optional { + if (event_seq == UINT64_MAX) return std::nullopt; + return exit_legs::Frame{ + ++event_seq, bar_index_, current_exit_leg_domain(), phase}; + }; + if (!legs.target().incarnation) + legs.attach(order_incarnation, position_cycle); + if (legs.last_action()) { + event_seq = std::max(event_seq, legs.last_action()->cause.event); + if (supplied && supplied->event <= legs.last_action()->cause.event) + supplied.reset(); + } + if (legs.target().incarnation != order_incarnation) + return ExitLegTransitionResult::StaleIdentity; + if (legs.target().owner != position_cycle) { + const auto bind_cause = mint(receipt_phase); + if (!bind_cause) return ExitLegTransitionResult::Exhausted; + const exit_legs::Action bind{legs.target(), legs.revision(), *bind_cause, + exit_legs::BindOwner{position_cycle}}; + if (legs.apply(legs.target(), bind) != exit_legs::Result::Applied) + return ExitLegTransitionResult::BindRefused; + supplied.reset(); + } + const auto cause = supplied ? supplied : mint(receipt_phase); + if (!cause) return ExitLegTransitionResult::Exhausted; + const exit_legs::Action action{ + legs.target(), legs.revision(), *cause, std::move(operation)}; + const auto result = legs.apply({order_incarnation, position_cycle}, action); + if (result == exit_legs::Result::Applied) return ExitLegTransitionResult::Applied; + if (result == exit_legs::Result::Replay) return ExitLegTransitionResult::Replay; + if (result == exit_legs::Result::Exhausted) + return ExitLegTransitionResult::RevisionExhausted; + return ExitLegTransitionResult::ActionRefused; +} + +std::optional BacktestEngine::validate_lifecycle_effects( + const execution::LifecycleEffects& lifecycle) const { + if (!lifecycle.pre_close && lifecycle.removals.empty()) return std::nullopt; + std::unordered_set seen_intents; + if (lifecycle.pre_close) { + if (!valid_lifecycle_phase(lifecycle.pre_close->phase)) + return execution::Status::InvalidLifecycle; + for (const auto& intent : lifecycle.pre_close->operations) { + const IdentityKey key{intent.order_incarnation, intent.created_seq}; + if (!seen_intents.insert(key).second) + return execution::Status::InvalidLifecycle; + if (const auto* bind = std::get_if(&intent.operation)) { + if (bind->owner != position_cycle_seq_) + return execution::Status::InvalidLifecycle; + } + const PendingOrder* order = find_unique_pending( + intent.order_incarnation, intent.created_seq); + if (!order) return execution::Status::InvalidLifecycle; + if (!same_target(order->legs.target(), intent.target) + || order->legs.revision() != intent.expected_revision) + return execution::Status::InvalidLifecycle; + } + } + std::unordered_set seen_removals; + for (const auto& removal : lifecycle.removals) { + if (removal.incarnation == 0) return execution::Status::InvalidLifecycle; + const IdentityKey key{removal.incarnation, removal.created_seq}; + if (!seen_removals.insert(key).second) + return execution::Status::InvalidLifecycle; + const PendingOrder* order = find_unique_pending( + removal.incarnation, removal.created_seq); + if (!order || order->type != OrderType::EXIT) + return execution::Status::InvalidLifecycle; + if (!same_target(order->legs.target(), removal.target) + || order->legs.revision() != removal.expected_revision) + return execution::Status::InvalidLifecycle; + } + return std::nullopt; +} + +std::optional BacktestEngine::preflight_settlement_lifecycle( + const execution::LifecycleEffects& lifecycle, + bool will_reset_to_flat, bool will_open_quoted) { + if (!lifecycle.pre_close && !will_reset_to_flat && !will_open_quoted) + return std::nullopt; + + std::vector copies; + copies.reserve(pending_orders_.size()); + for (const auto& order : pending_orders_) { + copies.push_back({order.incarnation, order.created_seq, order.type, + order.legs, false}); + } + uint64_t seq = exit_leg_event_seq_; + const int64_t old_cycle = position_cycle_seq_; + + if (lifecycle.pre_close) { + if (seq == UINT64_MAX) + throw std::overflow_error("exit lifecycle event exhausted"); + const exit_legs::Frame cause{ + ++seq, bar_index_, current_exit_leg_domain(), lifecycle.pre_close->phase}; + for (const auto& intent : lifecycle.pre_close->operations) { + auto* copy = find_leg_copy( + copies, intent.order_incarnation, intent.created_seq); + if (!copy) return execution::Status::InvalidLifecycle; + const auto result = transition_exit_leg( + copy->legs, copy->incarnation, intent.operation, cause, + seq, old_cycle); + if (result == ExitLegTransitionResult::Exhausted) + throw std::overflow_error("exit lifecycle event exhausted"); + if (result == ExitLegTransitionResult::RevisionExhausted) + throw std::overflow_error("exit lifecycle revision exhausted"); + if (result != ExitLegTransitionResult::Applied + && result != ExitLegTransitionResult::Replay) + return execution::Status::InvalidLifecycle; + } + } + + if (will_reset_to_flat) { + for (auto& copy : copies) { + if (copy.removed || copy.type != OrderType::EXIT) continue; + if (!copy.legs.target().incarnation) + copy.legs.attach(copy.incarnation, old_cycle); + if (seq == UINT64_MAX) + throw std::overflow_error("exit lifecycle event exhausted"); + const exit_legs::Frame cause{ + ++seq, bar_index_, current_exit_leg_domain(), + exit_legs::Phase::Observation}; + const exit_legs::Action action{ + copy.legs.target(), copy.legs.revision(), cause, + exit_legs::BindOwner{0}}; + // Flat cleanup unbinds the lifecycle's stored owner, which may + // still be zero for a prearmed exit. The pending instruction's + // incarnation must nevertheless match exactly. + const auto applied = copy.legs.apply( + {copy.incarnation, copy.legs.target().owner}, action); + if (applied == exit_legs::Result::Exhausted) + throw std::overflow_error("exit lifecycle revision exhausted"); + if (applied != exit_legs::Result::Applied) { + if (lifecycle.pre_close) + return execution::Status::InvalidLifecycle; + throw std::logic_error("exit lifecycle flat unbind refused"); + } + } + } + + for (const auto& removal : lifecycle.removals) { + auto* copy = find_leg_copy( + copies, removal.incarnation, removal.created_seq); + if (!copy || copy->type != OrderType::EXIT) + return execution::Status::InvalidLifecycle; + copy->removed = true; + } + + if (will_open_quoted) { + const int64_t new_cycle = next_position_cycle_seq_; + for (auto& copy : copies) { + if (copy.removed || copy.type != OrderType::EXIT) continue; + if (!copy.legs.target().incarnation) + copy.legs.attach(copy.incarnation, new_cycle); + if (copy.legs.target().owner == new_cycle) continue; + const auto result = transition_exit_leg( + copy.legs, copy.incarnation, exit_legs::BindOwner{new_cycle}, + std::nullopt, seq, new_cycle); + if (result == ExitLegTransitionResult::Exhausted) + throw std::overflow_error("exit lifecycle event exhausted"); + if (result == ExitLegTransitionResult::RevisionExhausted) + throw std::overflow_error("exit lifecycle revision exhausted"); + if (result != ExitLegTransitionResult::Applied + && result != ExitLegTransitionResult::Replay) + throw std::logic_error("exit lifecycle action refused"); + } + } + return std::nullopt; +} + +void BacktestEngine::apply_pre_close_lifecycle_batch( + const execution::LifecycleBatch& batch) { + const auto cause = next_leg_event(batch.phase); + for (const auto& intent : batch.operations) { + PendingOrder* order = find_unique_pending( + intent.order_incarnation, intent.created_seq); + if (!order) throw std::logic_error("exit lifecycle pre-close target missing"); + const auto result = transition_exit_leg( + order->legs, order->incarnation, intent.operation, cause, + exit_leg_event_seq_, position_cycle_seq_); + if (result == ExitLegTransitionResult::Exhausted) + throw std::overflow_error("exit lifecycle event exhausted"); + if (result == ExitLegTransitionResult::RevisionExhausted) + throw std::overflow_error("exit lifecycle revision exhausted"); + if (result == ExitLegTransitionResult::StaleIdentity) + throw std::logic_error("stale exit lifecycle instruction"); + if (result == ExitLegTransitionResult::BindRefused) + throw std::logic_error("exit lifecycle owner bind refused"); + if (result != ExitLegTransitionResult::Applied + && result != ExitLegTransitionResult::Replay) + throw std::logic_error("exit lifecycle action refused"); + } +} + +void BacktestEngine::apply_authorized_pending_removals( + const std::vector& removals) { + if (removals.empty()) return; + std::vector remaining = removals; + pending_orders_.erase( + std::remove_if(pending_orders_.begin(), pending_orders_.end(), + [&](const PendingOrder& order) { + if (order.type != OrderType::EXIT) return false; + for (auto it = remaining.begin(); it != remaining.end(); ++it) { + if (it->incarnation == order.incarnation + && it->created_seq == order.created_seq) { + remaining.erase(it); + return true; + } + } + return false; + }), + pending_orders_.end()); + if (!remaining.empty()) + throw std::logic_error("exit lifecycle removal target missing"); +} + +} // namespace pineforge diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 3bab9823c..2ee91d805 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -10,9 +10,11 @@ #include #include #include +#include #include #include #include +#include #ifndef PINEFORGE_SHORT_SEED_COLLISION_MATERIALIZE_LONG #define PINEFORGE_SHORT_SEED_COLLISION_MATERIALIZE_LONG 1 @@ -7129,10 +7131,17 @@ void BacktestEngine::apply_market_order_fill(PendingOrder& order, double fill_pr // The old from_entry bracket is dormant after a reducing sell and // reactivates only through its established reissue/margin lifecycle. // Do not erase pending_orders_ while the fill loop holds references. - mark_position_brackets_dormant_on_declined_reversal(bar); - close_opposite_then_enter(order.id, false, fill_price, - order.frozen_default_qty, -1, /*purge_pending_exits=*/false, - /*explicit_qty_prequantized=*/true, order.incarnation); + // Resolve the matcher price once; the helper is resolved-only. The + // exact-book gate currently requires zero slippage/fees, but this + // boundary still goes through apply_fill_slippage. + execution::LifecycleEffects lifecycle; + if (auto batch = select_declined_reversal_pre_close(bar)) + lifecycle.pre_close = std::move(*batch); + apply_resolved_close_opposite_then_enter( + order.id, false, apply_fill_slippage(fill_price, /*is_buy=*/false), + order.frozen_default_qty, -1, + /*explicit_qty_prequantized=*/true, order.incarnation, + std::move(lifecycle)); for (PendingOrder& sibling : pending_orders_) { if (sibling.type == OrderType::MARKET && sibling.created_seq > order.created_seq @@ -7938,19 +7947,37 @@ bool BacktestEngine::dormant_bracket_trail_leg_live(const PendingOrder& o) const && (!std::isnan(o.legs.prices().trail_points) || !std::isnan(o.legs.prices().trail_price)); } -void BacktestEngine::mark_position_brackets_dormant_on_declined_reversal(const Bar& bar) { - if (position_side_ == PositionSide::FLAT) return; - const auto cause = next_leg_event(); - for (PendingOrder& order : pending_orders_) { +std::optional +BacktestEngine::select_declined_reversal_pre_close(const Bar& bar) const { + if (position_side_ == PositionSide::FLAT) return std::nullopt; + execution::LifecycleBatch batch; + const auto upcoming = preview_next_leg_event(exit_legs::Phase::Observation); + const int direction = position_side_ == PositionSide::LONG ? 1 : -1; + const double prior_best = trail_best_before_bar_index_ == bar_index_ + ? trail_best_before_bar_ : trail_best_price_; + const bool open_slice = open_margin_slice_bar_ == bar_index_; + for (const PendingOrder& order : pending_orders_) { const bool standing = order.from_entry.empty() || cycle_filled_entry_ids_.count(order.from_entry) != 0; const auto selected = compat::pine::select_exit_suspension(order, - {cause, position_side_ == PositionSide::LONG ? 1 : -1, - position_entry_price_, syminfo_mintick_, bar.open, - trail_best_before_bar_index_ == bar_index_ ? trail_best_before_bar_ : trail_best_price_, - open_margin_slice_bar_ == bar_index_, standing}); - if (selected) apply_leg_action(order, *selected, cause); - } + {upcoming, direction, position_entry_price_, syminfo_mintick_, + bar.open, prior_best, open_slice, standing}); + if (!selected) continue; + execution::LifecycleIntent intent; + intent.order_incarnation = order.incarnation; + intent.created_seq = order.created_seq; + intent.target = order.legs.target(); + intent.expected_revision = order.legs.revision(); + intent.operation = *selected; + batch.operations.push_back(std::move(intent)); + } + return batch; +} + +void BacktestEngine::mark_position_brackets_dormant_on_declined_reversal(const Bar& bar) { + const auto batch = select_declined_reversal_pre_close(bar); + if (!batch) return; + apply_pre_close_lifecycle_batch(*batch); } // ── Inner-loop phase 1: order eligibility ───────────────────────────── diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index 73086c3f3..020e98c00 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -9,9 +9,11 @@ #include #include #include +#include #include #include #include +#include namespace pineforge { using namespace internal; @@ -133,6 +135,7 @@ void BacktestEngine::execute_market_entry(const std::string& id, bool is_long, d } if (created_position_side == PositionSide::FLAT && close_only_opposite) { + // fill_price is already resolved by apply_fill_slippage above. close_opposite_then_enter( id, is_long, fill_price, explicit_qty, explicit_qty_type, /*purge_pending_exits=*/!paired_flat_market_transaction, @@ -881,42 +884,34 @@ void BacktestEngine::settle_position_after_partial_exit( // Exposure transitions resolve activation once. Matchers never refresh a // deadline from whichever position happens to be current at read time. exit_legs::Frame BacktestEngine::next_leg_event(exit_legs::Phase phase) { - if (exit_leg_event_seq_ == UINT64_MAX) throw std::overflow_error("exit lifecycle event exhausted"); - const auto domain = stream_phase_ != StreamPhase::IDLE ? exit_legs::Domain::RawTicks - : bar_magnifier_enabled_ ? (coof_scheduler_active_ ? exit_legs::Domain::MagnifierCoof : exit_legs::Domain::Magnifier) - : coof_scheduler_active_ ? exit_legs::Domain::Coof : exit_legs::Domain::Ordinary; - return {++exit_leg_event_seq_, bar_index_, domain, phase}; + const auto frame = preview_next_leg_event(phase); + ++exit_leg_event_seq_; + return frame; } void BacktestEngine::apply_leg_action(PendingOrder& order, exit_legs::Operation operation, std::optional supplied) { // Rebinding can require a later receipt event at this same hook. Preserve // its phase when replacing that receipt; an after-margin completion must // not be recorded as processed during the earlier observation phase. - const auto receipt_phase = supplied ? supplied->phase : exit_legs::Phase::Observation; - if (!order.legs.target().incarnation) order.legs.attach(order.incarnation, position_cycle_seq_); - if (order.legs.last_action()) { - exit_leg_event_seq_ = std::max(exit_leg_event_seq_, order.legs.last_action()->cause.event); - if (supplied && supplied->event <= order.legs.last_action()->cause.event) supplied.reset(); - } - if (order.legs.target().incarnation != order.incarnation) + const auto result = transition_exit_leg( + order.legs, order.incarnation, std::move(operation), supplied, + exit_leg_event_seq_, position_cycle_seq_); + switch (result) { + case ExitLegTransitionResult::Applied: + case ExitLegTransitionResult::Replay: + return; + case ExitLegTransitionResult::Exhausted: + throw std::overflow_error("exit lifecycle event exhausted"); + case ExitLegTransitionResult::RevisionExhausted: + throw std::overflow_error("exit lifecycle revision exhausted"); + case ExitLegTransitionResult::StaleIdentity: throw std::logic_error("stale exit lifecycle instruction"); - // Current-owner selection is explicit. The legacy cause need not prove - // that its older bar-only producer owned this target's current cycle. - if (order.legs.target().owner != position_cycle_seq_) { - const auto bind_cause = next_leg_event(receipt_phase); - const exit_legs::Action bind{order.legs.target(), order.legs.revision(), bind_cause, - exit_legs::BindOwner{position_cycle_seq_}}; - if (order.legs.apply(order.legs.target(), bind) != exit_legs::Result::Applied) - throw std::logic_error("exit lifecycle owner bind refused"); - // A supplied batch cause predates this explicit rebind. The target - // receives a fresh operation receipt without asserting prior ownership. - supplied.reset(); - } - const auto cause = supplied ? *supplied : next_leg_event(receipt_phase); - const exit_legs::Action action{order.legs.target(), order.legs.revision(), cause, std::move(operation)}; - const auto result = order.legs.apply({order.incarnation, position_cycle_seq_}, action); - if (result != exit_legs::Result::Applied && result != exit_legs::Result::Replay) + case ExitLegTransitionResult::BindRefused: + throw std::logic_error("exit lifecycle owner bind refused"); + case ExitLegTransitionResult::ActionRefused: throw std::logic_error("exit lifecycle action refused"); + } + throw std::logic_error("exit lifecycle action refused"); } void BacktestEngine::bind_exit_activation(PendingOrder& order) { @@ -940,7 +935,10 @@ void BacktestEngine::unbind_exit_activations() { order.leg_activation.unbind(); if (!order.legs.target().incarnation) order.legs.attach(order.incarnation, position_cycle_seq_); const exit_legs::Action action{order.legs.target(), order.legs.revision(), next_leg_event(), exit_legs::BindOwner{0}}; - if (order.legs.apply(order.legs.target(), action) != exit_legs::Result::Applied) + // A prearmed lifecycle may not have acquired the physical cycle. + // Unbind its actual owner while preserving exact order identity. + if (order.legs.apply({order.incarnation, order.legs.target().owner}, action) + != exit_legs::Result::Applied) throw std::logic_error("exit lifecycle flat unbind refused"); } } @@ -1170,35 +1168,69 @@ void BacktestEngine::add_to_pyramid_market(const std::string& id, bool is_long, // close_only_opposite branch: TV semantic for opposite-direction entries // where the strategy.entry call was placed with ``close_only_opposite=true`` // — close part of the existing opposite position by tx_qty, then open the -// requested-direction remainder if any. +// requested-direction remainder if any. `fill_price` is already resolved. void BacktestEngine::close_opposite_then_enter(const std::string& id, bool is_long, double fill_price, double explicit_qty, int explicit_qty_type, bool purge_pending_exits, bool explicit_qty_prequantized, uint64_t entry_incarnation) { - double tx_qty = explicit_qty_prequantized + execution::LifecycleEffects lifecycle; + if (purge_pending_exits) lifecycle.removals = snapshot_exit_pending_removals(); + apply_resolved_close_opposite_then_enter( + id, is_long, fill_price, explicit_qty, explicit_qty_type, + explicit_qty_prequantized, entry_incarnation, std::move(lifecycle)); +} + +std::vector +BacktestEngine::snapshot_exit_pending_removals() const { + std::vector removals; + for (const auto& order : pending_orders_) { + if (order.type == OrderType::EXIT) { + removals.push_back({order.incarnation, order.created_seq, + order.legs.target(), order.legs.revision()}); + } + } + return removals; +} + +void BacktestEngine::apply_resolved_close_opposite_then_enter( + const std::string& id, bool is_long, double fill_price, + double explicit_qty, int explicit_qty_type, + bool explicit_qty_prequantized, uint64_t entry_incarnation, + execution::LifecycleEffects lifecycle) { + const double tx_qty = explicit_qty_prequantized ? explicit_qty : calc_qty_for_type(fill_price, explicit_qty, explicit_qty_type); - double close_qty = std::min(tx_qty, position_qty_); - // execute_partial_exit_qty applies slippage internally (mirrors its other - // callers, e.g. execute_partial_exit_by_percent). Pass the RAW fill_price — - // pre-slipping here would double-slip the close leg (issue #27). - execute_partial_exit_qty(fill_price, close_qty); - // The ordinary close-only-opposite path historically purges stale exits. - // A confirmed same-source flat MARKET pair is different: both transaction - // legs execute inside process_pending_orders, where erasing the vector - // would invalidate the active order reference and filled-index ledger. - // Its stale exits follow flip_market_position_to's safe next-pass cleanup. - if (purge_pending_exits) purge_exit_orders(); - double remainder = tx_qty - close_qty; + if (!std::isfinite(tx_qty) || tx_qty < 0.0) + throw std::invalid_argument("invalid close-opposite transaction quantity"); + if (tx_qty == 0.0) return; + const double signed_units = is_long ? tx_qty : -tx_qty; + double held = 0.0; + for (const auto& lot : pyramid_entries_) held += lot.qty; + const double signed_held = position_side_ == PositionSide::SHORT ? -held + : position_side_ == PositionSide::LONG ? held : 0.0; + const auto planned = order_action::plan( + signed_held, order_action::Transact{signed_units}); + if (!planned) + throw std::runtime_error("unrepresentable close-opposite transaction"); + if (planned->no_effect()) return; + + execution::Action action = order_action::Transact{signed_units}; + const double remainder = std::abs(planned->open_units()); if (remainder <= kQtyEpsilon) { - return; + if (!pyramid_entries_.empty() && planned->close_units() >= held) + action = execution::Flatten{}; + else + action = order_action::Reduce{planned->close_units()}; } - fill_price = apply_fill_slippage(fill_price, is_long); - PositionSide requested = is_long ? PositionSide::LONG : PositionSide::SHORT; - open_fresh_position( - requested, fill_price, remainder, id, entry_incarnation); + + const auto result = settle_execution_with_lifecycle( + action, execution::Fill{fill_price, id, {}, entry_incarnation}, + lifecycle); + if (result.status != execution::Status::Applied + && result.status != execution::Status::NoEffect) + throw std::runtime_error("invalid resolved close-opposite settlement"); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bfb9796fc..27b558efe 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,6 +1,7 @@ set(TEST_SOURCES test_unbounded_margin_admission test_resolved_execution + test_native_reversal_contract test_order_action test_order_action_integration test_placement_facts diff --git a/tests/test_native_reversal_contract.cpp b/tests/test_native_reversal_contract.cpp new file mode 100644 index 000000000..e4cac04fe --- /dev/null +++ b/tests/test_native_reversal_contract.cpp @@ -0,0 +1,965 @@ +// Direct native reversal contract witnesses. +// +// This file deliberately does not call BacktestEngine::run, feed a bar +// sequence, compile generated strategy code, or consume a reference tape. +// The first group records the contract that a future native reversal seam must +// satisfy. On d3996b4 these checks are expected to fail: they are failing- +// before witnesses for the migration, not a compatibility claim about +// TradingView output. The purge flag checks characterize an existing caller +// contract and must remain stable while the seam is introduced. + +#include + +#include +#include +#include +#include +#include +#include + +using namespace pineforge; + +namespace { + +int checks = 0; +int failures = 0; + +#define CHECK(value) do { \ + ++checks; \ + if (!(value)) { \ + ++failures; \ + std::printf("FAIL %d: %s\n", __LINE__, #value); \ + } \ +} while (0) + +void near(double actual, double expected, double tolerance = 1e-12) { + CHECK(std::isfinite(actual) && std::abs(actual - expected) <= tolerance); + if (!std::isfinite(actual) || std::abs(actual - expected) > tolerance) + std::printf(" actual=%.17g expected=%.17g\n", actual, expected); +} + +template +struct Access { + friend auto access(Tag) { return Member; } +}; + +struct CloseOppositeAccess { friend auto access(CloseOppositeAccess); }; +struct MarketEntryAccess { friend auto access(MarketEntryAccess); }; +struct SettleAccess { friend auto access(SettleAccess); }; +struct EffectsSettleAccess { friend auto access(EffectsSettleAccess); }; +struct SelectPreCloseAccess { friend auto access(SelectPreCloseAccess); }; +struct LegRevisionAccess { friend auto access(LegRevisionAccess); }; + +template struct Access; +template struct Access; +template struct Access; +template struct Access; +template struct Access; +template struct Access; + +class Book final : public BacktestEngine { +public: + Book() { + initial_capital_ = 10'000.0; + commission_type_ = CommissionType::CASH_PER_ORDER; + commission_value_ = 6.0; + syminfo_mintick_ = 0.01; + slippage_ = 0; + current_bar_ = {100.0, 125.0, 95.0, 120.0, 1.0, 60'000}; + bar_index_ = 1; + } + + void on_bar(const Bar&) override {} + + // A two-lot LONG book with historical entry tickets already paid. The + // physical roster is authoritative; the aggregate fields are projections + // needed by the legacy helper under test. + void seed_two_lots(PositionSide side = PositionSide::LONG) { + position_side_ = side; + position_cycle_seq_ = 4; + next_position_cycle_seq_ = 5; + position_entry_price_ = 106.0; + position_qty_ = 5.0; + position_entry_count_ = 2; + position_open_bar_ = 0; + pyramid_entries_.clear(); + + PyramidEntry first{100.0, 1'000, 2.0, "A", 0}; + first.entry_incarnation = 11; + first.entry_commission_account = 6.0; + PyramidEntry second{110.0, 2'000, 3.0, "B", 0}; + second.entry_incarnation = 12; + second.entry_commission_account = 6.0; + pyramid_entries_.push_back(first); + pyramid_entries_.push_back(second); + + id_unclosed_qty_.clear(); + id_unclosed_qty_["A"] = 2.0; + id_unclosed_qty_["B"] = 3.0; + cycle_filled_entry_ids_.clear(); + cycle_filled_entry_ids_.insert("A"); + cycle_filled_entry_ids_.insert("B"); + trades_.clear(); + range_end_trades_.clear(); + net_profit_sum_ = 0.0; + gross_profit_sum_ = 0.0; + gross_loss_sum_ = 0.0; + intraday_pnl_ = 0.0; + win_trades_count_ = 0; + loss_trades_count_ = 0; + eventrades_count_ = 0; + } + + void add_stale_exit() { + PendingOrder stale{}; + stale.id = "stale-exit"; + stale.from_entry = "A"; + stale.type = OrderType::EXIT; + stale.incarnation = 500; + stale.legs.attach(stale.incarnation, position_cycle_seq_); + pending_orders_.push_back(std::move(stale)); + } + + size_t pending_count() const { return pending_orders_.size(); } + double position() const { return signed_position_size(); } + const std::vector& lots() const { return pyramid_entries_; } + const std::vector& trades() const { return trades_; } + uint64_t fingerprint() const { return broker_state_hash(); } + void set_slippage(int ticks) { slippage_ = ticks; } + + void set_limit_fill(bool enabled) { current_fill_is_limit_ = enabled; } + void observe_actions(bool enabled) { stream_observe_actions_ = enabled; } + void exhaust_stream_actions() { + stream_action_sequence_ = std::numeric_limits::max(); + } + void exhaust_lifecycle_events() { + exit_leg_event_seq_ = std::numeric_limits::max() - 1; + } + void exhaust_trade_wins() { + win_trades_count_ = std::numeric_limits::max(); + } + uint64_t lifecycle_events() const { return exit_leg_event_seq_; } + uint64_t lifecycle_revision() const { + return pending_orders_.empty() ? 0 : pending_orders_.front().legs.revision(); + } + int64_t lifecycle_owner() const { + return pending_orders_.empty() ? -1 : pending_orders_.front().legs.target().owner; + } + PendingOrder* first_pending_address() { + return pending_orders_.empty() ? nullptr : &pending_orders_.front(); + } + bool lifecycle_last_is_bind_to(int64_t owner) const { + if (pending_orders_.empty() || !pending_orders_.front().legs.last_action()) return false; + const auto& op = pending_orders_.front().legs.last_action()->operation; + const auto* bind = std::get_if(&op); + return bind && bind->owner == owner; + } + + execution::Result settle_reduce(double units, double price = 120.0, + std::optional fee = std::nullopt) { + execution::Fill fill{price, "N", "native", 901, fee}; + return (this->*access(SettleAccess{}))( + order_action::Reduce{units}, fill); + } + + execution::Result settle_flatten(double price = 120.0) { + execution::Fill fill{price, "N", "native", 902}; + return (this->*access(SettleAccess{}))( + execution::Flatten{}, fill); + } + + execution::Result settle_effects(const execution::Action& action, + const execution::LifecycleEffects& effects) { + return (this->*access(EffectsSettleAccess{}))( + action, execution::Fill{120.0, "N", "native", 903}, effects); + } + std::optional selected_pre_close() const { + return (this->*access(SelectPreCloseAccess{}))(current_bar_); + } + void set_leg_owner(int64_t owner) { + exit_legs::Lifecycle replacement; + replacement.attach(pending_orders_.front().incarnation, owner); + pending_orders_.front().legs = std::move(replacement); + } + void set_leg_incarnation(uint64_t incarnation, int64_t owner) { + exit_legs::Lifecycle replacement; + replacement.attach(incarnation, owner); + pending_orders_.front().legs = std::move(replacement); + } + void bind_current_cycle_activation() { + pending_orders_.front().leg_activation.bind({position_cycle_seq_, 5, 5}); + } + void exhaust_leg_revision() { + pending_orders_.front().legs.*access(LegRevisionAccess{}) = UINT64_MAX; + } + void set_lifecycle_events(uint64_t value) { exit_leg_event_seq_ = value; } + const exit_legs::Lifecycle& first_legs() const { return pending_orders_.front().legs; } + void define_stop() { pending_orders_.front().legs.set_stop_price(90.0); } + void add_second_exit() { + PendingOrder order{}; + order.id = "second-exit"; + order.from_entry = "B"; + order.type = OrderType::EXIT; + order.incarnation = 501; + order.created_seq = 1; + order.legs.attach(order.incarnation, position_cycle_seq_); + pending_orders_.push_back(std::move(order)); + } + void seed_prior_leg_event() { + auto& legs = pending_orders_.front().legs; + const exit_legs::Frame frame{7, 1, exit_legs::Domain::Ordinary, + exit_legs::Phase::Observation}; + const exit_legs::Action action{legs.target(), legs.revision(), frame, + exit_legs::BindOwner{4}}; + if (legs.apply(legs.target(), action) != exit_legs::Result::Applied) + throw std::logic_error("invalid prior-event test fixture"); + exit_leg_event_seq_ = 7; + } + + // Calls the legacy compatibility helper directly. `price` is intentionally + // the raw source price here; the helper owns one fill-side slippage step per + // physical leg in its current implementation. + void reverse_raw(double price, double quantity, bool purge, uint64_t incarnation, + bool requested_long = false) { + (this->*access(CloseOppositeAccess{}))( + "R", requested_long, price, quantity, -1, purge, true, incarnation); + } + + // Reproduces the production caller's current boundary: execute_market_entry + // applies entry slippage before delegating to close_opposite_then_enter. + void reverse_from_market_entry(double raw_price, double quantity, + uint64_t incarnation) { + (this->*access(MarketEntryAccess{}))( + "R", false, raw_price, quantity, -1, + PositionSide::FLAT, true, false, 0.0, bar_index_, false, false, + false, incarnation); + } + + void exhaust_next_cycle() { + next_position_cycle_seq_ = std::numeric_limits::max(); + } +}; + +// Desired native contract: one accepted reversal execution has one current +// ticket, even when FIFO closes multiple lots and opens a remainder. Historical +// entry costs are not part of this assertion; only the current execution's +// charge is summed. d3996b4's close helper charges one CASH_PER_ORDER ticket +// per closed row and another on the opening row, so this is a failing-before +// witness for the proposed native seam. +void one_ticket_multi_lot_reversal_before() { + Book book; + book.seed_two_lots(); + book.reverse_raw(120.0, 6.0, false, 30); + + CHECK(book.position() == -1.0); + CHECK(book.trades().size() == 2); + CHECK(book.lots().size() == 1); + if (book.trades().size() == 2 && book.lots().size() == 1) { + // Trade::commission includes the proportional historical entry cost + // as well as the current exit charge. Remove the two already-paid + // entry tickets before isolating this reversal's current ticket. + const double historical_entries = 6.0 + 6.0; + const double current_ticket = book.trades()[0].commission + + book.trades()[1].commission + + book.lots()[0].entry_commission_account + - historical_entries; + near(current_ticket, 6.0); + } +} + +// Desired native contract: cycle allocation is preflighted before any physical +// close, report row, or queue cleanup. The existing helper closes the old lots, +// purges exits, and only then discovers that opening the remainder would exhaust +// the position-cycle counter. The fingerprint catches all of those mutations. +void cycle_exhaustion_is_strong_preflight_before() { + Book book; + book.seed_two_lots(); + book.add_stale_exit(); + book.exhaust_next_cycle(); + const auto before = book.fingerprint(); + + bool threw = false; + try { + book.reverse_raw(120.0, 6.0, true, 31); + } catch (const std::overflow_error&) { + threw = true; + } + + CHECK(threw); + CHECK(book.fingerprint() == before); + CHECK(book.position() == 5.0); + CHECK(book.lots().size() == 2); + CHECK(book.trades().empty()); + CHECK(book.pending_count() == 1); +} + +// Desired native boundary: the source caller resolves slippage once and passes +// that resolved execution to settlement. The current execute_market_entry path +// slips the sell price, then close_opposite_then_enter slips both physical legs +// again. This direct witness expects the one adverse sell step from 100 to 99.91 +// and fails on the current 99.82 result. +void production_caller_slips_once_before() { + Book book; + book.seed_two_lots(); + book.set_slippage(9); + book.reverse_from_market_entry(100.0, 6.0, 32); + + CHECK(book.trades().size() == 2); + CHECK(book.lots().size() == 1); + if (book.trades().size() == 2 && book.lots().size() == 1) { + near(book.trades()[0].exit_price, 99.91); + near(book.trades()[1].exit_price, 99.91); + near(book.lots()[0].price, 99.91); + } +} + +// Existing caller contract characterization. The purge flag is intentionally +// explicit because process_pending_orders iterates pending_orders_: callers +// passing false retain ownership of cleanup and avoid invalidating a live +// PendingOrder reference; a direct, already-detached caller may pass true. +void purge_flag_is_explicit_characterization() { + Book purging; + purging.seed_two_lots(); + purging.add_stale_exit(); + purging.reverse_raw(120.0, 5.0, true, 33); + CHECK(purging.position() == 0.0); + CHECK(purging.pending_count() == 0); + CHECK(purging.lifecycle_events() == 1); + + Book retained; + retained.seed_two_lots(); + retained.add_stale_exit(); + retained.reverse_raw(120.0, 5.0, false, 34); + CHECK(retained.position() == 0.0); + CHECK(retained.pending_count() == 1); +} + +// The reverse direction is the same native transaction with the sign flipped. +// Keep the lot roster and one-ticket invariant symmetric; a one-sided test can +// accidentally leave sell-side slippage or FIFO logic unexercised. +void one_ticket_reverse_direction() { + Book book; + book.seed_two_lots(PositionSide::SHORT); + book.reverse_raw(100.0, 6.0, false, 35, true); + + CHECK(book.position() == 1.0); + CHECK(book.trades().size() == 2); + CHECK(book.lots().size() == 1); + if (book.trades().size() == 2 && book.lots().size() == 1) { + const double historical_entries = 6.0 + 6.0; + const double current_ticket = book.trades()[0].commission + + book.trades()[1].commission + + book.lots()[0].entry_commission_account + - historical_entries; + near(current_ticket, 6.0); + } +} + +// A resolved LIMIT price is already at the broker level. The lifecycle must +// consume it verbatim even when the engine's configured slippage is non-zero. +void resolved_limit_price_is_not_slipped() { + Book book; + book.seed_two_lots(); + book.set_slippage(9); + book.set_limit_fill(true); + book.reverse_raw(100.0, 6.0, false, 36); + + CHECK(book.trades().size() == 2); + CHECK(book.lots().size() == 1); + if (book.trades().size() == 2 && book.lots().size() == 1) { + near(book.trades()[0].exit_price, 100.0); + near(book.trades()[1].exit_price, 100.0); + near(book.lots()[0].price, 100.0); + } +} + +// A partial native reduction never resets the position cycle, unbinds exits, +// or binds a new owner. It is a close-only execution with one physical FIFO +// trade and no lifecycle event. +void partial_close_keeps_cycle_and_lifecycle() { + Book book; + book.seed_two_lots(); + book.add_stale_exit(); + const auto events = book.lifecycle_events(); + const auto result = book.settle_reduce(2.0); + + CHECK(result.status == execution::Status::Applied); + CHECK(book.position() == 3.0); + CHECK(book.lots().size() == 1); + CHECK(book.trades().size() == 1); + CHECK(book.lifecycle_events() == events); + CHECK(book.lifecycle_owner() == 4); +} + +// A full flatten is still one resolved execution and consumes the old-cycle +// unbind event exactly once. It does not create a new owner or activation. +void full_close_unbinds_once() { + Book book; + book.seed_two_lots(); + book.add_stale_exit(); + const auto events = book.lifecycle_events(); + const auto result = book.settle_flatten(); + + CHECK(result.status == execution::Status::Applied); + CHECK(book.position() == 0.0); + CHECK(book.lots().empty()); + CHECK(book.trades().size() == 2); + CHECK(book.lifecycle_events() == events + 1); + CHECK(book.lifecycle_owner() == 0); +} + +// Retaining the EXIT gives the old-cycle unbind, an explicit owner rebind, +// and the requested BindOwner operation. Preserve all three existing receipts. +// The expected event/revision values are literal fixture +// facts, rather than a candidate-derived positive delta. +void retained_exit_rebinds_after_close() { + Book book; + book.seed_two_lots(); + book.add_stale_exit(); + book.reverse_raw(120.0, 6.0, false, 37); + + CHECK(book.position() == -1.0); + CHECK(book.pending_count() == 1); + CHECK(book.lifecycle_events() == 3); + CHECK(book.lifecycle_revision() == 3); + CHECK(book.lifecycle_owner() == 5); + CHECK(book.lifecycle_last_is_bind_to(5)); +} + +// A caller that retains cleanup ownership must not have its PendingOrder +// reference invalidated by the reversal's open leg. The vector address is a +// direct characterization of that contract; no copied/swapped queue is +// allowed as an implementation shortcut. +void retained_cleanup_preserves_pending_address() { + Book book; + book.seed_two_lots(); + book.add_stale_exit(); + PendingOrder* before = book.first_pending_address(); + book.reverse_raw(120.0, 6.0, false, 39); + CHECK(book.first_pending_address() == before); + CHECK(book.lifecycle_owner() == 5); +} + +void lifecycle_exhaustion_precedes_mutation() { + Book book; + book.seed_two_lots(); + book.add_stale_exit(); + book.exhaust_lifecycle_events(); + const auto before = book.fingerprint(); + bool threw = false; + try { + book.reverse_raw(120.0, 6.0, false, 38); + } catch (const std::overflow_error&) { + threw = true; + } + CHECK(threw); + CHECK(book.fingerprint() == before); + CHECK(book.position() == 5.0); + CHECK(book.trades().empty()); + CHECK(book.pending_count() == 1); +} + +void stream_and_trade_exhaustion_precede_mutation() { + Book stream; + stream.seed_two_lots(); + stream.observe_actions(true); + stream.exhaust_stream_actions(); + const auto stream_before = stream.fingerprint(); + bool stream_threw = false; + try { stream.settle_flatten(); } catch (const std::overflow_error&) { stream_threw = true; } + CHECK(stream_threw); + CHECK(stream.fingerprint() == stream_before); + CHECK(stream.position() == 5.0); + CHECK(stream.trades().empty()); + + Book trades; + trades.seed_two_lots(); + trades.exhaust_trade_wins(); + const auto trades_before = trades.fingerprint(); + bool trades_threw = false; + try { trades.settle_flatten(); } catch (const std::overflow_error&) { trades_threw = true; } + CHECK(trades_threw); + CHECK(trades.fingerprint() == trades_before); + CHECK(trades.position() == 5.0); + CHECK(trades.trades().empty()); +} + +void invalid_native_requests_are_noops() { + Book zero; + zero.seed_two_lots(); + const auto before_zero = zero.fingerprint(); + const auto no_effect = zero.settle_reduce(0.0); + CHECK(no_effect.status == execution::Status::NoEffect); + CHECK(zero.fingerprint() == before_zero); + + Book invalid; + invalid.seed_two_lots(); + const auto before_invalid = invalid.fingerprint(); + const auto bad_qty = invalid.settle_reduce(-1.0); + CHECK(bad_qty.status == execution::Status::InvalidQuantity); + CHECK(invalid.fingerprint() == before_invalid); + const auto bad_price = invalid.settle_reduce(1.0, std::numeric_limits::quiet_NaN()); + CHECK(bad_price.status == execution::Status::InvalidPrice); + CHECK(invalid.fingerprint() == before_invalid); + const auto bad_fee = invalid.settle_reduce( + 1.0, 120.0, std::numeric_limits::quiet_NaN()); + CHECK(bad_fee.status == execution::Status::InvalidAccounting); + CHECK(invalid.fingerprint() == before_invalid); +} + +execution::LifecycleIntent fixed_intent(exit_legs::Operation operation) { + return {500, 0, {500, 4}, 0, std::move(operation)}; +} + +execution::PendingRemoval fixed_removal() { return {500, 0, {500, 4}, 0}; } + +// Every supplied target fact is a precondition, including zero-valued sequence +// and unbound targets. Refusal cannot spend money or mutate pending orders. +void malformed_lifecycle_effects_are_refused() { + for (int kind = 0; kind < 15; ++kind) { + Book book; + book.seed_two_lots(); + book.add_stale_exit(); + execution::LifecycleEffects effects; + effects.pre_close.emplace(); + effects.pre_close->operations.push_back(fixed_intent(exit_legs::BindOwner{4})); + auto& intent = effects.pre_close->operations.front(); + switch (kind) { + case 0: intent.order_incarnation = 0; break; + case 1: intent.order_incarnation = 501; intent.target.incarnation = 501; break; + case 2: intent.created_seq = 1; break; + case 3: intent.target = {}; break; + case 4: intent.target.owner = 5; break; + case 5: intent.expected_revision = 1; break; + case 6: effects.pre_close->operations.push_back(intent); break; + case 7: + effects.pre_close->operations.clear(); + effects.pre_close->phase = static_cast(255); + break; + default: + effects.pre_close.reset(); + effects.removals.push_back(fixed_removal()); + if (kind == 8) effects.removals.front().expected_revision = 1; + if (kind == 9) effects.removals.front().target.owner = 5; + if (kind == 10) effects.removals.push_back(fixed_removal()); + if (kind == 11) effects.removals.front().incarnation = 0; + if (kind == 12) effects.removals.front().incarnation = 501; + if (kind == 13) effects.removals.front().target = {}; + if (kind == 14) effects.removals.front().created_seq = 1; + break; + } + const auto before = book.fingerprint(); + const auto result = book.settle_effects(order_action::Reduce{1.0}, effects); + CHECK(result.status == execution::Status::InvalidLifecycle); + CHECK(book.fingerprint() == before); + CHECK(book.position() == 5.0); + CHECK(book.trades().empty()); + } +} + +// A restored, prearmed EXIT can have concrete activation bounds for cycle 4 +// while its lifecycle still owns 0, or another stored owner. Flat cleanup at +// d3996b4 unbound the currently stored target; it did not first bind cycle 4. +// Both owners therefore get one unbind receipt, followed by the normal two +// new-owner receipts only when the execution opens a remainder. +void stored_exit_owner_is_unbound_on_full_close() { + for (int64_t owner : {int64_t{0}, int64_t{99}}) { + for (bool reversal : {false, true}) { + Book book; + book.seed_two_lots(); + book.add_stale_exit(); + book.set_leg_owner(owner); + book.bind_current_cycle_activation(); + PendingOrder* const pending = book.first_pending_address(); + CHECK(book.first_legs().target().incarnation == 500); + CHECK(book.lifecycle_owner() == owner); + CHECK(book.lifecycle_revision() == 0); + CHECK(book.lifecycle_events() == 0); + + const execution::Action action = reversal + ? execution::Action{order_action::Transact{-6.0}} + : execution::Action{execution::Flatten{}}; + execution::Result result; + try { + result = book.settle_effects(action, {}); + } catch (const std::exception& error) { + std::fprintf(stderr, "stored owner %lld, reversal %d: %s\n", + static_cast(owner), reversal, error.what()); + CHECK(false); + continue; + } + CHECK(result.status == execution::Status::Applied); + CHECK(result.closed_units == 5.0); + CHECK(result.opened_units == (reversal ? -1.0 : 0.0)); + CHECK(book.position() == (reversal ? -1.0 : 0.0)); + CHECK(book.lots().size() == (reversal ? 1u : 0u)); + CHECK(book.trades().size() == 2); + CHECK(book.pending_count() == 1); + CHECK(book.first_pending_address() == pending); + CHECK(book.first_legs().target().incarnation == 500); + CHECK(book.lifecycle_owner() == (reversal ? 5 : 0)); + CHECK(book.lifecycle_revision() == (reversal ? 3u : 1u)); + CHECK(book.lifecycle_events() == (reversal ? 3u : 1u)); + CHECK(book.first_legs().last_action().has_value()); + if (book.first_legs().last_action()) { + const auto& receipt = *book.first_legs().last_action(); + CHECK(receipt.target.incarnation == 500); + CHECK(receipt.target.owner == (reversal ? 5 : owner)); + CHECK(receipt.expected_revision == (reversal ? 2u : 0u)); + CHECK(receipt.cause.event == (reversal ? 3u : 1u)); + CHECK(receipt.cause.bar == 1); + CHECK(receipt.cause.domain == exit_legs::Domain::Ordinary); + CHECK(receipt.cause.phase == exit_legs::Phase::Observation); + CHECK(book.lifecycle_last_is_bind_to(reversal ? 5 : 0)); + } + const auto& activation = pending->leg_activation.bounds(); + CHECK(activation.has_value() == reversal); + if (activation) { + CHECK(activation->position_cycle == 5); + CHECK(activation->stop_first_bar == 1); + CHECK(activation->limit_first_bar == 1); + } + } + } +} + +// Accepting the actual stored owner does not authorize a caller to request an +// unrelated owner. This explicit instruction still fails before any effect. +void requested_foreign_owner_is_refused_before_close() { + Book book; + book.seed_two_lots(); + book.add_stale_exit(); + execution::LifecycleEffects effects; + effects.pre_close.emplace(); + effects.pre_close->operations.push_back(fixed_intent(exit_legs::BindOwner{5})); + const auto before = book.fingerprint(); + const auto result = book.settle_effects(execution::Flatten{}, effects); + CHECK(result.status == execution::Status::InvalidLifecycle); + CHECK(book.fingerprint() == before); + CHECK(book.position() == 5.0); + CHECK(book.trades().empty()); + CHECK(book.lifecycle_events() == 0); + CHECK(book.lifecycle_owner() == 4); + CHECK(book.lifecycle_revision() == 0); +} + +// The stored owner is not a wildcard for identity: pending incarnation 500 +// cannot act on lifecycle incarnation 999, even during whole-book cleanup. +void mismatched_exit_incarnation_is_refused_before_close() { + for (int64_t owner : {int64_t{0}, int64_t{99}}) { + for (bool reversal : {false, true}) { + Book book; + book.seed_two_lots(); + book.add_stale_exit(); + book.set_leg_incarnation(999, owner); + book.bind_current_cycle_activation(); + PendingOrder* const pending = book.first_pending_address(); + const auto before = book.fingerprint(); + bool refused = false; + const execution::Action action = reversal + ? execution::Action{order_action::Transact{-6.0}} + : execution::Action{execution::Flatten{}}; + try { + const auto result = book.settle_effects(action, {}); + refused = result.status == execution::Status::InvalidLifecycle; + } catch (const std::logic_error& error) { + refused = std::string(error.what()) == "exit lifecycle flat unbind refused"; + } + CHECK(refused); + CHECK(book.fingerprint() == before); + CHECK(book.position() == 5.0); + CHECK(book.lots().size() == 2); + CHECK(book.trades().empty()); + CHECK(book.pending_count() == 1); + CHECK(book.first_pending_address() == pending); + CHECK(pending->incarnation == 500); + CHECK(book.first_legs().target().incarnation == 999); + CHECK(book.lifecycle_owner() == owner); + CHECK(book.lifecycle_revision() == 0); + CHECK(book.lifecycle_events() == 0); + CHECK(!book.first_legs().last_action()); + const auto& activation = pending->leg_activation.bounds(); + CHECK(activation.has_value()); + if (activation) { + CHECK(activation->position_cycle == 4); + CHECK(activation->stop_first_bar == 5); + CHECK(activation->limit_first_bar == 5); + } + } + } +} + +// Exact explicit snapshots remain preconditions, including owner 0. Capture +// the real target, then change either its owner or definition revision before +// submitting that snapshot. Neither removal nor pre-close operation may apply. +void stale_exit_effect_snapshots_are_refused_before_close() { + for (int64_t owner : {int64_t{0}, int64_t{99}}) { + for (bool stale_revision : {false, true}) { + for (bool removal : {false, true}) { + Book book; + book.seed_two_lots(); + book.add_stale_exit(); + book.set_leg_owner(owner); + book.bind_current_cycle_activation(); + execution::LifecycleEffects effects; + if (removal) { + effects.removals.push_back({500, 0, {500, owner}, 0}); + } else { + effects.pre_close.emplace(); + effects.pre_close->operations.push_back( + {500, 0, {500, owner}, 0, exit_legs::BindOwner{4}}); + } + if (stale_revision) { + book.define_stop(); + } else { + book.set_leg_owner(owner == 0 ? 99 : 0); + } + PendingOrder* const pending = book.first_pending_address(); + const auto before = book.fingerprint(); + const auto result = book.settle_effects(order_action::Transact{-6.0}, effects); + CHECK(result.status == execution::Status::InvalidLifecycle); + CHECK(book.fingerprint() == before); + CHECK(book.position() == 5.0); + CHECK(book.lots().size() == 2); + CHECK(book.trades().empty()); + CHECK(book.pending_count() == 1); + CHECK(book.first_pending_address() == pending); + CHECK(book.first_legs().target().incarnation == 500); + CHECK(book.lifecycle_owner() == (stale_revision ? owner : (owner == 0 ? 99 : 0))); + CHECK(book.lifecycle_revision() == (stale_revision ? 1u : 0u)); + CHECK(book.lifecycle_events() == 0); + CHECK(!book.first_legs().last_action()); + const auto& activation = pending->leg_activation.bounds(); + CHECK(activation.has_value()); + if (activation) { + CHECK(activation->position_cycle == 4); + CHECK(activation->stop_first_bar == 5); + CHECK(activation->limit_first_bar == 5); + } + } + } + } +} + +void operation_window_is_literal() { + Book book; + book.seed_two_lots(); + book.add_stale_exit(); + book.set_lifecycle_events(10); + const exit_legs::Frame previous{4, 0, exit_legs::Domain::Ordinary, + exit_legs::Phase::Observation}; + const exit_legs::ObservationWindow window{previous, 120.0, 115.0}; + const exit_legs::Suspend suspend{{exit_legs::Leg::Stop, exit_legs::Leg::Limit}, + {}, window, {}}; + execution::LifecycleEffects effects; + effects.pre_close.emplace(); + effects.pre_close->operations.push_back(fixed_intent(suspend)); + const auto result = book.settle_effects(order_action::Reduce{1.0}, effects); + CHECK(result.status == execution::Status::Applied); + CHECK(book.position() == 4.0); + CHECK(book.lifecycle_events() == 11); + CHECK(book.first_legs().suspension().has_value()); + if (book.first_legs().suspension()) { + const auto& state = *book.first_legs().suspension(); + CHECK(state.cause.event == 11); + CHECK(state.window.has_value()); + if (state.window) { + CHECK(state.window->excluded.event == 4); + CHECK(state.window->excluded.bar == 0); + CHECK(state.window->best == 120.0 && state.window->prefix == 115.0); + } + } +} + +void source_selection_is_pure_and_empty_batch_is_real() { + for (bool selected : {false, true}) { + Book book; + book.seed_two_lots(); + book.add_stale_exit(); + if (selected) book.define_stop(); + const auto before = book.fingerprint(); + const auto batch = book.selected_pre_close(); + CHECK(batch.has_value()); + CHECK(book.fingerprint() == before); + if (!batch) continue; + CHECK(batch->operations.size() == (selected ? 1u : 0u)); + execution::LifecycleEffects effects; + effects.pre_close = batch; + const auto result = book.settle_effects(order_action::Reduce{1.0}, effects); + CHECK(result.status == execution::Status::Applied); + CHECK(book.lifecycle_events() == 1); + CHECK(book.position() == 4.0); + CHECK(book.first_legs().suspension().has_value() == selected); + if (selected && book.first_legs().suspension()) { + CHECK(book.first_legs().suspension()->window.has_value()); + if (book.first_legs().suspension()->window) + CHECK(book.first_legs().suspension()->window->excluded.event == 1); + } + } + + Book flat; + flat.add_stale_exit(); + execution::LifecycleEffects effects; + effects.pre_close.emplace(); + effects.removals.push_back({500, 0, {500, 0}, 0}); + const auto before = flat.fingerprint(); + const auto result = flat.settle_effects(order_action::Reduce{1.0}, effects); + CHECK(result.status == execution::Status::NoEffect); + CHECK(flat.fingerprint() == before); + CHECK(flat.pending_count() == 1); +} + +// The source selector historically applies through the current-owner binding +// transition. Preserve that transition even when the stored leg owner is 0; +// an exact snapshot of owner 0 must not be confused with a wildcard request. +void selected_pre_close_preserves_current_owner_binding() { + Book book; + book.seed_two_lots(); + book.add_stale_exit(); + book.set_leg_owner(0); + book.define_stop(); + const auto before = book.fingerprint(); + const auto batch = book.selected_pre_close(); + CHECK(batch && batch->operations.size() == 1); + CHECK(book.fingerprint() == before); + if (!batch || batch->operations.empty()) return; + CHECK(batch->operations.front().target.owner == 0); + execution::LifecycleEffects effects; + effects.pre_close = batch; + const auto result = book.settle_effects(order_action::Reduce{1.0}, effects); + CHECK(result.status == execution::Status::Applied); + CHECK(book.position() == 4.0); + CHECK(book.lifecycle_owner() == 4); + CHECK(book.lifecycle_revision() == 3); // Definition + owner bind + suspend. + CHECK(book.lifecycle_events() == 3); // Batch, owner bind, requested operation. + CHECK(book.first_legs().suspension().has_value()); + if (book.first_legs().suspension()) { + CHECK(book.first_legs().suspension()->cause.event == 3); + CHECK(book.first_legs().suspension()->window.has_value()); + if (book.first_legs().suspension()->window) + CHECK(book.first_legs().suspension()->window->excluded.event == 1); + } +} + +void complete_lifecycle_preflight_handles_exhaustion() { + for (bool revision : {false, true}) { + Book book; + book.seed_two_lots(); + book.add_stale_exit(); + execution::LifecycleEffects effects; + if (revision) book.exhaust_leg_revision(); + else { + book.set_lifecycle_events(UINT64_MAX); + effects.pre_close.emplace(); + } + const auto before = book.fingerprint(); + bool threw = false; + try { book.settle_effects(execution::Flatten{}, effects); } + catch (const std::overflow_error&) { threw = true; } + CHECK(threw); + CHECK(book.fingerprint() == before); + CHECK(book.position() == 5.0); + CHECK(book.trades().empty()); + } + + Book book; + book.seed_two_lots(); + book.add_stale_exit(); + book.define_stop(); + execution::LifecycleEffects effects; + effects.pre_close = book.selected_pre_close(); + book.exhaust_next_cycle(); + const auto before = book.fingerprint(); + bool threw = false; + try { book.settle_effects(order_action::Transact{-6.0}, effects); } + catch (const std::overflow_error&) { threw = true; } + CHECK(threw); + CHECK(book.fingerprint() == before); + CHECK(!book.first_legs().suspension()); + CHECK(book.lifecycle_events() == 0); +} + +void multiple_exits_and_prior_receipts_preserve_order() { + for (bool purge : {false, true}) { + Book book; + book.seed_two_lots(); + book.add_stale_exit(); + book.add_second_exit(); + book.reverse_raw(120.0, 6.0, purge, 904); + CHECK(book.position() == -1.0); + CHECK(book.pending_count() == (purge ? 0u : 2u)); + CHECK(book.lifecycle_events() == (purge ? 2u : 6u)); + if (!purge) CHECK(book.lifecycle_revision() == 3); + } + Book prior; + prior.seed_two_lots(); + prior.add_stale_exit(); + prior.seed_prior_leg_event(); + prior.reverse_raw(120.0, 6.0, false, 905); + CHECK(prior.lifecycle_events() == 10); + CHECK(prior.lifecycle_revision() == 4); + CHECK(prior.lifecycle_owner() == 5); +} + +// Adapter quantities are magnitudes. Invalid magnitudes must not silently +// reverse direction or return success to a caller that would then cancel +// sibling orders. Explicit native Transact signed units are a separate API. +void invalid_adapter_magnitudes_fail_before_effects() { + for (double quantity : {-1.0, std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}) { + Book book; + book.seed_two_lots(); + book.add_stale_exit(); + const auto before = book.fingerprint(); + bool threw = false; + try { book.reverse_raw(120.0, quantity, true, 906); } + catch (const std::invalid_argument&) { threw = true; } + CHECK(threw); + CHECK(book.fingerprint() == before); + CHECK(book.position() == 5.0); + CHECK(book.trades().empty()); + } + Book zero; + zero.seed_two_lots(); + zero.add_stale_exit(); + const auto before = zero.fingerprint(); + zero.reverse_raw(120.0, 0.0, true, 907); + CHECK(zero.fingerprint() == before); + CHECK(zero.pending_count() == 1); +} + +} // namespace + +int main() { + one_ticket_multi_lot_reversal_before(); + cycle_exhaustion_is_strong_preflight_before(); + production_caller_slips_once_before(); + purge_flag_is_explicit_characterization(); + one_ticket_reverse_direction(); + resolved_limit_price_is_not_slipped(); + partial_close_keeps_cycle_and_lifecycle(); + full_close_unbinds_once(); + retained_exit_rebinds_after_close(); + retained_cleanup_preserves_pending_address(); + lifecycle_exhaustion_precedes_mutation(); + stream_and_trade_exhaustion_precede_mutation(); + invalid_native_requests_are_noops(); + malformed_lifecycle_effects_are_refused(); + stored_exit_owner_is_unbound_on_full_close(); + requested_foreign_owner_is_refused_before_close(); + mismatched_exit_incarnation_is_refused_before_close(); + stale_exit_effect_snapshots_are_refused_before_close(); + operation_window_is_literal(); + source_selection_is_pure_and_empty_batch_is_real(); + selected_pre_close_preserves_current_owner_binding(); + complete_lifecycle_preflight_handles_exhaustion(); + multiple_exits_and_prior_receipts_preserve_order(); + invalid_adapter_magnitudes_fail_before_effects(); + std::printf("native reversal contract checks=%d failures=%d\n", checks, failures); + return failures == 0 ? 0 : 1; +}