From b8931d1a31d294632476603caa10c3bf5bca8290 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sun, 20 Sep 2026 13:14:59 +0800 Subject: [PATCH] Adapter re-lowering: the kernel owns equity recording; the Pine host only selects the policy and marks its cadence (R5 lane R1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NativeReportPolicy gains KernelRecordedAtHostMarks: the kernel performs the recording (update_equity_extremes + record_equity_point, the L2 code path) when the host marks a report point through the consumer's mark_script_report_point(engine, ts). The Pine host selects that policy in project() and marks its two cadence points (after the pending flushes on a published bar, and on a suppressed probe tail) instead of calling the engine's protected recording members itself; src/source/** no longer references update_equity_extremes / record_equity_point. The adapter's cadence (per published source slot, inside the callback, before the after-close fills), its bar.timestamp label and its own range-end producer and per-bar broker hash stay as they are — the kernel's KernelRecorded cadence cannot reproduce them, so the recording moved and the cadence did not. KernelRecordedAtHostMarks folds nothing into the continuation hash so every adapter per-bar hash is unchanged. Byte-identity: 312 corpus engine_trades.csv identical to main 06ef483 with the regenerated corpus, validation report identical, 24 cases' full equity curves (5,138,348 points, 105 metric fields) identical. tests/test_adapter_report_relower.cpp (421 checks: ordinary, COOF and suppressed-tail cadences against values harvested from the 06ef483 library). ci_verify release PASS (501). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CSmYixVAp4NqWpBZxkrLPT --- docs/pages/native-engine.md | 33 +- include/pineforge/native_run_spec.hpp | 8 + .../pineforge/source/pine_strategy_host.hpp | 4 + src/native_execution_consumer.cpp | 36 +- src/native_execution_consumer.hpp | 6 + src/native_run_spec.cpp | 1 + src/source/pine_adapter.cpp | 8 + src/source/pine_strategy_host.cpp | 21 +- tests/CMakeLists.txt | 1 + tests/test_adapter_report_relower.cpp | 530 ++++++++++++++++++ 10 files changed, 630 insertions(+), 18 deletions(-) create mode 100644 tests/test_adapter_report_relower.cpp diff --git a/docs/pages/native-engine.md b/docs/pages/native-engine.md index 45e14a98..3a64c450 100644 --- a/docs/pages/native-engine.md +++ b/docs/pages/native-engine.md @@ -136,9 +136,9 @@ Always set, with documented defaults in the header: Cash kinds are account currency per unit or per execution. - `close_execution`: `NextEligiblePoint` (default) or `AfterCalculation` - `allowed_open_directions`: `None`, `Long`, `Short`, `Both` (default) -- `report_policy`: `HostRecorded` (default) or `KernelRecorded`; - `report_open_position_at_end`: `false` (default). See *Reporting for native - hosts* below. +- `report_policy`: `HostRecorded` (default), `KernelRecorded` or + `KernelRecordedAtHostMarks`; `report_open_position_at_end`: `false` + (default). See *Reporting for native hosts* below. - `calculation`: `BarClose` (default), `BarCloseAndFills` or `EveryModeledPoint`; `max_recalculations_per_point`: `8` (default, any value including 0 is legal); `open_bar_view`: `Complete` (default) or @@ -731,6 +731,21 @@ curve is identical with and without an intrabar path. The result is one point per script bar, a finite drawdown/run-up walk, and metrics computed over a real series. +`NativeReportPolicy::KernelRecordedAtHostMarks` records the very same series +at the points the host marks, for a host whose report cadence is not one point +per calculation. The consumer never records on its own initiative under it: +the host calls the kernel's report mark from inside its own callback, naming +the label the point carries, and the kernel performs the extremes fold and the +curve append. The Pine adapter is that host — its report has one point per +published *source* slot, which is not the same series of instants: a +`calc_on_order_fills` re-entry marks the slot it opened at the fill and the +bar's ordinary close calculation then marks nothing, and a suppressed probe +tail advances source history, and marks, without running generated code at +all. The point also has to land inside the callback, before the adapter takes +that bar's broker-state hash and before its range-end rows re-mark the curve's +last point. Under this policy the kernel owns what a report point is and the +host owns only when. + `report_open_position_at_end` (`KernelRecorded` only) adds the rows a close of the still-open position at the last bar's close would record — one per physical lot, through the same row builder every full close uses, with @@ -742,11 +757,13 @@ exactly as the run left them; the rows appear in `fill_report` and in `report_trade_count()` / `get_report_trade()`, never in `closed_trade_count()` / `closed_trade()`. -Both fields are opt-in and fold into the continuation hash only once -`report_policy` is non-default, so a spec that does not ask for kernel -recording keeps the continuation identity it had before these fields existed. -Recording does move the broker-state hash, because the equity extremes it -folds are durable engine state. +Both fields are opt-in and fold into the continuation hash only under +`KernelRecorded` — the one policy under which the consumer decides, on its +own, to act between two points of a run. `HostRecorded` and +`KernelRecordedAtHostMarks` fold nothing, so a spec that does not hand the +kernel its cadence keeps the continuation identity it had before these fields +existed. Recording does move the broker-state hash, because the equity +extremes it folds are durable engine state. Per-trade reads: `closed_trade_count()` / `closed_trade(i)` return the closed rows this run booked; `report_trade_count()` / `get_report_trade(i)` span those diff --git a/include/pineforge/native_run_spec.hpp b/include/pineforge/native_run_spec.hpp index f3cb6dfb..e5bb20cc 100644 --- a/include/pineforge/native_run_spec.hpp +++ b/include/pineforge/native_run_spec.hpp @@ -42,9 +42,17 @@ enum class NativeAbortReporting : std::uint32_t { // calculation, so a bare host gets a truthful curve, finite drawdown/run-up // metrics and a report whose walk is not degenerate. Recording is reporting: // it books no cash and places no order. +// KernelRecordedAtHostMarks records the very same series, at the points the +// host marks: a host whose report cadence is not one point per calculation — +// a source adapter that re-enters its script on a fill, or publishes a bar +// its script never calculates — keeps that cadence and still stops owning +// what a report point is. The consumer never records on its own initiative +// under it, so it leaves the continuation identity exactly where +// HostRecorded leaves it (see hash_spec in native_execution_consumer.cpp). enum class NativeReportPolicy : std::uint32_t { HostRecorded = 0, KernelRecorded = 1, + KernelRecordedAtHostMarks = 2, }; enum class NativeOpenDirections : std::uint32_t { diff --git a/include/pineforge/source/pine_strategy_host.hpp b/include/pineforge/source/pine_strategy_host.hpp index 941c3ba8..b12ec09a 100644 --- a/include/pineforge/source/pine_strategy_host.hpp +++ b/include/pineforge/source/pine_strategy_host.hpp @@ -334,6 +334,10 @@ class PineStrategyHost : public NativeStrategyHost, public BrokerStateHashProvid void scheduler_feed_deferred_aux_security(int chart_index); void scheduler_finish_security_sequence(); void scheduler_record_range_end(const Bar&); + // One report point per published source slot. The kernel records it + // (NativeReportPolicy::KernelRecordedAtHostMarks); this host owns only + // the Pine cadence that says where the points fall. + void scheduler_mark_report_point(std::int64_t script_bar_ts); void scheduler_record_broker_hash(); void capture_script_continuation_hash(); void scheduler_update_session_state( diff --git a/src/native_execution_consumer.cpp b/src/native_execution_consumer.cpp index 29c697bd..eaab24eb 100644 --- a/src/native_execution_consumer.cpp +++ b/src/native_execution_consumer.cpp @@ -124,11 +124,14 @@ void hash_spec(Fnv& f, const NativeRunSpec& spec) noexcept { f.b(spec.initial_margin_fraction.has_value()); if (spec.initial_margin_fraction) f.d(*spec.initial_margin_fraction); f.u(native_intrabar_path_digest(spec.intrabar)); - // Report recording is opt-in, so it folds only when it is on: a spec that - // leaves the kernel out of its report keeps the continuation identity it - // had before the policy existed (same conditional shape as the precommit - // digest below). - if (spec.report_policy != NativeReportPolicy::HostRecorded) { + // Report recording folds only when the consumer records of its own + // initiative: a spec that leaves the kernel out of its report keeps the + // continuation identity it had before the policy existed (same + // conditional shape as the precommit digest below). A host-marked report + // (KernelRecordedAtHostMarks) is the same case: the consumer decides + // nothing and does nothing between two points of the run, so it folds + // nothing, exactly as HostRecorded folds nothing. + if (spec.report_policy == NativeReportPolicy::KernelRecorded) { f.u(static_cast(spec.report_policy)); f.b(spec.report_open_position_at_end); } @@ -5806,8 +5809,29 @@ void NativeExecutionConsumer::record_script_report_point( BacktestEngine& engine, int64_t script_open_ms) const { const auto* spec = spec_ptr(); if (!spec || spec->report_policy != NativeReportPolicy::KernelRecorded) return; + record_report_point(engine, script_open_ms); +} + +// The same fold and the same append, at an instant only the host can name +// (NativeReportPolicy::KernelRecordedAtHostMarks). A host whose report +// cadence is its own — a source adapter that publishes a script bar to its +// generated code, re-enters that script on a fill, or advances its source +// history over a bar the script never calculates — marks the point inside +// its own callback, where its broker-state hash and its report rows already +// read the curve. Recording is still the kernel's: the host names when, not +// what. Inert under every other policy, so a host that records its own +// report, or one that asked for the per-calculation cadence, is unaffected. +void NativeExecutionConsumer::mark_script_report_point( + BacktestEngine& engine, int64_t script_bar_ts) const { + const auto* spec = spec_ptr(); + if (!spec || spec->report_policy != NativeReportPolicy::KernelRecordedAtHostMarks) return; + record_report_point(engine, script_bar_ts); +} + +void NativeExecutionConsumer::record_report_point( + BacktestEngine& engine, int64_t report_ts) const { engine.update_equity_extremes(); - engine.record_equity_point(script_open_ms); + engine.record_equity_point(report_ts); } // A position still open when the feed ends is reported as the rows a close at diff --git a/src/native_execution_consumer.hpp b/src/native_execution_consumer.hpp index 12dc3ce5..6475eb1e 100644 --- a/src/native_execution_consumer.hpp +++ b/src/native_execution_consumer.hpp @@ -96,6 +96,11 @@ class NativeExecutionConsumer final : public IExecutionConsumer { } uint64_t high_water() const noexcept { return consumed_high_water_; } void reject_inherited_on_bar(BacktestEngine& engine); + // Report truth at the host's own cadence + // (NativeReportPolicy::KernelRecordedAtHostMarks): the host marks the + // script bar it has just published and the kernel records the point. + // Reporting only, and inert under every other policy. + void mark_script_report_point(BacktestEngine& engine, int64_t script_bar_ts) const; std::optional series_bar(std::size_t subscription) const; // L5 calculation timing readbacks. The partial bar is the lookahead-free // bar so far at the current cursor; the two counters are observation of @@ -335,6 +340,7 @@ class NativeExecutionConsumer final : public IExecutionConsumer { // are reporting-only: they mark equity and synthesize report rows, and // never book cash, place an order or move the broker book. void record_script_report_point(BacktestEngine& engine, int64_t script_open_ms) const; + void record_report_point(BacktestEngine& engine, int64_t report_ts) const; void record_open_position_report_rows(BacktestEngine& engine) const; void match_point(BacktestEngine& engine, const NativeDriverPoint& point); void match_discrete(BacktestEngine& engine, const NativeDriverPoint& point); diff --git a/src/native_run_spec.cpp b/src/native_run_spec.cpp index 18778f19..0bc0483a 100644 --- a/src/native_run_spec.cpp +++ b/src/native_run_spec.cpp @@ -94,6 +94,7 @@ bool valid_report_policy(NativeReportPolicy policy) noexcept { switch (policy) { case NativeReportPolicy::HostRecorded: case NativeReportPolicy::KernelRecorded: + case NativeReportPolicy::KernelRecordedAtHostMarks: return true; } return false; diff --git a/src/source/pine_adapter.cpp b/src/source/pine_adapter.cpp index 873ceb9d..1f6631dd 100644 --- a/src/source/pine_adapter.cpp +++ b/src/source/pine_adapter.cpp @@ -1465,6 +1465,14 @@ NativeRunSpec PineExecutionAdapter::project(const PineStrategyConfig& config, // Pine's request_abort surface reports a cooperative cancellation through // status, not through last_error(). Native-only hosts retain Error. spec.abort_reporting = NativeAbortReporting::Quiet; + // RP3/RP9 (L2): the kernel records the equity curve and its extremes; the + // source host keeps only the Pine cadence that marks where the points + // fall (pine_strategy_host.cpp scheduler_mark_report_point). TradingView's + // range-end report — which re-marks the curve's last point and re-folds + // every extreme from it (pine_strategy_host.cpp scheduler_record_range_end) + // — is report shape, not a mark-to-market row, so the kernel's own + // range-end producer stays off and report_open_position_at_end with it. + spec.report_policy = NativeReportPolicy::KernelRecordedAtHostMarks; // Contract P6: Pine pyramiding is adapter command policy. A resting source // entry must not consume a generic physical-lot cap before it fills, so // the projected native spec deliberately leaves max_open_lots unbounded. diff --git a/src/source/pine_strategy_host.cpp b/src/source/pine_strategy_host.cpp index 5a88b7f0..c3892639 100644 --- a/src/source/pine_strategy_host.cpp +++ b/src/source/pine_strategy_host.cpp @@ -1579,8 +1579,7 @@ void source::PineStrategyHost::scheduler_publish_source_bar( adapter_.flush_pending_entries(); adapter_.flush_pending_bracket_legs(); if (advance_source_index) { - update_equity_extremes(); - record_equity_point(bar.timestamp); + scheduler_mark_report_point(bar.timestamp); prev_bar_timestamp_ = bar.timestamp; } } @@ -1597,11 +1596,25 @@ void source::PineStrategyHost::scheduler_publish_suppressed_tail(const Bar& bar) chart_day_partition_.empty() ? nullptr : &chart_day_partition_); adapter_.begin_source_evaluation(); adapter_.observe_terminal_receipts(); - update_equity_extremes(); - record_equity_point(bar.timestamp); + scheduler_mark_report_point(bar.timestamp); prev_bar_timestamp_ = bar.timestamp; } +// The Pine report series has one point per SOURCE slot this host published, +// which is not the kernel's per-calculation cadence: a calc_on_order_fills +// re-entry marks the slot it opened at the fill, the ordinary close +// calculation then marks nothing, and the probe's suppressed tail marks a +// slot generated code never calculated. The point also has to land inside +// this callback, before scheduler_record_broker_hash() folds the extremes it +// just moved and before scheduler_record_range_end() re-marks the curve's +// last point. So the cadence stays here and the recording does not: the +// kernel owns what a report point is — the extremes fold and the curve +// append in engine.hpp — reached through the run spec's report policy. +void source::PineStrategyHost::scheduler_mark_report_point(std::int64_t script_bar_ts) { + as_native_consumer(execution_consumer()) + .mark_script_report_point(*this, script_bar_ts); +} + void source::PineStrategyHost::scheduler_record_broker_hash() { if (!broker_state_hash_recording_) return; last_script_continuation_hash_ = execution_consumer().continuation_hash(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4bbd857b..f40d21fa 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -76,6 +76,7 @@ set(TEST_SOURCES test_native_execution_terms test_native_report_truth test_native_margin_model + test_adapter_report_relower test_native_queued_reverse_to test_native_precommit_view test_native_fx_activation diff --git a/tests/test_adapter_report_relower.cpp b/tests/test_adapter_report_relower.cpp new file mode 100644 index 00000000..f7576e22 --- /dev/null +++ b/tests/test_adapter_report_relower.cpp @@ -0,0 +1,530 @@ +// R5 lane R1: the Pine adapter's report, re-lowered onto the kernel. +// +// The adapter no longer records the equity curve itself. Its run spec selects +// NativeReportPolicy::KernelRecordedAtHostMarks and the source host only marks +// the script bar it has just published; the consumer performs the extremes +// fold and the curve append (native_execution_consumer.cpp +// mark_script_report_point). Nothing about the report may move, so these +// witnesses are data: every equity point, every reported row (the range-end +// rows included) and the per-bar broker-state sequence of three adapter runs, +// observed on engine main 06ef483 — where PineStrategyHost still called +// update_equity_extremes() / record_equity_point() itself — and pinned here. +// +// Provenance of kOrdinary / kCalcOnOrderFills / kSuppressedTail below: this +// same TU, compiled unchanged against the 06ef483 library in a scratch +// worktree with -DPINEFORGE_R1_HARVEST (which prints the observed values as +// the initializers below instead of checking them), against that tree's +// libpineforge.a. Rebuild them the same way; never edit one by hand to make a +// run pass. +// +// The three runs are chosen for the three cadences the kernel's own +// per-calculation cadence does not have: +// * ordinary — one mark per published source slot, and a +// position open at the end, so TradingView's +// range-end rows (which re-mark the curve's last +// point) are part of the pin; +// * calc-on-order-fills — a resting stop fills intrabar, the fill re-enters +// the script and opens that bar's source slot, so +// the bar's point is marked at the fill and its +// ordinary close calculation marks nothing; +// * suppressed-tail — the probe's forming tail advances source history +// without invoking generated code at all, and still +// carries a point. +#include "native_current_fixture.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace r4_test; + +namespace { + +// A per-bar broker-state hash folds the consumer's continuation, and that +// folds this machine's resolved zoneinfo root and zone file paths +// (native_execution_consumer.cpp hash_tz_identity), so a raw sequence is not +// a portable pin. The probe below overrides the projection to fold one fixed +// execution hash instead: what remains is the generic broker state and the +// source extension — position, lots, realized sums, the equity extremes the +// recording order moves, and the closed rows — all of which are portable. +constexpr std::uint64_t kProbeExecutionHash = 0x5eed1234abcd0001ull; + +constexpr int kBars = 24; + +// A triangular wave in exact binary fractions: every price, every equity +// point and every hashed double is reproducible to the bit on any platform. +double price_at(int index) { + const int phase = index % 8; + const int triangle = phase < 4 ? phase : 8 - phase; + return 100.0 + 0.5 * triangle + 0.25 * (index % 3); +} + +std::vector feed() { + std::vector bars; + bars.reserve(static_cast(kBars)); + for (int i = 0; i < kBars; ++i) { + const double p = price_at(i); + bars.push_back({p, p + 0.5, p - 0.5, p + 0.25, 1.0, + T + static_cast(i) * 60000}); + } + return bars; +} + +// A generated-strategy-shaped source host: the body issues Pine commands and +// nothing else. Pyramiding 2 leaves the feed ending on two open lots, so the +// range-end producer emits one row per lot. +class ReportProbe final : public pineforge::source::PineStrategyHost { +public: + ReportProbe(bool calc_on_order_fills, bool suppress_tail) { + pineforge::source::PineStrategyConfig config; + config.initial_capital = 10000.0; + config.default_qty_type = static_cast(QtyType::FIXED); + config.default_qty_value = 2.0; + config.pyramiding = 2; + config.slippage = 0; + config.commission_type = static_cast(CommissionType::CASH_PER_ORDER); + config.commission_value = 2.0; + config.calc_on_order_fills = calc_on_order_fills; + configure_pine_strategy(config); + set_broker_state_hash_recording(true); + if (suppress_tail) set_probe_suppress_tail_logic(true); + } + + std::uint64_t broker_state_hash_projection() const override { + return broker_state_hash_from_execution_hash(kProbeExecutionHash); + } + + // How many times generated code ran. A suppressed tail advances source + // history without a body; a calc_on_order_fills re-entry runs one extra. + int source_bodies() const noexcept { return seen_; } + + void on_source_bar(const Bar& bar) override { + const int index = seen_++; + switch (index) { + case 2: + strategy_entry("A", true); // market: fills at the next open + break; + case 8: + strategy_close("A"); // one closed row + break; + case 12: + // A stop entry rests for several bars and fills intrabar. Under + // calc_on_order_fills that fill re-enters the script. + strategy_entry("B", true, na(), bar.high); + break; + case 18: + strategy_entry("C", true); // second lot, never closed + break; + default: + break; + } + } + +private: + int seen_ = 0; +}; + +// ── Observed / pinned shape ───────────────────────────────────────────── +struct Point { + std::int64_t time_ms; + double equity; + double open_profit; +}; + +struct Row { + std::int64_t entry_time; + std::int64_t exit_time; + double entry_price; + double exit_price; + double qty; + double pnl; + double commission; + double max_runup; + double max_drawdown; + int is_long; + int open_at_end; +}; + +struct Observed { + int source_bodies = 0; + std::vector curve; + std::vector hashes; + std::vector rows; + double max_drawdown = 0.0; + double max_runup = 0.0; + std::int64_t script_bars = 0; +}; + +struct Expected { + const char* name; + bool calc_on_order_fills; + bool suppress_tail; + const Point* curve; + std::size_t curve_len; + const std::uint64_t* hashes; + std::size_t hashes_len; + const Row* rows; + std::size_t rows_len; + double max_drawdown; + double max_runup; + std::int64_t script_bars; + int source_bodies; +}; + +Observed observe(bool calc_on_order_fills, bool suppress_tail) { + Observed out; + ReportProbe probe(calc_on_order_fills, suppress_tail); + const auto bars = feed(); + probe.run(bars.data(), static_cast(bars.size())); + CHECK(probe.last_error().empty()); + + out.source_bodies = probe.source_bodies(); + ReportC c{}; + probe.fill_report(&c); + for (std::int64_t i = 0; i < c.equity_curve_len; ++i) + out.curve.push_back({c.equity_curve[i].time_ms, c.equity_curve[i].equity, + c.equity_curve[i].open_profit}); + for (std::int64_t i = 0; i < c.broker_state_hash_len; ++i) + out.hashes.push_back(c.broker_state_hash[i]); + for (int i = 0; i < c.trades_len; ++i) { + const TradeC& t = c.trades[i]; + out.rows.push_back({t.entry_time, t.exit_time, t.entry_price, t.exit_price, + t.qty, t.pnl, t.commission, t.max_runup, t.max_drawdown, + t.is_long, t.open_at_end}); + } + out.max_drawdown = c.metrics.equity.max_equity_drawdown; + out.max_runup = c.metrics.equity.max_equity_runup; + out.script_bars = c.script_bars_processed; + BacktestEngine::free_report(&c); + return out; +} + +#ifdef PINEFORGE_R1_HARVEST +void emit(const char* symbol, const Observed& got) { + std::printf("constexpr Point k%s_curve[] = {\n", symbol); + for (const auto& p : got.curve) + std::printf(" {%lldLL, %.17g, %.17g},\n", + static_cast(p.time_ms), p.equity, p.open_profit); + std::printf("};\nconstexpr std::uint64_t k%s_hashes[] = {\n", symbol); + for (const auto h : got.hashes) + std::printf(" %lluull,\n", static_cast(h)); + std::printf("};\nconstexpr Row k%s_rows[] = {\n", symbol); + for (const auto& r : got.rows) + std::printf(" {%lldLL, %lldLL, %.17g, %.17g, %.17g, %.17g, %.17g, %.17g, %.17g, %d, %d},\n", + static_cast(r.entry_time), static_cast(r.exit_time), + r.entry_price, r.exit_price, r.qty, r.pnl, r.commission, + r.max_runup, r.max_drawdown, r.is_long, r.open_at_end); + std::printf("};\nconstexpr double k%s_max_drawdown = %.17g;\n", symbol, got.max_drawdown); + std::printf("constexpr double k%s_max_runup = %.17g;\n", symbol, got.max_runup); + std::printf("constexpr std::int64_t k%s_script_bars = %lldLL;\n", symbol, + static_cast(got.script_bars)); + std::printf("constexpr int k%s_source_bodies = %d;\n\n", symbol, got.source_bodies); +} + +#else +// ── Pinned data (see the provenance note at the top of this file) ─────── +// R1_PINNED_DATA_BEGIN +constexpr Point kOrdinary_curve[] = { + {1736121600000LL, 10000, 0}, + {1736121660000LL, 10000, 0}, + {1736121720000LL, 10000, 0}, + {1736121780000LL, 10000.5, 0.5}, + {1736121840000LL, 10002, 2}, + {1736121900000LL, 10001.5, 1.5}, + {1736121960000LL, 9999.5, -0.5}, + {1736122020000LL, 9999, -1}, + {1736122080000LL, 9998.5, -1.5}, + {1736122140000LL, 9994, 0}, + {1736122200000LL, 9994, 0}, + {1736122260000LL, 9994, 0}, + {1736122320000LL, 9994, 0}, + {1736122380000LL, 9994, 0}, + {1736122440000LL, 9994, 0}, + {1736122500000LL, 9994, 0}, + {1736122560000LL, 9994, 0}, + {1736122620000LL, 9994, 0}, + {1736122680000LL, 9994, 0}, + {1736122740000LL, 9994.5, 0.5}, + {1736122800000LL, 9996.5, 2.5}, + {1736122860000LL, 9992.5, -1.5}, + {1736122920000LL, 9991.5, -2.5}, + {1736122980000LL, 9982.5, 0}, +}; +constexpr std::uint64_t kOrdinary_hashes[] = { + 10745653892319225316ull, + 654166710196610614ull, + 2456842012581239437ull, + 15211423282159375352ull, + 5475310997625827819ull, + 1868084557302283868ull, + 12844521837661749768ull, + 7794193322322650710ull, + 12005484980189581804ull, + 16532170233224365583ull, + 5151322051032056509ull, + 9028249989010525320ull, + 7130971006667017821ull, + 5305426004486357042ull, + 14008651012356626639ull, + 10199982954144049202ull, + 7189662286452796648ull, + 4105438955193269090ull, + 13009018520791279746ull, + 10845816464256725954ull, + 5902817238111141386ull, + 2391671287236302291ull, + 9161843951562868602ull, + 3755107560978917339ull, +}; +constexpr Row kOrdinary_rows[] = { + {1736121780000LL, 1736122140000LL, 101.5, 100.5, 2, -6, 4, 0.5, 5, 1, 0}, + {1736122740000LL, 1736122980000LL, 101.75, 101.25, 2, -5, 4, 0.5, 4.5, 1, 1}, + {1736122800000LL, 1736122980000LL, 102.5, 101.25, 2, -6.5, 4, 0, 6, 1, 1}, +}; +constexpr double kOrdinary_max_drawdown = 19.5; +constexpr double kOrdinary_max_runup = 2.5; +constexpr std::int64_t kOrdinary_script_bars = 24LL; +constexpr int kOrdinary_source_bodies = 24; + +constexpr Point kCalcOnOrderFills_curve[] = { + {1736121600000LL, 10000, 0}, + {1736121660000LL, 10000, 0}, + {1736121720000LL, 10000, 0}, + {1736121780000LL, 10000.5, 0.5}, + {1736121840000LL, 10002, 2}, + {1736121900000LL, 10001.5, 1.5}, + {1736121960000LL, 9999.5, -0.5}, + {1736122020000LL, 9999, -1}, + {1736122080000LL, 9994, 0}, + {1736122140000LL, 9994, 0}, + {1736122200000LL, 9994, 0}, + {1736122260000LL, 9994.5, 0.5}, + {1736122320000LL, 9994.5, 0.5}, + {1736122380000LL, 9994, 0}, + {1736122440000LL, 9993.5, -0.5}, + {1736122500000LL, 9991.5, -2.5}, + {1736122560000LL, 9991.5, -2.5}, + {1736122620000LL, 9994.5, 0.5}, + {1736122680000LL, 9994.5, 0.5}, + {1736122740000LL, 9997.5, 3.5}, + {1736122800000LL, 10000.5, 6.5}, + {1736122860000LL, 9996.5, 2.5}, + {1736122920000LL, 9995.5, 1.5}, + {1736122980000LL, 9986.5, 0}, +}; +constexpr std::uint64_t kCalcOnOrderFills_hashes[] = { + 640618610822204803ull, + 11299712471057776633ull, + 5155270016184150984ull, + 8708804677021702526ull, + 9840199655078061859ull, + 7312097262099546258ull, + 16254649344269088860ull, + 6329817723120390666ull, + 4782141077789020563ull, + 7408772948043947908ull, + 12186365297265992155ull, + 5886016517255327168ull, + 10115846320175267942ull, + 16904162620310996284ull, + 3237947470625142033ull, + 7678585157942911246ull, + 8940605812285372839ull, + 15683275513850733144ull, + 2414910478950235662ull, + 1350715391845202621ull, + 2881021938410853139ull, + 16798183439249777557ull, + 8378184499229182396ull, + 3391536455516364329ull, +}; +constexpr Row kCalcOnOrderFills_rows[] = { + {1736121780000LL, 1736122080000LL, 101.5, 100.5, 2, -6, 4, 0.5, 4.5, 1, 0}, + {1736122260000LL, 1736122980000LL, 102, 101.25, 2, -5.5, 4, 0, 6.5, 1, 1}, + {1736122560000LL, 1736122980000LL, 100.25, 101.25, 2, -2, 4, 3.5, 3, 1, 1}, +}; +constexpr double kCalcOnOrderFills_max_drawdown = 15.5; +constexpr double kCalcOnOrderFills_max_runup = 9; +constexpr std::int64_t kCalcOnOrderFills_script_bars = 24LL; +constexpr int kCalcOnOrderFills_source_bodies = 28; + +constexpr Point kSuppressedTail_curve[] = { + {1736121600000LL, 10000, 0}, + {1736121660000LL, 10000, 0}, + {1736121720000LL, 10000, 0}, + {1736121780000LL, 10000.5, 0.5}, + {1736121840000LL, 10002, 2}, + {1736121900000LL, 10001.5, 1.5}, + {1736121960000LL, 9999.5, -0.5}, + {1736122020000LL, 9999, -1}, + {1736122080000LL, 9998.5, -1.5}, + {1736122140000LL, 9994, 0}, + {1736122200000LL, 9994, 0}, + {1736122260000LL, 9994, 0}, + {1736122320000LL, 9994, 0}, + {1736122380000LL, 9994, 0}, + {1736122440000LL, 9994, 0}, + {1736122500000LL, 9994, 0}, + {1736122560000LL, 9994, 0}, + {1736122620000LL, 9994, 0}, + {1736122680000LL, 9994, 0}, + {1736122740000LL, 9994.5, 0.5}, + {1736122800000LL, 9996.5, 2.5}, + {1736122860000LL, 9992.5, -1.5}, + {1736122920000LL, 9991.5, -2.5}, + {1736122980000LL, 9982.5, 0}, +}; +constexpr std::uint64_t kSuppressedTail_hashes[] = { + 10745653892319225316ull, + 654166710196610614ull, + 2456842012581239437ull, + 15211423282159375352ull, + 5475310997625827819ull, + 1868084557302283868ull, + 12844521837661749768ull, + 7794193322322650710ull, + 12005484980189581804ull, + 16532170233224365583ull, + 5151322051032056509ull, + 9028249989010525320ull, + 7130971006667017821ull, + 5305426004486357042ull, + 14008651012356626639ull, + 10199982954144049202ull, + 7189662286452796648ull, + 4105438955193269090ull, + 13009018520791279746ull, + 10845816464256725954ull, + 5902817238111141386ull, + 2391671287236302291ull, + 9161843951562868602ull, + 17509086294883612323ull, +}; +constexpr Row kSuppressedTail_rows[] = { + {1736121780000LL, 1736122140000LL, 101.5, 100.5, 2, -6, 4, 0.5, 5, 1, 0}, + {1736122740000LL, 1736122980000LL, 101.75, 101.25, 2, -5, 4, 0.5, 4.5, 1, 1}, + {1736122800000LL, 1736122980000LL, 102.5, 101.25, 2, -6.5, 4, 0, 6, 1, 1}, +}; +constexpr double kSuppressedTail_max_drawdown = 19.5; +constexpr double kSuppressedTail_max_runup = 2.5; +constexpr std::int64_t kSuppressedTail_script_bars = 24LL; +constexpr int kSuppressedTail_source_bodies = 23; + +constexpr Expected kScenarios[] = { + {"ordinary", false, false, + kOrdinary_curve, std::size(kOrdinary_curve), + kOrdinary_hashes, std::size(kOrdinary_hashes), + kOrdinary_rows, std::size(kOrdinary_rows), + kOrdinary_max_drawdown, kOrdinary_max_runup, + kOrdinary_script_bars, kOrdinary_source_bodies}, + {"calc-on-order-fills", true, false, + kCalcOnOrderFills_curve, std::size(kCalcOnOrderFills_curve), + kCalcOnOrderFills_hashes, std::size(kCalcOnOrderFills_hashes), + kCalcOnOrderFills_rows, std::size(kCalcOnOrderFills_rows), + kCalcOnOrderFills_max_drawdown, kCalcOnOrderFills_max_runup, + kCalcOnOrderFills_script_bars, kCalcOnOrderFills_source_bodies}, + {"suppressed-tail", false, true, + kSuppressedTail_curve, std::size(kSuppressedTail_curve), + kSuppressedTail_hashes, std::size(kSuppressedTail_hashes), + kSuppressedTail_rows, std::size(kSuppressedTail_rows), + kSuppressedTail_max_drawdown, kSuppressedTail_max_runup, + kSuppressedTail_script_bars, kSuppressedTail_source_bodies}, +}; +// R1_PINNED_DATA_END + +void compare(const Expected& pinned) { + scenario = pinned.name; + const Observed got = observe(pinned.calc_on_order_fills, pinned.suppress_tail); + + // One report point per script bar the source published — the suppressed + // tail, which runs no generated code at all, included. + REQUIRE(got.curve.size() == pinned.curve_len); + CHECK(got.curve.size() == static_cast(kBars)); + CHECK(got.script_bars == pinned.script_bars); + // Generated code ran on its own cadence, not the curve's: one body fewer + // than points for the suppressed tail, one more under calc_on_order_fills. + CHECK(got.source_bodies == pinned.source_bodies); + for (std::size_t i = 0; i < got.curve.size(); ++i) { + CHECK(got.curve[i].time_ms == pinned.curve[i].time_ms); + CHECK(got.curve[i].equity == pinned.curve[i].equity); + CHECK(got.curve[i].open_profit == pinned.curve[i].open_profit); + } + + // The metrics walk the recorded curve, so it moves with it. + CHECK(got.max_drawdown == pinned.max_drawdown); + CHECK(got.max_runup == pinned.max_runup); + + // Reported rows, the range-end rows among them: a range-end row is dated + // on the curve's last point and re-marks it, so it is a direct witness of + // where the last point was recorded. + REQUIRE(got.rows.size() == pinned.rows_len); + for (std::size_t i = 0; i < got.rows.size(); ++i) { + const Row& left = got.rows[i]; + const Row& right = pinned.rows[i]; + CHECK(left.entry_time == right.entry_time); + CHECK(left.exit_time == right.exit_time); + CHECK(left.entry_price == right.entry_price); + CHECK(left.exit_price == right.exit_price); + CHECK(left.qty == right.qty); + CHECK(left.pnl == right.pnl); + CHECK(left.commission == right.commission); + CHECK(left.max_runup == right.max_runup); + CHECK(left.max_drawdown == right.max_drawdown); + CHECK(left.is_long == right.is_long); + CHECK(left.open_at_end == right.open_at_end); + } + + // One hash per script bar, each taken after that bar's own extremes fold + // and its own point: the sequence pins the instant the mark happens, not + // only its value. + REQUIRE(got.hashes.size() == pinned.hashes_len); + CHECK(got.hashes.size() == got.curve.size()); + for (std::size_t i = 0; i < got.hashes.size(); ++i) + CHECK(got.hashes[i] == pinned.hashes[i]); +} + +// The adapter states the policy and nothing else: the recording itself is the +// kernel's, so the run spec it projects must carry the kernel report policy. +void adapter_states_the_policy() { + scenario = "policy"; + ReportProbe probe(false, false); + const auto bars = feed(); + probe.run(bars.data(), static_cast(bars.size())); + CHECK(probe.last_error().empty()); + const auto state = probe.native_state(); + REQUIRE(state.spec != nullptr); + CHECK(state.spec->report_policy == NativeReportPolicy::KernelRecordedAtHostMarks); + // TradingView's range-end report is report shape, not a mark-to-market + // row, so the kernel's own range-end producer stays off (§1.8 RP5). + CHECK(state.spec->report_open_position_at_end == false); +} + +#endif // PINEFORGE_R1_HARVEST + +} // namespace + +int main() { +#ifdef PINEFORGE_R1_HARVEST + std::printf("// harvested: paste between R1_PINNED_DATA_BEGIN/END\n"); + emit("Ordinary", observe(false, false)); + emit("CalcOnOrderFills", observe(true, false)); + emit("SuppressedTail", observe(false, true)); + return 0; +#else + try { + for (const Expected& pinned : kScenarios) compare(pinned); + adapter_states_the_policy(); + } catch (const Stop&) { + // A REQUIRE already recorded the failure. + } + std::printf("adapter report re-lowering: %d checks, %d failures\n", checks, failures); + return failures == 0 ? 0 : 1; +#endif +}