From bc214a44a4a92745f8d3a2e1135fd6b8cae24c9a Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sat, 12 Sep 2026 18:27:44 +0800 Subject: [PATCH 1/2] docs: track native refactor progress and R2 increments --- docs/native-refactor-progress.md | 88 ++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/native-refactor-progress.md diff --git a/docs/native-refactor-progress.md b/docs/native-refactor-progress.md new file mode 100644 index 0000000..46d0e03 --- /dev/null +++ b/docs/native-refactor-progress.md @@ -0,0 +1,88 @@ +# Standalone native engine refactor + +Status at 2026-09-12: **3 of 9 roadmap phases complete (33%)**. This counts +completed phases; it is not an estimate of elapsed work or remaining time. + +The acceptance boundary is a standalone C++ backtest and forward-execution +state machine. Codegen and explicit adapters own PineScript policy. During +this refactor, unchanged compatibility results are acceptable; parity +improvement resumes after the final native audit. + +## Completed and remaining work + +| Phase | Scope | Status | +| --- | --- | --- | +| R0 | Native settlement and CI convergence | Complete, [PR #241](https://github.com/pineforge-4pass/pineforge-engine/pull/241) | +| R1 | Native market orders, host, drivers, calendar and forward runner | Complete, [PR #243](https://github.com/pineforge-4pass/pineforge-engine/pull/243) | +| R2 | Resting orders, partial execution, owner-bound children and group effects | Design under review; next implementation slice | +| R3 | Accounting, admission, risk and observation ownership | Open | +| R3a | Reversal execution and lifecycle settlement | Complete, [PR #242](https://github.com/pineforge-4pass/pineforge-engine/pull/242) | +| R3b | Migrate remaining physical fill paths to shared settlement | Open | +| R4 | Complete codegen/adapter policy ownership and native independence | Open | +| R5 | Final requirement and compatibility audit | Open | +| R6 | Resume the parity improvement campaign after the audit | Queued | + +These are the nine phases in the campaign ledger, including the queued +return to parity improvement. No R2 design document or partial implementation +marks R2 complete. + +## Last accepted implementation + +PR #243 was squash merged as +`5f3299d90a02e3161659720fb8c2e82f7543ef7a`. Its tree +`770f7bd2913c7c9c579d38f5c3ffc8950676bb40` is identical to the independently +reviewed and measured candidate `ce995a2db1c3bd771369f105fcce2d1234d107b8`. + +- Linux Debug with ASan/UBSan: 317/317 CTests, no skips; direct WebSocket + check passed. The native proof includes 46 structured scenarios and actual + runner delivery failure, retry and replay. +- Fixed-population Cloud sweep: 72/72 cases and 4190/4190 scored probes, + with zero differences in full grades, raw trades, substantive verifier + results or non-product inputs against the pinned baseline. +- All nine PR checks and four post-merge workflows passed. Independent + final candidate review was GREEN. +- The actual gate execution `pineforge-pr-gate-7zlr7` returned + **FAIL: `target.not-positive` only**, because results did not improve. + This is retained as a FAIL under the authorized neutral-refactor exception. + The campaign baseline remains unchanged; promotion was skipped. + +These results apply to the R1 candidate, not to future R2 edits. The final +receipt is content-addressed in the campaign evidence store as +`7c4487fa35366a2a839f8663223f5f2085e8228ccfae7e772c52acf7f9ff996c`. + +## Next increments + +1. **Scoped physical closes.** Extend the existing inspection/settlement + path to close only surviving exposure from a particular opening request + in an exact position cycle. Whole-book FIFO remains the default. + This preparatory seam needs its own contract, native checks and review. +2. **Resting-order chronology and partial execution.** Add typed triggers + and live remainders to the existing driver/consumer path. Distinguish + actual tick prints from modeled OHLC crossings; protect limit prices. +3. **Causal relationships.** Activate owner-bound children and apply typed + group cancellation/reduction from committed events, with replacement, + exhaustion and replay behavior explicit. +4. **Integration proof.** Exercise the real native producer/consumer and + review the Pine mapping independently before accepting R2. + +The first relationship proposal is not accepted. Its review found ambiguous +tick interpolation, remaining-path ordering, same-point child activation, +and missing nonterminal partial executions. These decisions must be resolved +before relationship implementation. Opening-request identity may cover +several physical fragments; it is not a unique lot id. Canceling a working +remainder does not undo its already committed exposure. + +R2 preserves all currently supported calendar, session, timezone, DST and +timeframe behavior. Source labels remain inert in the native kernel; +`from_entry`, source call grouping, Pine OCA policy and dormant revival belong +to codegen/adapters. Range-end reporting remains nonphysical. + +## Publication and acceptance + +Publish reviewed increments in an evolving draft PR so implementation and +open decisions are reviewable. Draft and CI-first publication may precede +the full compatibility sweep. Before merge, require native proof, unchanged +fixed-population compatibility evidence, the actual gate result, independent +review of the final candidate, and green CI. Squash merge and verify the +resulting tree and post-merge checks separately. Never translate a neutral +gate failure into PASS or force baseline promotion. From 794d1f27bf46f23be238f8cdf9173d9a132d3b6b Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sat, 12 Sep 2026 18:36:29 +0800 Subject: [PATCH 2/2] Add scoped native closes for opening exposure --- docs/native-scoped-close.md | 85 ++++ include/pineforge/engine.hpp | 16 + include/pineforge/execution.hpp | 5 +- include/pineforge/execution_close_scope.hpp | 22 + src/engine_execution.cpp | 169 ++++-- tests/CMakeLists.txt | 1 + tests/test_native_scoped_close.cpp | 537 ++++++++++++++++++++ 7 files changed, 791 insertions(+), 44 deletions(-) create mode 100644 docs/native-scoped-close.md create mode 100644 include/pineforge/execution_close_scope.hpp create mode 100644 tests/test_native_scoped_close.cpp diff --git a/docs/native-scoped-close.md b/docs/native-scoped-close.md new file mode 100644 index 0000000..d3f50ed --- /dev/null +++ b/docs/native-scoped-close.md @@ -0,0 +1,85 @@ +# Scoped native settlement + +The trusted native settlement seam can close the surviving exposure created +by one opening request in one position cycle. It uses the engine's existing +physical book, FIFO allocation and execution fee accounting. + +This is a prerequisite for native owner-bound orders. It does not place an +order or implement a trigger, child lifecycle, group effect or broker fill +transport. The native host's current market request API remains unchanged. + +## Selector and entry points + +`` declares the additive +`pineforge::execution::close_scope_v1` types: + +```cpp +struct Book {}; +struct OpeningExposure { + std::uint64_t incarnation = 0; + std::int64_t cycle = 0; +}; +using CloseScope = std::variant; +``` + +Trusted subclasses can use these protected methods: + +```cpp +execution::SettlementInspection inspect_native_settlement_scoped( + const execution::Action&, const execution::Fill&, + execution::CloseScope) const; + +execution::Result settle_native_execution_scoped_at( + const execution::Action&, const execution::Fill&, + const execution::PhysicalExecutionContext&, execution::CloseScope); +``` + +`Book` retains whole-book Flatten, Reduce and Transact behavior. Every +original settlement and inspection method keeps its signature and selects +Book. Existing C++ aggregate layouts and C ABI exports are unchanged. + +`OpeningExposure` permits Flatten and Reduce only. It selects all surviving +physical fragments with the specified opening-request incarnation, in the +engine's exact current cycle. A partially filled opening request may create +several fragments with that incarnation. Labels and the closing fill's own +incarnation do not select exposure. The trusted caller validates run identity; +the selector itself is a run-local value. + +## Physical behavior + +Selected Reduce closes at most the selected exposure, in FIFO order within +that selection. Selected Flatten removes every selected fragment. Unrelated +lots retain their identity, order, quantities and historical costs. The cycle +ends only when the whole physical book becomes empty. + +The caller supplies an already resolved fill price. Inspection quotes one +execution ticket and reports the surviving whole-book quantity, lot count +and notional. The caller may lock that ticket in `Fill::commission_account` +before settlement. Current costs are allocated across the selected close +rows; paid entry costs remain historical and are divided proportionally +between closed quantities and survivors. + +Inspection does not authorize a later allocation. Settlement revalidates +the selector against the current book and recomputes allocation. No roster +index, callback, borrowed selector or saved plan is retained. + +## Refusals and failure + +Validation first checks finite price, explicit fee, quantity and book +integrity in the existing order. It then checks selected action and target +validity, before zero-effect handling. A zero/absent incarnation, stale or +nonpositive cycle, flat selected book, or selected Transact returns +`InvalidCloseTarget`. Invalid targets never fall back to Book. + +A zero Reduce on a valid target returns NoEffect; a nonzero explicit charge +on that no-effect call returns InvalidAccounting. For example, a NaN price +with an invalid selector returns InvalidPrice, while an absent owner with +Reduce zero and a finite nonzero fee returns InvalidCloseTarget. + +Checked refusals produce no rows or account effects. A selected settlement +cannot report Applied with zero closed units. Exceptions during commitment +retain the existing abort/discard-and-replay contract; this seam does not +promise rollback or in-place retry. + +See [refactor progress](native-refactor-progress.md) for the remaining +native order and adapter work. diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index bd35f95..be3574c 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -20,6 +20,7 @@ #include "broker_events.hpp" #include "quantity_intent.hpp" #include "execution.hpp" +#include "execution_close_scope.hpp" #include "market_admission.hpp" #include "reservation_expansion.hpp" #include "order_cancellation.hpp" @@ -1829,6 +1830,16 @@ class BacktestEngine { const execution::PhysicalExecutionContext& context); execution::SettlementInspection inspect_native_settlement( const execution::Action& action, const execution::Fill& fill) const; + // Synchronous selected-close extensions. Book preserves the original + // action semantics; OpeningExposure permits only Flatten/Reduce and is + // revalidated against the current physical book on every call. + execution::SettlementInspection inspect_native_settlement_scoped( + const execution::Action& action, const execution::Fill& fill, + execution::CloseScope scope) const; + execution::Result settle_native_execution_scoped_at( + const execution::Action& action, const execution::Fill& fill, + const execution::PhysicalExecutionContext& context, + execution::CloseScope scope); // 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. @@ -3881,6 +3892,11 @@ class BacktestEngine { double calc_qty_for_type(double fill_price, double qty_value, int qty_type) const; private: + execution::Result settle_with_context_scoped( + const execution::Action& action, const execution::Fill& fill, + const execution::LifecycleEffects& lifecycle, + const execution::PhysicalExecutionContext& context, + execution::CloseScope scope); enum class PositionReductionCause { SCRIPT_ORDER, // strategy.close / close_all / market exit / reversal BRACKET_EXIT, // a strategy.exit bracket leg fill diff --git a/include/pineforge/execution.hpp b/include/pineforge/execution.hpp index c79bed0..f270b7c 100644 --- a/include/pineforge/execution.hpp +++ b/include/pineforge/execution.hpp @@ -33,7 +33,10 @@ enum class Status { UnrepresentableQuantity, InvalidAccounting, // Caller-supplied lifecycle targets/revisions/operations that cannot be // applied to the current pending book. Not durable engine state. - InvalidLifecycle + InvalidLifecycle, + // Invalid or unavailable run-local opening exposure on a scoped close. + // Existing whole-book entry points do not return this status. + InvalidCloseTarget = 8 }; struct Result { diff --git a/include/pineforge/execution_close_scope.hpp b/include/pineforge/execution_close_scope.hpp new file mode 100644 index 0000000..65294d9 --- /dev/null +++ b/include/pineforge/execution_close_scope.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +namespace pineforge::execution { +inline namespace close_scope_v1 { + +struct Book {}; + +// Run-local opening provenance, not a unique individual fill-lot identity. +// The trusted caller owns run identity; settlement checks the current cycle +// and selects every surviving physical fragment of this incarnation. +struct OpeningExposure { + std::uint64_t incarnation = 0; + std::int64_t cycle = 0; +}; + +using CloseScope = std::variant; + +} // inline namespace close_scope_v1 +} // namespace pineforge::execution diff --git a/src/engine_execution.cpp b/src/engine_execution.cpp index f14bae9..1a344d6 100644 --- a/src/engine_execution.cpp +++ b/src/engine_execution.cpp @@ -19,6 +19,71 @@ void reserve_effects(std::vector& values, size_t extra) { ? values.max_size() : values.capacity() * 2; values.reserve(std::max(required, grown)); } + +struct CloseScopeInspection { + execution::Status status = execution::Status::Applied; + double held = 0.0; +}; + +// Called only after ordinary price/quantity/book validation. This value is +// local to the synchronous call and grants no saved allocation authority. +CloseScopeInspection inspect_close_scope( + const execution::CloseScope& scope, const execution::Action& action, + int64_t current_cycle, const std::vector& lots, + double whole_book_held) { + if (std::holds_alternative(scope)) + return {execution::Status::Applied, whole_book_held}; + const auto* exposure = std::get_if(&scope); + if (!exposure || exposure->incarnation == 0 || exposure->cycle <= 0 + || exposure->cycle != current_cycle + || (!std::holds_alternative(action) + && !std::holds_alternative(action))) + return {execution::Status::InvalidCloseTarget}; + double held = 0.0; + for (const auto& lot : lots) { + if (lot.entry_incarnation == exposure->incarnation) held += lot.qty; + } + if (held == 0.0) return {execution::Status::InvalidCloseTarget}; + if (!std::isfinite(held)) return {execution::Status::InvalidBook}; + return {execution::Status::Applied, held}; +} + +bool selected_for_close(const execution::CloseScope& scope, + const PyramidEntry& lot) { + if (std::holds_alternative(scope)) return true; + const auto* exposure = std::get_if(&scope); + return exposure && lot.entry_incarnation == exposure->incarnation; +} + +struct CloseSplit { + execution::Status status = execution::Status::Applied; + double amount = 0.0; + double kept = 0.0; +}; + +// Identical FIFO arithmetic for inspection and commit. Book callers pass +// their original request unchanged; selected reductions pass the request +// capped to selected exposure, independently of unrelated physical lots. +CloseSplit next_close_split(const PyramidEntry& lot, bool closes, bool flatten, + double requested, double& closed, double& remaining) { + const double amount = !closes ? 0.0 + : flatten ? lot.qty : std::min(lot.qty, remaining); + if (amount == 0.0) return {execution::Status::Applied, 0.0, lot.qty}; + const double kept = lot.qty - amount; + const double next_closed = closed + amount; + if (!std::isfinite(next_closed) || kept == lot.qty + || (!flatten && closed != 0.0 + && (next_closed == closed || next_closed == amount))) + return {execution::Status::UnrepresentableQuantity}; + if (!flatten) { + const double next_remaining = requested - next_closed; + if (next_remaining < 0.0 || next_remaining == remaining) + return {execution::Status::UnrepresentableQuantity}; + remaining = next_remaining; + } + closed = next_closed; + return {execution::Status::Applied, amount, kept}; +} } // namespace execution::Result BacktestEngine::settle_resolved_execution( @@ -42,13 +107,28 @@ execution::Result BacktestEngine::settle_execution_with_lifecycle( execution::Result BacktestEngine::settle_native_execution_at( const execution::Action& action, const execution::Fill& fill, const execution::PhysicalExecutionContext& context) { - return settle_with_context(action, fill, {}, context); + return settle_native_execution_scoped_at(action, fill, context, execution::Book{}); +} + +execution::Result BacktestEngine::settle_native_execution_scoped_at( + const execution::Action& action, const execution::Fill& fill, + const execution::PhysicalExecutionContext& context, + execution::CloseScope scope) { + return settle_with_context_scoped(action, fill, {}, context, scope); } execution::Result BacktestEngine::settle_with_context( const execution::Action& action, const execution::Fill& fill, const execution::LifecycleEffects& lifecycle, const execution::PhysicalExecutionContext& context) { + return settle_with_context_scoped(action, fill, lifecycle, context, execution::Book{}); +} + +execution::Result BacktestEngine::settle_with_context_scoped( + const execution::Action& action, const execution::Fill& fill, + const execution::LifecycleEffects& lifecycle, + const execution::PhysicalExecutionContext& context, + execution::CloseScope scope) { using execution::Status; if (!std::isfinite(fill.price)) return {Status::InvalidPrice}; if (fill.commission_account && !std::isfinite(*fill.commission_account)) @@ -82,7 +162,14 @@ execution::Result BacktestEngine::settle_with_context( } if (auto invalid = validate_lifecycle_effects(lifecycle)) return {*invalid}; - const double signed_held = position_side_ == PositionSide::SHORT ? -held : held; + const auto selection = inspect_close_scope( + scope, action, position_cycle_seq_, pyramid_entries_, held); + if (selection.status != Status::Applied) return {selection.status}; + const bool scoped = std::holds_alternative(scope); + const double allocation_requested = scoped && reduce + ? std::min(requested, selection.held) : requested; + const double signed_held = position_side_ == PositionSide::SHORT + ? -selection.held : selection.held; if (!flatten && requested == 0.0) return no_effect(); if ((flatten || reduce) && pyramid_entries_.empty()) return no_effect(); @@ -90,7 +177,7 @@ execution::Result BacktestEngine::settle_with_context( // FIFO allocation below is authoritative for the surviving position: it // must not reject a valid decimal lot merely because a differently grouped // scalar addition has a one-ulp difference. - if (reduce && !order_action::plan(signed_held, *reduce)) + if (reduce && !order_action::plan(signed_held, order_action::Reduce{allocation_requested})) return {Status::UnrepresentableQuantity}; if (transact && !order_action::plan(signed_held, *transact)) return {Status::UnrepresentableQuantity}; @@ -106,32 +193,22 @@ execution::Result BacktestEngine::settle_with_context( std::vector survivors; std::vector closing_indices; std::vector closing_quantities; - if (!flatten) survivors.reserve(pyramid_entries_.size()); + if (!flatten || scoped) survivors.reserve(pyramid_entries_.size()); closing_indices.reserve(closes ? pyramid_entries_.size() : 0); closing_quantities.reserve(closes ? pyramid_entries_.size() : 0); double closed = 0.0; - double remaining = requested; + double remaining = allocation_requested; for (size_t index = 0; index < pyramid_entries_.size(); ++index) { const auto& lot = pyramid_entries_[index]; - const double amount = !closes ? 0.0 - : flatten ? lot.qty : std::min(lot.qty, remaining); + const auto split = next_close_split(lot, closes && selected_for_close(scope, lot), + flatten, allocation_requested, closed, remaining); + if (split.status != Status::Applied) return {split.status}; + const double amount = split.amount; if (amount == 0.0) { survivors.push_back(lot); continue; } - const double kept = lot.qty - amount; - const double next_closed = closed + amount; - if (!std::isfinite(next_closed) || kept == lot.qty - || (!flatten && closed != 0.0 - && (next_closed == closed || next_closed == amount))) - return {Status::UnrepresentableQuantity}; - if (!flatten) { - const double next_remaining = requested - next_closed; - if (next_remaining < 0.0 || next_remaining == remaining) - return {Status::UnrepresentableQuantity}; - remaining = next_remaining; - } - closed = next_closed; + const double kept = split.kept; closing_indices.push_back(index); closing_quantities.push_back(amount); if (kept > 0.0) { @@ -146,6 +223,7 @@ execution::Result BacktestEngine::settle_with_context( } } + if (scoped && closed == 0.0) return no_effect(); const double opening = !transact ? 0.0 : opposite ? remaining : requested; if (opening > 0.0 && opposite && !survivors.empty()) return {Status::UnrepresentableQuantity}; @@ -275,6 +353,12 @@ execution::Result BacktestEngine::settle_with_context( execution::SettlementInspection BacktestEngine::inspect_native_settlement( const execution::Action& action, const execution::Fill& fill) const { + return inspect_native_settlement_scoped(action, fill, execution::Book{}); +} + +execution::SettlementInspection BacktestEngine::inspect_native_settlement_scoped( + const execution::Action& action, const execution::Fill& fill, + execution::CloseScope scope) const { using execution::Status; execution::SettlementInspection out; if (!std::isfinite(fill.price)) { @@ -316,7 +400,17 @@ execution::SettlementInspection BacktestEngine::inspect_native_settlement( return out; } } - const double signed_held = position_side_ == PositionSide::SHORT ? -held : held; + const auto selection = inspect_close_scope( + scope, action, position_cycle_seq_, pyramid_entries_, held); + if (selection.status != Status::Applied) { + out.status = selection.status; + return out; + } + const bool scoped = std::holds_alternative(scope); + const double allocation_requested = scoped && reduce + ? std::min(requested, selection.held) : requested; + const double signed_held = position_side_ == PositionSide::SHORT + ? -selection.held : selection.held; if (!flatten && requested == 0.0) { out.status = fill.commission_account && *fill.commission_account != 0.0 ? Status::InvalidAccounting : Status::NoEffect; @@ -327,7 +421,7 @@ execution::SettlementInspection BacktestEngine::inspect_native_settlement( ? Status::InvalidAccounting : Status::NoEffect; return out; } - if (reduce && !order_action::plan(signed_held, *reduce)) { + if (reduce && !order_action::plan(signed_held, order_action::Reduce{allocation_requested})) { out.status = Status::UnrepresentableQuantity; return out; } @@ -342,34 +436,23 @@ execution::SettlementInspection BacktestEngine::inspect_native_settlement( const bool closes = flatten || reduce || opposite; std::vector survivors; std::vector closing_quantities; - if (!flatten) survivors.reserve(pyramid_entries_.size()); + if (!flatten || scoped) survivors.reserve(pyramid_entries_.size()); closing_quantities.reserve(closes ? pyramid_entries_.size() : 0); double closed = 0.0; - double remaining = requested; + double remaining = allocation_requested; for (const auto& lot : pyramid_entries_) { - const double amount = !closes ? 0.0 - : flatten ? lot.qty : std::min(lot.qty, remaining); + const auto split = next_close_split(lot, closes && selected_for_close(scope, lot), + flatten, allocation_requested, closed, remaining); + if (split.status != Status::Applied) { + out.status = split.status; + return out; + } + const double amount = split.amount; if (amount == 0.0) { survivors.push_back(lot); continue; } - const double kept = lot.qty - amount; - const double next_closed = closed + amount; - if (!std::isfinite(next_closed) || kept == lot.qty - || (!flatten && closed != 0.0 - && (next_closed == closed || next_closed == amount))) { - out.status = Status::UnrepresentableQuantity; - return out; - } - if (!flatten) { - const double next_remaining = requested - next_closed; - if (next_remaining < 0.0 || next_remaining == remaining) { - out.status = Status::UnrepresentableQuantity; - return out; - } - remaining = next_remaining; - } - closed = next_closed; + const double kept = split.kept; closing_quantities.push_back(amount); if (kept > 0.0) { auto survivor = lot; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f5efc51..8799de1 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_scoped_close test_native_reversal_contract test_order_action test_native_order_core diff --git a/tests/test_native_scoped_close.cpp b/tests/test_native_scoped_close.cpp new file mode 100644 index 0000000..8fd352b --- /dev/null +++ b/tests/test_native_scoped_close.cpp @@ -0,0 +1,537 @@ +// Literal R2a kernel witnesses. No strategy driver, Pine policy, or helper oracle. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace pineforge; +namespace { +int checks = 0, failures = 0; +const char* current_case = "setup"; +#define CHECK(x) do { ++checks; if (!(x)) { ++failures; \ + std::printf("FAIL %s line %d: %s\n", current_case, __LINE__, #x); } } while (0) + +void near(double actual, double expected) { + const bool same = std::isfinite(actual) && std::isfinite(expected) + && std::abs(actual - expected) <= 1e-12 * std::max(1.0, std::abs(expected)); + if (!same) std::printf("actual=%.17g expected=%.17g\n", actual, expected); + CHECK(same); +} + +bool same_bits(double a, double b) { + std::uint64_t aa = 0, bb = 0; + static_assert(sizeof(aa) == sizeof(a)); + std::memcpy(&aa, &a, sizeof(a)); + std::memcpy(&bb, &b, sizeof(b)); + return aa == bb; +} + +// Compare all declared lot fields, including NaN payloads, never object padding. +void same_lot(const PyramidEntry& a, const PyramidEntry& b) { + CHECK(same_bits(a.price, b.price)); + CHECK(a.time == b.time); + CHECK(same_bits(a.qty, b.qty)); + CHECK(a.entry_id == b.entry_id); + CHECK(a.entry_bar_index == b.entry_bar_index); + CHECK(a.entry_comment == b.entry_comment); + CHECK(same_bits(a.max_runup, b.max_runup)); + CHECK(same_bits(a.max_drawdown, b.max_drawdown)); + CHECK(a.skip_entry_bar_high == b.skip_entry_bar_high); + CHECK(a.skip_entry_bar_low == b.skip_entry_bar_low); + CHECK(a.market_pyramid_add == b.market_pyramid_add); + CHECK(same_bits(a.entry_path_position, b.entry_path_position)); + CHECK(same_bits(a.entry_commission_account, b.entry_commission_account)); + CHECK(a.entry_incarnation == b.entry_incarnation); + CHECK(a.bracket_slot_shadowed == b.bracket_slot_shadowed); + CHECK(a.ordinary_market_open == b.ordinary_market_open); + CHECK(a.pooc_terminal_market_entry == b.pooc_terminal_market_entry); + CHECK(a.ordinary_stop_open == b.ordinary_stop_open); +} + +PyramidEntry lot(const char* label, std::uint64_t owner, double qty, double price, + double paid_cost, std::int64_t time, int index, + double runup = 60, double drawdown = 30) { + PyramidEntry value{price, time, qty, label, index}; + value.entry_comment = std::string("entry-") + label; + value.entry_incarnation = owner; + value.entry_commission_account = paid_cost; + value.max_runup = runup; + value.max_drawdown = drawdown; + return value; +} + +std::vector standard_lots() { + return {lot("A", 11, 2, 100, 4, 1000, 10), + lot("B", 22, 3, 110, 6, 2000, 20), + lot("C", 33, 4, 120, 8, 3000, 30)}; +} + +std::vector fragmented_lots() { + return {lot("A", 11, 2, 100, 4, 1000, 10), + lot("B1", 22, 1, 110, 2, 2000, 20, 20, 10), + lot("C", 33, 4, 120, 8, 3000, 30), + lot("B2", 22, 2, 115, 4, 4000, 40, 40, 20)}; +} + +class Fixture final : public BacktestEngine { +public: + Fixture() { + // Deliberately different from the supplied physical close coordinate. + current_bar_ = {100, 125, 95, 120, 1, 60000}; + bar_index_ = 1; + initial_capital_ = 10000; + commission_type_ = CommissionType::CASH_PER_ORDER; + commission_value_ = 6; + account_currency_fx_ = 1; + syminfo_.pointvalue = 1; + stream_observe_actions_ = true; + slippage_ = 9; + qty_step_ = 10; + pyramiding_ = 1; + } + void on_bar(const Bar&) override {} + + void seed(std::vector values, bool short_side = false, + std::int64_t cycle = 7) { + CHECK(pyramid_entries_.empty() && trades_.empty()); + CHECK(!values.empty()); + if (values.empty()) return; + pyramid_entries_ = std::move(values); + position_side_ = short_side ? PositionSide::SHORT : PositionSide::LONG; + position_cycle_seq_ = cycle; + next_position_cycle_seq_ = cycle + 1; + position_open_bar_ = pyramid_entries_.front().entry_bar_index; + position_entry_time_ = pyramid_entries_.front().time; + position_entry_count_ = static_cast(pyramid_entries_.size()); + position_qty_ = 0; + double weighted = 0; + for (const auto& entry : pyramid_entries_) { + position_qty_ += entry.qty; + weighted += entry.qty * entry.price; + id_unclosed_qty_[entry.entry_id] += entry.qty; + cycle_filled_entry_ids_.insert(entry.entry_id); + } + position_entry_price_ = weighted / position_qty_; + trail_best_price_ = position_entry_price_; + } + + execution::SettlementInspection inspect(const execution::Action& action, + const execution::Fill& fill, + execution::CloseScope scope) const { + return inspect_native_settlement_scoped(action, fill, scope); + } + execution::Result settle(const execution::Action& action, + const execution::Fill& fill, + execution::CloseScope scope) { + execution::PhysicalExecutionContext context; + context.effective_time_ms = 9000; + context.interval_index = 90; + return settle_native_execution_scoped_at(action, fill, context, scope); + } + void fee(double cash) { commission_value_ = cash; } + void fx(double value) { account_currency_fx_ = value; } + double position() const { return signed_position_size(); } + double average() const { return position_entry_price_; } + double net() const { return net_profit_sum_; } + double marked(double price) const { return marked_equity(price); } + std::int64_t cycle() const { return position_cycle_seq_; } + const auto& lots() const { return pyramid_entries_; } + const auto& rows() const { return trades_; } + const auto& actions() const { return stream_order_actions_; } + std::uint64_t fingerprint() const { return broker_state_hash(); } +}; + +execution::CloseScope owner(std::uint64_t incarnation = 22, std::int64_t cycle = 7) { + return execution::OpeningExposure{incarnation, cycle}; +} +execution::Fill fill(double price = 120, std::optional fee = {}) { + return execution::Fill{price, "close-child", "scoped", 900, fee}; +} + +struct Expected { + double closed; + double signed_units; + std::size_t lots; + double average; + double notional; + double ticket; + std::size_t rows; + std::int64_t cycle = 7; +}; + +execution::Result applied(Fixture& book, const execution::Action& action, + execution::CloseScope scope, execution::Fill execution_fill, + const Expected& expected) { + const auto fingerprint = book.fingerprint(); + const auto original_lots = book.lots(); + const auto first_row = book.rows().size(); + const auto first_action = book.actions().size(); + const auto inspection = book.inspect(action, execution_fill, scope); + CHECK(book.fingerprint() == fingerprint); + CHECK(book.lots().size() == original_lots.size()); + for (std::size_t i = 0; i < std::min(book.lots().size(), original_lots.size()); ++i) + same_lot(book.lots()[i], original_lots[i]); + CHECK(book.rows().size() == first_row && book.actions().size() == first_action); + CHECK(inspection.status == execution::Status::Applied); + CHECK(inspection.closed_units == expected.closed); + CHECK(inspection.opened_units == 0); + CHECK(!inspection.would_open); + CHECK(inspection.resulting_abs_units == std::abs(expected.signed_units)); + CHECK(inspection.resulting_lot_count == expected.lots); + near(inspection.resulting_abs_notional, expected.notional); + near(inspection.current_ticket, expected.ticket); + + // The trusted caller locks the inspected execution cost. No saved index/plan. + execution_fill.commission_account = inspection.current_ticket; + const auto result = book.settle(action, execution_fill, scope); + CHECK(result.status == inspection.status); + CHECK(result.status == execution::Status::Applied); + CHECK(result.closed_units == inspection.closed_units); + CHECK(result.closed_units == expected.closed); + if (std::holds_alternative(scope)) { + CHECK(inspection.closed_units > 0); + CHECK(result.closed_units > 0); + } + CHECK(result.opened_units == 0 && result.opened_lot_incarnation == 0); + near(result.current_ticket, expected.ticket); + CHECK(result.first_trade_index == first_row); + CHECK(result.closed_trade_count == expected.rows); + CHECK(book.rows().size() == first_row + expected.rows); + CHECK(book.actions().size() == first_action + expected.rows); + CHECK(book.lots().size() == expected.lots); + CHECK(book.position() == expected.signed_units); + near(book.average(), expected.average); + CHECK(book.cycle() == expected.cycle); + for (std::size_t i = first_row; i < book.rows().size(); ++i) { + const auto& row = book.rows()[i]; + CHECK(row.exit_time == 9000 && row.exit_bar_index == 90); + CHECK(row.exit_id == execution_fill.id && row.exit_comment == execution_fill.comment); + CHECK(row.exit_price == execution_fill.price); + CHECK(row.entry_incarnation != execution_fill.incarnation); + if (const auto* selected = std::get_if(&scope)) + CHECK(row.entry_incarnation == selected->incarnation); + } + for (std::size_t i = first_action; i < book.actions().size(); ++i) { + const auto& action_row = book.actions()[i]; + CHECK(!action_row.is_entry); + CHECK(action_row.timestamp_ms == 9000 && action_row.bar_index == 90); + CHECK(action_row.order_id == execution_fill.id && action_row.comment == execution_fill.comment); + CHECK(action_row.closed_trade_index == first_row + i - first_action); + CHECK(action_row.sequence == i + 1); + if (action_row.closed_trade_index < book.rows().size()) { + const auto& row = book.rows()[action_row.closed_trade_index]; + CHECK(action_row.entry_incarnation == row.entry_incarnation); + CHECK(action_row.quantity == row.qty && action_row.price == row.exit_price); + CHECK(action_row.is_long == row.is_long); + } + } + return result; +} + +void refused(Fixture& book, const execution::Action& action, + execution::CloseScope scope, execution::Fill execution_fill, + execution::Status status) { + const auto fingerprint = book.fingerprint(); + const auto original_lots = book.lots(); + const auto row_count = book.rows().size(); + const auto action_count = book.actions().size(); + const auto net = book.net(); + const auto position = book.position(); + const auto cycle = book.cycle(); + const auto inspection = book.inspect(action, execution_fill, scope); + CHECK(inspection.status == status); + CHECK(book.fingerprint() == fingerprint); + const auto result = book.settle(action, execution_fill, scope); + CHECK(result.status == status && result.status == inspection.status); + CHECK(result.closed_units == 0 && result.opened_units == 0); + CHECK(result.current_ticket == 0 && result.closed_trade_count == 0); + CHECK(result.opened_lot_incarnation == 0); + CHECK(book.fingerprint() == fingerprint); + CHECK(book.net() == net && book.position() == position && book.cycle() == cycle); + CHECK(book.rows().size() == row_count && book.actions().size() == action_count); + CHECK(book.lots().size() == original_lots.size()); + for (std::size_t i = 0; i < std::min(book.lots().size(), original_lots.size()); ++i) + same_lot(book.lots()[i], original_lots[i]); +} + +void check_row(const Fixture& book, std::size_t index, const char* id, + double qty, double entry_price, double commission, double pnl, + std::int64_t entry_time, int entry_index, bool is_long = true) { + CHECK(index < book.rows().size()); + if (index >= book.rows().size()) return; + const auto& row = book.rows()[index]; + CHECK(row.entry_incarnation == 22); + CHECK(row.entry_id == id && row.entry_comment == std::string("entry-") + id); + CHECK(row.qty == qty && row.entry_price == entry_price); + CHECK(row.entry_time == entry_time && row.entry_bar_index == entry_index); + CHECK(row.is_long == is_long); + near(row.commission, commission); + near(row.pnl, pnl); +} + +void long_non_oldest() { + current_case = "01-long-non-oldest"; + Fixture book; + const auto initial = standard_lots(); + book.seed(initial); + near(book.marked(120), 10052); + applied(book, order_action::Reduce{1}, owner(), fill(), {1, 8, 3, 112.5, 960, 6, 1}); + check_row(book, 0, "B", 1, 110, 8, 2, 2000, 20); + if (book.lots().size() == 3) { + same_lot(book.lots()[0], initial[0]); + same_lot(book.lots()[2], initial[2]); + auto expected = initial[1]; + expected.qty = 2; + expected.entry_commission_account = 4; + expected.max_runup = 40; + expected.max_drawdown = 20; + same_lot(book.lots()[1], expected); + } + near(book.marked(120), 10046); +} + +void short_non_oldest() { + current_case = "02-short-non-oldest"; + Fixture book; + const auto initial = standard_lots(); + book.seed(initial, true); + near(book.marked(100), 10092); + applied(book, order_action::Reduce{1}, owner(), fill(100), {1, -8, 3, 112.5, 800, 6, 1}); + check_row(book, 0, "B", 1, 110, 8, 2, 2000, 20, false); + if (book.lots().size() == 3) { + same_lot(book.lots()[0], initial[0]); + same_lot(book.lots()[2], initial[2]); + auto expected = initial[1]; + expected.qty = 2; + expected.entry_commission_account = 4; + expected.max_runup = 40; + expected.max_drawdown = 20; + same_lot(book.lots()[1], expected); + } + near(book.marked(100), 10086); +} + +void repeated_fragments_partial() { + current_case = "03-repeated-fragments-partial"; + Fixture book; + const auto initial = fragmented_lots(); + book.seed(initial); + applied(book, order_action::Reduce{2}, owner(), fill(), {2, 7, 3, 795.0 / 7, 840, 6, 2}); + check_row(book, 0, "B1", 1, 110, 5, 5, 2000, 20); + check_row(book, 1, "B2", 1, 115, 5, 0, 4000, 40); + if (book.lots().size() == 3) { + same_lot(book.lots()[0], initial[0]); + same_lot(book.lots()[1], initial[2]); + auto expected = initial[3]; + expected.qty = 1; + expected.entry_commission_account = 2; + expected.max_runup = 20; + expected.max_drawdown = 10; + same_lot(book.lots()[2], expected); + } + near(book.net(), 5); +} + +void repeated_fragments_flatten() { + current_case = "04-repeated-fragments-flatten"; + Fixture book; + const auto initial = fragmented_lots(); + book.seed(initial); + near(book.marked(120), 10042); + applied(book, execution::Flatten{}, owner(), fill(120, 9), {3, 6, 2, 680.0 / 6, 720, 9, 2}); + check_row(book, 0, "B1", 1, 110, 5, 5, 2000, 20); + check_row(book, 1, "B2", 2, 115, 10, 0, 4000, 40); + if (book.lots().size() == 2) { + same_lot(book.lots()[0], initial[0]); + same_lot(book.lots()[1], initial[2]); + } + near(book.net(), 5); + near(book.marked(120), 10033); +} + +void decimal_residue_then_flatten() { + current_case = "05-decimal-residue"; + Fixture book; + const auto sibling = lot("A", 11, 1, 100, 0, 1000, 10, 0, 0); + book.seed({sibling, lot("B1", 22, 0.1, 100, 0, 2000, 20, 0, 0), + lot("B2", 22, 0.2, 100, 0, 4000, 40, 0, 0)}); + book.fee(0); + applied(book, order_action::Reduce{0.3}, owner(), fill(100), {0.3, 1, 2, 100, 100, 0, 2}); + if (book.lots().size() == 2) { + same_lot(book.lots()[0], sibling); + CHECK(book.lots()[1].qty == 0x1p-55); + CHECK(book.lots()[1].entry_incarnation == 22); + CHECK(book.lots()[1].time == 4000 && book.lots()[1].entry_bar_index == 40); + } + CHECK(book.rows().size() == 2); + if (book.rows().size() == 2) { + CHECK(book.rows()[0].qty == 0.1); + CHECK(book.rows()[1].qty == 0x1.9999999999999p-3); + } + applied(book, execution::Flatten{}, owner(), fill(100), {0x1p-55, 1, 1, 100, 100, 0, 1}); + check_row(book, 2, "B2", 0x1p-55, 100, 0, 0, 4000, 40); + if (book.lots().size() == 1) same_lot(book.lots()[0], sibling); +} + +void oversized_selected_reduce() { + current_case = "06-oversized-reduce"; + Fixture book; + const auto initial = standard_lots(); + book.seed(initial); + applied(book, order_action::Reduce{std::numeric_limits::max()}, owner(), fill(), + {3, 6, 2, 680.0 / 6, 720, 6, 1}); + check_row(book, 0, "B", 3, 110, 12, 18, 2000, 20); + if (book.lots().size() == 2) { + same_lot(book.lots()[0], initial[0]); + same_lot(book.lots()[1], initial[2]); + } +} + +void historical_rebate_waiver() { + current_case = "07-historical-rebate-waiver"; + Fixture book; + const auto initial = standard_lots(); + book.seed(initial); + book.fx(2); + book.fee(99); + near(book.marked(120), 10122); + applied(book, order_action::Reduce{1}, owner(), fill(120, -6), {1, 8, 3, 112.5, 1920, -6, 1}); + check_row(book, 0, "B", 1, 110, -4, 24, 2000, 20); + if (book.lots().size() == 3) near(book.lots()[1].entry_commission_account, 4); + near(book.marked(120), 10128); + book.fx(3); + book.fee(77); + near(book.marked(120), 10188); + applied(book, execution::Flatten{}, owner(), fill(120, 0), {2, 6, 2, 680.0 / 6, 2160, 0, 1}); + check_row(book, 1, "B", 2, 110, 4, 56, 2000, 20); + if (book.lots().size() == 2) { + same_lot(book.lots()[0], initial[0]); + same_lot(book.lots()[1], initial[2]); + } + near(book.net(), 80); + near(book.marked(120), 10188); +} + +void invalid_targets() { + current_case = "08-invalid-targets"; + Fixture book; + book.seed(standard_lots()); + for (const auto target : {execution::OpeningExposure{22, 6}, + execution::OpeningExposure{22, 0}, + execution::OpeningExposure{22, -1}, + execution::OpeningExposure{0, 7}, + execution::OpeningExposure{99, 7}}) + refused(book, order_action::Reduce{1}, target, fill(), execution::Status::InvalidCloseTarget); + Fixture flat; + refused(flat, execution::Flatten{}, owner(), fill(), execution::Status::InvalidCloseTarget); + Fixture next_cycle; + next_cycle.seed(standard_lots(), false, 8); + refused(next_cycle, order_action::Reduce{1}, owner(), fill(), execution::Status::InvalidCloseTarget); + applied(next_cycle, order_action::Reduce{1}, owner(22, 8), fill(), {1, 8, 3, 112.5, 960, 6, 1, 8}); +} + +void inspection_is_not_authority() { + current_case = "09-inspection-not-authority"; + Fixture book; + const auto initial = standard_lots(); + book.seed(initial); + const auto before = book.fingerprint(); + const auto earlier = book.inspect(order_action::Reduce{1}, fill(), owner()); + CHECK(book.fingerprint() == before); + CHECK(earlier.status == execution::Status::Applied); + CHECK(earlier.closed_units == 1 && earlier.resulting_abs_units == 8); + CHECK(earlier.resulting_lot_count == 3 && earlier.current_ticket == 6); + applied(book, order_action::Reduce{2}, execution::Book{}, fill(120, 0), + {2, 7, 2, 810.0 / 7, 840, 0, 1}); + if (book.lots().size() == 2) { + same_lot(book.lots()[0], initial[1]); + same_lot(book.lots()[1], initial[2]); + } + // Locked price/ticket and selector are reused; old roster indexes are not. + const auto result = applied(book, order_action::Reduce{1}, owner(), fill(120, earlier.current_ticket), + {1, 6, 2, 700.0 / 6, 720, 6, 1}); + CHECK(result.first_trade_index == 1); + check_row(book, 1, "B", 1, 110, 8, 2, 2000, 20); + if (book.lots().size() == 2) same_lot(book.lots()[1], initial[2]); + applied(book, execution::Flatten{}, owner(), fill(120, 0), {2, 4, 1, 120, 480, 0, 1}); + refused(book, order_action::Reduce{1}, owner(), fill(), execution::Status::InvalidCloseTarget); + if (book.lots().size() == 1) same_lot(book.lots()[0], initial[2]); +} + +void selected_transact_refused() { + current_case = "10-selected-transact-refused"; + Fixture book; + book.seed(standard_lots()); + for (double units : {1.0, -1.0, 0.0}) + refused(book, order_action::Transact{units}, owner(), fill(), execution::Status::InvalidCloseTarget); +} + +void valid_target_validation() { + current_case = "11-valid-target-validation"; + Fixture book; + book.seed(standard_lots()); + refused(book, order_action::Reduce{0}, owner(), fill(), execution::Status::NoEffect); + refused(book, order_action::Reduce{0}, owner(), fill(120, 0), execution::Status::NoEffect); + for (double fee : {1.0, -1.0}) + refused(book, order_action::Reduce{0}, owner(), fill(120, fee), execution::Status::InvalidAccounting); + for (double units : {-1.0, std::numeric_limits::infinity()}) + refused(book, order_action::Reduce{units}, owner(), fill(), execution::Status::InvalidQuantity); + refused(book, order_action::Reduce{1}, owner(), fill(std::numeric_limits::quiet_NaN()), + execution::Status::InvalidPrice); + for (double fee : {std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) + refused(book, order_action::Reduce{1}, owner(), fill(120, fee), execution::Status::InvalidAccounting); + + // Independent review's exact mixed-invalid precedence addendum. Target + // validation follows ordinary value validation but precedes zero effects. + refused(book, order_action::Reduce{1}, owner(0, 0), + fill(std::numeric_limits::quiet_NaN()), execution::Status::InvalidPrice); + refused(book, order_action::Transact{std::numeric_limits::quiet_NaN()}, owner(), + fill(), execution::Status::InvalidQuantity); + refused(book, order_action::Transact{0}, owner(), fill(120, 0), + execution::Status::InvalidCloseTarget); + refused(book, order_action::Reduce{0}, owner(99, 7), fill(120, 1), + execution::Status::InvalidCloseTarget); + Fixture flat; + refused(flat, execution::Flatten{}, owner(), fill(120, 0), + execution::Status::InvalidCloseTarget); +} + +void huge_unrelated_sibling() { + current_case = "12-huge-unrelated-sibling"; + Fixture book; + const auto sibling = lot("A", 11, 0x1p60, 1, 0, 1000, 10, 0, 0); + book.seed({sibling, lot("B", 22, 1, 1, 0, 2000, 20, 0, 0)}); + CHECK(book.position() == 0x1p60); + near(book.marked(1), 10000); + applied(book, order_action::Reduce{1}, owner(), fill(1), {1, 0x1p60, 1, 1, 0x1p60, 6, 1}); + check_row(book, 0, "B", 1, 1, 6, -6, 2000, 20); + CHECK(book.position() == 0x1p60); + if (book.lots().size() == 1) same_lot(book.lots()[0], sibling); + near(book.marked(1), 9994); +} +} // namespace + +int main() { + static_assert(static_cast(execution::Status::InvalidCloseTarget) == 8); + long_non_oldest(); + short_non_oldest(); + repeated_fragments_partial(); + repeated_fragments_flatten(); + decimal_residue_then_flatten(); + oversized_selected_reduce(); + historical_rebate_waiver(); + invalid_targets(); + inspection_is_not_authority(); + selected_transact_refused(); + valid_target_validation(); + huge_unrelated_sibling(); + std::printf("%s test_native_scoped_close: %d checks, %d failures, 12 cases\n", + failures ? "FAIL" : "PASS", checks, failures); + return failures ? 1 : 0; +}