From 826e74d7cde27b29d0ffa13f1bf1d2d64a08967c Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sun, 18 Oct 2026 18:20:36 +0800 Subject: [PATCH 1/4] test(cr150-l8b): pin sealed trade percent prefix identity Add the standalone PINEFORGE_TEST_TRADE_PERCENT_PREFIX proof in the lane-owned metric source. It covers every sealed/resident split, left-fold rounding, pnl-based filters, zero guards, mutable tails and reset. Red by source inspection against a6abb14e8: fold_trade_percent_prefix and reset_trade_percent_prefix do not exist. No build, compilation or tests were run on CORE-TOP as instructed; the lab must record the first compiler diagnostic. --- src/engine_metrics.cpp | 235 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) diff --git a/src/engine_metrics.cpp b/src/engine_metrics.cpp index 34faf0b53..529bcaf97 100644 --- a/src/engine_metrics.cpp +++ b/src/engine_metrics.cpp @@ -320,3 +320,238 @@ pf_equity_stats_t compute_equity_stats(const pf_equity_point_t* curve, int64_t n } // namespace metrics } // namespace pineforge + +#if defined(PINEFORGE_TEST_TRADE_PERCENT_PREFIX) +#include +#include + +namespace { + +using pineforge::Trade; + +int prefix_checks = 0; +int prefix_failures = 0; + +void check_prefix(bool ok, const char* expression, int line) { + ++prefix_checks; + if (!ok) { + ++prefix_failures; + std::fprintf(stderr, "trade percent prefix: line %d: %s\n", line, expression); + } +} + +#define PREFIX_CHECK(expression) check_prefix((expression), #expression, __LINE__) + +// The vector branch proves L8B without the container-swap lane. The other +// overload uses TradeLedger's frozen storage operation after integration. +template +auto retire_test_rows(Rows& rows, std::size_t count, int) + -> decltype(rows.seal(rows.base() + count), void()) { + rows.seal(rows.base() + count); +} + +template +void retire_test_rows(Rows& rows, std::size_t count, long) { + rows.erase(rows.begin(), rows.begin() + static_cast(count)); +} + +class PercentPrefixProbe final : public pineforge::BacktestEngine { +public: + PercentPrefixProbe() : BacktestEngine(NativeConsumerBindTag{}) {} + void on_bar(const pineforge::Bar&) override {} + + using BacktestEngine::avg_trade_percent; + using BacktestEngine::avg_winning_trade_percent; + using BacktestEngine::avg_losing_trade_percent; + + void append(const Trade& row) { + trades_.push_back(row); + if (row.pnl > 0.0) ++win_trades_count_; + if (row.pnl < 0.0) ++loss_trades_count_; + } + + void retire(std::size_t count) { + std::size_t folded = 0; + for (const auto& row : trades_) { + if (folded == count) break; + fold_trade_percent_prefix(row); + ++folded; + } + PREFIX_CHECK(folded == count); + retire_test_rows(trades_, count, 0); + } + + std::size_t resident_count() const { + std::size_t count = 0; + for (const auto& row : trades_) { (void)row; ++count; } + return count; + } + + void replace_last(const Trade& row) { + const auto& old = trades_.back(); + if (old.pnl > 0.0) --win_trades_count_; + if (old.pnl < 0.0) --loss_trades_count_; + trades_.back() = row; + if (row.pnl > 0.0) ++win_trades_count_; + if (row.pnl < 0.0) ++loss_trades_count_; + } + + void truncate_last() { + const auto& old = trades_.back(); + if (old.pnl > 0.0) --win_trades_count_; + if (old.pnl < 0.0) --loss_trades_count_; + trades_.resize(trades_.size() - 1); + } + + void reset_prefix_and_rows() { + reset_trade_percent_prefix(); + trades_.clear(); + win_trades_count_ = 0; + loss_trades_count_ = 0; + } + + void zero_win_loss_guards() { + win_trades_count_ = 0; + loss_trades_count_ = 0; + } +}; + +Trade percent_row(double pnl, double pct) { + Trade row{}; + row.pnl = pnl; + row.pnl_pct = pct; + return row; +} + +// Frozen scalar loops from a6abb14e8, independent of the new prefix fold. +// Keep the signed losing percentage and the pnl-based filters verbatim. +double reference_percent(const std::vector& rows, int filter) { + if (filter == 0) { + int n = static_cast(rows.size()); + if (n <= 0) return 0.0; + double s = 0.0; + for (const auto& t : rows) s += t.pnl_pct; + return s / static_cast(n); + } + int guard = 0; + for (const auto& t : rows) { + if (filter > 0 ? t.pnl > 0.0 : t.pnl < 0.0) ++guard; + } + if (guard <= 0) return 0.0; + double s = 0.0; + int c = 0; + for (const auto& t : rows) { + if (filter > 0 ? t.pnl > 0.0 : t.pnl < 0.0) { s += t.pnl_pct; ++c; } + } + return (c > 0) ? (s / static_cast(c)) : 0.0; +} + +void compare_prefix(const PercentPrefixProbe& probe, const std::vector& rows) { + const double expected[] = {reference_percent(rows, 0), reference_percent(rows, 1), + reference_percent(rows, -1)}; + // Repeated reads must not consume or refold the sealed prefix. + for (int read = 0; read != 2; ++read) { + const double actual[] = {probe.avg_trade_percent(), probe.avg_winning_trade_percent(), + probe.avg_losing_trade_percent()}; + for (int index = 0; index != 3; ++index) + PREFIX_CHECK(std::memcmp(&actual[index], &expected[index], sizeof(double)) == 0); + } +} + +void every_prefix_split(const std::vector& rows) { + for (std::size_t split = 0; split <= rows.size(); ++split) { + PercentPrefixProbe probe; + for (const auto& row : rows) probe.append(row); + compare_prefix(probe, rows); + probe.retire(split); + PREFIX_CHECK(probe.resident_count() == rows.size() - split); + compare_prefix(probe, rows); + const auto remainder = probe.resident_count(); + probe.retire(remainder); + PREFIX_CHECK(probe.resident_count() == 0); + compare_prefix(probe, rows); + } +} + +void test_literal_prefix_splits() { + every_prefix_split({}); + every_prefix_split({percent_row(0.0, -0.0), percent_row(-0.0, 8.0)}); + every_prefix_split({percent_row(1.0, 1e16), percent_row(1.0, -1e16), + percent_row(1.0, 1.0)}); + every_prefix_split({percent_row(-1.0, -1e16), percent_row(-1.0, 1e16), + percent_row(-1.0, -1.0)}); + every_prefix_split({percent_row(2.0, -7.0), percent_row(-2.0, 11.0), + percent_row(0.0, 3.0), percent_row(5.0, 9.0), + percent_row(-3.0, -5.0)}); + const double nan = std::numeric_limits::quiet_NaN(); + const double inf = std::numeric_limits::infinity(); + every_prefix_split({percent_row(1.0, 2.0), percent_row(0.0, nan), + percent_row(-1.0, -3.0), percent_row(nan, 4.0)}); + every_prefix_split({percent_row(1.0, nan), percent_row(-1.0, nan), + percent_row(1.0, 5.0), percent_row(-1.0, -6.0)}); + every_prefix_split({percent_row(1.0, inf), percent_row(-1.0, -inf)}); +} + +void test_many_prefix_splits() { + std::vector rows; + std::uint64_t bits = 0xb6a8e5c7113d92f1ULL; + for (int index = 0; index != 256; ++index) { + bits ^= bits << 13; + bits ^= bits >> 7; + bits ^= bits << 17; + const double pnl = static_cast(static_cast(bits % 7) - 3); + const int exponent = static_cast((bits >> 8) % 201) - 100; + const double pct = std::ldexp(static_cast(static_cast(bits >> 48) - 32768), + exponent); + rows.push_back(percent_row(pnl, pct)); + } + every_prefix_split(rows); +} + +void test_append_rewrite_truncate_and_reset() { + PercentPrefixProbe probe; + std::vector rows; + for (int index = 0; index != 96; ++index) { + const auto row = percent_row(static_cast(index % 3 - 1), + static_cast(index - 40) / 7.0); + probe.append(row); + rows.push_back(row); + compare_prefix(probe, rows); + if (probe.resident_count() > 2) probe.retire(probe.resident_count() - 2); + compare_prefix(probe, rows); + } + rows.back() = percent_row(1.0, -17.0); + probe.replace_last(rows.back()); + compare_prefix(probe, rows); + rows.pop_back(); + probe.truncate_last(); + compare_prefix(probe, rows); + probe.retire(probe.resident_count()); + compare_prefix(probe, rows); + + probe.zero_win_loss_guards(); + PREFIX_CHECK(probe.avg_winning_trade_percent() == 0.0); + PREFIX_CHECK(probe.avg_losing_trade_percent() == 0.0); + probe.reset_prefix_and_rows(); + compare_prefix(probe, {}); + rows = {percent_row(-1.0, -12.0), percent_row(1.0, 3.0)}; + for (const auto& row : rows) probe.append(row); + compare_prefix(probe, rows); + probe.retire(1); + compare_prefix(probe, rows); + probe.reset_prefix_and_rows(); + compare_prefix(probe, {}); +} + +#undef PREFIX_CHECK + +} // namespace + +int main() { + test_literal_prefix_splits(); + test_many_prefix_splits(); + test_append_rewrite_truncate_and_reset(); + std::printf("trade percent prefix: %d checks, %d failures\n", prefix_checks, prefix_failures); + return prefix_failures == 0 ? 0 : 1; +} +#endif From 2bbd96d01371865212beeec440c64f832b2ba914 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sun, 18 Oct 2026 18:23:06 +0800 Subject: [PATCH 2/4] feat(cr150-l8b): seed percent averages from sealed trade rows Keep all/winning/losing percentage sums and counts at the sealed prefix. Continue each original left fold over resident rows, preserving pnl filters, signed loss percentages and existing win/loss guards. Add protected fold/reset operations for L8c; do not install streaming or change P00 declarations. OPEN for supervisor: hash coverage for trade_percent_prefix_ needs an out-of-row hash/waiver change; the member remains inside a broker-state region so the guard can report it. L8c must wire fold-before-retire and new-run reset. No build, compilation or tests were run on this machine. --- include/pineforge/engine.hpp | 41 ++++++++++++++++++++++++++++-------- src/engine_metrics.cpp | 17 +++++++++++++++ 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 47f7f4ffa..63aac70c3 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -983,11 +983,11 @@ class BacktestEngine { return (n > 0) ? (net_profit_sum_ / (double)n) : 0.0; } double avg_trade_percent() const { - int n = (int)trades_.size(); - if (n <= 0) return 0.0; - double s = 0.0; - for (const auto& t : trades_) s += t.pnl_pct; - return s / (double)n; + double s = trade_percent_prefix_.all_sum; + std::size_t n = trade_percent_prefix_.all_count; + // Continue the original left fold over the resident rows. + for (const auto& t : trades_) { s += t.pnl_pct; ++n; } + return n > 0 ? s / static_cast(n) : 0.0; } double avg_winning_trade() const { return (win_trades_count_ > 0) ? (gross_profit_sum_ / (double)win_trades_count_) : 0.0; @@ -997,8 +997,8 @@ class BacktestEngine { } double avg_winning_trade_percent() const { if (win_trades_count_ <= 0) return 0.0; - double s = 0.0; - int c = 0; + double s = trade_percent_prefix_.win_sum; + std::size_t c = trade_percent_prefix_.win_count; for (const auto& t : trades_) { if (t.pnl > 0.0) { s += t.pnl_pct; ++c; } } @@ -1006,8 +1006,8 @@ class BacktestEngine { } double avg_losing_trade_percent() const { if (loss_trades_count_ <= 0) return 0.0; - double s = 0.0; - int c = 0; + double s = trade_percent_prefix_.loss_sum; + std::size_t c = trade_percent_prefix_.loss_count; for (const auto& t : trades_) { if (t.pnl < 0.0) { s += t.pnl_pct; ++c; } } @@ -2290,6 +2290,29 @@ class BacktestEngine { // it from the spec at every begin, and the spec digest folds the spec's // value. Private, so no host writes it (R5 lane K-ULP4). double native_quantity_tolerance_ = 0.0; + +protected: + // Sealed closed rows only; range-end report rows never enter these sums. + // Each accessor continues its own sum one resident row at a time, so a + // separately accumulated tail cannot change floating-point grouping. + struct TradePercentPrefix { + double all_sum = 0.0; + double win_sum = 0.0; + double loss_sum = 0.0; + std::size_t all_count = 0; + std::size_t win_count = 0; + std::size_t loss_count = 0; + }; + // @broker-state begin + TradePercentPrefix trade_percent_prefix_; + // @broker-state end + + // The sealing caller folds each final row exactly once, in global row + // order, immediately before retiring it from trades_. This operation + // does not change storage, logical indices or the win/loss guards. + void fold_trade_percent_prefix(const Trade& trade); + // The streaming owner clears this prefix with the run's closed rows. + void reset_trade_percent_prefix(); }; } // inline namespace engine_script_run_v19 diff --git a/src/engine_metrics.cpp b/src/engine_metrics.cpp index 529bcaf97..ac1335c59 100644 --- a/src/engine_metrics.cpp +++ b/src/engine_metrics.cpp @@ -319,6 +319,23 @@ pf_equity_stats_t compute_equity_stats(const pf_equity_point_t* curve, int64_t n } } // namespace metrics + +void BacktestEngine::fold_trade_percent_prefix(const Trade& trade) { + trade_percent_prefix_.all_sum += trade.pnl_pct; + ++trade_percent_prefix_.all_count; + if (trade.pnl > 0.0) { + trade_percent_prefix_.win_sum += trade.pnl_pct; + ++trade_percent_prefix_.win_count; + } else if (trade.pnl < 0.0) { + trade_percent_prefix_.loss_sum += trade.pnl_pct; + ++trade_percent_prefix_.loss_count; + } +} + +void BacktestEngine::reset_trade_percent_prefix() { + trade_percent_prefix_ = {}; +} + } // namespace pineforge #if defined(PINEFORGE_TEST_TRADE_PERCENT_PREFIX) From 381964a0b9b6c7837052a6faf4f60bb5476d0d4b Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sun, 18 Oct 2026 21:05:20 +0800 Subject: [PATCH 3/4] fix(cr150-l8b): waive derived trade percent prefix state DESIGN rev 6 section 3.4 requires the same left folds over sealed rows followed by the resident tail, while retaining the existing closed-row digest hash. The six prefix values are reconstructible from those immutable already-hashed rows and add no independent decision state. Add an explicit reasoned waiver for trade_percent_prefix_ rather than new seal-dependent hash bytes. Addresses the coverage failure in lab rj-20261018t125021-5a52a9. No build, test, guard or verification command was run here. --- scripts/broker_state_hash_waivers.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/broker_state_hash_waivers.txt b/scripts/broker_state_hash_waivers.txt index 25f82e901..30ddc3839 100644 --- a/scripts/broker_state_hash_waivers.txt +++ b/scripts/broker_state_hash_waivers.txt @@ -82,3 +82,4 @@ symbol_calendar_opens_ # C-ABI transport of that calendar (syminfo metadata "sym waypoint_chain_bar_ # Transient within-bar value: the script bar whose path took a waypoint margin call (rest_waypoint_margin_call), set on that bar's path and cleared by the same bar's post-script close; compared only for equality with the current script bar, so it is dead at every bar boundary. script_average_held_ # Transient within-pass flag: hold_script_average_price sets it after a current-close exit flattened the book, and release_script_average_price clears it right after on_source_bar returns, before the pass's orders settle; it is false at every fold (the same class as security_history_publication_replay_). symbol_feeds_lent_ # Ownership flag: whether the installed symbol feeds' bars are held in symbol_feeds_ or lent to the kernel's run spec; the folds read the same keys, counts and digest either way and no decision reads it (AR-R4). +trade_percent_prefix_ # Derived all/winning/losing percentage sums and counts: exact left folds from 0.0 over immutable sealed rows, filtered by pnl sign (DESIGN rev 6 section 3.4). Their count and closed-row digest are already folded in broker_state_hash; replaying those rows reconstructs this prefix, which carries no independent decision state. Folding it separately would make the hash depend on the sealed/resident storage split. From 9fe2f405bbf58e86a998608f08e7b3f55eb2b55e Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sun, 18 Oct 2026 23:10:18 +0800 Subject: [PATCH 4/4] test(cr150-l8b): register trade percent prefix unit Apply the L8B FIXES from core-cr150-read-wave1. Move the unit introduced by 826e74d7c out of engine_metrics.cpp into tests/test_trade_percent_prefix.cpp, preserving its body and assertions, removing only the compile-mode wrapper and adding standalone includes. Append the TEST_SOURCES entry and pineforge_add_unit_test call after all existing test CMake lines, preserving the private-fixture evidence pins. Existing metric definitions and published source anchors retain their line numbers. No build, test, guard or verification command was run here. --- src/engine_metrics.cpp | 235 --------------------------- tests/CMakeLists.txt | 3 + tests/test_trade_percent_prefix.cpp | 239 ++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 235 deletions(-) create mode 100644 tests/test_trade_percent_prefix.cpp diff --git a/src/engine_metrics.cpp b/src/engine_metrics.cpp index ac1335c59..538525b65 100644 --- a/src/engine_metrics.cpp +++ b/src/engine_metrics.cpp @@ -337,238 +337,3 @@ void BacktestEngine::reset_trade_percent_prefix() { } } // namespace pineforge - -#if defined(PINEFORGE_TEST_TRADE_PERCENT_PREFIX) -#include -#include - -namespace { - -using pineforge::Trade; - -int prefix_checks = 0; -int prefix_failures = 0; - -void check_prefix(bool ok, const char* expression, int line) { - ++prefix_checks; - if (!ok) { - ++prefix_failures; - std::fprintf(stderr, "trade percent prefix: line %d: %s\n", line, expression); - } -} - -#define PREFIX_CHECK(expression) check_prefix((expression), #expression, __LINE__) - -// The vector branch proves L8B without the container-swap lane. The other -// overload uses TradeLedger's frozen storage operation after integration. -template -auto retire_test_rows(Rows& rows, std::size_t count, int) - -> decltype(rows.seal(rows.base() + count), void()) { - rows.seal(rows.base() + count); -} - -template -void retire_test_rows(Rows& rows, std::size_t count, long) { - rows.erase(rows.begin(), rows.begin() + static_cast(count)); -} - -class PercentPrefixProbe final : public pineforge::BacktestEngine { -public: - PercentPrefixProbe() : BacktestEngine(NativeConsumerBindTag{}) {} - void on_bar(const pineforge::Bar&) override {} - - using BacktestEngine::avg_trade_percent; - using BacktestEngine::avg_winning_trade_percent; - using BacktestEngine::avg_losing_trade_percent; - - void append(const Trade& row) { - trades_.push_back(row); - if (row.pnl > 0.0) ++win_trades_count_; - if (row.pnl < 0.0) ++loss_trades_count_; - } - - void retire(std::size_t count) { - std::size_t folded = 0; - for (const auto& row : trades_) { - if (folded == count) break; - fold_trade_percent_prefix(row); - ++folded; - } - PREFIX_CHECK(folded == count); - retire_test_rows(trades_, count, 0); - } - - std::size_t resident_count() const { - std::size_t count = 0; - for (const auto& row : trades_) { (void)row; ++count; } - return count; - } - - void replace_last(const Trade& row) { - const auto& old = trades_.back(); - if (old.pnl > 0.0) --win_trades_count_; - if (old.pnl < 0.0) --loss_trades_count_; - trades_.back() = row; - if (row.pnl > 0.0) ++win_trades_count_; - if (row.pnl < 0.0) ++loss_trades_count_; - } - - void truncate_last() { - const auto& old = trades_.back(); - if (old.pnl > 0.0) --win_trades_count_; - if (old.pnl < 0.0) --loss_trades_count_; - trades_.resize(trades_.size() - 1); - } - - void reset_prefix_and_rows() { - reset_trade_percent_prefix(); - trades_.clear(); - win_trades_count_ = 0; - loss_trades_count_ = 0; - } - - void zero_win_loss_guards() { - win_trades_count_ = 0; - loss_trades_count_ = 0; - } -}; - -Trade percent_row(double pnl, double pct) { - Trade row{}; - row.pnl = pnl; - row.pnl_pct = pct; - return row; -} - -// Frozen scalar loops from a6abb14e8, independent of the new prefix fold. -// Keep the signed losing percentage and the pnl-based filters verbatim. -double reference_percent(const std::vector& rows, int filter) { - if (filter == 0) { - int n = static_cast(rows.size()); - if (n <= 0) return 0.0; - double s = 0.0; - for (const auto& t : rows) s += t.pnl_pct; - return s / static_cast(n); - } - int guard = 0; - for (const auto& t : rows) { - if (filter > 0 ? t.pnl > 0.0 : t.pnl < 0.0) ++guard; - } - if (guard <= 0) return 0.0; - double s = 0.0; - int c = 0; - for (const auto& t : rows) { - if (filter > 0 ? t.pnl > 0.0 : t.pnl < 0.0) { s += t.pnl_pct; ++c; } - } - return (c > 0) ? (s / static_cast(c)) : 0.0; -} - -void compare_prefix(const PercentPrefixProbe& probe, const std::vector& rows) { - const double expected[] = {reference_percent(rows, 0), reference_percent(rows, 1), - reference_percent(rows, -1)}; - // Repeated reads must not consume or refold the sealed prefix. - for (int read = 0; read != 2; ++read) { - const double actual[] = {probe.avg_trade_percent(), probe.avg_winning_trade_percent(), - probe.avg_losing_trade_percent()}; - for (int index = 0; index != 3; ++index) - PREFIX_CHECK(std::memcmp(&actual[index], &expected[index], sizeof(double)) == 0); - } -} - -void every_prefix_split(const std::vector& rows) { - for (std::size_t split = 0; split <= rows.size(); ++split) { - PercentPrefixProbe probe; - for (const auto& row : rows) probe.append(row); - compare_prefix(probe, rows); - probe.retire(split); - PREFIX_CHECK(probe.resident_count() == rows.size() - split); - compare_prefix(probe, rows); - const auto remainder = probe.resident_count(); - probe.retire(remainder); - PREFIX_CHECK(probe.resident_count() == 0); - compare_prefix(probe, rows); - } -} - -void test_literal_prefix_splits() { - every_prefix_split({}); - every_prefix_split({percent_row(0.0, -0.0), percent_row(-0.0, 8.0)}); - every_prefix_split({percent_row(1.0, 1e16), percent_row(1.0, -1e16), - percent_row(1.0, 1.0)}); - every_prefix_split({percent_row(-1.0, -1e16), percent_row(-1.0, 1e16), - percent_row(-1.0, -1.0)}); - every_prefix_split({percent_row(2.0, -7.0), percent_row(-2.0, 11.0), - percent_row(0.0, 3.0), percent_row(5.0, 9.0), - percent_row(-3.0, -5.0)}); - const double nan = std::numeric_limits::quiet_NaN(); - const double inf = std::numeric_limits::infinity(); - every_prefix_split({percent_row(1.0, 2.0), percent_row(0.0, nan), - percent_row(-1.0, -3.0), percent_row(nan, 4.0)}); - every_prefix_split({percent_row(1.0, nan), percent_row(-1.0, nan), - percent_row(1.0, 5.0), percent_row(-1.0, -6.0)}); - every_prefix_split({percent_row(1.0, inf), percent_row(-1.0, -inf)}); -} - -void test_many_prefix_splits() { - std::vector rows; - std::uint64_t bits = 0xb6a8e5c7113d92f1ULL; - for (int index = 0; index != 256; ++index) { - bits ^= bits << 13; - bits ^= bits >> 7; - bits ^= bits << 17; - const double pnl = static_cast(static_cast(bits % 7) - 3); - const int exponent = static_cast((bits >> 8) % 201) - 100; - const double pct = std::ldexp(static_cast(static_cast(bits >> 48) - 32768), - exponent); - rows.push_back(percent_row(pnl, pct)); - } - every_prefix_split(rows); -} - -void test_append_rewrite_truncate_and_reset() { - PercentPrefixProbe probe; - std::vector rows; - for (int index = 0; index != 96; ++index) { - const auto row = percent_row(static_cast(index % 3 - 1), - static_cast(index - 40) / 7.0); - probe.append(row); - rows.push_back(row); - compare_prefix(probe, rows); - if (probe.resident_count() > 2) probe.retire(probe.resident_count() - 2); - compare_prefix(probe, rows); - } - rows.back() = percent_row(1.0, -17.0); - probe.replace_last(rows.back()); - compare_prefix(probe, rows); - rows.pop_back(); - probe.truncate_last(); - compare_prefix(probe, rows); - probe.retire(probe.resident_count()); - compare_prefix(probe, rows); - - probe.zero_win_loss_guards(); - PREFIX_CHECK(probe.avg_winning_trade_percent() == 0.0); - PREFIX_CHECK(probe.avg_losing_trade_percent() == 0.0); - probe.reset_prefix_and_rows(); - compare_prefix(probe, {}); - rows = {percent_row(-1.0, -12.0), percent_row(1.0, 3.0)}; - for (const auto& row : rows) probe.append(row); - compare_prefix(probe, rows); - probe.retire(1); - compare_prefix(probe, rows); - probe.reset_prefix_and_rows(); - compare_prefix(probe, {}); -} - -#undef PREFIX_CHECK - -} // namespace - -int main() { - test_literal_prefix_splits(); - test_many_prefix_splits(); - test_append_rewrite_truncate_and_reset(); - std::printf("trade percent prefix: %d checks, %d failures\n", prefix_checks, prefix_failures); - return prefix_failures == 0 ? 0 : 1; -} -#endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d11ecee74..a5acc3188 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7087,3 +7087,6 @@ pineforge_add_unit_test(test_cr150_contract_headers) target_sources(test_cr150_contract_headers PRIVATE test_cr150_contract_headers_c.c) set_target_properties(test_cr150_contract_headers PROPERTIES C_STANDARD 11 C_STANDARD_REQUIRED ON C_EXTENSIONS OFF) + +list(APPEND TEST_SOURCES test_trade_percent_prefix) +pineforge_add_unit_test(test_trade_percent_prefix) diff --git a/tests/test_trade_percent_prefix.cpp b/tests/test_trade_percent_prefix.cpp new file mode 100644 index 000000000..c5b0abcfc --- /dev/null +++ b/tests/test_trade_percent_prefix.cpp @@ -0,0 +1,239 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using pineforge::Trade; + +int prefix_checks = 0; +int prefix_failures = 0; + +void check_prefix(bool ok, const char* expression, int line) { + ++prefix_checks; + if (!ok) { + ++prefix_failures; + std::fprintf(stderr, "trade percent prefix: line %d: %s\n", line, expression); + } +} + +#define PREFIX_CHECK(expression) check_prefix((expression), #expression, __LINE__) + +// The vector branch proves L8B without the container-swap lane. The other +// overload uses TradeLedger's frozen storage operation after integration. +template +auto retire_test_rows(Rows& rows, std::size_t count, int) + -> decltype(rows.seal(rows.base() + count), void()) { + rows.seal(rows.base() + count); +} + +template +void retire_test_rows(Rows& rows, std::size_t count, long) { + rows.erase(rows.begin(), rows.begin() + static_cast(count)); +} + +class PercentPrefixProbe final : public pineforge::BacktestEngine { +public: + PercentPrefixProbe() : BacktestEngine(NativeConsumerBindTag{}) {} + void on_bar(const pineforge::Bar&) override {} + + using BacktestEngine::avg_trade_percent; + using BacktestEngine::avg_winning_trade_percent; + using BacktestEngine::avg_losing_trade_percent; + + void append(const Trade& row) { + trades_.push_back(row); + if (row.pnl > 0.0) ++win_trades_count_; + if (row.pnl < 0.0) ++loss_trades_count_; + } + + void retire(std::size_t count) { + std::size_t folded = 0; + for (const auto& row : trades_) { + if (folded == count) break; + fold_trade_percent_prefix(row); + ++folded; + } + PREFIX_CHECK(folded == count); + retire_test_rows(trades_, count, 0); + } + + std::size_t resident_count() const { + std::size_t count = 0; + for (const auto& row : trades_) { (void)row; ++count; } + return count; + } + + void replace_last(const Trade& row) { + const auto& old = trades_.back(); + if (old.pnl > 0.0) --win_trades_count_; + if (old.pnl < 0.0) --loss_trades_count_; + trades_.back() = row; + if (row.pnl > 0.0) ++win_trades_count_; + if (row.pnl < 0.0) ++loss_trades_count_; + } + + void truncate_last() { + const auto& old = trades_.back(); + if (old.pnl > 0.0) --win_trades_count_; + if (old.pnl < 0.0) --loss_trades_count_; + trades_.resize(trades_.size() - 1); + } + + void reset_prefix_and_rows() { + reset_trade_percent_prefix(); + trades_.clear(); + win_trades_count_ = 0; + loss_trades_count_ = 0; + } + + void zero_win_loss_guards() { + win_trades_count_ = 0; + loss_trades_count_ = 0; + } +}; + +Trade percent_row(double pnl, double pct) { + Trade row{}; + row.pnl = pnl; + row.pnl_pct = pct; + return row; +} + +// Frozen scalar loops from a6abb14e8, independent of the new prefix fold. +// Keep the signed losing percentage and the pnl-based filters verbatim. +double reference_percent(const std::vector& rows, int filter) { + if (filter == 0) { + int n = static_cast(rows.size()); + if (n <= 0) return 0.0; + double s = 0.0; + for (const auto& t : rows) s += t.pnl_pct; + return s / static_cast(n); + } + int guard = 0; + for (const auto& t : rows) { + if (filter > 0 ? t.pnl > 0.0 : t.pnl < 0.0) ++guard; + } + if (guard <= 0) return 0.0; + double s = 0.0; + int c = 0; + for (const auto& t : rows) { + if (filter > 0 ? t.pnl > 0.0 : t.pnl < 0.0) { s += t.pnl_pct; ++c; } + } + return (c > 0) ? (s / static_cast(c)) : 0.0; +} + +void compare_prefix(const PercentPrefixProbe& probe, const std::vector& rows) { + const double expected[] = {reference_percent(rows, 0), reference_percent(rows, 1), + reference_percent(rows, -1)}; + // Repeated reads must not consume or refold the sealed prefix. + for (int read = 0; read != 2; ++read) { + const double actual[] = {probe.avg_trade_percent(), probe.avg_winning_trade_percent(), + probe.avg_losing_trade_percent()}; + for (int index = 0; index != 3; ++index) + PREFIX_CHECK(std::memcmp(&actual[index], &expected[index], sizeof(double)) == 0); + } +} + +void every_prefix_split(const std::vector& rows) { + for (std::size_t split = 0; split <= rows.size(); ++split) { + PercentPrefixProbe probe; + for (const auto& row : rows) probe.append(row); + compare_prefix(probe, rows); + probe.retire(split); + PREFIX_CHECK(probe.resident_count() == rows.size() - split); + compare_prefix(probe, rows); + const auto remainder = probe.resident_count(); + probe.retire(remainder); + PREFIX_CHECK(probe.resident_count() == 0); + compare_prefix(probe, rows); + } +} + +void test_literal_prefix_splits() { + every_prefix_split({}); + every_prefix_split({percent_row(0.0, -0.0), percent_row(-0.0, 8.0)}); + every_prefix_split({percent_row(1.0, 1e16), percent_row(1.0, -1e16), + percent_row(1.0, 1.0)}); + every_prefix_split({percent_row(-1.0, -1e16), percent_row(-1.0, 1e16), + percent_row(-1.0, -1.0)}); + every_prefix_split({percent_row(2.0, -7.0), percent_row(-2.0, 11.0), + percent_row(0.0, 3.0), percent_row(5.0, 9.0), + percent_row(-3.0, -5.0)}); + const double nan = std::numeric_limits::quiet_NaN(); + const double inf = std::numeric_limits::infinity(); + every_prefix_split({percent_row(1.0, 2.0), percent_row(0.0, nan), + percent_row(-1.0, -3.0), percent_row(nan, 4.0)}); + every_prefix_split({percent_row(1.0, nan), percent_row(-1.0, nan), + percent_row(1.0, 5.0), percent_row(-1.0, -6.0)}); + every_prefix_split({percent_row(1.0, inf), percent_row(-1.0, -inf)}); +} + +void test_many_prefix_splits() { + std::vector rows; + std::uint64_t bits = 0xb6a8e5c7113d92f1ULL; + for (int index = 0; index != 256; ++index) { + bits ^= bits << 13; + bits ^= bits >> 7; + bits ^= bits << 17; + const double pnl = static_cast(static_cast(bits % 7) - 3); + const int exponent = static_cast((bits >> 8) % 201) - 100; + const double pct = std::ldexp(static_cast(static_cast(bits >> 48) - 32768), + exponent); + rows.push_back(percent_row(pnl, pct)); + } + every_prefix_split(rows); +} + +void test_append_rewrite_truncate_and_reset() { + PercentPrefixProbe probe; + std::vector rows; + for (int index = 0; index != 96; ++index) { + const auto row = percent_row(static_cast(index % 3 - 1), + static_cast(index - 40) / 7.0); + probe.append(row); + rows.push_back(row); + compare_prefix(probe, rows); + if (probe.resident_count() > 2) probe.retire(probe.resident_count() - 2); + compare_prefix(probe, rows); + } + rows.back() = percent_row(1.0, -17.0); + probe.replace_last(rows.back()); + compare_prefix(probe, rows); + rows.pop_back(); + probe.truncate_last(); + compare_prefix(probe, rows); + probe.retire(probe.resident_count()); + compare_prefix(probe, rows); + + probe.zero_win_loss_guards(); + PREFIX_CHECK(probe.avg_winning_trade_percent() == 0.0); + PREFIX_CHECK(probe.avg_losing_trade_percent() == 0.0); + probe.reset_prefix_and_rows(); + compare_prefix(probe, {}); + rows = {percent_row(-1.0, -12.0), percent_row(1.0, 3.0)}; + for (const auto& row : rows) probe.append(row); + compare_prefix(probe, rows); + probe.retire(1); + compare_prefix(probe, rows); + probe.reset_prefix_and_rows(); + compare_prefix(probe, {}); +} + +#undef PREFIX_CHECK + +} // namespace + +int main() { + test_literal_prefix_splits(); + test_many_prefix_splits(); + test_append_rewrite_truncate_and_reset(); + std::printf("trade percent prefix: %d checks, %d failures\n", prefix_checks, prefix_failures); + return prefix_failures == 0 ? 0 : 1; +}