Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 32 additions & 9 deletions include/pineforge/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -984,11 +984,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<double>(n) : 0.0;
}
double avg_winning_trade() const {
return (win_trades_count_ > 0) ? (gross_profit_sum_ / (double)win_trades_count_) : 0.0;
Expand All @@ -998,17 +998,17 @@ 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; }
}
return (c > 0) ? (s / (double)c) : 0.0;
}
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; }
}
Expand Down Expand Up @@ -2291,6 +2291,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
Expand Down
1 change: 1 addition & 0 deletions scripts/broker_state_hash_waivers.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
17 changes: 17 additions & 0 deletions src/engine_metrics.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -319,4 +319,21 @@ 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
3 changes: 3 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7101,3 +7101,6 @@ list(APPEND TEST_SOURCES test_trade_ledger)
pineforge_add_unit_test(test_trade_ledger)
list(APPEND TEST_SOURCES test_report_sink_format)
pineforge_add_unit_test(test_report_sink_format)

list(APPEND TEST_SOURCES test_trade_percent_prefix)
pineforge_add_unit_test(test_trade_percent_prefix)
239 changes: 239 additions & 0 deletions tests/test_trade_percent_prefix.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
#include <pineforge/engine.hpp>

#include <cmath>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <vector>
#include <cstdio>
#include <cstring>

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 <class Rows>
auto retire_test_rows(Rows& rows, std::size_t count, int)
-> decltype(rows.seal(rows.base() + count), void()) {
rows.seal(rows.base() + count);
}

template <class Rows>
void retire_test_rows(Rows& rows, std::size_t count, long) {
rows.erase(rows.begin(), rows.begin() + static_cast<std::ptrdiff_t>(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<Trade>& rows, int filter) {
if (filter == 0) {
int n = static_cast<int>(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<double>(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<double>(c)) : 0.0;
}

void compare_prefix(const PercentPrefixProbe& probe, const std::vector<Trade>& 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<Trade>& 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<double>::quiet_NaN();
const double inf = std::numeric_limits<double>::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<Trade> 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<double>(static_cast<int>(bits % 7) - 3);
const int exponent = static_cast<int>((bits >> 8) % 201) - 100;
const double pct = std::ldexp(static_cast<double>(static_cast<int>(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<Trade> rows;
for (int index = 0; index != 96; ++index) {
const auto row = percent_row(static_cast<double>(index % 3 - 1),
static_cast<double>(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;
}
Loading