diff --git a/docs/design/native-feature-parity.md b/docs/design/native-feature-parity.md index dd71f6399..58e1f46d6 100644 --- a/docs/design/native-feature-parity.md +++ b/docs/design/native-feature-parity.md @@ -327,7 +327,7 @@ Union of F §2.vi (9), O §2.vi (5 + 1) and S §2.6 (7), deduplicated. S had no | 5 | "`engine_aux_security.cpp` includes `source/pine_strategy_host.hpp`" (ADR L129-132) | False at `73817c1`: its includes are `engine_internal.hpp`, `ta.hpp` and std headers (*fresh* re-read; the run-failure codes added `run_failure.hpp`, a kernel header). No kernel TU includes a source header | `sha256:560a7f68b243d921382a69ed0390c398f28370ac066b84f30b5f96cbb385fb60` engine_aux_security.cpp:6-14 | F | | 6 | "Kernel TUs reference adapter types (`source::StrategyOverrides` …)" (ADR L129-131) | True only as a forward-declared opaque pointer the kernel never dereferences; the independence checker whitelists exactly that symbol | at `73817c1`; overtaken by `3f4fff5b` (§2.ii a), after which the rich begin carries `overrides_opaque` native_host.hpp:758 and the independence checker whitelists nothing | F O | | 7 | `engine.hpp` hosts "the ten-significant-digit money rule, KI-62, the `strategy.close` batching + entry-id ledger, a TradingView margin-call toggle" (ADR L131-134) | Two of four survive as code. Money rule: a comment block; the arithmetic is in the adapter. KI-62: orphaned comments + one live, hashed lot flag. Close batching + id ledger: gone from the kernel (the ledger is in the adapter; the old names survive only in comments). Live: `margin_call_enabled_`, the KI-62 flag and — unmentioned by the ADR — string-sentinel decoding of adapter comments | the money-rule block was lines 927-961 of `engine.hpp` and R5 lane E6 deleted it (lines 73-180 were and are live code), the arithmetic `source_money_round` pine_adapter.cpp:558 and its twin `tv_money_round` pine_policy_support.hpp:9-15; `skip_entry_bar_high` engine.hpp:153, `skip_entry_bar_low` engine_state_hash.cpp:74, `declare_opened_lot_entry_bar_mask` engine_path_resolve.cpp:95; the ledger `close_logical_units_` pine_adapter.hpp:2580; `closed_trade_close_cause` engine_trade_accessors.cpp:158. **Overtaken:** the margin-call toggle and the string decoding are both gone — see ADR-0001, "Current state vs. target" | F O | -| 8 | "No kernel-level C API. `c_abi.cpp` exports only version/descriptor symbols" (ADR L136-138) | 80 runtime `PF_API` symbols: native configure, the 12-symbol stream family, pending-order views, broker-state hash, security feeds, FX curve, reports. The 80 compiled-strategy runtime symbols in `c_abi.cpp` are distinct from the native C host API in `native_c_host.cpp`: the latter exposes submit, replace, cancel and a callback table to a C host. | `sha256:733786ed81c9137223b98e1887e28d01afdb3c64ba5e8ca52ce5161f2caf3f75` c_abi.cpp:325-1013, `sha256:6327f1829019fdd172cd76a96e3d8c9c0af1610ad0dc9878640dda0ca21a686b` check_c_abi_runtime.py:20-81, c_abi.cpp:946, `sha256:5ff4c65a56410fe29b21cd68bc21ac87bdb34b77344e4f75f90efa8887fe48e8` c_abi.cpp:664-788, `sha256:051fe087eb9d30e9960dc4f0ab3f7cc837531ebeaa71b5871a3f1cdc22fb37c2` c_abi.cpp:566-641, `sha256:f18d46ac323234f7e3082367d30840b704b8a7548f7ae95ee8cff8714f29fd50` c_abi.cpp:545-562, `sha256:6a9ef4e688f33dc10ea8297d9eaa8144ea7923ef6ee6f8fe9ff20f6af4e9acb3` c_abi.cpp:881-917, c_abi.cpp:1013 | F O S | +| 8 | "No kernel-level C API. `c_abi.cpp` exports only version/descriptor symbols" (ADR L136-138) | 80 runtime `PF_API` symbols: native configure, the 12-symbol stream family, pending-order views, broker-state hash, security feeds, FX curve, reports. The 80 compiled-strategy runtime symbols in `c_abi.cpp` are distinct from the native C host API in `native_c_host.cpp`: the latter exposes submit, replace, cancel and a callback table to a C host. | `sha256:a60fa3c03ba568fefb8995ec149560ca7aac3eadcd2a4485315470484edefca4` c_abi.cpp:325-1013, `sha256:6327f1829019fdd172cd76a96e3d8c9c0af1610ad0dc9878640dda0ca21a686b` check_c_abi_runtime.py:20-81, c_abi.cpp:946, `sha256:5ff4c65a56410fe29b21cd68bc21ac87bdb34b77344e4f75f90efa8887fe48e8` c_abi.cpp:664-788, `sha256:051fe087eb9d30e9960dc4f0ab3f7cc837531ebeaa71b5871a3f1cdc22fb37c2` c_abi.cpp:566-641, `sha256:f18d46ac323234f7e3082367d30840b704b8a7548f7ae95ee8cff8714f29fd50` c_abi.cpp:545-562, `sha256:6a9ef4e688f33dc10ea8297d9eaa8144ea7923ef6ee6f8fe9ff20f6af4e9acb3` c_abi.cpp:881-917, c_abi.cpp:1013 | F O S | | 9 | "No examples. `PINEFORGE_BUILD_EXAMPLES` is 'none yet'" (ADR L139) | The option is inert, but two Pine-free `NativeStrategyHost` examples exist, are compiled by the independence checker and tested through the live runner, only under `PINEFORGE_BUILD_LIVE_RUNNER`. Wording: "no top-level examples target" | at `73817c1` (overtaken; ADR-0001, "Current state vs. target") | F O S | | 10 | Follow-ups (1)-(4) (ADR L168-170) | Add native reporting, sizing, margin, HTF — and, per R5, calc-timing, price grid and order ergonomics. Without them "usable without Pine" is a build-system claim, not a feature claim | §3.5 | F | | 11 | Build layout | One `pineforge` library always appends the source list; a kernel-only target does not exist yet; `compat/pine/market_admission.cpp` sits in the main list, a boundary bug. **Overtaken:** `PINEFORGE_KERNEL_SOURCES` CMakeLists.txt:115 and `add_library` CMakeLists.txt:161 build `PineForge::kernel`, and the compat unit is in `PINEFORGE_SOURCE_LAYER_SOURCES` CMakeLists.txt:91 | `add_library` CMakeLists.txt:161 | S (F, O agree in §2.i) | @@ -414,7 +414,7 @@ closure markers and `scripts/check_design_inventory.py`). | L3 | `Sized{side, basis, time, grid_policy, reserve_percent_fee}` as the 6th `OrderIntent`; `ScopeFraction{fraction, claim}` in `ReductionSize` → A.2 | at L3 the adapter emitted neither. Since R2 it lowers a default cash / percent opening frozen at the signal onto `Sized{CashValue, AtAcceptance, SignalOnTick, ExplicitUnits}`, since N11 its placement-time quantity is the kernel's `native_sized_units`, and since R5 lane F7 a typed quantity (`qty_type` cash / percent) converts through the same query and a staged FX series keeps `Sized` wherever the acceptance coordinate converts at the source's rate (`tests/test_adapter_sizing_relower.cpp` cases 1-4, 8, 11, 12). It still never emits `ScopeFraction` (case 5) | percent-of-equity probe, zero fee, no grid: exact; with grid / fee: differences explained by SZ10 only (F). Differential: `Sized` vs a host-resolved `HostSized` on the same facts → identical `ExecutionAppliedEvent` (O). Gap, reversal, fee-bearing first lot; two 50 % siblings, partial close, pending parent, replacement (S) | | L4 | `NativeMarginModel`, `native_liquidation_price()`, `resolve_margin_call_units`, `on_native_margin_call`, `RequestDefinition::origin`, `MarginCallEvent` → A.3 | at L4 the adapter left `margin` unset. Since R5 `project()` declares a maintenance-only model (per-side `initial_*` 0, `maintenance_*` = `margin_long` / `margin_short`, `PathAdverseExtremeMark`, TradingView's equity basis) and answers its three hooks with TradingView's scheduling, money and slice (MG6, MG7), while every opening is still `AdmitWithHostMargin` (MG4) | first a plain maintenance breach in both hosts (S); then a margin-call probe reproduced with `ShortfallMultiple 4`: same bar, same side; quantity deltas itemized against MG15 (F, O) | | L5 | `NativeCalculationTrigger`, `on_native_recalculate(…, reason, cause)`, `on_native_sub_bar`, `current_partial_bar()`, `NativeOpenBarView` → A.4 | the adapter keeps `BarClose` + its own cascade; `born_on_remaining_path` (native_execution_consumer.cpp:6551) and `drain_applied_notifications` (native_execution_consumer.cpp:7234) are frozen; accessors are inert | COOF probe re-expressed natively: same fills where the TV waypoint rule is inactive (F); a host that flips on its own fill produces the adapter's cascade in shape (O); callback reason, cursor, newborn eligibility and event ordinals compared (S) | -| L6 | `NativeTimeframeSubscription`, `on_native_timeframe_bar`, `native_series_bar` → A.5, §2.iv | `subscriptions` empty for the adapter; registration-gated pump; `engine_security.cpp` and pine_strategy_host.cpp:1091-1175 untouched; corpus diff before / after (O: highest parity risk of any lane) | HTF-filter probe on a 24x7 symbol: exact (F); `"D"` over a 15m feed equals a `TimeframeAggregator` baseline bar for bar (O); installed-feed and no-feed cases (S) | +| L6 | `NativeTimeframeSubscription`, `on_native_timeframe_bar`, `native_series_bar` → A.5, §2.iv | `subscriptions` empty for the adapter; registration-gated pump; `engine_security.cpp` and the host's `scheduler_prepare_security_sequence` (pine_strategy_host.cpp:1636) untouched; corpus diff before / after (O: highest parity risk of any lane) | HTF-filter probe on a 24x7 symbol: exact (F); `"D"` over a 15m feed equals a `TimeframeAggregator` baseline bar for bar (O); installed-feed and no-feed cases (S) | | L7 | `native_working_requests()`, `cancel_all()`, `cancel_where(label)`, `TriggerAnchor`, `TrailTicks`, zero offset, `ReplaceOptions`; toolkit `submit_bracket`, `OrderBook` → A.6 | new kinds unused by the adapter; anchor defaults to `Absolute`; the adapter's `tick * 0.5` sentinel keeps working | bracket + trailing probe through the builder: exact where TR5 / FP7 are inactive (on-grid levels) (F); the zero-offset trail test asserts the run no longer fails (O); parent-limit + stop sibling, parent rejection, replacement, parent-cycle revival (S) | | L8 | `NativePriceGrid { None, QuantizeFills, QuantizeFillsAndTriggers }`, `NativeGridRounding` → A.7 | `None` for the adapter, permanently — **native-only by ruling, audit lane P6: §3.6.2** | a host with `QuantizeFills` books on-grid prices; a second test pins that `QuantizeFillsAndTriggers` still differs from TV's half-tick threshold (FP5) (O) | | L9 | `NativeRiskLimits`, `NativeRiskEvent`, `MatchRejectReason::RiskLimit` → A.8 | `risk` unset for the adapter; its ledger (`SourceDayLedger` pine_adapter.hpp:1423) untouched — **retained after measurement, audit lane N12; native-only by ruling, audit lane P6: §3.6.1** | risk probes: same halt bar (F); each limit + the day-boundary basis (O); breach, forced close, cancellation, next-day reset (S) | @@ -1750,7 +1750,7 @@ Callback chronology contract (the normative part of the lane; F P4.1 + O G8): 1. At one point: match and settle → `on_native_applied` per applied event, FIFO (rule already at `on_native_applied` native-engine.md:1729-1730) → with `BarCloseAndFills`, one recalculation at the fill cursor (reason `OrderFill`), driven from the existing notification drain (native_execution_consumer.cpp:7978-7990) under its re-entrancy guard, bounded by `max_recalculations_per_point`. S would run it *before* the drain; not chosen, the drain order is frozen (E4). 2. Requests born in any of these callbacks follow the existing birth rule: eligible on the unconsumed rest of the bar (`born_on_remaining_path` native_execution_consumer.cpp:6551). `born_on_remaining_path` (native_execution_consumer.cpp:6551) is not touched — the adapter's COOF deferral (`pending_coof_requests_`, element type `PendingCoofRequest` pine_adapter.hpp:1961-1991) is calibrated against it. -3. `EveryModeledPoint`: a calculation at each magnifier sample / observed print, in batch as well as stream (every delivery loop asks the per-point guard native_execution_consumer.hpp:1283-1290, whose body is `recalculate_at_modeled_point` native_execution_consumer.cpp:7824). `on_native_tick` stays the observation hook (S). +3. `EveryModeledPoint`: a calculation at each magnifier sample / observed print, in batch as well as stream (every delivery loop asks the per-point guard `recalculate_at_point` native_execution_consumer.hpp:1290-1295, whose body is `recalculate_at_modeled_point` native_execution_consumer.cpp:7824). `on_native_tick` stays the observation hook (S). 4. `current_partial_bar()` derives from the driver points already recorded (`record_driver` native_execution_consumer.cpp:3336); valid in bar-open, applied and tick callbacks. 5. The kernel never attempts language-state rollback; that stays Pine's (`snapshot / restore / commit_coof_script_state`, pine_scheduler_native.cpp:54-106) (O). @@ -1817,7 +1817,7 @@ NativeGridRounding grid_rounding = NativeGridRounding::HalfUp; ``` - `QuantizeFills`: the kernel rounds `resolved_price` onto `price_tick` **before** slippage (`HalfUp`) or in the direction that favours the resting order (`Directional`). `QuantizeFillsAndTriggers`: the matcher additionally compares the quantized path against the raw level. -- TUs: `native_run_spec.cpp` (validation), `native_execution_consumer.cpp` (resolve path native_execution_consumer.cpp:4211-4233, `execute_current` path native_execution_consumer.cpp:7552), `native_matching.hpp` (threshold form). The dead helpers were the reference arithmetic and are deleted (§2.ii e); the kernel's own step is `native_matching::apply_slippage` native_matching.hpp:259. +- TUs: `native_run_spec.cpp` (validation), `native_execution_consumer.cpp` (resolve path `native_matching::apply_slippage` native_execution_consumer.cpp:7191-7195, `execute_current` path native_execution_consumer.cpp:7552), `native_matching.hpp` (threshold form). The dead helpers were the reference arithmetic and are deleted (§2.ii e); the kernel's own step is `native_matching::apply_slippage` native_matching.hpp:259. ### A.8 L9 — risk limits (F P3 + O G7 + S P5) @@ -1836,7 +1836,7 @@ std::optional risk; // NativeRunSpec - Breach: `MatchRejectReason::RiskLimit` for openings (O; F: `RiskHalted`), an optional kernel-issued flatten through A.3's kernel-originated request (`origin = KernelRisk`, F), and a `NativeRiskEvent` in `native_events` (native_host.hpp:1335) (O) or a callback `on_native_risk_event` (S — a virtual, so only inside an epoch batch). The day key uses the existing calendar (`session_day_ordinal` native_calendar.hpp:416), not a chart clock. - S's `directions` / `max_units` / `max_position_units` are existing spec fields (MG1-MG3), not duplicated. -- Interim (F): a C++ host can build all four today from `native_marked_equity` + `on_native_applied`, which is what the adapter does (pine_adapter.cpp:17321-17329); a toolkit helper is an acceptable stand-in until L9. +- Interim (F): a C++ host can build all four today from `native_marked_equity` + `on_native_applied`, which is what the adapter does (`update_risk_state` pine_adapter.cpp:17492-17500; `on_applied` pine_adapter.cpp:24394); a toolkit helper is an acceptable stand-in until L9. - TUs: `native_run_spec.{hpp,cpp}`, `native_execution_consumer.cpp` (`admit_opening_inspect` native_execution_consumer.cpp:3716 and the per-point walk), `native_order.hpp`, the state hash. ### A.9 Single-source proposals not adopted diff --git a/docs/pages/pine-to-native.md b/docs/pages/pine-to-native.md index c3e3969e6..448a04d72 100644 --- a/docs/pages/pine-to-native.md +++ b/docs/pages/pine-to-native.md @@ -126,7 +126,7 @@ rather than defaulted. | `currency` | `currency` native_run_spec.hpp:639 | `currency` pineforge.h:499 | `hello_kernel.cpp` | With `ticker` native_run_spec.hpp:636, `tickerid` native_run_spec.hpp:637, `type` native_run_spec.hpp:638, `basecurrency` native_run_spec.hpp:640, `description` native_run_spec.hpp:641 and `volumetype` native_run_spec.hpp:642 this is Pine's whole `syminfo` block, named rather than inherited from a chart. | | `commission_type` | `NativeFeeKind` native_run_spec.hpp:19 | `fee_kind` pineforge.h:502 | `native_open_lots_strategy.cpp` | `Percent`, `CashPerUnit`, `CashPerExecution` — the same three TradingView offers. | | `commission_value` | `fee_value` native_run_spec.hpp:658 | `fee_value` pineforge.h:502 | `native_open_lots_strategy.cpp` | Quoted per execution by `quote_execution_commissions` engine_execution.cpp:1690. A cash-per-execution ticket is split by units over every slice of that execution. | -| `slippage` | `slippage_ticks` native_run_spec.hpp:652 | `slippage_ticks` pineforge.h:502 | `native_price_grid_strategy.cpp` | Raw `ticks * price_tick`, applied once native_execution_consumer.cpp:7120-7124. TradingView's round-then-slip order is adapter-only. | +| `slippage` | `slippage_ticks` native_run_spec.hpp:652 | `slippage_ticks` pineforge.h:502 | `native_price_grid_strategy.cpp` | Raw `ticks * price_tick`, applied once by `native_matching::apply_slippage` native_execution_consumer.cpp:7191-7195. TradingView's round-then-slip order is adapter-only. | | `process_orders_on_close` | `NativeCloseExecution` native_run_spec.hpp:33 | `close_execution` pineforge.h:503 | `native_open_lots_strategy.cpp` | `AfterCalculation` emits the post-calculation close point native_execution_consumer.cpp:8637-8638, so a request submitted in bar *k*'s calculation fills at bar *k*'s own close. | | `pyramiding` | `max_open_lots` native_run_spec.hpp:664 | `max_open_lots` pineforge.h:505 | `native_open_lots_strategy.cpp` | Caps surviving+new physical lots (`resulting_lot_count` native_execution_consumer.cpp:3744). The Pine adapter keeps its own per-cycle *entry count*, ruled **adapter-policy** in ADR-0001's table row `max_open_lots`; measured against TradingView it is not TradingView's rule, and this cap is the closer one (14 of 15 tape scenarios against 13, the private test `test_pyramiding_count_differential`). Lowered onto the cap, four corpus probes move away from TradingView, for want of its first-eligible-point rule, so the adapter keeps its count. | | `default_qty_type` | `Sized` native_order.hpp:164 with `CashValue` native_order.hpp:91 or `EquityFraction` native_order.hpp:97 | `PF_NATIVE_INTENT_SIZED` native_c_api.h:348 with `size_basis` native_c_api.h:2147 | `native_sized_report_strategy.cpp` | The size is a property of the *request*, not of the run: a native host may size one order by cash and the next by equity fraction. `Transact` native_order.hpp:41 is the fixed-units form. Pine itself has no per-call `qty_type` — TradingView's compiler refuses one — so a script's `strategy.entry(qty = ...)` is always units; the source layer's typed per-call entry, which only generated or C++ callers reach, converts its money exactly as the declared default does. | diff --git a/src/c_abi.cpp b/src/c_abi.cpp index 5ca7ea09c..418b345c4 100644 --- a/src/c_abi.cpp +++ b/src/c_abi.cpp @@ -336,8 +336,8 @@ extern "C" { /* trade_index is a REPORT row index: pf_report_t::trades is trades_ followed * by range_end_trades_ (fill_trades_section, engine_report.cpp), so the - * accessor indexes that same row space (report_trade_count / - * get_report_trade). + * accessor uses closed_trade_close_cause's -1 as the resident-window check; + * get_report_trade keeps global indices, including after a sealed prefix. * * round-4b F3: this bounded the index by trade_count() == trades_.size(), * so every range-end row (the position still open after the final bar, @@ -353,7 +353,7 @@ PF_API uint64_t strategy_closed_trade_entry_incarnation( pf_strategy_t s, int trade_index) { if (!s) return 0; const auto* engine = static_cast(s); - if (trade_index < 0 || trade_index >= engine->report_trade_count()) return 0; + if (engine->closed_trade_close_cause(trade_index) < 0) return 0; return engine->get_report_trade(trade_index).entry_incarnation; } @@ -370,25 +370,25 @@ PF_API uint64_t strategy_closed_trade_entry_incarnation( * Returned pointers are valid until the next run() (or stream call) on this * handle, like strategy_get_last_error -- the runtime's own std::string * storage backing them is untouched until then. NULL on a NULL handle or an - * out-of-range trade_index. */ + * out-of-range/streamed row, checked by the close-cause query before indexing. */ PF_API const char* strategy_closed_trade_entry_id(pf_strategy_t s, int trade_index) { if (!s) return nullptr; const auto* engine = static_cast(s); - if (trade_index < 0 || trade_index >= engine->report_trade_count()) return nullptr; + if (engine->closed_trade_close_cause(trade_index) < 0) return nullptr; return engine->get_report_trade(trade_index).entry_id.c_str(); } PF_API const char* strategy_closed_trade_exit_id(pf_strategy_t s, int trade_index) { if (!s) return nullptr; const auto* engine = static_cast(s); - if (trade_index < 0 || trade_index >= engine->report_trade_count()) return nullptr; + if (engine->closed_trade_close_cause(trade_index) < 0) return nullptr; return engine->get_report_trade(trade_index).exit_id.c_str(); } PF_API const char* strategy_closed_trade_exit_comment(pf_strategy_t s, int trade_index) { if (!s) return nullptr; const auto* engine = static_cast(s); - if (trade_index < 0 || trade_index >= engine->report_trade_count()) return nullptr; + if (engine->closed_trade_close_cause(trade_index) < 0) return nullptr; return engine->get_report_trade(trade_index).exit_comment.c_str(); } diff --git a/src/engine_trade_accessors.cpp b/src/engine_trade_accessors.cpp index f173621b6..95511c2dd 100644 --- a/src/engine_trade_accessors.cpp +++ b/src/engine_trade_accessors.cpp @@ -157,6 +157,7 @@ double BacktestEngine::open_trade_max_runup_percent(int idx) const { // reversal-driven close -- SCRIPT. int BacktestEngine::closed_trade_close_cause(int i) const { if (i < 0 || i >= report_trade_count()) return -1; + if (static_cast(i) < trades_.base()) return -1; const Trade& t = get_report_trade(i); if (t.open_at_end) return static_cast(execution::CloseCause::RangeEnd); if (t.close_cause != execution::CloseCause::Unspecified) diff --git a/tests/cr150.d/core-cr150-l8d.cmake b/tests/cr150.d/core-cr150-l8d.cmake new file mode 100644 index 000000000..5026da5ef --- /dev/null +++ b/tests/cr150.d/core-cr150-l8d.cmake @@ -0,0 +1,2 @@ +list(APPEND TEST_SOURCES test_trade_accessors_l8d) +pineforge_add_unit_test(test_trade_accessors_l8d) diff --git a/tests/test_trade_accessors_l8d.cpp b/tests/test_trade_accessors_l8d.cpp new file mode 100644 index 000000000..eaf4b690c --- /dev/null +++ b/tests/test_trade_accessors_l8d.cpp @@ -0,0 +1,217 @@ +// CR-150 L8d: sealed report rows keep their global indices but have no C view. +// Include the C declarations first: engine.hpp suppresses per-strategy decls. +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +using pineforge::BacktestEngine; +using pineforge::Trade; +using pineforge::execution::CloseCause; + +int checks = 0; +int failures = 0; + +#define CHECK(condition) do { \ + ++checks; \ + if (!(condition)) { \ + ++failures; \ + std::fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #condition); \ + } \ +} while (0) + +template +void no_throw(const char* accessor, int index, F action) { + try { + action(); + } catch (const std::exception& error) { + ++failures; + std::fprintf(stderr, "FAIL %s(%d) threw: %s\n", accessor, index, error.what()); + } catch (...) { + ++failures; + std::fprintf(stderr, "FAIL %s(%d) threw a non-standard exception\n", accessor, index); + } +} + +class Probe final : public BacktestEngine { +public: + Probe() : BacktestEngine(NativeConsumerBindTag{}) {} + void on_bar(const pineforge::Bar&) override {} + void append(const Trade& trade) { trades_.push_back(trade); } + void append_range_end(const Trade& trade) { range_end_trades_.push_back(trade); } + void seal(std::size_t end) { trades_.seal(end); } + std::size_t base() const { return trades_.base(); } + pf_strategy_t handle() { return static_cast(this); } +}; + +Trade row(int n) { + Trade trade{}; + trade.entry_incarnation = 100 + n; + trade.entry_id = "entry-" + std::to_string(n) + std::string(40, 'e'); + trade.exit_id = "exit-" + std::to_string(n) + std::string(40, 'x'); + trade.exit_comment = "comment-" + std::to_string(n) + std::string(40, 'c'); + return trade; +} + +void absent(pf_strategy_t handle, int index) { + // Catch each call separately: the red test names every escaping accessor. + no_throw("strategy_closed_trade_entry_incarnation", index, [&] { + CHECK(strategy_closed_trade_entry_incarnation(handle, index) == 0); + }); + no_throw("strategy_closed_trade_entry_id", index, [&] { + CHECK(strategy_closed_trade_entry_id(handle, index) == nullptr); + }); + no_throw("strategy_closed_trade_exit_id", index, [&] { + CHECK(strategy_closed_trade_exit_id(handle, index) == nullptr); + }); + no_throw("strategy_closed_trade_exit_comment", index, [&] { + CHECK(strategy_closed_trade_exit_comment(handle, index) == nullptr); + }); + no_throw("strategy_closed_trade_close_cause", index, [&] { + CHECK(strategy_closed_trade_close_cause(handle, index) == -1); + }); + if (handle) { + no_throw("BacktestEngine::closed_trade_close_cause", index, [&] { + CHECK(static_cast(handle)->closed_trade_close_cause(index) == -1); + }); + } +} + +void text(const char* actual, const std::string& expected) { + CHECK(actual != nullptr); + if (actual) CHECK(std::strcmp(actual, expected.c_str()) == 0); +} + +void present(Probe& probe, int index, const Trade& expected, int cause) { + const auto handle = probe.handle(); + CHECK(strategy_closed_trade_entry_incarnation(handle, index) == expected.entry_incarnation); + const char* entry = strategy_closed_trade_entry_id(handle, index); + const char* exit = strategy_closed_trade_exit_id(handle, index); + const char* comment = strategy_closed_trade_exit_comment(handle, index); + text(entry, expected.entry_id); + text(exit, expected.exit_id); + text(comment, expected.exit_comment); + const Trade& resident = probe.get_report_trade(index); + CHECK(entry == resident.entry_id.c_str()); + CHECK(exit == resident.exit_id.c_str()); + CHECK(comment == resident.exit_comment.c_str()); + CHECK(strategy_closed_trade_close_cause(handle, index) == cause); + CHECK(probe.closed_trade_close_cause(index) == cause); +} + +void internal_read_still_fails(const Probe& probe, int index) { + try { + (void)probe.get_report_trade(index); + CHECK(false); + } catch (const std::exception& error) { + CHECK(dynamic_cast(&error) != nullptr); + CHECK(pineforge::classify_run_failure(error).code == + pineforge::RunFailureCode::engine_invariant); + } +} + +void resident_window() { + Probe probe; + std::array closed{{row(0), row(1), row(2), row(3)}}; + // A valid legacy row can have incarnation 0; it must still expose its ids. + closed[0].entry_incarnation = 0; + closed[1].exit_from_bracket = true; + closed[2].close_cause = CloseCause::Liquidation; + closed[2].exit_from_bracket = true; + closed[3].close_cause = CloseCause::RiskLimit; + const std::array causes{{1, 2, 3, 4}}; + for (const Trade& trade : closed) probe.append(trade); + + std::array range_end{{row(10), row(11)}}; + range_end[0].open_at_end = range_end[1].open_at_end = true; + range_end[0].close_cause = CloseCause::Liquidation; + range_end[0].exit_from_bracket = true; + range_end[1].entry_id.clear(); + range_end[1].exit_id.clear(); + range_end[1].exit_comment.clear(); + for (const Trade& trade : range_end) probe.append_range_end(trade); + + CHECK(probe.base() == 0); + CHECK(probe.trade_count() == 4); + CHECK(probe.report_trade_count() == 6); + for (int i = 0; i < 4; ++i) present(probe, i, closed[i], causes[i]); + present(probe, 4, range_end[0], 6); + present(probe, 5, range_end[1], 6); + absent(probe.handle(), -1); + absent(probe.handle(), std::numeric_limits::min()); + absent(probe.handle(), 6); + absent(probe.handle(), std::numeric_limits::max()); + + // L8c owns publication; its storage effect is the real ledger seal here. + probe.seal(2); + CHECK(probe.base() == 2); + CHECK(probe.trade_count() == 4); + CHECK(probe.report_trade_count() == 6); + absent(probe.handle(), 0); + absent(probe.handle(), 1); // base-1 must not reach TradeLedger::at(). + internal_read_still_fails(probe, 1); + present(probe, 2, closed[2], causes[2]); // base is a global index. + present(probe, 3, closed[3], causes[3]); + present(probe, 4, range_end[0], 6); + present(probe, 5, range_end[1], 6); + absent(probe.handle(), 6); + + probe.seal(4); + CHECK(probe.base() == 4); + CHECK(probe.trade_count() == 4); + CHECK(probe.report_trade_count() == 6); + for (int i = 0; i < 4; ++i) absent(probe.handle(), i); + internal_read_still_fails(probe, 3); + present(probe, 4, range_end[0], 6); + present(probe, 5, range_end[1], 6); + + // A new resident row keeps its global index; range-end rows follow it. + Trade next = row(20); + next.close_cause = CloseCause::FillCap; + probe.append(next); + CHECK(probe.base() == 4); + CHECK(probe.trade_count() == 5); + CHECK(probe.report_trade_count() == 7); + absent(probe.handle(), 3); + present(probe, 4, next, 5); + present(probe, 5, range_end[0], 6); + present(probe, 6, range_end[1], 6); + absent(probe.handle(), 7); +} + +void empty_windows() { + absent(nullptr, 0); + Probe probe; + absent(probe.handle(), 0); + probe.append(row(0)); + probe.seal(1); + CHECK(probe.base() == 1); + CHECK(probe.report_trade_count() == 1); + absent(probe.handle(), 0); // Fully retired, with no range-end suffix. + absent(probe.handle(), 1); + + Probe range_only; + Trade end = row(1); + end.open_at_end = true; + range_only.append_range_end(end); + CHECK(range_only.trade_count() == 0); + present(range_only, 0, end, 6); + absent(range_only.handle(), 1); +} + +} // namespace + +int main() { + no_throw("resident_window", 0, resident_window); + no_throw("empty_windows", 0, empty_windows); + std::printf("test_trade_accessors_l8d: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +}