diff --git a/README.md b/README.md index 974b947c5..25bed2282 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The separate PineForge compiler, [`pineforge-codegen`](https://github.com/pinefo - **Open runtime.** The engine and native live runner are Apache-2.0. The separately distributed [PineForge compiler](https://github.com/pineforge-4pass/pineforge-codegen-oss/blob/main/LICENSE) is source-available under the PineForge Source License 1.2 from its release 1.3.0 (release 1.2.0 keeps the PineForge Source License 1.1, and releases up to 1.1.0 keep the PolyForm Noncommercial terms they shipped with); personal trading is free, and commercial use requires a separate license; the capital in an account a proprietary-trading firm or funded-trader program provides or allocates, including a challenge, evaluation or simulated account, is investment capital. Public reference strategies, benchmarks and validation tooling are available in their respective repositories; the community-script test set is not redistributed. - **Fast.** In-process, no interpreter: median **36× faster than PyneCore** on the 200 strategies both engines time (100 corpus probes and 100 held-out private strategies), measured at engine `35db01c8` on 2026-09-29 on an AWS c7a.8xlarge with PyneCore timed as a subprocess, its interpreter start-up included (PineForge runs a median 807k bars/s per strategy over its 201 slots with the bar magnifier on; [method](benchmarks/results/speed.md)). Parameter sweeps re-run a loaded `.so` with new inputs — no recompile, no fork. - **Deterministic to the bit.** Two runs with the same inputs produce identical trade lists. Same on Linux and macOS. -- **Yours to embed.** 145 `PF_API` declarations across five installed headers — `pineforge.h`'s 94 (74 of the 80 runtime exports, the other six being declared in `execution_observer.h` and `selected_window.h`, three each, and up to 20 per-strategy functions a generated module defines), `native_c_api.h`'s 43 native-host declarations and `selected_window_plan.h`'s two planner declarations — an ABI that is append-only within a major version from 1.0 on. Call it from C, Python, Rust, Go, Node, Julia — or let an AI agent drive it over MCP. +- **Yours to embed.** 148 `PF_API` declarations across six installed headers — `pineforge.h`'s 94 (74 of the 83 runtime exports, the other nine being declared in `execution_observer.h`, `selected_window.h` and `report_sink.h`, three each, and up to 20 per-strategy functions a generated module defines), `native_c_api.h`'s 43 native-host declarations and `selected_window_plan.h`'s two planner declarations — an ABI that is append-only within a major version from 1.0 on. Call it from C, Python, Rust, Go, Node, Julia — or let an AI agent drive it over MCP. Recorded outputs and their additional API declarations described here are part of **1.5.0**. @@ -519,11 +519,11 @@ is in [CHANGELOG.md](CHANGELOG.md). The counts below include recorded outputs and describe the released 1.5.0 API. -A newly generated strategy `.so` exposes 96 compiled-strategy `PF_API` declarations -(80 runtime implementations plus sixteen generated exports) plus 43 native-host -declarations: 139 `PF_API` exports in total; `use_bar_magnifier = true` adds +A newly generated strategy `.so` exposes 99 compiled-strategy `PF_API` declarations +(83 runtime implementations plus sixteen generated exports) plus 43 native-host +declarations: 142 `PF_API` exports in total; `use_bar_magnifier = true` adds `strategy_declares_bar_magnifier` and a module that records [outputs](docs/outputs.md) its -three outputs exports, bringing the inventories to at most 100 and 143. Older modules lack the six opt-in +three outputs exports, bringing the inventories to at most 103 and 146. Older modules lack the six opt-in [checked-settings exports](docs/checked-settings.md). Older modules can also lack the two opt-in [execution-capability exports](docs/strategy-capabilities.md). In an optimized build `nm -gU` also shows libc++'s diff --git a/docker/run_failure_codes.json b/docker/run_failure_codes.json index 6b2917352..ee7f5bec4 100644 --- a/docker/run_failure_codes.json +++ b/docker/run_failure_codes.json @@ -931,6 +931,18 @@ "retryable": false, "since": "1.5.0" }, + "report_sink_failed": { + "args": {}, + "class": "resource", + "description": "The engine could not create or write the report sink. The run has no completed sink or valid trailer.", + "english": [ + "report sink open failed", + "report sink write failed", + "report sink write made no progress" + ], + "retryable": false, + "since": "1.7.0" + }, "request_symbol_invalid": { "args": {}, "class": "strategy", @@ -1057,7 +1069,8 @@ "stream_monthly_input", "stream_fx_curve_input", "fx_series_calc_on_order_fills", - "fx_series_bar_magnifier" + "fx_series_bar_magnifier", + "stream_report_sink" ] } }, @@ -1073,7 +1086,8 @@ "request.security symbol feeds and recorded request series support historical runs only", "timestamped account-currency FX does not support calc_on_order_fills", "timestamped account-currency FX is not supported by streaming", - "timestamped account-currency FX is not supported with bar magnifier" + "timestamped account-currency FX is not supported with bar magnifier", + "report sink does not support streaming" ], "retryable": false, "since": "1.4.0" diff --git a/docker/run_failure_codes_diff.json b/docker/run_failure_codes_diff.json index f79d448ff..3c7e38a09 100644 --- a/docker/run_failure_codes_diff.json +++ b/docker/run_failure_codes_diff.json @@ -8,15 +8,99 @@ "to": { "version": null, "unreleased": true, - "catalogSha256": "7d61bad51f1f624a83fe90abb64646758c89f6e23c099e52167f9f1c11994d26" + "catalogSha256": "5c3bf3c9e0cc53c9ef83e64b70488bb9d15c7e286bbf4bba0d1242f6bb416074" }, "catalogSchema": { "before": "pineforge-run-failure-catalog/v1", "after": "pineforge-run-failure-catalog/v1" }, "metadataChanges": [], - "added": [], - "changed": [], + "added": [ + { + "code": "report_sink_failed", + "collection": "codes", + "entry": { + "args": {}, + "class": "resource", + "description": "The engine could not create or write the report sink. The run has no completed sink or valid trailer.", + "english": [ + "report sink open failed", + "report sink write failed", + "report sink write made no progress" + ], + "retryable": false, + "since": "1.7.0" + } + } + ], + "changed": [ + { + "code": "run_mode_unsupported", + "collection": "codes", + "fields": [ + { + "path": "args.feature.values", + "beforePresent": true, + "afterPresent": true, + "before": [ + "stream_native_security_feed", + "stream_symbol_feeds", + "stream_calc_on_order_fills", + "stream_probe_overrides", + "stream_account_currency_fx", + "stream_instrument_feeds", + "stream_monthly_input", + "stream_fx_curve_input", + "fx_series_calc_on_order_fills", + "fx_series_bar_magnifier" + ], + "after": [ + "stream_native_security_feed", + "stream_symbol_feeds", + "stream_calc_on_order_fills", + "stream_probe_overrides", + "stream_account_currency_fx", + "stream_instrument_feeds", + "stream_monthly_input", + "stream_fx_curve_input", + "fx_series_calc_on_order_fills", + "fx_series_bar_magnifier", + "stream_report_sink" + ] + }, + { + "path": "english", + "beforePresent": true, + "afterPresent": true, + "before": [ + "a declared native FX curve requires confirmed-bar stream input", + "native instrument feeds are not supported by streaming", + "native request.security feed supports historical runs only", + "native stream cannot use historical probe/tail overrides", + "native stream refuses monthly input", + "native stream requires close-only calculation; calc_on_order_fills is unsupported", + "request.security symbol feeds and recorded request series support historical runs only", + "timestamped account-currency FX does not support calc_on_order_fills", + "timestamped account-currency FX is not supported by streaming", + "timestamped account-currency FX is not supported with bar magnifier" + ], + "after": [ + "a declared native FX curve requires confirmed-bar stream input", + "native instrument feeds are not supported by streaming", + "native request.security feed supports historical runs only", + "native stream cannot use historical probe/tail overrides", + "native stream refuses monthly input", + "native stream requires close-only calculation; calc_on_order_fills is unsupported", + "request.security symbol feeds and recorded request series support historical runs only", + "timestamped account-currency FX does not support calc_on_order_fills", + "timestamped account-currency FX is not supported by streaming", + "timestamped account-currency FX is not supported with bar magnifier", + "report sink does not support streaming" + ] + } + ] + } + ], "removed": [], "deprecated": [] } diff --git a/docs/adr/0001-kernel-adapter-boundary.md b/docs/adr/0001-kernel-adapter-boundary.md index 2540bbb5a..06a1d4595 100644 --- a/docs/adr/0001-kernel-adapter-boundary.md +++ b/docs/adr/0001-kernel-adapter-boundary.md @@ -423,7 +423,7 @@ citations, 264 are in `src/source/` and its headers; 6 sit in kernel files (`float_band_eq` `ta_compare_band.hpp:38`), the TV-named fields of the frozen `pf_pending_order_v1_t` mirror, and the lot-flag pair above. - **The C API trades.** Two disjoint runtime inventories, both pinned. - `src/c_abi.cpp` implements the **80** codegen-facing runtime `PF_API` symbols that + `src/c_abi.cpp` implements the **83** codegen-facing runtime `PF_API` symbols that `scripts/check_c_abi_runtime.py` pins by name, and `src/native_c_host.cpp` implements the **43** additive symbols of ``. A C host creates a host from a callback table (`strategy_native_host_create_v1` `native_c_api.h:2682`), runs a batch diff --git a/docs/coverage.md b/docs/coverage.md index 0a54d1817..4c5029d1c 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -62,7 +62,7 @@ ## Public C ABI `` is the **single canonical consumer header**. It has -exactly 94 public `PF_API` declarations: 74 of the 80 runtime implementations (the other six are declared in `execution_observer.h` and `selected_window.h`) and twenty +exactly 94 public `PF_API` declarations: 74 of the 83 runtime implementations (the other nine are declared in `execution_observer.h`, `selected_window.h` and `report_sink.h`) and twenty per-strategy generated exports. Every compiled PineForge strategy `.so` exports that public set, except that `strategy_declares_bar_magnifier` is exported only by a script that declares `use_bar_magnifier = true` and the three [outputs](outputs.md) exports only by a module that records outputs; older modules lack the six @@ -132,7 +132,7 @@ single `.hpp`): | Module | Header | Source | Pine-facing role | | ------------------ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public C ABI | `pineforge.h` | `c_abi.cpp` (+ layout `static_assert`s) | 94 public `PF_API` declarations: 74 of the 80 runtime implementations plus twenty per-strategy generated exports. `strategy_configure_native_fx_curve_v1` stages the additive native FX curve. | +| Public C ABI | `pineforge.h` | `c_abi.cpp` (+ layout `static_assert`s) | 94 public `PF_API` declarations: 74 of the 83 runtime implementations plus twenty per-strategy generated exports. `strategy_configure_native_fx_curve_v1` stages the additive native FX curve. | | Engine | `engine.hpp` | `engine_run.cpp`, `engine_stream.cpp`, `engine_execution.cpp`, `engine_orders.cpp`, `engine_path_resolve.cpp`, `engine_trade_accessors.cpp`, `engine_security.cpp`, `engine_lower_tf.cpp`, `engine_report.cpp`, `native_execution_consumer.cpp` | One-shot and continuous lifecycle, native request matching/settlement, orders, reports, inputs / syminfo, magnifier, TF aggregation, and `request.security` plumbing. | | Engine internals | `engine_internal.hpp` | (private cross-TU header) | `pineforge::internal::*` types and helpers shared between engine `.cpp` partitions; not part of the public ABI. | | Technical analysis | `ta.hpp` | `ta_moving_averages.cpp`, `ta_oscillators.cpp`, `ta_volatility_trend.cpp`, `ta_extremes_volume.cpp`, `ta_misc.cpp` | Official `ta.*` functions and series variables backed by stateful runtime classes with `compute` / `recompute`, plus `pivot_point_levels(...)` free function. | diff --git a/docs/design/native-feature-parity.md b/docs/design/native-feature-parity.md index e16897b2c..4ac951fa8 100644 --- a/docs/design/native-feature-parity.md +++ b/docs/design/native-feature-parity.md @@ -175,7 +175,7 @@ Columns: **Feature** (adapter mechanism, cited) · **Native today** (`yes` / `pa | OT3 | Indicators, series, math, matrix, map, string utils | **yes** — `ta.hpp` includes only `na/series/window_sum` `window_sum` ta.hpp:2-8 | K | L11 (naming only) | F:J3 | (single-source row) TV-calibrated numerics → §2.ii b | | OT4 | Cooperative abort | **yes** — `request_abort` engine.hpp:2175, `NativeAbortReporting` native_run_spec.hpp:41-44 | K | — | F:J4 | (single-source) | | OT5 | C-level run configuration, stream, FX curve, contract probe | **yes** — `strategy_configure_native_v1` c_abi.cpp:980, FX curve `strategy_configure_native_fx_curve_v1` c_abi.cpp:1051, probe `strategy_execution_contract` c_abi.cpp:971, stream `strategy_stream_begin` c_abi.cpp:698 **Extended:** `pf_native_run_spec_ext_v1`'s policy tail now carries `intrabar`, `path_order`, `abort_reporting` and the slot-label / feed-tolerance policies (`PF_NATIVE_SPEC_EXT_INTRABAR`, `PF_NATIVE_SPEC_EXT_FEED_POLICY`), so nothing of `NativeRunSpec` but `identity` is unreachable from C (`tests/test_native_c_api.c`). | K | — | O:J4 | The v1 C spec `pf_native_run_spec_v1` pineforge.h:510 omitted `intrabar`, `path_order` and `abort_reporting`; they now travel in the extension's policy tail, `pf_native_abort_reporting_e` native_c_api.h:911 among them (F §2.iii) | -| OT6 | C-level order submission + strategy callbacks | **no** — none of the original runtime `PF_API` symbols; then listed as a refusal on native-engine.md **Closed:** `strategy_native_host_create_v1` takes a C callback table and `strategy_native_submit_v1` / `_replace_v1` / `_cancel_v1` / `_cancel_all_v1` / `_cancel_where_v1` / `_execute_current_v1` command the same kernel under the same legality rule; the 41 `strategy_native_*` declarations plus two `strategy_configure_native_ext_*` declarations are a second, disjoint inventory beside the 80 runtime exports (and eight generated per-strategy exports) (`tests/test_native_c_api.c`, `tests/test_native_c_api_frozen_header.cpp`). | K | L13 | F:J6 O:J5 | R5-7: post-v1. S covers it in its §2.3 without a row. | +| OT6 | C-level order submission + strategy callbacks | **no** — none of the original runtime `PF_API` symbols; then listed as a refusal on native-engine.md **Closed:** `strategy_native_host_create_v1` takes a C callback table and `strategy_native_submit_v1` / `_replace_v1` / `_cancel_v1` / `_cancel_all_v1` / `_execute_current_v1` command the same kernel under the same legality rule; the 41 `strategy_native_*` declarations plus two `strategy_configure_native_ext_*` declarations are a second, disjoint inventory beside the 83 runtime exports (and eight generated per-strategy exports) (`tests/test_native_c_api.c`, `tests/test_native_c_api_frozen_header.cpp`). | K | L13 | F:J6 O:J5 | R5-7: post-v1. S covers it in its §2.3 without a row. | | OT7 | *(quirk)* Pine language state: series, barstate / session flags, position-view freezing (`PineLanguageState` pine_language_state.hpp:12-68; `publish_series` pine_scheduler_native.cpp:354-389, `chart_index` pine_scheduler_native.cpp:610-743) | n/a | A | — | S:R1 | (single-source) **Closed (E25, lane E20's finding 2; E26, E25's findings 1 and 3):** `session.islastbar` reads one script bar ahead on every driving path: the chart timeframe's next retained bar, an aggregated chart's next bucket (a scheduler lookahead, deleted when the rule moved into the kernel), the calendar for the live tail's bar and for every bar of a stream, warmup included — a stream continues past its replay, so only a batch run's final bar reads "last" from having no successor. Since R4 slice C an aggregated or realtime bar read every in-session bar as last. **What TradingView does:** it ends a session at the session DAY — the flag belongs to the last chart bar whose successor belongs to another session day, in session or not, and `session.isfirstbar` is its dual, the bar after a session's last one. The ordinal is `internal::session_trading_day_index` timeframe.hpp:145, the exchange-timezone day rolling at the symbol's day stamp (09:30 ET on NYSE RTH, midnight on 24x7, 17:00 ET on a 1700-1700 forex session), not local midnight; the unmerged form, since a fused holiday daily bar still holds two intraday sessions. The three `lab tv` tapes (the private fixture set `session_islastbar`) give that rule bar for bar with no disagreement, and the engine now holds 255/255 NYSE:F last bars, 255/255 first bars and 370/370 ETH last bars on the chart-timeframe AND the aggregated path (it held 1, 0 and 1 before). Early closes come with it: the three NYSE:F half days flag 12:45 ET without a holiday calendar, because the next bar is the next session day's. **Generic since R5 lane F5:** the kernel computes that reading for every host — `NativeDecisionContext::in_session` market_driver.hpp:161 and the facts after it (`opens_session_day`, `closes_session_day`, `closes_session_day_open_ended`), in C the session bytes of `pf_native_decision_v1` native_c_api.h:1485 — from the run's own calendar and input (`present_session_day` native_execution_consumer.cpp:8273), so a bare host reproduces the tapes flag for flag (the private test `test_native_session_day_facts`, kernel-only). The adapter computes no regular-day rule of its own: `scheduler_update_session_state` pine_strategy_host.cpp:2235 selects the kernel's facts into the flags, and only widens the plain `session.isfirstbar` / `islastbar` pair to the chart's day on an extended-hours chart (R5 lane K-SESSION-WINDOWS F2: TradingView's `isfirstbar` / `islastbar` span the pre- and post-market bars there while the `_regular` pair keeps the regular day, `tests/test_session_extended_day_tape.cpp`), which are `PineStrategyHost` members now (`session_ismarket_` pine_strategy_host.hpp:1186; `BacktestEngine` holds no Pine session state), and only its live-probe tail reads the open-ended close at its batch's final bar (the private test `test_session_day_facts_adapter`). Selecting the kernel also ended a calc_on_order_fills defect of the adapter's lookahead, which read two bars ahead on a bar a fill recalculation had already published (a fill on a day's second-to-last bar flagged that bar last and the day's real last bar first). **Ruled, not open:** a bar with nothing held after it — a stream's bar, a live-tail bar — steps the calendar one width on, so it cannot see an early close the session string does not declare (ADR-0001, "Session-day facts at a bar with nothing held after it"). **Closed (F1):** a `calc_on_order_fills` recalculation reads the flags of the bar it runs on, not the previous bar's (the kernel presents the current bar's facts on that recalculation), and the close callback after it no longer looks one retained bar too far ahead (§3.8, AG3). | | OT8 | *(quirk)* Live-tail / probe-suppress harness flags (`realtime_tail_` pine_strategy_host.cpp:307) | n/a — **contained since N14** (the flags were `BacktestEngine` members until then; now `source::PineStrategyHost` state behind two kernel virtual seams, §2.ii q) | A | N14 | F:J7 S:C5 | parity-campaign tooling | @@ -299,7 +299,7 @@ Depends on L7 (working view), L3 (so `Sized` is in v1 of the C request) and the **Status (L10): done.** `examples/native/` holds eighteen hosts, sixteen C++ and two C, built by `PINEFORGE_BUILD_EXAMPLES`; `include/pineforge/native_module.hpp` defines `PINEFORGE_EXPORT_NATIVE_STRATEGY(Class)`. The bullets below are the plan L10 worked from. -- Move `runner/examples/native_market_strategy.cpp` and `native_selected_strategy.cpp` to a top-level `examples/native/`, built by `PINEFORGE_BUILD_EXAMPLES` (then "none yet" and guarding nothing CMakeLists.txt:37; the examples build only under `PINEFORGE_BUILD_LIVE_RUNNER` CMakeLists.txt:512-513, with `native_market_example` runner/CMakeLists.txt:54). Link the kernel target (S), or `PineForge::pineforge` during migration (O) — no SQLite / curl / OpenSSL (runner/CMakeLists.txt:1-4). +- Move `runner/examples/native_market_strategy.cpp` and `native_selected_strategy.cpp` to a top-level `examples/native/`, built by `PINEFORGE_BUILD_EXAMPLES` (then "none yet" and guarding nothing CMakeLists.txt:37; the examples build only under `PINEFORGE_BUILD_LIVE_RUNNER` CMakeLists.txt:512-514, with `native_market_example` runner/CMakeLists.txt:54). Link the kernel target (S), or `PineForge::pineforge` during migration (O) — no SQLite / curl / OpenSSL (runner/CMakeLists.txt:1-4). - Build (1) standalone executables with a `main()` that runs batch + stream on embedded bars (the guide already contains that `main` native-engine.md:3237-3305) and (2) the same MODULE targets the runner tests load; keep the C-ABI shims so the live runner can still `dlopen` them (F, O, S). - Update the three places that pin the paths: `native_market_example` runner/CMakeLists.txt:54, `NATIVE_EXAMPLES` check_native_include_independence.py:49, the tests at `test_native_example_batch` runner/CMakeLists.txt:158. - Add a minimal "hello, kernel" host (~60 lines, no C ABI — O) and one example per v1 feature lane as it lands; each doubles as that lane's twin host (F). @@ -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:824758a7e8cd92fd99f7371510cdbc3e2d6b87f5c67656d3dbf4f51b7d42426d` c_abi.cpp:325-1013, `sha256:6327f1829019fdd172cd76a96e3d8c9c0af1610ad0dc9878640dda0ca21a686b` check_c_abi_runtime.py:20-81, c_abi.cpp:946, `sha256:24eb6e797706a9ded1b6af43a6fcce10e6565f9f601f27a5240bc0240c627211` 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) | 83 runtime `PF_API` symbols: native configure, the 12-symbol stream family, pending-order views, broker-state hash, security feeds, FX curve, reports. The 83 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:824758a7e8cd92fd99f7371510cdbc3e2d6b87f5c67656d3dbf4f51b7d42426d` c_abi.cpp:325-1013, `sha256:6327f1829019fdd172cd76a96e3d8c9c0af1610ad0dc9878640dda0ca21a686b` check_c_abi_runtime.py:20-81, c_abi.cpp:946, `sha256:24eb6e797706a9ded1b6af43a6fcce10e6565f9f601f27a5240bc0240c627211` 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) | @@ -343,7 +343,7 @@ the campaign's own anchor audit found that the first thing to rot: | the request-epoch sentence | `native_order_v4` | `native_order_v7` native_order.hpp:25 | | the two host-epoch sentences | host epoch v16 | `engine_script_run_v19` native_host.hpp:20 | | "What still requires Pine compatibility to build" | `BacktestEngine` still holds `CapAttachment` / `OrderPriority` / `IntradayCap` | `engine.hpp` has zero references to them; the cap type is `CapAttachment` intraday_cap.hpp:19 and the constructor is `PineStrategyHost`'s pine_strategy_host.hpp:289 | -| the rich-`run` paragraph | the rich `run` overload fails a native host | `run_rich` native_execution_consumer.cpp:10562 shows no such refusal (still unpinned by a test; the page says so) | +| the rich-`run` paragraph | the rich `run` overload fails a native host | `run_rich` native_execution_consumer.cpp:10561 shows no such refusal (still unpinned by a test; the page says so) | | added | — | the self-aggregation recipe beside the subscriptions it is an alternative to; the open-bar lookahead warning with `current_partial_bar` native_host.hpp:1073; and `set_native_security_feed` documented as the pre-run door for a series' authoritative bars, refused in-run | --- diff --git a/docs/pages/abi-stability.md b/docs/pages/abi-stability.md index 4ddc5ef5d..0ee0daf9d 100644 --- a/docs/pages/abi-stability.md +++ b/docs/pages/abi-stability.md @@ -67,11 +67,11 @@ Three layers: ## Symbol inventory -The counts below include recorded outputs and describe the released 1.5.0 API; recorded outputs are not part of 1.4.0. +The counts below describe the current API, including recorded outputs and the report sink; recorded outputs are not part of 1.4.0. -A built strategy `.so` exposes 96 compiled-strategy `PF_API` declarations -(80 runtime implementations plus sixteen generated exports) plus 43 native-host -declarations: 139 `PF_API` exports in total; a script that declares +A built strategy `.so` exposes 99 compiled-strategy `PF_API` declarations +(83 runtime implementations plus sixteen generated exports) plus 43 native-host +declarations: 142 `PF_API` exports in total; a script that declares `use_bar_magnifier = true` also exports `strategy_declares_bar_magnifier`, and a module that records outputs its three outputs exports. `nm -gU` also reports libc++'s `std::piecewise_construct`; no project-internal C++ symbol is exported. The grouped table below is a guide, @@ -124,7 +124,7 @@ not the complete inventory: | `strategy_signal_safety_receipt` | @ref pf_outputs (a recording module's own) | Sixteen per-strategy exports include the five create/run/free lifecycle -functions. The remaining 80 runtime implementations, including the +functions. The remaining 83 runtime implementations, including the closed-trade incarnation accessor, are force-linked into each strategy library, so consumers resolve the same complete ABI from the strategy `.so`. All additions remain covered by the minor-version append-only guarantee. diff --git a/docs/pages/index.md b/docs/pages/index.md index 5df22eeef..2c70b9cf2 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -137,9 +137,9 @@ directly, so a Pine-layer symbol reaching one is a link error. The counts below include recorded outputs and describe the released 1.5.0 API. -The public C surface is **145 `PF_API` declarations** across the five installed headers that declare any: +The public C surface is **148 `PF_API` declarations** across the six installed headers that declare any: -- `` — **94**: 74 of the 80 runtime implementations (the other six are declared in `execution_observer.h` and `selected_window.h`) plus twenty +- `` — **94**: 74 of the 83 runtime implementations (the other nine are declared in `execution_observer.h`, `selected_window.h` and `report_sink.h`) plus twenty per-strategy generated exports (`strategy_declares_bar_magnifier` only in a script that declares `use_bar_magnifier = true`, the three recorded-outputs exports only in a module that records outputs). This is what a compiled strategy `.so` exports and what a harness calls. Older modules lack the six @@ -149,15 +149,15 @@ The public C surface is **145 `PF_API` declarations** across the five installed direction, where the host drives the kernel itself. Submit, replace, cancel, execute, read the book, read the lots. Additive: no symbol, struct or behaviour of the first set changes. -- `` and `` — - three each: the six of the 80 runtime implementations that `pineforge.h` - does not declare (each header's version reader, the execution observer's - setter and observation reader, the selected window's setter and counts - reader). +- ``, `` and `` — + three each: the nine of the 83 runtime implementations that `pineforge.h` + does not declare: each header's version reader and its setter/query pair + for execution observation, selected-window counts or the report-sink + receipt. - `` — two: the selected-window planner's version reader and `pf_plan_selected_primary_v1`. -`scripts/check_c_abi_runtime.py` pins both symbol inventories (80 and 43); +`scripts/check_c_abi_runtime.py` pins both symbol inventories (83 and 43); `scripts/check_native_c_api_surface.py` separately pins the host COVERAGE block. | Group | Symbols | Reference | @@ -171,10 +171,10 @@ The public C surface is **145 `PF_API` declarations** across the five installed | Types | `pf_bar_t`, `pf_trade_tick_t`, `pf_trade_t`, `pf_report_t`, metrics, diagnostics, trace, equity, version, and `pf_magnifier_distribution_t` | @ref pf_types | | Native kernel host (C) | `strategy_native_host_create_v1`, `strategy_native_run_v1`, the submit / replace / cancel family, the position, working-book, open-lot, event and state reads, the cohort and subscription calls, and `strategy_configure_native_ext_v1` / `_result_v1` | `native_c_api.h` | -A newly generated PineForge strategy `.so` exports 123 `PF_API` symbols (80 compiled-strategy -declarations plus 41 `strategy_native_*` declarations and two -`strategy_configure_native_ext_*` declarations; 124 with -`strategy_declares_bar_magnifier` in a script that declares the magnifier) and the toolchain +A newly generated PineForge strategy `.so` includes 83 compiled-strategy runtime +`PF_API` symbols and 43 native-host symbols (41 `strategy_native_*` declarations and two +`strategy_configure_native_ext_*` declarations), alongside the per-strategy +exports described above and the toolchain `std::piecewise_construct` symbol; no project-internal C++ symbol is exported — see **[ABI stability](@ref abi_stability)** for the full guarantee. diff --git a/docs/pages/native-engine.md b/docs/pages/native-engine.md index 53fe6cad2..a4ea668d1 100644 --- a/docs/pages/native-engine.md +++ b/docs/pages/native-engine.md @@ -470,7 +470,7 @@ spec fields. The rich `run(bars, n, input_tf, script_tf, inputs, syminfo, overrides, …)` overload (`engine.hpp:1801-1813`) is **not** refused as a source mutation: it reaches `NativeExecutionConsumer::run_rich` -(`native_execution_consumer.cpp:10562-10604`), which admits the begin, checks the +(`native_execution_consumer.cpp:10561-10611`), which admits the begin, checks the timeframe arguments against the spec, preflights and pumps the batch exactly like the plain overload. `inputs` / `syminfo` / `overrides` are carried only as `NativeBeginArgs` fields to `prepare_native_begin` — the overrides as the diff --git a/docs/pages/run-failure-codes.md b/docs/pages/run-failure-codes.md index 5a134f25d..a74c9f7fa 100644 --- a/docs/pages/run-failure-codes.md +++ b/docs/pages/run-failure-codes.md @@ -142,13 +142,14 @@ The `outputs_limit` and `outputs_rejected` rows below are released in 1.5.0, and | `report_result_invalid` | `engine_fault` | no | `field` (string), `reason` (vocab, 4 values) | The report producer generated an invalid canonical result: an engine or harness fault, never a strategy failure. | | `report_shape_request_invalid` | `input` | no | `option` (vocab, 2 values), `value` (scalar) | The report-shape request is invalid: the report shape or the curve point budget carries a value the runner refuses. | | `report_shape_unsupported` | `unsupported` | no | `shape` (string) | The runner does not support the requested report shape. The first selected-window build supports the full shape only. | +| `report_sink_failed` | `resource` | no | none | The engine could not create or write the report sink. The run has no completed sink or valid trailer. | | `request_symbol_invalid` | `strategy` | no | none | A request.security symbol is not a valid symbol. | | `request_timeframe_invalid` | `strategy` | no | `api` (vocab, 2 values) | A request timeframe literal does not parse. | | `request_timeframe_unsupported` | `unsupported` | no | `input_tf` (timeframe, optional), `reason` (vocab, 7 values), `script_tf` (timeframe, optional) | A request timeframe cannot be served for this run's chart timeframe. | | `request_unsupported` | `unsupported` | no | `line` (integer, optional), `reason` (vocab, 2 values) | A request shape PineForge does not support. | | `results_digest_unsupported` | `unsupported` | no | `requested` (string) | The runner does not support the requested results digest. The first selected-window build declares no digest. | | `run_aborted` | `engine_fault` | yes | none | The run was aborted on request. | -| `run_mode_unsupported` | `unsupported` | no | `feature` (vocab, 10 values) | A feature the requested run mode does not support. | +| `run_mode_unsupported` | `unsupported` | no | `feature` (vocab, 11 values) | A feature the requested run mode does not support. | | `run_options_rejected` | `input` | no | `input_tf` (timeframe, optional), `option` (vocab, 11 values), `script_tf` (timeframe, optional) | The run's own options (timeframes, intrabar path, magnifier, path order) were refused. | | `run_request_invalid` | `input` | no | `option` (vocab, 20 values) | The harness command line or one of its JSON options is invalid. | | `security_feed_refused` | `input` | no | `reason` (vocab, 11 values) | An auxiliary or native request.security feed was refused. | diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 3f549cf1a..d32e9d81e 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -44,10 +44,10 @@ #define PINEFORGE_NO_STRATEGY_DECLS // Angle-bracket form is the installed public path (deliberate). #include - -// Generated modules using the full script lifecycle reset must be rebuilt -// against a runtime providing this hook. This is an internal C++ capability; -// it does not change any public C POD or exported C function signature. +#include +// Generated script lifecycle users must be rebuilt against this runtime. +// These internal C++ capabilities change no public C POD or C signature. +#define PINEFORGE_HAS_TRADE_HISTORY_POLICY_V1 1 #define PINEFORGE_HAS_SCRIPT_RUN_PREPARE_V1 1 #define PINEFORGE_HAS_NATIVE_LIVE_V1 1 namespace pineforge { @@ -2292,6 +2292,59 @@ class BacktestEngine { // value. Private, so no host writes it (R5 lane K-ULP4). double native_quantity_tolerance_ = 0.0; + // Reporting configuration and run-local output ownership. Copies retain + // configuration, never a writer, receipt, or an active-call barrier. + struct ReportSinkState { + bool enabled = false; + bool trade_history_streamable = false; + std::string path, run_id, scratch_dir; + std::uint32_t active_calls = 0; + std::uint32_t stream_calls = 0; + std::unique_ptr writer; + pf_report_sink_receipt_v1 receipt{}; + ReportSinkState() = default; + ReportSinkState(const ReportSinkState& other) + : enabled(other.enabled), trade_history_streamable(other.trade_history_streamable), + path(other.path), run_id(other.run_id), scratch_dir(other.scratch_dir) {} + ReportSinkState& operator=(const ReportSinkState& other) { + if (this != &other) *this = ReportSinkState(other); + return *this; + } + ReportSinkState(ReportSinkState&&) noexcept = default; + ReportSinkState& operator=(ReportSinkState&&) noexcept = default; + }; + // @broker-state begin + mutable ReportSinkState report_sink_state_; + // @broker-state end + + class ReportSinkCallScope { + ReportSinkState& state; + bool stream; + public: + explicit ReportSinkCallScope(const BacktestEngine& engine, bool is_stream = false) + : state(engine.report_sink_state_), stream(is_stream) { + ++state.active_calls; + if (stream) ++state.stream_calls; + } + ~ReportSinkCallScope() { + if (stream) --state.stream_calls; + --state.active_calls; + } + ReportSinkCallScope(const ReportSinkCallScope&) = delete; + ReportSinkCallScope& operator=(const ReportSinkCallScope&) = delete; + }; + int report_sink_setter_status() const; + void begin_report_sink(); + void finish_report_sink(ReportC* out) const; + +public: + // The additive C ABI delegates here; receipt queries never bind a consumer. + int set_report_sink_v1(const pf_report_sink_v1* sink); + int report_sink_receipt_v1(pf_report_sink_receipt_v1* out) const noexcept; + // Codegen opts in only for modules that do not read closedtrades.*. + // L6B1 still retains every row; L8c consumes this policy at sealing. + void set_trade_history_streamable(bool streamable); + bool trade_history_streamable() const { return report_sink_state_.trade_history_streamable; } 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 diff --git a/include/pineforge/pineforge.h b/include/pineforge/pineforge.h index 6131e3fdc..ba0d6c84a 100644 --- a/include/pineforge/pineforge.h +++ b/include/pineforge/pineforge.h @@ -367,7 +367,7 @@ typedef struct pf_report_s { /* Trades */ int total_trades; /**< Closed-trade count (== trades_len), including * the range-end close of a position still open - * after the final bar (pf_trade_t::open_at_end). */ + * after the final bar; with a sink trades_len is 0. */ pf_trade_t* trades; /**< Heap array of closed trades; script-driven exits * first, then the range-end rows (open_at_end=1). */ int trades_len; /**< Length of #trades. */ @@ -414,7 +414,7 @@ typedef struct pf_report_s { * On a completed KernelRecorded run len == script_bars_processed; a * Failed or Aborted run can expose a shorter prefix (check * strategy_get_last_error), and metrics describe that prefix. NOTE int64_t - * length (ctypes: c_int64). */ + * length (ctypes: c_int64). With a report sink: NULL / 0 length. */ pf_equity_point_t* equity_curve; int64_t equity_curve_len; /* Per-script-bar broker-state hash when recording is on and report @@ -425,7 +425,7 @@ typedef struct pf_report_s { * native hosts alike: each point latches the continuation, and the * scalar folds the last latch. A Failed or Aborted run can have a * shorter recorded prefix; its last row need not equal the scalar. - * ABI v4. */ + * ABI v4. With a report sink: NULL / 0 length. */ uint64_t* broker_state_hash; int64_t broker_state_hash_len; } pf_report_t; @@ -1873,4 +1873,7 @@ PF_API int strategy_signal_safety_receipt(pf_strategy_t s, char* json, size_t ca * not move this header's own declaration count. */ #include +/** Feature probe: include for the additive sink ABI. */ +#define PINEFORGE_HAS_REPORT_SINK_V1 1 + #endif /* PINEFORGE_H */ diff --git a/include/pineforge/run_failure_codes.hpp b/include/pineforge/run_failure_codes.hpp index 28a145089..d84ceef3a 100644 --- a/include/pineforge/run_failure_codes.hpp +++ b/include/pineforge/run_failure_codes.hpp @@ -40,45 +40,46 @@ enum class RunFailureCode : std::uint16_t { report_result_invalid = 25, report_shape_request_invalid = 26, report_shape_unsupported = 27, - request_symbol_invalid = 28, - request_timeframe_invalid = 29, - request_timeframe_unsupported = 30, - request_unsupported = 31, - results_digest_unsupported = 32, - run_aborted = 33, - run_mode_unsupported = 34, - run_options_rejected = 35, - run_request_invalid = 36, - security_feed_refused = 37, - setting_rejected = 38, - setting_unsupported = 39, - strategy_create_failed = 40, - strategy_library_incompatible = 41, - strategy_runtime_error = 42, - strategy_settings_rejected = 43, - strategy_value_invalid = 44, - stream_input_rejected = 45, - symbol_feeds_refused = 46, - symbol_metadata_rejected = 47, - syminfo_unreadable = 48, - window_boundary_unaligned = 49, - window_calendar_unsupported = 50, - window_feed_identity_changed = 51, - window_feed_range_invalid = 52, - window_input_cap_exceeded = 53, - window_legacy_flag_conflict = 54, - window_lineage_mismatch = 55, - window_mode_unsupported = 56, - window_no_observations = 57, - window_preroll_out_of_range = 58, - window_prestart_fill = 59, - window_prestart_state_violation = 60, - window_pruner_unsupported = 61, - window_range_invalid = 62, - window_report_mismatch = 63, - window_request_invalid = 64, + report_sink_failed = 28, + request_symbol_invalid = 29, + request_timeframe_invalid = 30, + request_timeframe_unsupported = 31, + request_unsupported = 32, + results_digest_unsupported = 33, + run_aborted = 34, + run_mode_unsupported = 35, + run_options_rejected = 36, + run_request_invalid = 37, + security_feed_refused = 38, + setting_rejected = 39, + setting_unsupported = 40, + strategy_create_failed = 41, + strategy_library_incompatible = 42, + strategy_runtime_error = 43, + strategy_settings_rejected = 44, + strategy_value_invalid = 45, + stream_input_rejected = 46, + symbol_feeds_refused = 47, + symbol_metadata_rejected = 48, + syminfo_unreadable = 49, + window_boundary_unaligned = 50, + window_calendar_unsupported = 51, + window_feed_identity_changed = 52, + window_feed_range_invalid = 53, + window_input_cap_exceeded = 54, + window_legacy_flag_conflict = 55, + window_lineage_mismatch = 56, + window_mode_unsupported = 57, + window_no_observations = 58, + window_preroll_out_of_range = 59, + window_prestart_fill = 60, + window_prestart_state_violation = 61, + window_pruner_unsupported = 62, + window_range_invalid = 63, + window_report_mismatch = 64, + window_request_invalid = 65, }; -inline constexpr std::uint16_t kRunFailureCodeCount = 65; +inline constexpr std::uint16_t kRunFailureCodeCount = 66; } // namespace pineforge diff --git a/scripts/broker_state_hash_waivers.txt b/scripts/broker_state_hash_waivers.txt index 30ddc3839..c71a743a1 100644 --- a/scripts/broker_state_hash_waivers.txt +++ b/scripts/broker_state_hash_waivers.txt @@ -82,4 +82,5 @@ 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). +report_sink_state_ # Report-only path/run-id/scratch configuration, copied history-retention policy, writer ownership, trailer receipt and transient setter-call barriers (CR-150 L6B1). No fill, admission, price, position or account calculation consumes it; sink-on/off runs must have identical broker hashes. Copying drops the writer/receipt/barriers. L8c may use the history policy only to retire already-final report rows, never executable state. 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. diff --git a/scripts/check_c_abi_runtime.py b/scripts/check_c_abi_runtime.py index 143071835..bec64384a 100644 --- a/scripts/check_c_abi_runtime.py +++ b/scripts/check_c_abi_runtime.py @@ -113,6 +113,9 @@ "pf_selected_window_version", "strategy_set_selected_window_v1", "strategy_selected_window_counts_v1", + "pf_report_sink_version", + "strategy_set_report_sink_v1", + "strategy_report_sink_receipt_v1", }) # The headers under include/pineforge/ whose PF_API declarations are read as one @@ -123,10 +126,11 @@ "pineforge.h", "execution_observer.h", "selected_window.h", + "report_sink.h", ) -EXPECTED_PUBLIC_DECLARATIONS = 100 -EXPECTED_RUNTIME_IMPLEMENTATIONS = 80 +EXPECTED_PUBLIC_DECLARATIONS = 103 +EXPECTED_RUNTIME_IMPLEMENTATIONS = 83 # Runtime setters whose data a stream cannot take: the source host refuses # stream_begin() while any of them holds data (the native daily feed since its diff --git a/scripts/test_ci_verify.py b/scripts/test_ci_verify.py index 9ddd7f0b2..f3b606899 100644 --- a/scripts/test_ci_verify.py +++ b/scripts/test_ci_verify.py @@ -1199,12 +1199,12 @@ def test_public_and_runtime_export_counts_are_exact(self): _pf_api_names, ) # The public count is read the way the script reads it: pineforge.h - # plus the two additive v1 headers. + # plus the three additive v1 headers. header = [name for relative in RUNTIME_HEADERS for name in _pf_api_names(ROOT / 'include/pineforge' / relative)] runtime = _pf_api_names(ROOT / 'src/c_abi.cpp') - self.assertEqual(EXPECTED_PUBLIC_DECLARATIONS, 100) - self.assertEqual(EXPECTED_RUNTIME_IMPLEMENTATIONS, 80) + self.assertEqual(EXPECTED_PUBLIC_DECLARATIONS, 103) + self.assertEqual(EXPECTED_RUNTIME_IMPLEMENTATIONS, 83) self.assertEqual(len(EXPECTED_RUNTIME), EXPECTED_RUNTIME_IMPLEMENTATIONS) self.assertEqual(len(header), EXPECTED_PUBLIC_DECLARATIONS) self.assertEqual(len(header), len(set(header))) diff --git a/src/c_abi.cpp b/src/c_abi.cpp index 65257d757..de667dfe5 100644 --- a/src/c_abi.cpp +++ b/src/c_abi.cpp @@ -32,10 +32,10 @@ * strategy_configure_native_v1, strategy_configure_native_fx_curve_v1, * strategy_configure_native_fx_curve_ext_v1, * pf_execution_observer_version, strategy_set_execution_observer_v1, - * strategy_execution_observation_v1, pf_selected_window_version, - * strategy_set_selected_window_v1, strategy_selected_window_counts_v1 — the - * authoritative list is EXPECTED_RUNTIME in - * scripts/check_c_abi_runtime.py, enforced by CI). The other + * strategy_execution_observation_v1, pf_selected_window_version, pf_report_sink_version, + * strategy_set_selected_window_v1, strategy_selected_window_counts_v1, + * strategy_set_report_sink_v1, strategy_report_sink_receipt_v1 — the + * authoritative list is EXPECTED_RUNTIME in scripts/check_c_abi_runtime.py (CI). Other * `extern "C"` symbols listed in pineforge.h (strategy_create, * run_backtest, etc.) are emitted per-compiled-strategy by the * codegen, not here. @@ -1302,3 +1302,24 @@ PF_API int strategy_selected_window_counts_v1( } } /* extern "C" */ + +// Additive CR-150 report sink ABI. P00 owns the frozen declarations. +#include + +extern "C" { + +PF_API uint32_t pf_report_sink_version(void) { return 1; } + +PF_API int strategy_set_report_sink_v1(pf_strategy_t s, const pf_report_sink_v1* sink) { + return pf_cabi_int(s, [&] { + if (!s) return -1; + return static_cast(s)->set_report_sink_v1(sink); + }); +} + +PF_API int strategy_report_sink_receipt_v1(pf_strategy_t s, pf_report_sink_receipt_v1* out) { + if (!s) return -1; + return static_cast(s)->report_sink_receipt_v1(out); +} + +} // extern "C" diff --git a/src/engine_report.cpp b/src/engine_report.cpp index 9a9e75116..27be230fb 100644 --- a/src/engine_report.cpp +++ b/src/engine_report.cpp @@ -54,20 +54,20 @@ void BacktestEngine::trace(const std::string& name, double value) { // function asking for the rows' own owner (NativeExecutionConsumer:: // fill_snapshot_report), so a live stream's rows survive such a refusal. void BacktestEngine::fill_report(ReportC* out) const { - // A read from inside a capture or an observer call is refused before any - // gate: the empty report, latched for the terminal helper, and no recursive - // present_report. + const ReportSinkCallScope sink_call(*this); + // A recursive capture/observer read is refused before any row gate; + // the terminal helper consumes its reentry latch. if (NativeExecutionConsumer::refuse_report_in_dispatch(*this, out)) return; if (!NativeExecutionConsumer::rows_are_current(*this)) { *out = ReportC{}; return; } - // A selected window that has sealed, and a generation admitted with an - // observer that has sealed, answer from the captured presentation (or the - // empty report) and never reach present_report again; an OFF engine, a - // selected run still admitted-open and a generation admitted with neither a - // window nor an observer fall through unchanged. - if (NativeExecutionConsumer::fill_selected_report(*this, out)) return; + // Sealed selected/observed generations use their capture (or the empty + // report). All other reads retain the ordinary presentation path. + if (NativeExecutionConsumer::fill_selected_report(*this, out)) { + finish_report_sink(out); + return; + } fill_trades_section(out); out->input_bars_processed = diag_input_bars_processed_; @@ -92,9 +92,9 @@ void BacktestEngine::fill_report(ReportC* out) const { out->broker_state_hash_len = hn; out->broker_state_hash = hn > 0 ? new uint64_t[hn] : nullptr; if (hn > 0) std::copy(broker_state_hashes_.begin(), broker_state_hashes_.end(), out->broker_state_hash); + finish_report_sink(out); } - // Copy ``trades_`` — followed by ``range_end_trades_``, the report-only rows // of a position still open after the final bar // (NativeExecutionConsumer::append_open_position_report_rows) — into a @@ -582,3 +582,163 @@ double BacktestEngine::output_constant_value(int index) const { } } // namespace pineforge + +// CR-150 L6B1: path sink runtime. Kept after the existing report/output code +// so the line-pinned documentation above retains its source coordinates. +#include "report_sink_writer.hpp" + +namespace pineforge { + +int BacktestEngine::report_sink_setter_status() const { + const auto& sink = report_sink_state_; + const auto* consumer = execution_consumer_slot_.ptr + ? &as_native_consumer(*execution_consumer_slot_.ptr) : nullptr; + if (sink.stream_calls != 0 || stream_phase_ == StreamPhase::REALTIME + || (consumer && consumer->running() && consumer->state_phase() != NativeRunPhase::Batch)) + return -2; + if (sink.active_calls != 0 || (consumer && consumer->running())) return -3; + return 0; +} + +int BacktestEngine::set_report_sink_v1(const pf_report_sink_v1* descriptor) { + if (descriptor && (descriptor->struct_size != sizeof(*descriptor) + || descriptor->version != 1 || !descriptor->path || !*descriptor->path)) return -1; + const int status = report_sink_setter_status(); + if (status != 0) return status; + // Construct the full replacement first. Allocation failure cannot erase + // an installed sink, its receipt or its copied strings. + ReportSinkState next; + next.trade_history_streamable = report_sink_state_.trade_history_streamable; + if (descriptor) { + next.enabled = true; + next.path = descriptor->path; + next.run_id = descriptor->run_id ? descriptor->run_id : ""; + if (descriptor->scratch_dir) next.scratch_dir = descriptor->scratch_dir; + else { + const auto slash = next.path.find_last_of('/'); + next.scratch_dir = slash == std::string::npos ? "." + : slash == 0 ? "/" : next.path.substr(0, slash); + } + } + report_sink_state_ = std::move(next); + return 0; +} + +int BacktestEngine::report_sink_receipt_v1(pf_report_sink_receipt_v1* out) const noexcept { + if (!out || out->struct_size != sizeof(*out) || out->version != 1) return -1; + pf_report_sink_receipt_v1 local{}; + if (report_sink_state_.enabled) local = report_sink_state_.receipt; + local.struct_size = sizeof local; + local.version = 1; + *out = local; + return 0; +} + +void BacktestEngine::set_trade_history_streamable(bool streamable) { + if (report_sink_setter_status() != 0) + throw coded(RunFailureCode::engine_invariant, {}, + "trade history policy cannot change during a run"); + report_sink_state_.trade_history_streamable = streamable; +} + +void BacktestEngine::begin_report_sink() { + auto& sink = report_sink_state_; + sink.writer.reset(); + sink.receipt = {}; + if (!sink.enabled) return; + sink.receipt.trades_retained = sink.trade_history_streamable ? 0u : 1u; + const pf_report_sink_v1 descriptor{sizeof(pf_report_sink_v1), 1, + sink.path.c_str(), sink.run_id.c_str(), sink.scratch_dir.c_str()}; + try { + sink.writer = std::make_unique(descriptor); + sink.writer->write_header({outputs_enabled_, trace_enabled_, broker_state_hash_recording_, + outputs_slots_, outputs_constants_}); + } catch (const report_sink::WriterError& error) { + sink.writer.reset(); + const auto code = error.reason() == report_sink::WriterFailure::io + ? RunFailureCode::report_sink_failed : RunFailureCode::engine_invariant; + throw coded(code, {}, error.what()); + } +} + +void BacktestEngine::finish_report_sink(ReportC* out) const { + auto& sink = report_sink_state_; + if (!sink.enabled) return; + try { + // Partial reports still answer as summaries, but never finish a sink. + // The row/capture gates in fill_report already reject another attempt's + // rows and a failed selected/observed generation's presentation. + const auto* consumer = execution_consumer_slot_.ptr + ? &as_native_consumer(*execution_consumer_slot_.ptr) : nullptr; + if (sink.writer && !sink.receipt.complete && last_run_status_ == 0 + && last_error_.empty() && consumer + && consumer->state_kind() == NativeLifecycleKind::Completed) { + for (int64_t i = 0; i < out->equity_curve_len; ++i) { + sink.writer->write_e(out->equity_curve[i]); + ++sink.receipt.count_e; + } + for (int i = 0; i < out->trades_len; ++i) { + const TradeC& row = out->trades[i]; + pf_trade_t wire{}; + wire.entry_time = row.entry_time; + wire.exit_time = row.exit_time; + wire.entry_price = row.entry_price; + wire.exit_price = row.exit_price; + wire.pnl = row.pnl; + wire.pnl_pct = row.pnl_pct; + wire.is_long = row.is_long; + wire.max_runup = row.max_runup; + wire.max_drawdown = row.max_drawdown; + wire.qty = row.qty; + wire.commission = row.commission; + wire.entry_bar_index = row.entry_bar_index; + wire.exit_bar_index = row.exit_bar_index; + wire.open_at_end = row.open_at_end; + const Trade& identity = get_report_trade(i); + sink.writer->write_t(i + INT64_C(1), wire, identity.entry_incarnation, + closed_trade_close_cause(i), identity.entry_id, identity.exit_id, + identity.exit_comment); + ++sink.receipt.count_t; + } + const auto trailer = sink.writer->write_trailer(); + sink.receipt.count_e = trailer.count_e; + sink.receipt.count_t = trailer.count_t; + sink.receipt.count_o = trailer.count_o; + sink.receipt.count_v = trailer.count_v; + sink.receipt.count_k = trailer.count_k; + sink.receipt.count_x = trailer.count_x; + sink.receipt.count_h = trailer.count_h; + std::memcpy(sink.receipt.sha256_hex, trailer.sha256_hex, sizeof trailer.sha256_hex); + sink.receipt.complete = 1; + } + } catch (const report_sink::WriterError& error) { + sink.writer.reset(); + free_report(out); + *out = {}; + auto& engine = const_cast(*this); + engine.last_run_status_ = 1; + const auto code = error.reason() == report_sink::WriterFailure::io + ? RunFailureCode::report_sink_failed : RunFailureCode::engine_invariant; + const auto failure = coded(code, {}, error.what()); + note_run_failure(engine, failure.what(), classify_run_failure(failure)); + throw failure; + } catch (...) { + sink.writer.reset(); + free_report(out); + *out = {}; + const_cast(*this).last_run_status_ = 1; + throw; + } + delete[] out->trades; + delete[] out->equity_curve; + delete[] out->broker_state_hash; + delete[] out->trace; + delete[] out->trace_names; + out->trades = nullptr; out->trades_len = 0; + out->equity_curve = nullptr; out->equity_curve_len = 0; + out->broker_state_hash = nullptr; out->broker_state_hash_len = 0; + out->trace = nullptr; out->trace_len = 0; + out->trace_names = nullptr; out->trace_names_len = 0; +} + +} // namespace pineforge diff --git a/src/native_execution_consumer.cpp b/src/native_execution_consumer.cpp index 5368a4ff4..d85449cc8 100644 --- a/src/native_execution_consumer.cpp +++ b/src/native_execution_consumer.cpp @@ -2910,9 +2910,9 @@ bool NativeExecutionConsumer::begin_ready(BacktestEngine& engine, NativeRunPhase // The generation is admitted here: after the reset and the last refusal, // before the first host callback of the run. admit_selected_generation(engine); - // RULING A48: one generic capability, wired once per run. A host that - // declares ownership supplies the closing-row magnitudes; the kernel then - // keeps no excursion model of its own for this run. + engine.begin_report_sink(); + // RULING A48: the host's closing-row excursion capability is wired once + // per run; the kernel then keeps no excursion model of its own. if (auto* excursion_owner = native_host(engine)) { if (excursion_owner->owns_lot_excursions()) { engine.lot_excursion_hook_ = @@ -10468,8 +10468,8 @@ void NativeExecutionConsumer::pump_batch(BacktestEngine& engine, const Bar* bars } (void)check_abort_or_projection(engine, NativeFailureOperation::Input); } - void NativeExecutionConsumer::run_simple(BacktestEngine& engine, const Bar* bars, int n) { + const BacktestEngine::ReportSinkCallScope sink_call(engine); const EntryExitGuard exit_scope(*this, engine, false); if (refuse_dispatch_reentry()) return; const AttemptScope attempt(*this); @@ -10504,13 +10504,13 @@ void NativeExecutionConsumer::run_simple(BacktestEngine& engine, const Bar* bars fail_unclassified(engine, NativeFailureOperation::Input); } } - void NativeExecutionConsumer::run_tf(BacktestEngine& engine, const Bar* input_bars, int n_input, const std::string& input_tf, const std::string& script_tf, bool bar_magnifier, int magnifier_samples, MagnifierDistribution magnifier_dist) { + const BacktestEngine::ReportSinkCallScope sink_call(engine); const EntryExitGuard exit_scope(*this, engine, false); if (refuse_dispatch_reentry()) return; const AttemptScope attempt(*this); @@ -10558,7 +10558,6 @@ void NativeExecutionConsumer::run_tf(BacktestEngine& engine, fail_unclassified(engine, NativeFailureOperation::Input); } } - void NativeExecutionConsumer::run_rich(BacktestEngine& engine, const Bar* input_bars, int n_input, const std::string& input_tf, const std::string& script_tf, @@ -10567,6 +10566,7 @@ void NativeExecutionConsumer::run_rich(BacktestEngine& engine, const void* overrides, bool bar_magnifier, int magnifier_samples, MagnifierDistribution magnifier_dist) { + const BacktestEngine::ReportSinkCallScope sink_call(engine); const EntryExitGuard exit_scope(*this, engine, false); if (refuse_dispatch_reentry()) return; const AttemptScope attempt(*this); @@ -10609,21 +10609,21 @@ void NativeExecutionConsumer::run_rich(BacktestEngine& engine, fail_unclassified(engine, NativeFailureOperation::Input); } } - bool NativeExecutionConsumer::stream_begin(BacktestEngine& engine, const Bar* warmup_bars, int n_warmup, const std::string& input_tf, const std::string& script_tf) { + const BacktestEngine::ReportSinkCallScope sink_call(engine, true); const EntryExitGuard exit_scope(*this, engine, true); if (refuse_dispatch_reentry()) return false; const AttemptScope attempt(*this); - // The latest attempt is minted before any check below, so a refusal over a - // live stream is a later attempt of its own (owner_serial stays the live - // generation's). + // Sink streams are outside v1; refuse before staging or touching the file. if (!mint_attempt(engine, exit_scope.nested())) return false; - // Preserve the live stream before asking the provider to stage/configure a - // new run. The legacy route diagnoses this state first; in particular, - // no warmup copy, adapter reset, or broker/spec mutation may occur. + if (engine.report_sink_state_.enabled) { + render(engine, "report sink does not support streaming", RunFailureCode::run_mode_unsupported, + {{"feature", "stream_report_sink"}}); + return false; + } if (engine.stream_phase_ == BacktestEngine::StreamPhase::REALTIME) { render(engine, "stream is already realtime"); return false; diff --git a/src/run_failure_registry.inc b/src/run_failure_registry.inc index d84a88f11..71145d996 100644 --- a/src/run_failure_registry.inc +++ b/src/run_failure_registry.inc @@ -165,6 +165,7 @@ static constexpr const char* kRegistryValues[] = { "stream_fx_curve_input", "fx_series_calc_on_order_fills", "fx_series_bar_magnifier", + "stream_report_sink", "input_timeframe", "script_timeframe", "timeframes", @@ -427,65 +428,65 @@ static constexpr RegistryArg kRegistryArgs[] = { {"line", RunFailureArgKind::integer, true, 147, 0}, {"reason", RunFailureArgKind::vocab, false, 147, 2}, {"requested", RunFailureArgKind::string, false, 149, 0}, - {"feature", RunFailureArgKind::vocab, false, 149, 10}, - {"input_tf", RunFailureArgKind::timeframe, true, 159, 0}, - {"option", RunFailureArgKind::vocab, false, 159, 11}, - {"script_tf", RunFailureArgKind::timeframe, true, 170, 0}, - {"option", RunFailureArgKind::vocab, false, 170, 20}, - {"reason", RunFailureArgKind::vocab, false, 190, 11}, - {"entrypoint", RunFailureArgKind::vocab, true, 201, 4}, - {"input", RunFailureArgKind::pine_source, true, 205, 0}, - {"reason", RunFailureArgKind::vocab, true, 205, 21}, - {"abi", RunFailureArgKind::integer, true, 226, 0}, - {"missing", RunFailureArgKind::vocab, true, 226, 13}, - {"reason", RunFailureArgKind::vocab, false, 239, 8}, - {"field", RunFailureArgKind::vocab, false, 247, 30}, - {"field", RunFailureArgKind::string, false, 277, 0}, - {"reason", RunFailureArgKind::vocab, false, 277, 3}, - {"field", RunFailureArgKind::vocab, true, 280, 6}, - {"input_tf", RunFailureArgKind::timeframe, true, 286, 0}, - {"reason", RunFailureArgKind::vocab, false, 286, 41}, - {"script_tf", RunFailureArgKind::timeframe, true, 327, 0}, - {"field", RunFailureArgKind::vocab, false, 327, 14}, - {"reason", RunFailureArgKind::vocab, false, 341, 4}, - {"bound", RunFailureArgKind::vocab, false, 345, 3}, - {"next_boundary_ms", RunFailureArgKind::integer, false, 348, 0}, - {"previous_boundary_ms", RunFailureArgKind::integer, false, 348, 0}, - {"value_ms", RunFailureArgKind::integer, false, 348, 0}, - {"session", RunFailureArgKind::nullable_string, false, 348, 0}, - {"timezone", RunFailureArgKind::string, false, 348, 0}, - {"actual_sha256", RunFailureArgKind::string, false, 348, 0}, - {"expected_sha256", RunFailureArgKind::string, false, 348, 0}, - {"feed_id", RunFailureArgKind::string, false, 348, 0}, - {"end_ms", RunFailureArgKind::integer, false, 348, 0}, - {"fed_start_ms", RunFailureArgKind::integer, false, 348, 0}, - {"time_ms", RunFailureArgKind::integer, false, 348, 0}, - {"actual", RunFailureArgKind::integer, false, 348, 0}, - {"cap_name", RunFailureArgKind::vocab, false, 348, 8}, - {"limit", RunFailureArgKind::integer, false, 356, 0}, - {"option", RunFailureArgKind::string, false, 356, 0}, - {"actual", RunFailureArgKind::scalar, false, 356, 0}, - {"expected", RunFailureArgKind::scalar, false, 356, 0}, - {"field", RunFailureArgKind::string, false, 356, 0}, - {"capability", RunFailureArgKind::string, false, 356, 0}, - {"requested", RunFailureArgKind::integer, true, 356, 0}, - {"timeframe", RunFailureArgKind::string, true, 356, 0}, - {"end_ms", RunFailureArgKind::integer, false, 356, 0}, - {"start_ms", RunFailureArgKind::integer, false, 356, 0}, - {"requested", RunFailureArgKind::integer, false, 356, 0}, - {"fill_ms", RunFailureArgKind::integer, false, 356, 0}, - {"start_ms", RunFailureArgKind::integer, false, 356, 0}, - {"position_size", RunFailureArgKind::number, false, 356, 0}, - {"resting_orders", RunFailureArgKind::integer, false, 356, 0}, - {"time_ms", RunFailureArgKind::integer, false, 356, 0}, - {"pruner", RunFailureArgKind::string, false, 356, 0}, - {"end_ms", RunFailureArgKind::integer, false, 356, 0}, - {"fed_start_ms", RunFailureArgKind::integer, false, 356, 0}, - {"start_ms", RunFailureArgKind::integer, false, 356, 0}, - {"actual", RunFailureArgKind::integer, false, 356, 0}, - {"expected", RunFailureArgKind::integer, false, 356, 0}, - {"field", RunFailureArgKind::vocab, false, 356, 3}, - {"option", RunFailureArgKind::vocab, false, 359, 13}, + {"feature", RunFailureArgKind::vocab, false, 149, 11}, + {"input_tf", RunFailureArgKind::timeframe, true, 160, 0}, + {"option", RunFailureArgKind::vocab, false, 160, 11}, + {"script_tf", RunFailureArgKind::timeframe, true, 171, 0}, + {"option", RunFailureArgKind::vocab, false, 171, 20}, + {"reason", RunFailureArgKind::vocab, false, 191, 11}, + {"entrypoint", RunFailureArgKind::vocab, true, 202, 4}, + {"input", RunFailureArgKind::pine_source, true, 206, 0}, + {"reason", RunFailureArgKind::vocab, true, 206, 21}, + {"abi", RunFailureArgKind::integer, true, 227, 0}, + {"missing", RunFailureArgKind::vocab, true, 227, 13}, + {"reason", RunFailureArgKind::vocab, false, 240, 8}, + {"field", RunFailureArgKind::vocab, false, 248, 30}, + {"field", RunFailureArgKind::string, false, 278, 0}, + {"reason", RunFailureArgKind::vocab, false, 278, 3}, + {"field", RunFailureArgKind::vocab, true, 281, 6}, + {"input_tf", RunFailureArgKind::timeframe, true, 287, 0}, + {"reason", RunFailureArgKind::vocab, false, 287, 41}, + {"script_tf", RunFailureArgKind::timeframe, true, 328, 0}, + {"field", RunFailureArgKind::vocab, false, 328, 14}, + {"reason", RunFailureArgKind::vocab, false, 342, 4}, + {"bound", RunFailureArgKind::vocab, false, 346, 3}, + {"next_boundary_ms", RunFailureArgKind::integer, false, 349, 0}, + {"previous_boundary_ms", RunFailureArgKind::integer, false, 349, 0}, + {"value_ms", RunFailureArgKind::integer, false, 349, 0}, + {"session", RunFailureArgKind::nullable_string, false, 349, 0}, + {"timezone", RunFailureArgKind::string, false, 349, 0}, + {"actual_sha256", RunFailureArgKind::string, false, 349, 0}, + {"expected_sha256", RunFailureArgKind::string, false, 349, 0}, + {"feed_id", RunFailureArgKind::string, false, 349, 0}, + {"end_ms", RunFailureArgKind::integer, false, 349, 0}, + {"fed_start_ms", RunFailureArgKind::integer, false, 349, 0}, + {"time_ms", RunFailureArgKind::integer, false, 349, 0}, + {"actual", RunFailureArgKind::integer, false, 349, 0}, + {"cap_name", RunFailureArgKind::vocab, false, 349, 8}, + {"limit", RunFailureArgKind::integer, false, 357, 0}, + {"option", RunFailureArgKind::string, false, 357, 0}, + {"actual", RunFailureArgKind::scalar, false, 357, 0}, + {"expected", RunFailureArgKind::scalar, false, 357, 0}, + {"field", RunFailureArgKind::string, false, 357, 0}, + {"capability", RunFailureArgKind::string, false, 357, 0}, + {"requested", RunFailureArgKind::integer, true, 357, 0}, + {"timeframe", RunFailureArgKind::string, true, 357, 0}, + {"end_ms", RunFailureArgKind::integer, false, 357, 0}, + {"start_ms", RunFailureArgKind::integer, false, 357, 0}, + {"requested", RunFailureArgKind::integer, false, 357, 0}, + {"fill_ms", RunFailureArgKind::integer, false, 357, 0}, + {"start_ms", RunFailureArgKind::integer, false, 357, 0}, + {"position_size", RunFailureArgKind::number, false, 357, 0}, + {"resting_orders", RunFailureArgKind::integer, false, 357, 0}, + {"time_ms", RunFailureArgKind::integer, false, 357, 0}, + {"pruner", RunFailureArgKind::string, false, 357, 0}, + {"end_ms", RunFailureArgKind::integer, false, 357, 0}, + {"fed_start_ms", RunFailureArgKind::integer, false, 357, 0}, + {"start_ms", RunFailureArgKind::integer, false, 357, 0}, + {"actual", RunFailureArgKind::integer, false, 357, 0}, + {"expected", RunFailureArgKind::integer, false, 357, 0}, + {"field", RunFailureArgKind::vocab, false, 357, 3}, + {"option", RunFailureArgKind::vocab, false, 360, 13}, }; static constexpr RegistryCode kRegistryCodes[] = { @@ -517,6 +518,7 @@ static constexpr RegistryCode kRegistryCodes[] = { {"report_result_invalid", RunFailureClass::engine_fault, false, 34, 2}, {"report_shape_request_invalid", RunFailureClass::input, false, 36, 2}, {"report_shape_unsupported", RunFailureClass::unsupported, false, 38, 1}, + {"report_sink_failed", RunFailureClass::resource, false, 39, 0}, {"request_symbol_invalid", RunFailureClass::strategy, false, 39, 0}, {"request_timeframe_invalid", RunFailureClass::strategy, false, 39, 1}, {"request_timeframe_unsupported", RunFailureClass::unsupported, false, 40, 3}, @@ -556,5 +558,5 @@ static constexpr RegistryCode kRegistryCodes[] = { {"window_request_invalid", RunFailureClass::input, false, 104, 1}, }; -static_assert(sizeof(kRegistryCodes) / sizeof(kRegistryCodes[0]) == 65, +static_assert(sizeof(kRegistryCodes) / sizeof(kRegistryCodes[0]) == 66, "the registry must have one row per RunFailureCode"); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bad64a536..12a3679c3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7110,8 +7110,6 @@ endif() # CR-150 L8a: keep the existing CMake line pins stable. 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) @@ -7138,3 +7136,31 @@ list(SORT CR150_LANE_CMAKE) foreach(cr150_lane_file IN LISTS CR150_LANE_CMAKE) include("${cr150_lane_file}") endforeach() + +# --- CR-150 core-cr150-l6b1 begin --- +# CR-150 L6B1: appended after every pre-existing line pin. A real source +# strategy module, with the same whole-archive link as release modules. +if(PINEFORGE_BUILD_SOURCE_LAYER AND UNIX) + add_library(report_sink_l6b1_strategy MODULE test_report_sink_l6b1.cpp) + target_compile_definitions(report_sink_l6b1_strategy PRIVATE PINEFORGE_L6B1_MODULE=1) + target_link_libraries(report_sink_l6b1_strategy PRIVATE pineforge) + if(APPLE) + target_link_options(report_sink_l6b1_strategy PRIVATE + "-Wl,-force_load,$") + else() + target_link_options(report_sink_l6b1_strategy PRIVATE + "-Wl,--whole-archive" "$" "-Wl,--no-whole-archive") + endif() + target_compile_options(report_sink_l6b1_strategy PRIVATE -ffp-contract=off -UNDEBUG) + set_target_properties(report_sink_l6b1_strategy PROPERTIES + PREFIX "" SUFFIX ".so" LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/report_sink_l6b1") + list(APPEND TEST_SOURCES test_report_sink_l6b1) + pineforge_add_unit_test(test_report_sink_l6b1) + target_link_libraries(test_report_sink_l6b1 PRIVATE ${CMAKE_DL_LIBS}) + target_compile_definitions(test_report_sink_l6b1 PRIVATE + PINEFORGE_L6B1_MODULE_PATH="$" + PINEFORGE_L6B1_PYTHON="${Python3_EXECUTABLE}" + PINEFORGE_L6B1_DOCKER="${PROJECT_SOURCE_DIR}/docker") + add_dependencies(test_report_sink_l6b1 report_sink_l6b1_strategy) +endif() +# --- CR-150 core-cr150-l6b1 end --- diff --git a/tests/cr150.d/core-cr150-l6b1.cmake b/tests/cr150.d/core-cr150-l6b1.cmake new file mode 100644 index 000000000..19f879443 --- /dev/null +++ b/tests/cr150.d/core-cr150-l6b1.cmake @@ -0,0 +1,2 @@ +list(APPEND TEST_SOURCES test_report_sink_format) +pineforge_add_unit_test(test_report_sink_format) diff --git a/tests/ownership/manifest.v1.json b/tests/ownership/manifest.v1.json index acc3dca27..9cae29c67 100644 --- a/tests/ownership/manifest.v1.json +++ b/tests/ownership/manifest.v1.json @@ -264,6 +264,7 @@ {"code": "report_result_invalid", "class": "engine_fault", "retryable": false, "since": "1.5.0", "appendix": "C-window", "reachability": "no", "route": "producer schema/invariant defect; uninjected source corpus reachability fails acceptance"}, {"code": "report_shape_request_invalid", "class": "input", "retryable": false, "since": "1.5.0", "appendix": "C-window", "reachability": "no", "route": "caller representation options"}, {"code": "report_shape_unsupported", "class": "unsupported", "retryable": false, "since": "1.5.0", "appendix": "C-window", "reachability": "no", "route": "caller/deployed report capability"}, + {"code": "report_sink_failed", "class": "resource", "retryable": false, "since": "1.7.0", "appendix": "catalog-extension", "reachability": "indirect", "route": "caller sink path or valid report volume encounters storage failure; engine resource output, never a strategy value failure"}, {"code": "request_symbol_invalid", "class": "strategy", "retryable": false, "since": "1.4.0", "appendix": "C-existing", "reachability": "direct", "route": "source symbol argument"}, {"code": "request_timeframe_invalid", "class": "strategy", "retryable": false, "since": "1.4.0", "appendix": "C-existing", "reachability": "direct", "route": "source timeframe argument"}, {"code": "request_timeframe_unsupported", "class": "unsupported", "retryable": false, "since": "1.4.0", "appendix": "C-existing", "reachability": "direct", "route": "source requests unsupported timeframe"}, diff --git a/tests/test_report_sink_l6b1.cpp b/tests/test_report_sink_l6b1.cpp new file mode 100644 index 000000000..f822fce31 --- /dev/null +++ b/tests/test_report_sink_l6b1.cpp @@ -0,0 +1,487 @@ +// CR-150 L6B1: the real, whole-archive strategy module below is built from +// this same file. On f397d88d linking/loading fails on the missing sink ABI: +// P00 declares it but supplies no runtime definitions. No fake runtime/tape. +#include +#include +#include +#include + +#ifdef PINEFORGE_L6B1_MODULE +#include +#include +#include +#include +#include +#include +#include + +namespace { +class SinkFixture final : public pineforge::source::PineStrategyHost { +public: + SinkFixture() { + pineforge::source::PineStrategyConfig config; + config.initial_capital = 10000.0; + config.default_qty_value = 1.0; + config.commission_value = 0.125; + configure_pine_strategy(config); + } + std::string mode; + int busy_result = 99; + void on_source_bar(const pineforge::Bar&) override { + if (bar_index_ == 0) busy_result = strategy_set_report_sink_v1(this, nullptr); + if (bar_index_ == 2 && mode == "throw") throw std::runtime_error("fixture failed"); + if (bar_index_ == 2 && mode == "abort") request_abort(); + if (mode != "empty" && (bar_index_ == 0 || bar_index_ == 2 || bar_index_ == 4)) + strategy_entry(bar_index_ == 2 ? "S\\\"\n" : "L", bar_index_ != 2, + std::numeric_limits::quiet_NaN(), + std::numeric_limits::quiet_NaN(), 1.0, "entry"); + } +}; +// A Pine pre-roll captures strategy_entry as an intent; it cannot create a +// carry-in. Use the real native host for this refusal, as the selected-report +// integration fixture does: submit at bar 0, fill at bar 1, window at bar 3. +class CarryInFixture final : public pineforge::NativeStrategyHost { +public: + void configure_run() { + pineforge::NativeRunSpec spec; + spec.identity = {"report-sink-carry-in", ++runs_}; + spec.input_tf = "1"; + spec.script_tf = "1"; + spec.ticker = "MOCK"; + spec.tickerid = "TEST:MOCK"; + spec.type = "crypto"; + spec.currency = "USD"; + spec.timezone = "UTC"; + spec.session = "24x7"; + spec.initial_capital = 10000.0; + spec.point_value = 1.0; + spec.account_fx = 1.0; + spec.price_tick = 0.01; + spec.fee_kind = pineforge::NativeFeeKind::CashPerExecution; + spec.fee_value = 0.0; + spec.report_policy = pineforge::NativeReportPolicy::KernelRecorded; + // The lot stays open: include its range-end trade row so capture reaches + // the trade-before-window check ahead of the separate open-lot check. + spec.report_open_position_at_end = true; + if (configure_native(spec).status != pineforge::NativeSetupStatus::Applied) + throw std::runtime_error("carry-in fixture configuration rejected"); + } + void on_native_run_begin() override { entered_ = false; } + void on_native_bar(const pineforge::Bar&, const pineforge::NativeDecisionContext&) override { + if (entered_) return; + entered_ = true; + const auto placed = submit({pineforge::native_order::Transact{1.0}, "carry-in", ""}); + if (placed.status != pineforge::native_order::SubmitStatus::Accepted || !placed.handle) + throw std::runtime_error("carry-in fixture entry rejected"); + } +private: + std::uint64_t runs_ = 0; + bool entered_ = false; +}; +pineforge::BacktestEngine* engine(pf_strategy_t s) { + return static_cast(s); +} +SinkFixture* fixture(pf_strategy_t s) { + return static_cast(engine(s)); +} +std::vector input(const pf_bar_t* bars, int n) { + std::vector result; + for (int i = 0; i < n; ++i) + result.push_back({bars[i].open, bars[i].high, bars[i].low, bars[i].close, + bars[i].volume, bars[i].timestamp}); + return result; +} +} +extern "C" { +pf_strategy_t strategy_create(const char*) { + return static_cast(new SinkFixture); +} +pf_strategy_t l6b1_create_carry_in() { + return static_cast(new CarryInFixture); +} +void strategy_free(pf_strategy_t s) { delete engine(s); } +void strategy_set_input(pf_strategy_t s, const char*, const char* value) { + fixture(s)->mode = value ? value : ""; +} +void strategy_set_override(pf_strategy_t, const char*, const char*) {} +void run_backtest_full(pf_strategy_t s, pf_bar_t* bars, int n, const char* input_tf, + const char* script_tf, int magnifier, int samples, + pf_magnifier_distribution_t distribution, pf_report_t* out) { + *out = {}; + try { + const auto values = input(bars, n); + if (auto* carry = dynamic_cast(engine(s))) carry->configure_run(); + engine(s)->run(values.data(), n, input_tf, script_tf, magnifier != 0, samples, + static_cast(distribution)); + engine(s)->fill_report(reinterpret_cast(out)); + } catch (const std::exception& error) { + pineforge::BacktestEngine::free_report(reinterpret_cast(out)); + *out = {}; + pineforge::note_run_failure(*engine(s), "run_backtest_full", error); + } +} +void run_backtest(pf_strategy_t s, pf_bar_t* bars, int n, pf_report_t* out) { + run_backtest_full(s, bars, n, "1", "1", 0, 4, PF_MAGNIFIER_ENDPOINTS, out); +} +void report_free(pf_report_t* report) { + pineforge::BacktestEngine::free_report(reinterpret_cast(report)); +} +int l6b1_busy_result(pf_strategy_t s) { return fixture(s)->busy_result; } +void l6b1_fill_again(pf_strategy_t s, pf_report_t* out) { + engine(s)->fill_report(reinterpret_cast(out)); +} +} + +#else +#include +#include +#include "../src/sha256.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +void* open_module() { + void* module = dlopen(PINEFORGE_L6B1_MODULE_PATH, RTLD_NOW | RTLD_LOCAL); + if (!module) throw std::runtime_error(dlerror()); + return module; +} +template T symbol(void* module, const char* name) { + const auto address = dlsym(module, name); + if (!address) throw std::runtime_error(std::string("missing runtime export: ") + name); + return reinterpret_cast(address); +} +struct Api { + void* module = open_module(); + // First red: P00 provides the declaration, L6B1 must supply the symbol. + decltype(&pf_report_sink_version) version = symbol(module, "pf_report_sink_version"); + decltype(&strategy_set_report_sink_v1) set = symbol(module, "strategy_set_report_sink_v1"); + decltype(&strategy_report_sink_receipt_v1) receipt = symbol(module, "strategy_report_sink_receipt_v1"); + decltype(&strategy_create) create = symbol(module, "strategy_create"); + pf_strategy_t (*create_carry)() = symbol(module, "l6b1_create_carry_in"); + decltype(&strategy_free) destroy = symbol(module, "strategy_free"); + decltype(&run_backtest) run = symbol(module, "run_backtest"); + decltype(&report_free) free = symbol(module, "report_free"); + decltype(&strategy_set_input) mode = symbol(module, "strategy_set_input"); + decltype(&strategy_get_last_error) error = symbol(module, "strategy_get_last_error"); + decltype(&strategy_get_last_error_code) code = symbol(module, "strategy_get_last_error_code"); + decltype(&strategy_last_run_status) status = symbol(module, "strategy_last_run_status"); + decltype(&strategy_broker_state_hash) hash = symbol(module, "strategy_broker_state_hash"); + decltype(&strategy_closed_trade_entry_incarnation) incarnation = symbol(module, "strategy_closed_trade_entry_incarnation"); + decltype(&strategy_closed_trade_close_cause) cause = symbol(module, "strategy_closed_trade_close_cause"); + decltype(&strategy_closed_trade_entry_id) entry = symbol(module, "strategy_closed_trade_entry_id"); + decltype(&strategy_closed_trade_exit_id) exit = symbol(module, "strategy_closed_trade_exit_id"); + decltype(&strategy_closed_trade_exit_comment) comment = symbol(module, "strategy_closed_trade_exit_comment"); + decltype(&strategy_set_selected_window_v1) selected = symbol(module, "strategy_set_selected_window_v1"); + decltype(&strategy_set_execution_observer_v1) observer = symbol(module, "strategy_set_execution_observer_v1"); + decltype(&strategy_stream_begin) stream = symbol(module, "strategy_stream_begin"); + decltype(&strategy_stream_end) end = symbol(module, "strategy_stream_end"); + int (*busy)(pf_strategy_t) = symbol(module, "l6b1_busy_result"); + void (*again)(pf_strategy_t, pf_report_t*) = symbol(module, "l6b1_fill_again"); +}; +std::string read(const std::string& path) { + std::ifstream file(path, std::ios::binary); + return {std::istreambuf_iterator(file), std::istreambuf_iterator()}; +} +std::vector feed() { + std::vector bars; + for (int i = 0; i < 8; ++i) { + const double price = 100.0 + i * 0.25; + bars.push_back({price, price + 0.5, price - 0.5, price + 0.125, + 1.0, INT64_C(1700000040000) + i * INT64_C(60000)}); + } + return bars; +} +pf_report_sink_receipt_v1 receipt(const Api& api, pf_strategy_t handle) { + pf_report_sink_receipt_v1 value{}; + value.struct_size = sizeof value; + value.version = 1; + assert(api.receipt(handle, &value) == 0); + return value; +} +void install(const Api& api, pf_strategy_t handle, const std::string& path) { + // All three buffers go out of scope or are changed before the run. + std::string name = path, id = "lane-\"L6B1\"\n", scratch = "."; + pf_report_sink_v1 sink{sizeof sink, 1, name.c_str(), id.c_str(), scratch.c_str()}; + assert(api.set(handle, &sink) == 0); + name.assign("wrong-path"); id.assign("wrong-id"); scratch.assign("wrong-dir"); +} +std::vector records(const std::string& bytes) { + std::vector result; + for (std::size_t at = 0; at < bytes.size();) { + const auto end = bytes.find('\n', at); + assert(end != std::string::npos); + if (bytes[at] == '[') result.push_back(bytes.substr(at, end - at)); + at = end + 1; + } + return result; +} +std::string equity(const pf_equity_point_t& point) { + using pineforge::report_sink::f64; + return "[\"E\"," + std::to_string(point.time_ms) + ',' + f64(point.equity) + + ',' + f64(point.open_profit) + ']'; +} +std::string trade(const Api& api, pf_strategy_t handle, int i, const pf_trade_t& row) { + using pineforge::report_sink::f64; + using pineforge::report_sink::escaped_string; + std::string line = "[\"T\"," + std::to_string(i + 1) + + (row.is_long ? ",\"long\"," : ",\"short\",") + + std::to_string(row.entry_time) + ',' + std::to_string(row.exit_time); + for (const auto value : {row.entry_price, row.exit_price, row.qty, row.pnl, row.pnl_pct, + row.max_runup, row.max_drawdown, row.commission}) + line += ',' + f64(value); + line += ',' + std::to_string(row.entry_bar_index) + ',' + std::to_string(row.exit_bar_index); + line += row.open_at_end ? ",true," : ",false,"; + line += std::to_string(api.incarnation(handle, i)) + ',' + std::to_string(api.cause(handle, i)); + for (const auto value : {api.entry(handle, i), api.exit(handle, i), api.comment(handle, i)}) + line += ',' + escaped_string(value ? value : ""); + return line + ']'; +} +int observed(void* context, const pf_execution_boundary_v1* boundary, pf_boundary_receipt_v1* out) { + ++*static_cast(context); + *out = {sizeof(*out), 1, boundary->run_generation, 0, 0, 0, 0}; + return 0; +} +int rejected(void*, const pf_execution_boundary_v1*, pf_boundary_receipt_v1*) { return -1; } + +void verify_reference_reader(const std::string& path) { + // An independent Python parse and hashlib digest over this real module's + // bytes. Every prefix ending before the trailer must be refused. + static constexpr const char* script = R"PY( +import hashlib +import json +from pathlib import Path +import sys +sys.path.insert(0, sys.argv[1]) +from pf_sink import SinkReader, SinkFormatError +path = Path(sys.argv[2]) +data = path.read_bytes() +lines = data.splitlines(keepends=True) +trailer = json.loads(lines[-1]) +assert hashlib.sha256(b''.join(lines[:-1])).hexdigest() == trailer['sha256'] +with SinkReader(path) as reader: + rows = list(reader) + assert reader.complete and reader.trailer == trailer + assert reader.counts['E'] > 0 + assert set(reader.counts) == {'E', 'T'} +for count in range(len(lines)): + cut = path.with_name(path.name + '.cut-' + str(count)) + cut.write_bytes(b''.join(lines[:count])) + try: + with SinkReader(cut) as reader: + list(reader) + except SinkFormatError: + pass + else: + raise AssertionError('reader accepted a sink without its trailer') +)PY"; + const auto child = fork(); + assert(child >= 0); + if (child == 0) { + execl(PINEFORGE_L6B1_PYTHON, PINEFORGE_L6B1_PYTHON, "-B", "-c", script, + PINEFORGE_L6B1_DOCKER, path.c_str(), static_cast(nullptr)); + _exit(127); + } + int status = 0; + pid_t waited; + do { waited = waitpid(child, &status, 0); } while (waited < 0 && errno == EINTR); + assert(waited == child && WIFEXITED(status) && WEXITSTATUS(status) == 0); +} + +void equal_run(const Api& api, const std::string& path, bool selected, bool observer, bool empty, + std::size_t window_from = 0) { + const auto baseline = api.create("{}"), streamed = api.create("{}"); + auto bars = feed(); + int callbacks = 0; + pf_execution_observer_v1 ob{sizeof ob, 1, &callbacks, observed}; + for (const auto handle : {baseline, streamed}) { + if (empty) api.mode(handle, "mode", "empty"); + if (selected) { + pf_selected_window_config_v1 window{sizeof window, 1, bars[window_from].timestamp, + bars.back().timestamp + 60000}; + assert(api.selected(handle, &window) == 0); + } + if (observer) assert(api.observer(handle, &ob) == 0); + } + install(api, streamed, path); + assert(receipt(api, streamed).complete == 0); + pf_report_t before{}, after{}; + api.run(baseline, bars.data(), static_cast(bars.size()), &before); + api.run(streamed, bars.data(), static_cast(bars.size()), &after); + assert(api.error(baseline)[0] == '\0' && api.error(streamed)[0] == '\0'); + assert(api.status(streamed) == 0 && api.busy(streamed) == -3); + assert(api.hash(baseline) == api.hash(streamed)); + assert(before.total_trades == after.total_trades && before.net_profit == after.net_profit); + assert(std::memcmp(&before.metrics, &after.metrics, sizeof(before.metrics)) == 0); + assert(after.trades == nullptr && after.trades_len == 0); + assert(after.equity_curve == nullptr && after.equity_curve_len == 0); + assert(after.broker_state_hash == nullptr && after.broker_state_hash_len == 0); + assert(after.trace == nullptr && after.trace_len == 0); + assert(after.script_bars_processed == before.script_bars_processed); + const auto bytes = read(path); + const auto rows = records(bytes); + assert(bytes.find("\"run_id\":\"lane-\\\"L6B1\\\"\\u000a\"") != std::string::npos); + assert(bytes.find(std::string("\"engine\":\"") + PINEFORGE_VERSION_STRING + '"') != std::string::npos); + assert(bytes.find("\"type\":\"E\"") < bytes.find("\"type\":\"T\"")); + assert(rows.size() == static_cast(before.equity_curve_len + before.trades_len)); + std::size_t cursor = 0; + for (int64_t i = 0; i < before.equity_curve_len; ++i) assert(rows[cursor++] == equity(before.equity_curve[i])); + bool has_closed = false, has_open = false, has_short = false; + for (int i = 0; i < before.trades_len; ++i) { + assert(rows[cursor++] == trade(api, baseline, i, before.trades[i])); + has_closed |= !before.trades[i].open_at_end; + has_open |= before.trades[i].open_at_end != 0; + has_short |= !before.trades[i].is_long; + } + assert(empty ? before.trades_len == 0 : (has_closed && has_open && has_short)); + const auto got = receipt(api, streamed); + assert(got.complete == 1 && got.trades_retained == 1); + assert(got.count_e == before.equity_curve_len && got.count_t == before.trades_len); + assert(got.count_o == 0 && got.count_v == 0 && got.count_k == 0 && got.count_x == 0 && got.count_h == 0); + const auto trailer = bytes.find("{\"trailer\":"); + assert(trailer != std::string::npos); + pineforge::report_sink::Sha256 hash; + hash.update(std::string_view(bytes).substr(0, trailer)); + assert(hash.hex_digest() == got.sha256_hex); + assert(bytes.substr(trailer) == "{\"trailer\":\"pf-report-sink\",\"version\":1,\"counts\":{\"E\":" + + std::to_string(got.count_e) + ",\"T\":" + std::to_string(got.count_t) + + "},\"sha256\":\"" + got.sha256_hex + "\"}\n"); + for (const auto byte : got.reserved) assert(byte == 0); + verify_reference_reader(path); + pf_report_t again{}; + api.again(streamed, &again); + assert(read(path) == bytes && receipt(api, streamed).count_e == got.count_e); + assert(std::memcmp(&again.metrics, &after.metrics, sizeof(after.metrics)) == 0); + api.free(&again); api.free(&after); api.free(&before); + // A reused handle truncates a fresh generation and resets both counts. + api.mode(streamed, "mode", "empty"); + api.run(streamed, bars.data(), static_cast(bars.size()), &after); + assert(receipt(api, streamed).complete == 1 && receipt(api, streamed).count_t == 0); + api.free(&after); + assert(api.set(streamed, nullptr) == 0 && receipt(api, streamed).complete == 0); + api.destroy(streamed); api.destroy(baseline); + assert(!observer || callbacks == 3); +} +void refusals(const Api& api, const std::string& directory) { + auto handle = api.create("{}"); + assert(api.version() == 1); + assert(api.set(nullptr, nullptr) == -1); + pf_report_sink_receipt_v1 bad; + std::memset(&bad, 0xa5, sizeof bad); + const auto saved = bad; + assert(api.receipt(handle, &bad) == -1 && std::memcmp(&bad, &saved, sizeof bad) == 0); + bad.struct_size = sizeof bad; bad.version = 1; + const auto valid = bad; + assert(api.receipt(nullptr, &bad) == -1 && std::memcmp(&bad, &valid, sizeof bad) == 0); + assert(api.receipt(handle, nullptr) == -1); + bad.version = 2; + const auto wrong_version = bad; + assert(api.receipt(handle, &bad) == -1 + && std::memcmp(&bad, &wrong_version, sizeof bad) == 0); + auto initial = receipt(api, handle); + assert(initial.count_e == 0 && initial.complete == 0 && initial.sha256_hex[0] == '\0'); + pf_report_sink_v1 sink{sizeof sink, 1, "", nullptr, nullptr}; + assert(api.set(handle, &sink) == -1); + sink.path = "."; sink.version = 2; + assert(api.set(handle, &sink) == -1); + sink.version = 1; --sink.struct_size; + assert(api.set(handle, &sink) == -1); + sink = {sizeof sink, 1, nullptr, nullptr, nullptr}; + assert(api.set(handle, &sink) == -1); + auto bars = feed(); + install(api, handle, directory + "/stream-refused.ndjson"); + assert(api.stream(handle, bars.data(), 1, "1", "1") != 0); + assert(receipt(api, handle).complete == 0); + assert(api.set(handle, nullptr) == 0); + assert(api.stream(handle, bars.data(), 1, "1", "1") == 0); + sink = {sizeof sink, 1, "/unused", nullptr, nullptr}; + assert(api.set(handle, &sink) == -2 && api.set(handle, nullptr) == -2); + assert(api.end(handle, 0) == 0); + api.destroy(handle); + handle = api.create("{}"); + const auto default_path = directory + "/default-strings.ndjson"; + sink = {sizeof sink, 1, default_path.c_str(), nullptr, nullptr}; + assert(api.set(handle, &sink) == 0); + api.mode(handle, "mode", "empty"); + pf_report_t defaults{}; + api.run(handle, bars.data(), static_cast(bars.size()), &defaults); + const auto completed = receipt(api, handle); + assert(completed.complete == 1); + assert(read(default_path).find("\"run_id\":\"\"") != std::string::npos); + sink.version = 2; + assert(api.set(handle, &sink) == -1); + const auto unchanged = receipt(api, handle); + assert(std::memcmp(&completed, &unchanged, sizeof completed) == 0); + api.free(&defaults); + api.destroy(handle); + for (const std::string mode : {"throw", "abort", "observer", "selected", "io", "write-io"}) { + handle = mode == "selected" ? api.create_carry() : api.create("{}"); + const std::string path = mode == "io" ? directory + "/missing/failed.ndjson" + : mode == "write-io" ? "/dev/full" : directory + '/' + mode + ".ndjson"; + if (mode == "write-io") { + std::ifstream full("/dev/full"); + if (!full) { api.destroy(handle); continue; } // Linux-only write fault. + } + install(api, handle, path); + if (mode == "throw" || mode == "abort") api.mode(handle, "mode", mode.c_str()); + if (mode == "observer") { + pf_execution_observer_v1 ob{sizeof ob, 1, nullptr, rejected}; + assert(api.observer(handle, &ob) == 0); + } + if (mode == "selected") { + pf_selected_window_config_v1 window{sizeof window, 1, bars[3].timestamp, + bars.back().timestamp + 60000}; + assert(api.selected(handle, &window) == 0); + } + pf_report_t report{}; + api.run(handle, bars.data(), static_cast(bars.size()), &report); + const auto got = receipt(api, handle); + if (got.complete != 0 || got.sha256_hex[0] != '\0') + std::fprintf(stderr, "refusal case=%s complete=%u status=%d code=%s error=%s path=%s\n", + mode.c_str(), static_cast(got.complete), api.status(handle), api.code(handle), + api.error(handle), path.c_str()); + assert(got.complete == 0 && got.sha256_hex[0] == '\0'); + assert(api.status(handle) != 0 || api.error(handle)[0] != '\0'); + if (mode == "selected") { + std::fprintf(stderr, "selected carry-in refusal: code=%s error=%s\n", + api.code(handle), api.error(handle)); + assert(std::strcmp(api.code(handle), "engine_invariant") == 0); + assert(std::strstr(api.error(handle), + "native selected report: a trade was entered before the window start") != nullptr); + } + if (mode == "io" || mode == "write-io") assert(std::strcmp(api.code(handle), "report_sink_failed") == 0); + if (mode != "write-io") assert(read(path).find("{\"trailer\":") == std::string::npos); + api.free(&report); api.destroy(handle); + } +} +} +int main() { + try { + Api api; + char directory[] = "/tmp/pineforge-l6b1-XXXXXX"; + if (!mkdtemp(directory)) throw std::runtime_error("cannot create test output directory"); + refusals(api, directory); + equal_run(api, std::string(directory) + "/ordinary.ndjson", false, false, false); + equal_run(api, std::string(directory) + "/selected.ndjson", true, false, false); + equal_run(api, std::string(directory) + "/selected-preroll.ndjson", true, false, false, 3); + equal_run(api, std::string(directory) + "/observed.ndjson", false, true, false); + equal_run(api, std::string(directory) + "/selected-observed.ndjson", true, true, false); + equal_run(api, std::string(directory) + "/empty.ndjson", false, false, true); + std::printf("L6B1 sink ABI, real-module E/T identity, receipt, lifecycle: passed (%s)\n", directory); + return 0; + } catch (const std::exception& error) { + std::fprintf(stderr, "L6B1: %s\n", error.what()); + return 1; + } +} +#endif