diff --git a/CHANGELOG.md b/CHANGELOG.md index a730d4aae..8f699648d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ README's *Releases* section and on the GitHub releases page. From 1.0.0 the version number follows semantic versioning over the surfaces the [public contract](docs/pages/public-contract.md) lists. +## Unreleased + +- **Recorded outputs.** A module can record what it computes on each bar + besides its trades: doubles in named slots per bar, doubles kept once per + run, and events with an optional message, one row per bar the module + publishes (`docs/outputs.md`). A generated module or a C++ host writes them + through new protected `BacktestEngine` members (`declare_outputs`, + `output_run_begin`, `output_bar`, `output_value`, `output_event`, + `output_constant`); a caller reads them through nine new runtime exports of + `pineforge.h`, group `pf_outputs` (`strategy_outputs_set_enabled`, the + `strategy_outputs_*` readers, the size-prefixed `pf_output_event_v1_t`), + present in every module built on this engine, and a recording module adds + `strategy_outputs_api_version`, `strategy_outputs_manifest` and + `strategy_signal_safety_receipt`. `docker/run_json.py --outputs` writes the + record as the report's `outputs` block. Every refusal carries a run-failure + code: the new `outputs_rejected` (reasons `not_declared`, `run_in_progress`, + `recalculated_after_clear`), the new `outputs_limit` (reasons + `too_many_events`, `too_many_rows`), `strategy_library_incompatible` with the + new reasons `outputs_api_mismatch` and `outputs_manifest_invalid`, and + `engine_invariant` for a broken recorder precondition (all additions to + `docker/run_failure_codes.json`). Recording is off until a caller + turns it on, and nothing a run computes depends on it: trades, reports, + metrics and every hash are byte-identical, `pf_report_t` and every existing + symbol are unchanged, and `PF_ABI_VERSION` stays 4. The new members enter + `engine_script_run_v19` without an epoch; a C++ object compiled against an + earlier v19 header is rebuilt with the library. + ## 1.4.0 — 2026-10-08 **[1.4.0](https://github.com/pineforge-4pass/pineforge-engine/releases/tag/v1.4.0)**, diff --git a/README.md b/README.md index f90f96534..bab3614fb 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,10 @@ 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, 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.** 124 `PF_API` declarations across two headers — `pineforge.h`'s 81 (64 runtime exports and up to 17 per-strategy functions a generated module defines) and `native_c_api.h`'s 43 native-host 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.** 136 `PF_API` declarations across two headers — `pineforge.h`'s 93 (73 runtime exports and up to 20 per-strategy functions a generated module defines) and `native_c_api.h`'s 43 native-host 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 +unreleased work targeted for **1.5.0**; they are not part of 1.4.0. **[1.4.0](https://github.com/pineforge-4pass/pineforge-engine/releases/tag/v1.4.0) was released on 2026-10-08**, paired with @@ -501,11 +504,13 @@ API, the script ABI epoch and the pairing with codegen — is the [public contract](docs/pages/public-contract.md); what a 0.x user must act on is in [CHANGELOG.md](CHANGELOG.md). -A newly generated strategy `.so` exposes 80 compiled-strategy `PF_API` declarations -(64 runtime implementations plus sixteen generated exports) plus 43 native-host -declarations: 123 `PF_API` exports in total; a script that declares -`use_bar_magnifier = true` also exports `strategy_declares_bar_magnifier`, bringing -the inventories to 81 and 124. Older modules lack the six opt-in +The counts below include recorded outputs and describe the unreleased API targeted for 1.5.0; recorded outputs are not part of 1.4.0. + +A newly generated strategy `.so` exposes 89 compiled-strategy `PF_API` declarations +(73 runtime implementations plus sixteen generated exports) plus 43 native-host +declarations: 132 `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 93 and 136. 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/README.md b/docker/README.md index 9a74c00bc..b00a23870 100644 --- a/docker/README.md +++ b/docker/README.md @@ -266,6 +266,33 @@ docker run --rm \ or an index naming no symbol: the key is absent and the report is what it was before this variable existed, apart from `elapsed_seconds`. +### Recorded outputs (the harness's `--outputs`) + +This section describes unreleased support targeted for **1.5.0**. Released +1.4.0 images do not provide recorded outputs. + +A library compiled to record outputs keeps, besides its trades, values per bar, +values per run and events with an optional message (`docs/outputs.md` in the +engine repository). `run_json.py --outputs` turns recording on and writes the +record as the report's `outputs` key, just before `fingerprint`, with +`"outputs": true` in `applied_runtime`: the bars' times, each listed series, +the run constants, each horizontal level's price and every event (sequence, +output, bar, phase, value, message). Its keys are listed in the engine's +report-schema page. Without the flag the report is unchanged. On a library that +records no outputs the run fails before it starts: one line +`{"engine":"pineforge","error":"--outputs: this library records no outputs (compile the script as an indicator, or with outputs on)","code":"outputs_rejected","args":{"reason":"not_declared"}}` +on stdout, harness exit 1. With `--bench` the timed runs record too. + +Recording preserves the typed provenance rules described below. The runtime +fact `"outputs": true` remains a boolean in both `applied_runtime` and the +fingerprinted provenance; input and override normalization runs before the +fingerprint is built. Recording does not certify a foreign source or replace +the native settings receipt. Without `--outputs`, no output API is called and +no `"outputs": false` runtime field is added. A provenance normalization +failure keeps the existing `fingerprint: null` behavior, including a recorded +output block when recording succeeded; an invalid output manifest instead +produces the structured failure line and no partial report. + The engine catches run errors (TF mismatch, unsupported emulation flags, unknown-input-TF, etc.) into `strategy_get_last_error()` and, with the 1.4.0 runtime, its code/args getters. The failure line below describes both diff --git a/docker/run_failure_codes.json b/docker/run_failure_codes.json index 5838c35de..ad5370175 100644 --- a/docker/run_failure_codes.json +++ b/docker/run_failure_codes.json @@ -249,6 +249,19 @@ "null admission history event", "null strategy", "null strategy, key or value", + "outputs: bar opened at {open_ms} is before the last recorded bar", + "outputs: declare_outputs repeated with other counts", + "outputs: declare_outputs while a run is in progress", + "outputs: declared counts must not be negative", + "outputs: event sequence overflow", + "outputs: output {output} is out of range", + "outputs: output_bar before any output_run_begin", + "outputs: output_constant before any output_run_begin", + "outputs: output_event with no open bar", + "outputs: output_value with no open bar", + "outputs: run constant {index} changed within a run", + "outputs: run constant {index} is out of range", + "outputs: series slot {slot} is out of range", "params_json is reserved; use checked setters", "quantity fraction requires a positive finite denominator", "quantity grid requires a finite positive step", @@ -453,6 +466,51 @@ "retryable": true, "since": "1.4.0" }, + "outputs_limit": { + "args": { + "max": { + "kind": "integer" + }, + "reason": { + "kind": "vocab", + "values": [ + "too_many_events", + "too_many_rows" + ] + } + }, + "class": "strategy_limit", + "description": "A recorded-outputs capacity limit was reached during the run.", + "english": [ + "outputs: the event queue is full", + "outputs: the row count is at its limit" + ], + "retryable": false, + "since": "1.5.0" + }, + "outputs_rejected": { + "args": { + "reason": { + "kind": "vocab", + "values": [ + "not_declared", + "run_in_progress", + "recalculated_after_clear" + ] + } + }, + "class": "input", + "description": "Recorded outputs were asked of a strategy library that records none, or the recording cannot be honoured as asked.", + "english": [ + "--outputs: this library records no outputs (compile the script as an indicator, or with outputs on)", + "--outputs: {message}", + "outputs: bar {bar} was recalculated after its events were cleared", + "outputs: recording cannot change during a run", + "outputs: this module declares no outputs" + ], + "retryable": false, + "since": "1.5.0" + }, "pine_array_error": { "args": { "collection": { @@ -1117,13 +1175,26 @@ "abi_mismatch", "symbol_missing", "setter_missing", - "settings_api_mismatch" + "settings_api_mismatch", + "outputs_api_mismatch", + "outputs_manifest_invalid" ] } }, "class": "engine_fault", "description": "The compiled strategy library does not match the harness.", "english": [ + "--outputs: event {index} could not be read", + "--outputs: series slot {slot} could not be read", + "--outputs: the bar times could not be read", + "--outputs: the library lacks {export}, which recording needs; rebuild.", + "--outputs: the library returned no outputs manifest: {error}", + "--outputs: the library's outputs API version is {version}, the harness reads 1; rebuild.", + "--outputs: the outputs manifest is not JSON: {error}", + "--outputs: the outputs manifest is not a JSON object", + "--outputs: the outputs manifest lists no output {index}, which an event names", + "--outputs: the outputs manifest names series slot {slot}, which the library does not record", + "--outputs: the outputs manifest's {key} is not a list of entries with {fields}", "cannot load the strategy library: {error}", "checked settings API mismatch: the strategy library must export strategy_settings_api_version() == 1, strategy_create_checked, strategy_set_input_checked and strategy_set_override_checked, or none of them; rebuild.", "pineforge ABI mismatch: .so reports {abi}, harness expects 4; rebuild.", diff --git a/docker/run_failure_codes_diff.json b/docker/run_failure_codes_diff.json index 340142bc2..dd90d91e8 100644 --- a/docker/run_failure_codes_diff.json +++ b/docker/run_failure_codes_diff.json @@ -8,15 +8,545 @@ "to": { "version": null, "unreleased": true, - "catalogSha256": "dcd701fe4bdd57a38d6858e96f719c1001f07f4a035f6b8ad25d720d4b83f601" + "catalogSha256": "bd2b3dcbe1646275cb392e8f3408cccab21186671be4908fb72aa0d54f19d596" }, "catalogSchema": { "before": "pineforge-run-failure-catalog/v1", "after": "pineforge-run-failure-catalog/v1" }, "metadataChanges": [], - "added": [], - "changed": [], + "added": [ + { + "code": "outputs_limit", + "collection": "codes", + "entry": { + "args": { + "max": { + "kind": "integer" + }, + "reason": { + "kind": "vocab", + "values": [ + "too_many_events", + "too_many_rows" + ] + } + }, + "class": "strategy_limit", + "description": "A recorded-outputs capacity limit was reached during the run.", + "english": [ + "outputs: the event queue is full", + "outputs: the row count is at its limit" + ], + "retryable": false, + "since": "1.5.0" + } + }, + { + "code": "outputs_rejected", + "collection": "codes", + "entry": { + "args": { + "reason": { + "kind": "vocab", + "values": [ + "not_declared", + "run_in_progress", + "recalculated_after_clear" + ] + } + }, + "class": "input", + "description": "Recorded outputs were asked of a strategy library that records none, or the recording cannot be honoured as asked.", + "english": [ + "--outputs: this library records no outputs (compile the script as an indicator, or with outputs on)", + "--outputs: {message}", + "outputs: bar {bar} was recalculated after its events were cleared", + "outputs: recording cannot change during a run", + "outputs: this module declares no outputs" + ], + "retryable": false, + "since": "1.5.0" + } + } + ], + "changed": [ + { + "code": "engine_invariant", + "collection": "codes", + "fields": [ + { + "path": "english", + "beforePresent": true, + "afterPresent": true, + "before": [ + "Pine OCA member sequence exhausted", + "Pine adapter produced invalid native run spec field {field}", + "Pine anchored OCA member sequence exhausted", + "Pine execution adapter is not bound to a native host", + "Pine native adapter failed to configure projected run spec", + "Pine native run counter exhausted", + "Pine source command sequence exhausted", + "Pine source sequence exhausted", + "UDT arena requires undo coordinator", + "UDT checkpoint generation exhausted", + "UDT coordinator checkpoint is not active", + "admission event has no outstanding allocation", + "admission event sequence exhausted", + "admission receipt requires its exact earlier command", + "auxiliary request.security bar does not map to a native chart bar", + "auxiliary request.security chart routing is not initialized", + "auxiliary request.security routing requires a calling chart bar", + "bad_function_call", + "bar count must be non-negative", + "bars must be non-null for a nonempty array", + "cannot copy admission journal with active captures", + "cannot move admission journal with active captures", + "cannot replace admission journal with active captures", + "cannot reset admission journal with unfinished events", + "closed trade counter exhausted", + "command capture requires its allocated event", + "command event requires an observation", + "configure refused while ready; use a fresh host", + "configure refused while running", + "core-sized quantity without the core's quotient", + "current execution points require a guarded target", + "definition identity mismatch", + "deque::_M_range_check: __n (which is {n})>= this->size() (which is {size})", + "draft does not belong to this admission review", + "empty prepared cancel", + "empty prepared replace", + "empty prepared submit", + "execution effect capacity exhausted", + "exit lifecycle already attached", + "first admission review cannot be replaced", + "input was not installed", + "invalid Pine exit placement evidence", + "invalid UDT checkpoint token", + "invalid UDT coordinator checkpoint token", + "invalid UDT undo generation", + "invalid batch arguments", + "invalid exit lifecycle fork", + "invalid exit-leg activation bounds", + "invalid or repeated reservation expansion capture", + "invalid order-birth dispatch cursor", + "invalid order-birth fill evaluation", + "map key index invariant violation", + "map restore from invalid snapshot", + "map.size: result exceeds int range", + "market draft requires one original command observation", + "matrix restore from invalid snapshot", + "native aborted host has no reusable run spec", + "native active trail has no representable trigger", + "native auxiliary feed parse failed at begin", + "native auxiliary feed projection found an unaligned input", + "native bar count is invalid", + "native bars require a non-null array", + "native begin is forbidden while running", + "native begin requires Ready", + "native bound terms have no remaining quantity", + "native calendar apply failed", + "native calendar parse failed during input preflight", + "native cancel install failed", + "native cancel produced no preparation", + "native cohort allocation refused", + "native cohort scope identity mismatch", + "native cohort selection is empty", + "native cohort selection is flat", + "native current evaluated allowance mismatch", + "native current evaluation failed", + "native current evaluation has no terminal outcome", + "native current evaluation install failed", + "native current evaluation lost target without outcome", + "native current execution failed", + "native current execution projection/abort failure", + "native current terminal drain failed", + "native driver-log capacity exhausted", + "native execution install failed after settlement", + "native execution install missing preparation", + "native execution receipt mismatch", + "native host already failed", + "native host-sized arithmetic probe did not fail", + "native host-sized candidate silently skipped after binding", + "native host-sized opening has no side", + "native host-sized opening shape is unknown", + "native instrument series lost its feed", + "native intrabar path produced no samples", + "native journal position is not retained", + "native limit state has no limit trigger", + "native liquidation submit install failed", + "native liquidation submit produced no preparation", + "native liquidation withdrawal install failed", + "native liquidation withdrawal produced no preparation", + "native mutation install failed", + "native mutation install missing preparation", + "native nonfillable state reached matched coordinator", + "native nonfillable state reached terms coordinator", + "native notification identity mismatch", + "native notification requires native host", + "native observation capacity exhausted", + "native opening scope identity mismatch", + "native order incarnation and timeline counters must be distinct", + "native order incarnation must be nonzero", + "native order incarnation reused or regressed", + "native precommit requires a native host", + "native preflight requires a staged spec", + "native prepare_native_begin cannot reenter", + "native projection mismatch", + "native public input cannot reenter an active callback or input", + "native replace install failed", + "native replace produced no preparation", + "native reversal plan reached an action-only settlement", + "native run identity requires a nonempty session key and positive run number", + "native run number does not exceed high-water", + "native run number must exceed consumed high-water", + "native run requires configure_native", + "native run spec rejected", + "native script interval lookup failed", + "native selected scope identity mismatch", + "native session key already bound", + "native session key cannot change on a reused host", + "native settlement commit failed", + "native settlement inspection failed", + "native staged account-currency FX was rejected", + "native stop state has no stop trigger", + "native stream timeframe parse failed", + "native stream_advance_time requires realtime", + "native stream_begin requires Ready", + "native stream_end lost running state", + "native stream_end requires a running host", + "native submit install failed", + "native submit produced no preparation", + "native terminal history is empty", + "native terminal outcome missing", + "native terms preparation did not produce a mutation", + "native terms require a native host", + "native terms roster units are not finite", + "native time advance regresses the decision floor", + "native timeframe subscription feed was refused", + "native timeframe subscription names an undeclared auxiliary feed", + "native timeframe subscription names an undeclared instrument feed", + "native timeframe subscription parse failed at begin", + "native timeframe subscription registration is inconsistent", + "native timeframe subscriptions require a native strategy host", + "native timeline ordinal exhausted", + "native timeline ordinal must be nonzero", + "native timeline ordinal reused or regressed", + "native trailing offset is not representable", + "native unresolved execution authority", + "native unresolved execution quantity", + "native warmup bar count is invalid", + "native warmup bars require a non-null array", + "native working-request capacity exhausted", + "native working-request preparation failed", + "null admission history event", + "null strategy", + "null strategy, key or value", + "params_json is reserved; use checked setters", + "quantity fraction requires a positive finite denominator", + "quantity grid requires a finite positive step", + "quantity reservation requires an original request", + "receipt buffer too small", + "request data: the run spec holds other symbol feeds", + "request data: the run spec no longer holds the symbol feeds", + "request.security_lower_tf auxiliary routing is not initialized", + "request.security_lower_tf could not synthesize auxiliary sub-bars", + "request.security_lower_tf: internal error - passed integer-ratio check but emulation support returned false (requested '{requested}', input '{input}')", + "required byte count pointer is null", + "reservation closure requires an admitted incarnation", + "reservation source requires distinct live incarnations", + "review capture requires an allocated batch checkpoint", + "review requires a known checkpoint", + "settings are frozen after execution begins", + "shortest-decimal conversion failed", + "sizing revision cannot rewrite or precede its latest cause", + "sizing revision requires committed cause", + "source broker fill sequence exhausted", + "source delayed market epoch exhausted", + "source placement handle is absent", + "source placement incarnation is out of range", + "std::get: wrong index for variant", + "strategy output pointer is null", + "stream is already realtime", + "unknown source series", + "vector::_M_range_check: __n (which is {n}) >= this->size() (which is {size})", + "{any}", + "{entrypoint}: {message}" + ], + "after": [ + "Pine OCA member sequence exhausted", + "Pine adapter produced invalid native run spec field {field}", + "Pine anchored OCA member sequence exhausted", + "Pine execution adapter is not bound to a native host", + "Pine native adapter failed to configure projected run spec", + "Pine native run counter exhausted", + "Pine source command sequence exhausted", + "Pine source sequence exhausted", + "UDT arena requires undo coordinator", + "UDT checkpoint generation exhausted", + "UDT coordinator checkpoint is not active", + "admission event has no outstanding allocation", + "admission event sequence exhausted", + "admission receipt requires its exact earlier command", + "auxiliary request.security bar does not map to a native chart bar", + "auxiliary request.security chart routing is not initialized", + "auxiliary request.security routing requires a calling chart bar", + "bad_function_call", + "bar count must be non-negative", + "bars must be non-null for a nonempty array", + "cannot copy admission journal with active captures", + "cannot move admission journal with active captures", + "cannot replace admission journal with active captures", + "cannot reset admission journal with unfinished events", + "closed trade counter exhausted", + "command capture requires its allocated event", + "command event requires an observation", + "configure refused while ready; use a fresh host", + "configure refused while running", + "core-sized quantity without the core's quotient", + "current execution points require a guarded target", + "definition identity mismatch", + "deque::_M_range_check: __n (which is {n})>= this->size() (which is {size})", + "draft does not belong to this admission review", + "empty prepared cancel", + "empty prepared replace", + "empty prepared submit", + "execution effect capacity exhausted", + "exit lifecycle already attached", + "first admission review cannot be replaced", + "input was not installed", + "invalid Pine exit placement evidence", + "invalid UDT checkpoint token", + "invalid UDT coordinator checkpoint token", + "invalid UDT undo generation", + "invalid batch arguments", + "invalid exit lifecycle fork", + "invalid exit-leg activation bounds", + "invalid or repeated reservation expansion capture", + "invalid order-birth dispatch cursor", + "invalid order-birth fill evaluation", + "map key index invariant violation", + "map restore from invalid snapshot", + "map.size: result exceeds int range", + "market draft requires one original command observation", + "matrix restore from invalid snapshot", + "native aborted host has no reusable run spec", + "native active trail has no representable trigger", + "native auxiliary feed parse failed at begin", + "native auxiliary feed projection found an unaligned input", + "native bar count is invalid", + "native bars require a non-null array", + "native begin is forbidden while running", + "native begin requires Ready", + "native bound terms have no remaining quantity", + "native calendar apply failed", + "native calendar parse failed during input preflight", + "native cancel install failed", + "native cancel produced no preparation", + "native cohort allocation refused", + "native cohort scope identity mismatch", + "native cohort selection is empty", + "native cohort selection is flat", + "native current evaluated allowance mismatch", + "native current evaluation failed", + "native current evaluation has no terminal outcome", + "native current evaluation install failed", + "native current evaluation lost target without outcome", + "native current execution failed", + "native current execution projection/abort failure", + "native current terminal drain failed", + "native driver-log capacity exhausted", + "native execution install failed after settlement", + "native execution install missing preparation", + "native execution receipt mismatch", + "native host already failed", + "native host-sized arithmetic probe did not fail", + "native host-sized candidate silently skipped after binding", + "native host-sized opening has no side", + "native host-sized opening shape is unknown", + "native instrument series lost its feed", + "native intrabar path produced no samples", + "native journal position is not retained", + "native limit state has no limit trigger", + "native liquidation submit install failed", + "native liquidation submit produced no preparation", + "native liquidation withdrawal install failed", + "native liquidation withdrawal produced no preparation", + "native mutation install failed", + "native mutation install missing preparation", + "native nonfillable state reached matched coordinator", + "native nonfillable state reached terms coordinator", + "native notification identity mismatch", + "native notification requires native host", + "native observation capacity exhausted", + "native opening scope identity mismatch", + "native order incarnation and timeline counters must be distinct", + "native order incarnation must be nonzero", + "native order incarnation reused or regressed", + "native precommit requires a native host", + "native preflight requires a staged spec", + "native prepare_native_begin cannot reenter", + "native projection mismatch", + "native public input cannot reenter an active callback or input", + "native replace install failed", + "native replace produced no preparation", + "native reversal plan reached an action-only settlement", + "native run identity requires a nonempty session key and positive run number", + "native run number does not exceed high-water", + "native run number must exceed consumed high-water", + "native run requires configure_native", + "native run spec rejected", + "native script interval lookup failed", + "native selected scope identity mismatch", + "native session key already bound", + "native session key cannot change on a reused host", + "native settlement commit failed", + "native settlement inspection failed", + "native staged account-currency FX was rejected", + "native stop state has no stop trigger", + "native stream timeframe parse failed", + "native stream_advance_time requires realtime", + "native stream_begin requires Ready", + "native stream_end lost running state", + "native stream_end requires a running host", + "native submit install failed", + "native submit produced no preparation", + "native terminal history is empty", + "native terminal outcome missing", + "native terms preparation did not produce a mutation", + "native terms require a native host", + "native terms roster units are not finite", + "native time advance regresses the decision floor", + "native timeframe subscription feed was refused", + "native timeframe subscription names an undeclared auxiliary feed", + "native timeframe subscription names an undeclared instrument feed", + "native timeframe subscription parse failed at begin", + "native timeframe subscription registration is inconsistent", + "native timeframe subscriptions require a native strategy host", + "native timeline ordinal exhausted", + "native timeline ordinal must be nonzero", + "native timeline ordinal reused or regressed", + "native trailing offset is not representable", + "native unresolved execution authority", + "native unresolved execution quantity", + "native warmup bar count is invalid", + "native warmup bars require a non-null array", + "native working-request capacity exhausted", + "native working-request preparation failed", + "null admission history event", + "null strategy", + "null strategy, key or value", + "outputs: bar opened at {open_ms} is before the last recorded bar", + "outputs: declare_outputs repeated with other counts", + "outputs: declare_outputs while a run is in progress", + "outputs: declared counts must not be negative", + "outputs: event sequence overflow", + "outputs: output {output} is out of range", + "outputs: output_bar before any output_run_begin", + "outputs: output_constant before any output_run_begin", + "outputs: output_event with no open bar", + "outputs: output_value with no open bar", + "outputs: run constant {index} changed within a run", + "outputs: run constant {index} is out of range", + "outputs: series slot {slot} is out of range", + "params_json is reserved; use checked setters", + "quantity fraction requires a positive finite denominator", + "quantity grid requires a finite positive step", + "quantity reservation requires an original request", + "receipt buffer too small", + "request data: the run spec holds other symbol feeds", + "request data: the run spec no longer holds the symbol feeds", + "request.security_lower_tf auxiliary routing is not initialized", + "request.security_lower_tf could not synthesize auxiliary sub-bars", + "request.security_lower_tf: internal error - passed integer-ratio check but emulation support returned false (requested '{requested}', input '{input}')", + "required byte count pointer is null", + "reservation closure requires an admitted incarnation", + "reservation source requires distinct live incarnations", + "review capture requires an allocated batch checkpoint", + "review requires a known checkpoint", + "settings are frozen after execution begins", + "shortest-decimal conversion failed", + "sizing revision cannot rewrite or precede its latest cause", + "sizing revision requires committed cause", + "source broker fill sequence exhausted", + "source delayed market epoch exhausted", + "source placement handle is absent", + "source placement incarnation is out of range", + "std::get: wrong index for variant", + "strategy output pointer is null", + "stream is already realtime", + "unknown source series", + "vector::_M_range_check: __n (which is {n}) >= this->size() (which is {size})", + "{any}", + "{entrypoint}: {message}" + ] + } + ] + }, + { + "code": "strategy_library_incompatible", + "collection": "codes", + "fields": [ + { + "path": "args.reason.values", + "beforePresent": true, + "afterPresent": true, + "before": [ + "load_failed", + "abi_missing", + "abi_mismatch", + "symbol_missing", + "setter_missing", + "settings_api_mismatch" + ], + "after": [ + "load_failed", + "abi_missing", + "abi_mismatch", + "symbol_missing", + "setter_missing", + "settings_api_mismatch", + "outputs_api_mismatch", + "outputs_manifest_invalid" + ] + }, + { + "path": "english", + "beforePresent": true, + "afterPresent": true, + "before": [ + "cannot load the strategy library: {error}", + "checked settings API mismatch: the strategy library must export strategy_settings_api_version() == 1, strategy_create_checked, strategy_set_input_checked and strategy_set_override_checked, or none of them; rebuild.", + "pineforge ABI mismatch: .so reports {abi}, harness expects 4; rebuild.", + "strategy .so predates pf_abi_version (ABI v1); rebuild it against the current pineforge runtime (pf_report_t grew).", + "the strategy library has no strategy_set_syminfo_metadata, so syminfo.mincontract cannot be applied", + "the strategy library has no {export}", + "the strategy library has no {setter}, so syminfo.{key} cannot be applied" + ], + "after": [ + "--outputs: event {index} could not be read", + "--outputs: series slot {slot} could not be read", + "--outputs: the bar times could not be read", + "--outputs: the library lacks {export}, which recording needs; rebuild.", + "--outputs: the library returned no outputs manifest: {error}", + "--outputs: the library's outputs API version is {version}, the harness reads 1; rebuild.", + "--outputs: the outputs manifest is not JSON: {error}", + "--outputs: the outputs manifest is not a JSON object", + "--outputs: the outputs manifest lists no output {index}, which an event names", + "--outputs: the outputs manifest names series slot {slot}, which the library does not record", + "--outputs: the outputs manifest's {key} is not a list of entries with {fields}", + "cannot load the strategy library: {error}", + "checked settings API mismatch: the strategy library must export strategy_settings_api_version() == 1, strategy_create_checked, strategy_set_input_checked and strategy_set_override_checked, or none of them; rebuild.", + "pineforge ABI mismatch: .so reports {abi}, harness expects 4; rebuild.", + "strategy .so predates pf_abi_version (ABI v1); rebuild it against the current pineforge runtime (pf_report_t grew).", + "the strategy library has no strategy_set_syminfo_metadata, so syminfo.mincontract cannot be applied", + "the strategy library has no {export}", + "the strategy library has no {setter}, so syminfo.{key} cannot be applied" + ] + } + ] + } + ], "removed": [], "deprecated": [] } diff --git a/docker/run_json.py b/docker/run_json.py index d1e975398..456f6ed06 100755 --- a/docker/run_json.py +++ b/docker/run_json.py @@ -87,13 +87,37 @@ "source_values_sha256": ""}}}}} Without --symbol-feeds (or with an index naming no symbol) the key is absent. +--outputs reads what a library that records outputs recorded (the group +pf_outputs of pineforge.h; docs/outputs.md): the report gains "outputs", +written just before "fingerprint", and applied_runtime (and so +provenance.runtime) holds "outputs": true. See build_outputs_block: + "outputs": { + "schema_version": "pineforge-outputs/v1", + "message_format": "pineforge/v1", + "manifest_sha256": "", + "manifest": { ...the library's outputs manifest... }, + "bars": {"open_ms": [int, ...], "close_ms": [int, ...]}, + "series": [{"slot": int, "output": "", "values": [number|null, ...]}, ...], + "constants": [number|null, ...], # one per run-constant index + "hlines": [{"output": "", "price": number|null}, ...], + "events": [{"sequence": int, "output": "", "bar_index": int, + "bar_open_ms": int, "bar_close_ms": int, "ordinal_in_bar": int, + "phase": "batch" | "warmup" | "realtime", + "value": number|null, "message": str|null, + "freq": str}, ...] # freq: an alert output's events only + } +A double that is not finite is null, as everywhere in the report; a time +equal to INT64_MIN is null; a slot or constant whose manifest encoding is +"rgba-u32" is written as an integer. --bench --outputs records in the timed +runs too. Without --outputs nothing of this is read or written. + A failed run prints one line instead, exit status 1 (2 for a command line argparse refuses): {"engine":"pineforge","error":"","code":"","args":{...}} That is the run's own error, a --syminfo the harness rejects (see apply_syminfo), a --symbol-feeds it cannot install (see load_symbol_feeds), a -setting the strategy refuses, or any other failure of the harness (see -failure_line and main). "code" is a stable code of the closed vocabulary +setting the strategy refuses, an --outputs it cannot honour (see OutputsError), +or any other failure of the harness (see failure_line and main). "code" is a stable code of the closed vocabulary docker/run_failure_codes.json and "args" its typed arguments. The engine's code is read only from strategy_get_last_error_code and its args from strategy_get_last_error_args. A run failure from a library without the code @@ -124,6 +148,7 @@ import sys import time import traceback +import types from datetime import datetime, timezone from pathlib import Path @@ -2369,6 +2394,26 @@ class ReportC(ctypes.Structure): ] +class OutputEventC(ctypes.Structure): + """Mirror of pf_output_event_v1_t (strategy_outputs_event_get).""" + _fields_ = [ + ("struct_version", ctypes.c_uint32), + ("size", ctypes.c_uint32), + ("sequence", ctypes.c_uint64), + ("output_index", ctypes.c_int32), + ("bar_index", ctypes.c_int32), + ("bar_open_ms", ctypes.c_int64), + ("bar_close_ms", ctypes.c_int64), + ("ordinal_in_bar", ctypes.c_uint32), + ("phase", ctypes.c_uint32), + ("confirmed", ctypes.c_uint32), + ("reserved0", ctypes.c_uint32), + ("value", ctypes.c_double), + ("message_hash64", ctypes.c_uint64), + ("message", ctypes.c_char_p), + ] + + class PfVersionC(ctypes.Structure): """Mirror of pf_version_t (returned by value from pf_version_get).""" _fields_ = [("major", ctypes.c_int), ("minor", ctypes.c_int), @@ -2585,11 +2630,49 @@ def load_strategy(so_path: Path) -> ctypes.CDLL: lib.strategy_set_magnifier_volume_weighted.argtypes = [ctypes.c_void_p, ctypes.c_int] lib.strategy_set_magnifier_volume_weighted.restype = None + # Recorded outputs (--outputs). The readers are runtime exports of every + # library built on an engine that has them; the manifest and the version + # are a recording library's own. All hasattr-guarded: require_outputs + # refuses --outputs by name on a library that lacks them. + _bind_outputs(lib) + lib.strategy_free.argtypes = [ctypes.c_void_p] lib.report_free.argtypes = [ctypes.POINTER(ReportC)] return lib +def _bind_outputs(lib) -> None: + signatures = { + "strategy_outputs_set_enabled": (ctypes.c_int, [ctypes.c_void_p, ctypes.c_int]), + "strategy_outputs_series_count": (ctypes.c_int, [ctypes.c_void_p]), + "strategy_outputs_bars_len": (ctypes.c_int64, [ctypes.c_void_p]), + "strategy_outputs_bar_times_copy": (ctypes.c_int, [ + ctypes.c_void_p, ctypes.c_int64, ctypes.POINTER(ctypes.c_int64), + ctypes.POINTER(ctypes.c_int64), ctypes.c_int64, ctypes.POINTER(ctypes.c_int64)]), + "strategy_outputs_series_copy": (ctypes.c_int, [ + ctypes.c_void_p, ctypes.c_int, ctypes.c_int64, ctypes.POINTER(ctypes.c_double), + ctypes.c_int64, ctypes.POINTER(ctypes.c_int64)]), + "strategy_outputs_events_len": (ctypes.c_int, [ctypes.c_void_p]), + "strategy_outputs_event_get": (ctypes.c_int, [ + ctypes.c_void_p, ctypes.c_int, ctypes.POINTER(OutputEventC), ctypes.c_size_t]), + "strategy_outputs_events_clear": (None, [ctypes.c_void_p]), + "strategy_outputs_constants_copy": (ctypes.c_int, [ + ctypes.c_void_p, ctypes.POINTER(ctypes.c_double), ctypes.c_int]), + "strategy_outputs_api_version": (ctypes.c_uint32, []), + "strategy_outputs_manifest": (ctypes.c_int, [ + ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t), + ctypes.c_char_p, ctypes.c_size_t]), + "strategy_signal_safety_receipt": (ctypes.c_int, [ + ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t), + ctypes.c_char_p, ctypes.c_size_t]), + } + for name, (restype, argtypes) in signatures.items(): + if hasattr(lib, name): + fn = getattr(lib, name) + fn.restype = restype + fn.argtypes = argtypes + + # --- Creating a strategy and applying the run's settings ---------------------- # # A library exporting the checked settings API (strategy_settings_api_version() @@ -2770,6 +2853,15 @@ class SyminfoError(RunFailure, ValueError): reports it as the one failure line (exit 1), never as a traceback.""" +class OutputsError(RunFailure, ValueError): + """An --outputs the harness cannot honour, with its code: outputs_rejected + {reason} for a library that records no outputs (or whose switch the engine + refuses: the engine's own code), strategy_library_incompatible{reason: + outputs_api_mismatch | outputs_manifest_invalid} for a recording library + whose outputs exports the harness cannot read. main() reports it as the + one failure line (exit 1).""" + + # This file is vendored: pineforge-release copies it from the pineforge-engine # tag at every release. Keep the lot-grid handling (mincontract) identical in # both repos, or a sync drops it. @@ -3172,6 +3264,264 @@ def symbol_feeds_record(symbols) -> dict: } for sym in symbols}} +# --- Recorded outputs (--outputs) -------------------------------------------- +# +# A library that records outputs (docs/outputs.md) exports +# strategy_outputs_api_version and its manifest; every library built on an +# engine with the group has the readers. The harness switches recording on +# before the run, then reads the rows, the run constants and the events once, +# after it: a batch caller never clears. + +OUTPUTS_API_VERSION = 1 +OUTPUTS_SCHEMA_VERSION = "pineforge-outputs/v1" +OUTPUTS_MESSAGE_FORMAT = "pineforge/v1" +OUTPUT_PHASES = {0: "batch", 1: "warmup", 2: "realtime"} +_INT64_MIN = -(2 ** 63) +_OUTPUTS_EXPORTS = ( + "strategy_outputs_manifest", "strategy_outputs_set_enabled", + "strategy_outputs_series_count", "strategy_outputs_bars_len", + "strategy_outputs_bar_times_copy", "strategy_outputs_series_copy", + "strategy_outputs_events_len", "strategy_outputs_event_get", + "strategy_outputs_constants_copy") +_NO_OUTPUTS = ("--outputs: this library records no outputs (compile the script as an " + "indicator, or with outputs on)") + + +def _outputs_incompatible(text: str, reason: str) -> OutputsError: + return OutputsError("--outputs: " + text, "strategy_library_incompatible", + {"reason": reason}) + + +def require_outputs(lib) -> None: + """OutputsError unless `lib` records outputs, has every export recording + reads, and answers the outputs API version this harness reads.""" + if not hasattr(lib, "strategy_outputs_api_version"): + raise OutputsError(_NO_OUTPUTS, "outputs_rejected", {"reason": "not_declared"}) + for name in _OUTPUTS_EXPORTS: + if not hasattr(lib, name): + raise _outputs_incompatible( + f"the library lacks {name}, which recording needs; rebuild.", + "outputs_api_mismatch") + version = int(lib.strategy_outputs_api_version()) + if version != OUTPUTS_API_VERSION: + raise _outputs_incompatible( + f"the library's outputs API version is {version}, the harness reads " + f"{OUTPUTS_API_VERSION}; rebuild.", "outputs_api_mismatch") + + +def enable_outputs(lib, strat) -> None: + """Switch recording on for `strat`. A refusal is the engine's own failure, + its text after "--outputs: " and its code from the getters.""" + if lib.strategy_outputs_set_enabled(strat, 1) == 0: + return + text = "" + if hasattr(lib, "strategy_get_last_error"): + text = _c_text(lib.strategy_get_last_error(strat)) + engine = engine_failure_code(lib, strat) + code, args = engine if engine is not None and engine[0] else ( + "outputs_rejected", {"reason": "not_declared"}) + raise OutputsError("--outputs: " + (text or "the library refused to record"), code, args) + + +def _outputs_manifest(lib, strat) -> bytes: + required = ctypes.c_size_t(0) + error = ctypes.create_string_buffer(512) + lib.strategy_outputs_manifest(strat, None, 0, ctypes.byref(required), error, len(error)) + status = -1 + buffer = None + if required.value > 0: + buffer = ctypes.create_string_buffer(required.value) + status = lib.strategy_outputs_manifest(strat, buffer, required.value, + ctypes.byref(required), error, len(error)) + if status != 0 or buffer is None: + raise _outputs_incompatible( + "the library returned no outputs manifest: " + + error.value.decode("utf-8", "replace"), "outputs_manifest_invalid") + return buffer.raw[:required.value - 1] + + +def make_outputs_reader(lib, strat): + """The reader build_outputs_block takes: callables over the C readers of + `strat`. Each copies when called; an event's message is copied out with + the event (the library lends it only until the next call that runs or + clears).""" + + def bar_times(): + n = int(lib.strategy_outputs_bars_len(strat)) + opens = (ctypes.c_int64 * max(n, 1))() + closes = (ctypes.c_int64 * max(n, 1))() + written = ctypes.c_int64(0) + if n > 0 and lib.strategy_outputs_bar_times_copy( + strat, 0, opens, closes, n, ctypes.byref(written)) != 0: + raise _outputs_incompatible("the bar times could not be read", + "outputs_api_mismatch") + return list(opens[:written.value]), list(closes[:written.value]) + + def series(slot): + n = int(lib.strategy_outputs_bars_len(strat)) + values = (ctypes.c_double * max(n, 1))() + written = ctypes.c_int64(0) + if lib.strategy_outputs_series_copy(strat, slot, 0, values, n, + ctypes.byref(written)) != 0: + raise _outputs_incompatible(f"series slot {slot} could not be read", + "outputs_api_mismatch") + return list(values[:written.value]) + + def constants(): + n = int(lib.strategy_outputs_constants_copy(strat, None, 0)) + values = (ctypes.c_double * max(n, 1))() + if n > 0: + lib.strategy_outputs_constants_copy(strat, values, n) + return list(values[:max(n, 0)]) + + def events(): + out = [] + for index in range(int(lib.strategy_outputs_events_len(strat))): + event = OutputEventC() + if lib.strategy_outputs_event_get(strat, index, ctypes.byref(event), + ctypes.sizeof(event)) != 0: + raise _outputs_incompatible(f"event {index} could not be read", + "outputs_api_mismatch") + # A c_char_p field reads as a bytes copy: taken here, while the + # library still lends the text. + out.append(types.SimpleNamespace( + **{name: getattr(event, name) for name, _ in OutputEventC._fields_})) + return out + + return types.SimpleNamespace( + manifest=lambda: _outputs_manifest(lib, strat), + bar_times=bar_times, + series_count=lambda: int(lib.strategy_outputs_series_count(strat)), + series=series, + constants=constants, + events=events, + ) + + +def _output_value(value, encoding): + """A slot or constant value: null when not finite, an integer under the + rgba-u32 encoding, else the double.""" + number = _num(value) + if number is not None and encoding == "rgba-u32": + return int(number) + return number + + +def _output_time(ms) -> int | None: + ms = int(ms) + return None if ms == _INT64_MIN else ms + + +def _manifest_entries(manifest: dict, key: str, fields: tuple) -> list: + """Validate the indices and output IDs this reader consumes before use.""" + entries = manifest.get(key, []) + if not isinstance(entries, list) or not all( + isinstance(e, dict) and all(f in e for f in fields) for e in entries): + raise _outputs_incompatible( + f"the outputs manifest's {key} is not a list of entries with {', '.join(fields)}", + "outputs_manifest_invalid") + for entry in entries: + for field in fields: + value = entry[field] + if field in ("index", "slot"): + valid = type(value) is int and value >= 0 + else: + valid = isinstance(value, str) and bool(value) + if not valid: + raise _outputs_incompatible( + f"the outputs manifest's {key} entry has an invalid {field}", + "outputs_manifest_invalid") + return entries + + +def build_outputs_block(reader) -> dict: + """The report's "outputs" block, from a reader of callables (see + make_outputs_reader): manifest() -> the raw manifest bytes, + bar_times() -> (opens, closes), series_count() -> int, + series(slot) -> [double], constants() -> [double], events() -> records + with the fields of pf_output_event_v1_t (message as bytes or None). + + series[] has one entry per manifest series[] entry, in slot order; + hlines[] one per hline output, its price the run constant its manifest + entry names (null when na or not written, as in a run with no rows); + an event of an alert output also carries the output's freq. A manifest + that is not a JSON object, names a slot the library does not record, or + lacks an output an event names is an OutputsError.""" + raw = reader.manifest() + try: + manifest = json.loads(raw) + except (ValueError, UnicodeDecodeError, RecursionError) as error: + raise _outputs_incompatible(f"the outputs manifest is not JSON: {error}", + "outputs_manifest_invalid") from None + if not isinstance(manifest, dict): + raise _outputs_incompatible("the outputs manifest is not a JSON object", + "outputs_manifest_invalid") + output_entries = _manifest_entries(manifest, "outputs", ("index", "id")) + series_entries = _manifest_entries(manifest, "series", ("slot", "output")) + constant_entries = _manifest_entries(manifest, "constants", ("index",)) + outputs = {entry["index"]: entry for entry in output_entries} + opens, closes = reader.bar_times() + slots = reader.series_count() + series = [] + for entry in sorted(series_entries, key=lambda e: e["slot"]): + slot = entry["slot"] + if not isinstance(slot, int) or not 0 <= slot < slots: + raise _outputs_incompatible( + f"the outputs manifest names series slot {slot}, which the library " + f"does not record", "outputs_manifest_invalid") + encoding = entry.get("encoding") + series.append({"slot": slot, "output": entry.get("output"), + "values": [_output_value(v, encoding) for v in reader.series(slot)]}) + encodings = {entry["index"]: entry.get("encoding") for entry in constant_entries} + constants = [_output_value(v, encodings.get(k)) for k, v in enumerate(reader.constants())] + hlines = [] + for entry in output_entries: + if entry.get("kind") != "hline": + continue + price = entry.get("price") + index = price.get("constant") if isinstance(price, dict) else None + hlines.append({"output": entry.get("id"), + "price": (constants[index] if isinstance(index, int) + and 0 <= index < len(constants) else None)}) + events = [] + for event in reader.events(): + output_index = int(event.output_index) + if output_index not in outputs: + raise _outputs_incompatible( + f"the outputs manifest lists no output {output_index}, which an event " + f"names", "outputs_manifest_invalid") + output = outputs[output_index] + message = event.message + if isinstance(message, bytes): + message = message.decode("utf-8", "replace") + row = { + "sequence": int(event.sequence), + "output": output.get("id"), + "bar_index": int(event.bar_index), + "bar_open_ms": _output_time(event.bar_open_ms), + "bar_close_ms": _output_time(event.bar_close_ms), + "ordinal_in_bar": int(event.ordinal_in_bar), + "phase": OUTPUT_PHASES.get(int(event.phase), str(int(event.phase))), + "value": _num(event.value), + "message": message, + } + if output.get("kind") == "alert": + row["freq"] = output.get("freq") + events.append(row) + return { + "schema_version": OUTPUTS_SCHEMA_VERSION, + "message_format": manifest.get("message_format", OUTPUTS_MESSAGE_FORMAT), + "manifest_sha256": hashlib.sha256(raw).hexdigest(), + "manifest": manifest, + "bars": {"open_ms": [_output_time(t) for t in opens], + "close_ms": [_output_time(t) for t in closes]}, + "series": series, + "constants": constants, + "hlines": hlines, + "events": events, + } + + def fmt_utc(ms: int) -> str: return datetime.fromtimestamp( ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC") @@ -3560,6 +3910,10 @@ def _main(argv=None) -> int: "request.security reads, keyed by the exact symbol string " "and timeframe (strategy_set_symbol_feed / _facts); see " "load_symbol_feeds.") + ap.add_argument("--outputs", action="store_true", + help="Record the library's outputs (a library compiled to record " + "them) and write them as the report's \"outputs\" block; see " + "build_outputs_block. A failure on a library that records none.") args = ap.parse_args(argv) inputs = parse_kv_json(args.inputs, "--inputs") @@ -3578,6 +3932,8 @@ def _main(argv=None) -> int: lib = load_strategy(args.so) checked = uses_checked_settings(lib) + if args.outputs: + require_outputs(lib) # Volume-weighted magnifier only meaningful when the magnifier is on. vw_on = bool(args.magnifier_volume_weighted) and bar_magnifier == 1 @@ -3611,6 +3967,8 @@ def _make_state(): lib.strategy_set_chart_timezone(st, args.chart_tz.encode()) if vw_on and hasattr(lib, "strategy_set_magnifier_volume_weighted"): lib.strategy_set_magnifier_volume_weighted(st, 1) + if args.outputs: + enable_outputs(lib, st) except BaseException: lib.strategy_free(st) raise @@ -3701,6 +4059,10 @@ def _run(st, rep): applied_runtime["syminfo"] = syminfo_applied if symbol_feeds: applied_runtime["symbol_feeds"] = symbol_feeds_record(symbol_feeds) + outputs_block = None + if args.outputs: + outputs_block = build_outputs_block(make_outputs_reader(lib, state)) + applied_runtime["outputs"] = True incarnation_accessor = getattr( lib, "strategy_closed_trade_entry_incarnation", None) trade_entry_incarnations = ( @@ -3717,6 +4079,8 @@ def _run(st, rep): out["diagnostics"]["throughput"] = _throughput_block( report.input_bars_processed, timing["samples_ns"], bar_magnifier=bar_magnifier) + if outputs_block is not None: + out["outputs"] = outputs_block try: # The frozen helpers' regex readers never see the C++: the release # reader below owns every declared value, and only the digest of diff --git a/docker/run_json_codes_test.py b/docker/run_json_codes_test.py index 9190c7cd2..19c2f8ce5 100644 --- a/docker/run_json_codes_test.py +++ b/docker/run_json_codes_test.py @@ -836,6 +836,186 @@ def test_strategy_library_errors_stay_runtime_errors(): run_json.check_abi(FakeLib(("pf_abi_version",), {"pf_abi_version": 3})) +# --- --outputs ------------------------------------------------------------------ + +OUTPUTS_EXPORTS = ("strategy_outputs_api_version",) + run_json._OUTPUTS_EXPORTS + + +@pytest.mark.parametrize("names,returns,code,args,text", [ + ((), {}, "outputs_rejected", {"reason": "not_declared"}, run_json._NO_OUTPUTS), + (("strategy_outputs_api_version",), {"strategy_outputs_api_version": 1}, + "strategy_library_incompatible", {"reason": "outputs_api_mismatch"}, + "--outputs: the library lacks strategy_outputs_manifest, which recording needs; rebuild."), + (OUTPUTS_EXPORTS, {"strategy_outputs_api_version": 2}, + "strategy_library_incompatible", {"reason": "outputs_api_mismatch"}, + "--outputs: the library's outputs API version is 2, the harness reads 1; rebuild."), +], ids=["records-none", "lacks-manifest", "api-mismatch"]) +def test_an_outputs_refusal_is_coded_before_any_state(harness, names, returns, code, args, text): + lib = fake_lib(*names, returns=returns) + status, out = harness(lib, "--outputs") + doc = line_of(out) + assert status == 1 + assert doc == {"engine": "pineforge", "error": text, "code": code, "args": args} + assert "strategy_create" not in lib.names() + + +def test_a_switch_the_engine_refuses_carries_the_engine_code(harness): + lib = fake_lib("strategy_get_last_error", "strategy_get_last_error_code", + "strategy_get_last_error_args", *OUTPUTS_EXPORTS, returns={ + "strategy_outputs_api_version": 1, + "strategy_outputs_set_enabled": -1, + "strategy_get_last_error": b"outputs: this module declares no outputs", + "strategy_get_last_error_code": b"outputs_rejected", + "strategy_get_last_error_args": b'{"reason":"not_declared"}'}) + status, out = harness(lib, "--outputs") + doc = line_of(out) + assert status == 1 + assert doc == {"engine": "pineforge", + "error": "--outputs: outputs: this module declares no outputs", + "code": "outputs_rejected", "args": {"reason": "not_declared"}} + assert ("strategy_free", ST) in lib.calls + assert "run_backtest_full" not in lib.names() + + +def manifest_entry_fixture(): + return { + "outputs": [{"index": 0, "id": "opaque/id:\u03b1", "kind": "plotshape"}, + {"index": 1, "id": "h0", "kind": "hline", + "price": {"constant": 0}}], + "series": [{"slot": 1, "output": "opaque/id:\u03b1", "encoding": "rgba-u32"}, + {"slot": 0, "output": "opaque/id:\u03b1"}], + "constants": [{"index": 0, "output": "h0", "param": "price"}], + "future_metadata": {"unconsumed": [True, None]}, + } + + +def manifest_entry_fake_lib(manifest): + """Fake-library error witness through the real main/C-reader boundary. + + Only ctypes.CDLL is substituted by harness; manifest parsing, reader + copies, report serialization, cleanup and failure handling are real. + This is not a compiled-engine or recorder witness. + """ + raw = json.dumps(manifest, indent=2, ensure_ascii=False).encode() + + def copy_manifest(st, out, capacity, required, error, error_capacity): + ctypes.cast(required, ctypes.POINTER(ctypes.c_size_t))[0] = len(raw) + 1 + if capacity < len(raw) + 1: + return PF_SMALL + ctypes.memmove(out, raw + b"\0", len(raw) + 1) + return PF_OK + + def copy_times(st, start, opens, closes, capacity, written): + opens[0], closes[0] = 1000, 2000 + ctypes.cast(written, ctypes.POINTER(ctypes.c_int64))[0] = 1 + return PF_OK + + def copy_series(st, slot, start, out, capacity, written): + out[0] = [1.25, 1286557951.0][slot] + ctypes.cast(written, ctypes.POINTER(ctypes.c_int64))[0] = 1 + return PF_OK + + def copy_constants(st, out, capacity): + if capacity: + out[0] = 50.0 + return 1 + + def copy_event(st, index, out, capacity): + event = run_json.OutputEventC( + struct_version=1, size=ctypes.sizeof(run_json.OutputEventC), + sequence=1, output_index=0, bar_index=0, bar_open_ms=1000, + bar_close_ms=2000, ordinal_in_bar=0, phase=0, confirmed=1, + value=1.0, message=b"recorded") + ctypes.memmove(out, ctypes.byref(event), ctypes.sizeof(event)) + return PF_OK + + return fake_lib(*OUTPUTS_EXPORTS, returns={ + "strategy_outputs_api_version": 1, "strategy_outputs_set_enabled": 0, + "strategy_outputs_series_count": 2, "strategy_outputs_bars_len": 1, + "strategy_outputs_events_len": 1, + }, impl={ + "strategy_outputs_manifest": copy_manifest, + "strategy_outputs_bar_times_copy": copy_times, + "strategy_outputs_series_copy": copy_series, + "strategy_outputs_constants_copy": copy_constants, + "strategy_outputs_event_get": copy_event, + }) + + +@pytest.mark.parametrize("key,field", [ + ("outputs", "index"), ("constants", "index"), ("series", "slot")]) +@pytest.mark.parametrize("value", [[], {}, None, "x", 0.0, True, False, -1], + ids=["array", "object", "null", "text", "float", + "true", "false", "negative"]) +def test_manifest_entry_index_failure_at_real_harness_boundary(harness, key, field, value): + manifest = manifest_entry_fixture() + manifest[key][0][field] = value + lib = manifest_entry_fake_lib(manifest) + status, out = harness(lib, "--outputs") + doc = line_of(out) + assert status == 1 + assert (doc["code"], doc["args"]) == ( + "strategy_library_incompatible", {"reason": "outputs_manifest_invalid"}) + assert harness.stderr == "" + assert lib.names().count("run_backtest_full") == 1 + assert lib.names().count("report_free") == lib.names().count("strategy_free") == 1 + + +@pytest.mark.parametrize("key,field", [("outputs", "id"), ("series", "output")]) +@pytest.mark.parametrize("value", [None, "", 0, 1.5, True, False, [], {}, "missing"], + ids=["null", "empty", "integer", "float", "true", "false", + "array", "object", "missing"]) +def test_manifest_entry_id_failure_at_real_harness_boundary(harness, key, field, value): + manifest = manifest_entry_fixture() + if value == "missing": + del manifest[key][0][field] + else: + manifest[key][0][field] = value + lib = manifest_entry_fake_lib(manifest) + status, out = harness(lib, "--outputs") + doc = line_of(out) + assert status == 1 + assert (doc["code"], doc["args"]) == ( + "strategy_library_incompatible", {"reason": "outputs_manifest_invalid"}) + assert harness.stderr == "" + assert lib.names().count("run_backtest_full") == 1 + assert lib.names().count("report_free") == lib.names().count("strategy_free") == 1 + + +def test_manifest_entry_valid_wire_control_at_real_harness_boundary(harness): + manifest = manifest_entry_fixture() + lib = manifest_entry_fake_lib(manifest) + status, out = harness(lib, "--outputs") + assert status == 0 + assert harness.stderr == "" + block = json.loads(out)["outputs"] + assert block["manifest"] == manifest + import hashlib + raw = json.dumps(manifest, indent=2, ensure_ascii=False).encode() + assert block["manifest_sha256"] == hashlib.sha256(raw).hexdigest() + assert json.dumps(block["series"], separators=(",", ":")) == ( + '[{"slot":0,"output":"opaque/id:\\u03b1","values":[1.25]},' + '{"slot":1,"output":"opaque/id:\\u03b1","values":[1286557951]}]') + assert block["constants"] == [50.0] + assert block["hlines"] == [{"output": "h0", "price": 50.0}] + assert block["events"] == [{ + "sequence": 1, "output": "opaque/id:\u03b1", "bar_index": 0, + "bar_open_ms": 1000, "bar_close_ms": 2000, "ordinal_in_bar": 0, + "phase": "batch", "value": 1.0, "message": "recorded"}] + assert lib.names().count("report_free") == lib.names().count("strategy_free") == 1 + + +def test_manifest_entry_invalid_manifest_is_unread_with_outputs_off(harness): + lib = manifest_entry_fake_lib({"outputs": [{"index": []}]}) + status, out = harness(lib) + plain_status, plain_out = harness(fake_lib()) + assert status == plain_status == 0 + assert report_of(out) == report_of(plain_out) + assert "outputs" not in json.loads(out) + assert not any(name.startswith("strategy_outputs_") for name in lib.names()) + assert harness.stderr == "" + + SYMINFO_SETTERS = ("strategy_set_syminfo_metadata", "strategy_set_syminfo_mintick", "strategy_set_syminfo_pointvalue", "strategy_set_syminfo_timezone", "strategy_set_syminfo_session") @@ -2266,3 +2446,241 @@ def test_producer_hardening_settings_guard_needs_defining_include(): after = run_json._release_legacy_declarations(include + guard + FOREIGN_TU, allow_unresolved=True) assert "ghost" not in before assert after["ghost"]["default"] == 7 + + +# --- Recorded outputs with typed release provenance --------------------------- +# These controls use FakeLib through main and the real C readers/normalizer. +# They do not establish compiled recorder behavior or actual producer origin. +# transpiled=True models the internal fact supplied by the Pine entrypoint; +# the separate real-image producer-boundary suite owns that origin proof. +COMBINED_OUTPUTS_CPP = '''class GeneratedStrategy { + GeneratedStrategy() { + pineforge::source::PineStrategyConfig cfg{}; + cfg.initial_capital = 10000.0; + configure_pine_strategy(cfg); + } + void init() { + len = get_input_int("len", 10); + armed = get_input_bool("armed", true); + text = get_input_string("text", std::string("base")); + src = get_input_source("Source", _src_close_)[0]; + _src_close_.clear(); + } +};''' +COMBINED_OUTPUTS_INPUTS = {"len": "14", "armed": "0", "text": "false", "Source": "hl2"} + + +def combined_outputs_receipt(): + receipt = legacy_receipt( + receipt_row("len", "int", "10", "14"), + receipt_row("armed", "bool", "true", "false"), + receipt_row("text", "string", "base", "false"), + receipt_row("Source", "source", "close", "hl2")) + defaults = dict(run_json.STRATEGY_SEED, initial_capital=10000.0, + calc_on_order_fills=False) + for name, kind in run_json._RELEASE_OVERRIDE_TYPES.items(): + default = str(defaults[name]) + if kind == "bool": + default = default.lower() + receipt["overrides"].append(receipt_row( + name, kind, default, "5000" if name == "initial_capital" else default)) + return receipt + + +def combined_outputs_fake_lib(manifest, receipt, checked=False): + lib = manifest_entry_fake_lib(manifest) + settings = checked_lib(receipt=receipt) + names = list(CHECKED_EXPORTS) if checked else [] + if receipt is not None: + names.append("strategy_get_effective_settings") + for name in names: + setattr(lib, name, lib._fn(name, getattr(settings, name))) + return lib + + +def combined_outputs_args(tmp_path, transpiled): + source = tmp_path / "combined.cpp" + source.write_text(COMBINED_OUTPUTS_CPP, encoding="utf-8") + return ["--generated-cpp", str(source), "--transpiled", str(transpiled).lower(), + *settings_args(inputs=COMBINED_OUTPUTS_INPUTS, + overrides={"initial_capital": "5000"})] + + +@pytest.fixture +def combined_outputs_harness(harness): + # Legacy numeric normalization calls libc. Substitute only the strategy + # library; retain real strtoll/strtod rather than replacing normalization. + native_cdll = ctypes.CDLL + + def run(lib, *args): + def load(path, **kwargs): + return native_cdll(path, **kwargs) if path is None else lib + result = harness(lib, *args, cdll=load) + run.stderr = harness.stderr + return result + + return run + + +@pytest.mark.parametrize("checked", [False, True], ids=["legacy", "checked"]) +@pytest.mark.parametrize("transpiled", [False, True], ids=["foreign", "producer-fact-control"]) +def test_outputs_typed_provenance_on_off_reader_control( + combined_outputs_harness, tmp_path, checked, transpiled): + harness = combined_outputs_harness + args = combined_outputs_args(tmp_path, transpiled) + manifest = manifest_entry_fixture() + documents = [] + for enabled in (False, True): + lib = combined_outputs_fake_lib(manifest, combined_outputs_receipt(), checked) + status, out = harness(lib, *args, *(["--outputs"] if enabled else [])) + assert status == 0 and harness.stderr == "" + assert out.count("\n") == 1 + doc = json.loads(out) + documents.append(doc) + fp = doc["fingerprint"] + raw = run_json.base64.b64decode(fp["token"]) + assert json.loads(raw) == fp["provenance"] + assert fp["digest"] == "sha256:" + run_json.hashlib.sha256(raw).hexdigest() + provenance = fp["provenance"] + assert provenance["codegen"]["transpiled_from_pine"] is transpiled + assert provenance["codegen"]["generated_cpp_sha256"] == run_json.hashlib.sha256( + COMBINED_OUTPUTS_CPP.encode()).hexdigest() + expected = {"len": 14, "armed": False, "text": "false", "Source": "hl2"} + assert provenance["applied"]["inputs"] == expected + for name, value in expected.items(): + assert type(provenance["inputs"][name]["value"]) is type(value) + assert provenance["inputs"][name]["value"] == value + assert provenance["inputs"]["Source"]["default"] == "close" + assert provenance["applied"]["overrides"] == {"initial_capital": 5000.0} + assert type(provenance["strategy"]["initial_capital"]) is float + assert doc["applied_inputs"] == COMBINED_OUTPUTS_INPUTS + assert doc["applied_overrides"] == {"initial_capital": "5000"} + assert provenance["runtime"] == doc["applied_runtime"] + if enabled: + assert doc["applied_runtime"]["outputs"] is True + assert ("strategy_outputs_set_enabled", ST, 1) in lib.calls + names = lib.names() + assert names.index("strategy_outputs_set_enabled") < names.index("run_backtest_full") + assert names.index("run_backtest_full") < names.index("strategy_outputs_manifest") + raw_manifest = json.dumps(manifest, indent=2, ensure_ascii=False).encode() + block = doc["outputs"] + assert list(block) == ["schema_version", "message_format", "manifest_sha256", + "manifest", "bars", "series", "constants", "hlines", "events"] + assert block["schema_version"] == "pineforge-outputs/v1" + assert block["message_format"] == "pineforge/v1" + assert block["manifest"] == manifest + assert block["manifest_sha256"] == run_json.hashlib.sha256(raw_manifest).hexdigest() + assert block["bars"] == {"open_ms": [1000], "close_ms": [2000]} + assert block["series"] == [ + {"slot": 0, "output": "opaque/id:\u03b1", "values": [1.25]}, + {"slot": 1, "output": "opaque/id:\u03b1", "values": [1286557951]}] + assert type(block["series"][1]["values"][0]) is int + assert block["constants"] == [50.0] + assert block["hlines"] == [{"output": "h0", "price": 50.0}] + assert block["events"] == [{ + "sequence": 1, "output": "opaque/id:\u03b1", "bar_index": 0, + "bar_open_ms": 1000, "bar_close_ms": 2000, "ordinal_in_bar": 0, + "phase": "batch", "value": 1.0, "message": "recorded"}] + else: + assert "outputs" not in doc and "outputs" not in doc["applied_runtime"] + assert not any(name.startswith("strategy_outputs_") for name in lib.names()) + create = "strategy_create_checked" if checked else "strategy_create" + assert lib.names().count(create) == 1 + assert lib.names().count("report_free") == lib.names().count("strategy_free") == 1 + off, on = documents + on_provenance = json.loads(json.dumps(on["fingerprint"]["provenance"])) + assert on_provenance["runtime"].pop("outputs") is True + assert on_provenance == off["fingerprint"]["provenance"] + assert run_json.build_fingerprint(on_provenance) == off["fingerprint"] + assert on["fingerprint"]["digest"] != off["fingerprint"]["digest"] + invalid = combined_outputs_fake_lib({"outputs": [{"index": True}]}, + combined_outputs_receipt(), checked) + status, out = harness(invalid, *args) + assert status == 0 and harness.stderr == "" + assert report_of(out) == report_of(json.dumps(off)) + assert not any(name.startswith("strategy_outputs_") for name in invalid.names()) + assert invalid.names().count("report_free") == invalid.names().count("strategy_free") == 1 + + +@pytest.mark.parametrize("receipt", [None, legacy_receipt({"name": "broken"})], + ids=["absent-receipt", "malformed-receipt"]) +def test_outputs_does_not_certify_foreign_source_reader_control( + combined_outputs_harness, tmp_path, receipt): + harness = combined_outputs_harness + args = combined_outputs_args(tmp_path, False) + documents = [] + for enabled in (False, True): + lib = combined_outputs_fake_lib(manifest_entry_fixture(), receipt) + status, out = harness(lib, *args, *(["--outputs"] if enabled else [])) + assert status == 0 and harness.stderr == "" + doc = json.loads(out) + provenance = doc["fingerprint"]["provenance"] + documents.append(provenance) + assert provenance["codegen"]["transpiled_from_pine"] is False + assert provenance["applied"]["inputs"] == COMBINED_OUTPUTS_INPUTS + assert provenance["applied"]["overrides"] == {"initial_capital": "5000"} + for name in COMBINED_OUTPUTS_INPUTS: + assert provenance["inputs"][name]["value"] is None + assert provenance["inputs"][name]["resolution"]["reason"] == "foreign_unverified_source" + assert provenance["strategy"]["initial_capital"] is None + assert provenance["strategy_resolution"]["initial_capital"]["reason"] == "foreign_unverified_source" + assert lib.names().count("report_free") == lib.names().count("strategy_free") == 1 + off, on = documents + assert on["runtime"].pop("outputs") is True + assert on == off + + +@pytest.mark.parametrize("key,field,value", [ + ("outputs", "index", True), ("constants", "index", -1), + ("series", "slot", 0.0), ("outputs", "id", ""), ("series", "output", None)]) +def test_outputs_manifest_refusal_precedes_normalization_reader_control( + combined_outputs_harness, tmp_path, monkeypatch, key, field, value): + harness = combined_outputs_harness + manifest = manifest_entry_fixture() + manifest[key][0][field] = value + lib = combined_outputs_fake_lib(manifest, combined_outputs_receipt(), checked=True) + normalized = [] + original = run_json.normalize_release_provenance + + def observe(*args): + normalized.append(True) + return original(*args) + + monkeypatch.setattr(run_json, "normalize_release_provenance", observe) + status, out = harness(lib, *combined_outputs_args(tmp_path, False), "--outputs") + assert status == 1 and harness.stderr == "" + doc = line_of(out) + assert (doc["code"], doc["args"]) == ( + "strategy_library_incompatible", {"reason": "outputs_manifest_invalid"}) + assert normalized == [] + assert lib.names().count("run_backtest_full") == 1 + assert lib.names().count("report_free") == lib.names().count("strategy_free") == 1 + + +@pytest.mark.parametrize("enabled", [False, True], ids=["outputs-off", "outputs-on"]) +@pytest.mark.parametrize("defect", ["missing-override", "integer-domain", "source-selector"]) +def test_outputs_normalization_failure_keeps_report_and_cleanup_reader_control( + combined_outputs_harness, tmp_path, enabled, defect): + harness = combined_outputs_harness + receipt = combined_outputs_receipt() + if defect == "missing-override": + receipt["overrides"] = [] + elif defect == "integer-domain": + receipt["inputs"][0]["effective_value"] = str(2**53) + else: + receipt["inputs"][3]["effective_value"] = "14" + lib = combined_outputs_fake_lib(manifest_entry_fixture(), receipt, checked=True) + status, out = harness(lib, *combined_outputs_args(tmp_path, False), + *(["--outputs"] if enabled else [])) + assert status == 0 and harness.stderr == "" and out.count("\n") == 1 + doc = json.loads(out) + assert doc["fingerprint"] is None + assert ("outputs" in doc) is enabled + if enabled: + assert doc["applied_runtime"]["outputs"] is True + assert doc["outputs"]["constants"] == [50.0] + else: + assert "outputs" not in doc["applied_runtime"] + assert not any(name.startswith("strategy_outputs_") for name in lib.names()) + assert lib.names().count("run_backtest_full") == 1 + assert lib.names().count("report_free") == lib.names().count("strategy_free") == 1 diff --git a/docs/adr/0001-kernel-adapter-boundary.md b/docs/adr/0001-kernel-adapter-boundary.md index 743f7c602..cd686a052 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 **64** codegen-facing runtime `PF_API` symbols that + `src/c_abi.cpp` implements the **73** 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 @@ -572,6 +572,7 @@ Names the vocabulary does not match but the audits named, with their rulings: | `inputs_`, `get_input_*`, `syminfo_metadata_`, `set_syminfo_metadata`, `enum class QtyType`, `SymInfo` | `include/pineforge/engine.hpp` | **retained**: the run-time ingress the frozen C ABI exposes (`strategy_set_input*`, `strategy_set_syminfo_*`). Their comments explain the vocabulary; no kernel decision reads a Pine name. | | `[pineforge] WARNING: …`, `on_margin_call` | `src/session_time.cpp`, `native_c_api.h` | not residue: the project's own name and a broker term. | | `legacy_tolerance`, `NativeLegacyTolerance`, `LegacyTolerant`, `native_legacy_tolerance_enabled` | `NativeRunSpec::legacy_tolerance` and the deprecated spellings beside it (`include/pineforge/native_run_spec.hpp`); read by `src/market_driver.cpp` and folded into the run spec's digest by `src/native_execution_consumer.cpp` | **retained, the member name frozen by the standalone native C++ ABI** (design §2.ii row c; the round-2 and third audits' last "legacy" spelling, R5 lane F6). Lane L12 renamed the policy -- the type is `NativeFeedTolerance`, the predicate `native_feed_tolerance_enabled`, the enumerator `FeedTolerant`, and a C host sets it as `pf_native_run_spec_ext_v1::feed_tolerance` -- but not the member, because `NativeRunSpec` belongs to `native_run_spec_v3` and `scripts/check_native_cpp_versions.py` pins it verbatim: `NativeFeedTolerance legacy_tolerance = NativeFeedTolerance::None;` is a required policy member, and its digest fold `f.u(static_cast(spec.legacy_tolerance));` is pinned text. A rename, or a same-offset alias through an anonymous union (which rewrites that pinned struct text), is therefore a `native_run_spec_v4` decision, not a lane's; the member is renamed `feed_tolerance` there. The word names no TradingView rule: it is a feed-shape policy (structural-only batch bars, non-negative warm-up OHLC) that the Pine adapter and any C host may set, and the three deprecated spellings beside it stay aliases the same checker pins. | +| `declare_outputs`, `output_run_begin`, `output_bar`, `output_value`, `output_event`, `output_constant`, `outputs_enabled_`; the readers `outputs_declared`, `set_outputs_enabled`, `output_series_count`, `output_bars_len`, `output_bar_times_copy`, `output_series_copy`, `output_events_len`, `output_event_at`, `output_events_clear`, `output_constants_count`, `output_constant_value` and their element type `OutputEvent` | `include/pineforge/engine.hpp`, `src/engine_report.cpp` | **retained as codegen ABI, generic reporting** (lane SM-E1): named slots of doubles kept per bar, doubles kept once per run, and events with an optional text, one row per bar the host publishes (`docs/outputs.md`). The writers are protected (the codegen ABI and the BASE-CLASS SEAMS rows of `native_c_api.h`); the public readers and `OutputEvent` serve the `strategy_outputs_*` C exports only. No matching, fill, margin, sizing or report decision reads them; they sit outside both hashed regions and stay off until a caller enables them. What a slot, an output or a constant means lives in the manifest the generated module exports, not in the kernel. | Kernel state the adapter sets — the mechanism rulings. The vocabulary gate cannot see a mechanism, so a ruling below is held by reading. Since R5 lane H-DOCGATES, @@ -765,6 +766,7 @@ number names its symbol on this tree. | `on_native_timeframe_bar` (`native_host.hpp:885`) and `NativeTimeframeBarContext::interval` (`native_host.hpp:809`) | **query routed** | The callback signature stays frozen. `strategy_native_timeframe_bar_interval_v1` is valid only during that callback and copies the kernel calendar interval without deriving it from the delivered bar or delivery timestamp. | | `magnifier_sub_bars_total` / `magnifier_sample_ticks_total` (`engine_report.cpp:45-46`) | **kernel-owned report facts** | The generic intrabar driver already increments `NativeDriverStatistics::sub_bars_processed` / `sample_ticks_processed` for every retained path (`native_execution_consumer.cpp:8132-8144`); the kernel now publishes those same counters for bare hosts. They are report fields, not new durable state: the existing `native_continuation_hash` fold of `NativeDriverStatistics` (`native_execution_consumer.cpp:1098-1103`) sees the same values, so no hash contract moves. | | `pf_native_lot_excursion_v1` (`native_c_api.h:1912`) | **additive fee tail** | `entry_commission` carries the C++ `ClosedLotExcursionFacts::entry_commission` in account currency. The callback table publishes a trailing layout marker so F4 callers still receive the base facts length; current callers receive the fee tail without changing `PF_NATIVE_API_VERSION`. | +| `strategy_outputs_set_enabled` and the eight `strategy_outputs_*` readers (`pineforge.h`, group `pf_outputs`) | **additive runtime exports** | Recording is off by default, and nothing a run computes reads the record. The event copy is size-prefixed like `strategy_pending_order_get`, and `PF_ABI_VERSION` stays 4. The writers are C++ only in this version (the BASE-CLASS SEAMS rows of `native_c_api.h`): a C host declares no outputs, so its handle answers every reader as empty. | ## TradingView-calibrated kernel mechanisms (rule 2's own account) diff --git a/docs/coverage.md b/docs/coverage.md index 3828d5474..6a06866f4 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -62,10 +62,10 @@ ## Public C ABI `` is the **single canonical consumer header**. It has -exactly 81 public `PF_API` declarations: 64 runtime implementations and seventeen +exactly 93 public `PF_API` declarations: 73 runtime implementations 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`; older modules lack the six +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 opt-in [checked-settings exports](checked-settings.md) and the two opt-in [execution-capability exports](strategy-capabilities.md). The historical 28-symbol module sentence was not a current module inventory; the grouped table below is a guide, not the count: @@ -132,7 +132,7 @@ single `.hpp`): | Module | Header | Source | Pine-facing role | | ------------------ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public C ABI | `pineforge.h` | `c_abi.cpp` (+ layout `static_assert`s) | 81 public `PF_API` declarations: 64 runtime implementations plus seventeen 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) | 93 public `PF_API` declarations: 73 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 a870bb07c..4e6a4e49c 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:2174, `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:930, FX curve `strategy_configure_native_fx_curve_v1` c_abi.cpp:1001, probe `strategy_execution_contract` c_abi.cpp:921, stream `strategy_stream_begin` c_abi.cpp:658 **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 64 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` / `_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 73 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:267-302, `chart_index` pine_scheduler_native.cpp:570-703) | 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 (`tests/fixtures/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:7622), so a bare host reproduces the tapes flag for flag (`tests/test_native_session_day_facts.cpp`, kernel-only). The adapter computes no regular-day rule of its own: `scheduler_update_session_state` pine_strategy_host.cpp:2167 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:1138; `BacktestEngine` holds no Pine session state), and only its live-probe tail reads the open-ended close at its batch's final bar (`tests/test_session_day_facts_adapter.cpp`). 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:282-284) | 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 | @@ -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:489-507 and its twin `tv_money_round` pine_policy_support.hpp:9-15; `skip_entry_bar_high` engine.hpp:152, `skip_entry_bar_low` engine_state_hash.cpp:66, `declare_opened_lot_entry_bar_mask` engine_path_resolve.cpp:95; the ledger `close_logical_units_` pine_adapter.hpp:2512-2542; `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) | 64 runtime `PF_API` symbols: native configure, the 12-symbol stream family, pending-order views, broker-state hash, security feeds, FX curve, reports. The 64 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:d19d72e63bbaf66ac67cb2e5180aaeb89dd0e756a5ea8c4d43ea43b737d1afbd` c_abi.cpp:308-963, `sha256:4b7e398c78b276b34c4247e8271242b48aa195cea29126f4e809073f2665e1ea` check_c_abi_runtime.py:20-80, c_abi.cpp:896, `sha256:3b43bbaa40f1bab3c7ffa4e20d45fd8ea2ba321e8a54b7cbe45dc625dfca59f6` c_abi.cpp:624-743, `sha256:db54076d46a71efb0c7556c97494317e7fca443278b6963c3edf79ce918d72a1` c_abi.cpp:549-601, `sha256:f18d46ac323234f7e3082367d30840b704b8a7548f7ae95ee8cff8714f29fd50` c_abi.cpp:528-545, `sha256:2cb41f4006caae992ee41605054a3397689ae00bf3080011db1bba6412ffcd87` c_abi.cpp:836-867, c_abi.cpp:963 | F O S | +| 8 | "No kernel-level C API. `c_abi.cpp` exports only version/descriptor symbols" (ADR L136-138) | 73 runtime `PF_API` symbols: native configure, the 12-symbol stream family, pending-order views, broker-state hash, security feeds, FX curve, reports. The 73 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:d19d72e63bbaf66ac67cb2e5180aaeb89dd0e756a5ea8c4d43ea43b737d1afbd` c_abi.cpp:308-963, `sha256:4b7e398c78b276b34c4247e8271242b48aa195cea29126f4e809073f2665e1ea` check_c_abi_runtime.py:20-80, c_abi.cpp:896, `sha256:3b43bbaa40f1bab3c7ffa4e20d45fd8ea2ba321e8a54b7cbe45dc625dfca59f6` c_abi.cpp:624-743, `sha256:db54076d46a71efb0c7556c97494317e7fca443278b6963c3edf79ce918d72a1` c_abi.cpp:549-601, `sha256:f18d46ac323234f7e3082367d30840b704b8a7548f7ae95ee8cff8714f29fd50` c_abi.cpp:528-545, `sha256:2cb41f4006caae992ee41605054a3397689ae00bf3080011db1bba6412ffcd87` c_abi.cpp:836-867, c_abi.cpp:963 | 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:114 and `add_library` CMakeLists.txt:155 build `PineForge::kernel`, and the compat unit is in `PINEFORGE_SOURCE_LAYER_SOURCES` CMakeLists.txt:91 | `add_library` CMakeLists.txt:155 | S (F, O agree in §2.i) | diff --git a/docs/outputs.md b/docs/outputs.md new file mode 100644 index 000000000..3e5ecb242 --- /dev/null +++ b/docs/outputs.md @@ -0,0 +1,251 @@ +# Recorded outputs + +Recorded outputs are unreleased, targeted for 1.5.0, and not part of 1.4.0. + +A module can record what it computes on each bar besides its trades: values +kept per bar, values kept once per run, and events with an optional message. +A generated module records them for a script compiled to record outputs; a +C++ host records them through the `BacktestEngine` members below. A caller +reads the record through the `strategy_outputs_*` functions of +`` (group `pf_outputs`), and `docker/run_json.py +--outputs` writes it into the JSON report. + +Recording is off until a caller turns it on. Nothing a run computes depends on +it: the trades, the report (`pf_report_t`), every hash and `PF_ABI_VERSION` +are what they are with recording off, and no matching, fill, margin, sizing or +report path reads the record. `tests/test_outputs_off_identity.cpp` runs one +trading host with outputs undeclared, declared and off, and recording, and +holds the trades, the equity curve, the per-bar broker-state hashes and the +stream-state hash equal. + +## The record + +A module declares the record's shape once: `S` series slots per row, `O` +outputs an event may name, and `K` run constants. What each slot, output and +constant means is not the engine's business; the module's manifest +(`strategy_outputs_manifest`) says it. + +- **Rows.** One per bar the module published in this run, keyed by the bar's + open time and also holding its close time. A row's position is its + `bar_index`. A module that opens at most one row per calculation, as a + generated one does, never records more rows than the run dispatched + calculations (`strategy_script_bars_processed`). +- **Values.** A double per slot per row. NaN is na; a slot not written in a + row stays NaN; the last write of a slot in a row wins. +- **Events.** Each names an output and the row it was recorded on, carries a + double and, optionally, a message. `sequence` numbers the run's events from + 1; `ordinal_in_bar` numbers one output's events within its row from 0; + `phase` is the run phase (batch, stream warm-up, stream realtime); + `confirmed` is 0 only for an event recorded while a stream finalizes a bar + it never saw close (`strategy_stream_end` with a partial input bar); + `message_hash64` is the FNV-1a 64 hash of every byte recorded as the + message (0 without a message). A C reader receives the message as + NUL-terminated text, so a message holding a NUL byte reads to its first NUL + while its hash covers all of it: re-hash the bytes you recorded, not the + text you read back. +- **Run constants.** One double per index per run, for a value fixed for the + whole run, such as a level read from an input. NaN until written; the + run's first write stores it and a later write must be equal (bit for bit, + or both NaN). + +A colour is an ordinary value. A manifest that marks a slot or a constant with +the encoding `rgba-u32` stores a colour as the integer R·2²⁴ + G·2¹⁶ + B·2⁸ + A, +whose eight hex digits read as the CSS literal `#RRGGBBAA` (A is opacity, 255 +opaque), and na as NaN. The engine stores the double and knows nothing of +colours. + +## Writing the record from C++ + +The writers are protected members of `BacktestEngine` +(`include/pineforge/engine.hpp`), so a generated module or a C++ host +subclass calls them; a C host has no writer in this version. Each writer but +`declare_outputs` and `output_run_begin` returns at once while recording is +off, so a module can call them unconditionally; generated code tests +`outputs_enabled_` first. + +| Member | Call it | Fails when | +|---|---|---| +| `declare_outputs(slots, outputs, run_constants = 0)` | once, before the first run (a generated constructor) | a count is negative; the host is running; a second call names other counts | +| `output_run_begin()` | at the start of every run, before any bar; a C++ host calls it in `on_native_run_begin` | never; calling it twice is calling it once | +| `output_bar(open_ms, close_ms)` | first, in every calculation the host publishes | `output_run_begin()` was never called; `open_ms` is below the last row's; the row count reaches `INT_MAX` | +| `output_value(slot, value)` | after `output_bar` | no row is open; `slot` is out of range | +| `output_event(output, value)`, `output_event(output, value, message)` | after `output_bar` | no row is open; `output` is out of range; the queue holds `INT_MAX` events | +| `output_constant(index, value)` | any time in the run | `output_run_begin()` was never called; `index` is out of range; the value differs from the run's first write | + +Call `output_bar` first in every calculation you write in. A row stays open +until the next `output_bar`, so a calculation that skips it and still writes +lands in the previous bar's row. + +Each failure carries a run-failure code (`docs/pages/run-failure-codes.md`) +beside its text. A broken precondition of the table above is a host or +generated-code defect, thrown as `std::logic_error` and coded +`engine_invariant`. An event queue or a row count that reaches `INT_MAX` +throws `std::runtime_error` coded `outputs_limit` (reason `too_many_events` +or `too_many_rows`). A recalculation of a bar after a caller cleared that +bar's events throws `std::runtime_error` coded `outputs_rejected` (reason +`recalculated_after_clear`), the code of the switch's refusals below. +Thrown inside a callback, the exception fails the run with its text in +`last_error()` (`strategy_get_last_error`) and its code in +`strategy_get_last_error_code`. A caller reads nothing after a failed run: +the record then holds whatever the run wrote before it failed. + +**The open and close a C++ host states.** The open is +`NativeDecisionContext::script_bar_open_ms`. The close is the second argument; +the script interval's end, `context.script_interval.next_period_open_ms`, is +the natural choice, and a host that cannot state a close passes `INT64_MIN`. + +**Row identity.** The host states which bar a calculation is about, so the +record follows the bars the host publishes, not the calculations the kernel +counts: + +- an open above the last row's appends a row (every slot NaN, the ordinals at + 0); +- an open equal to the last row's recalculates that row: its values return + to NaN, its close is replaced, its ordinals restart, and its queued events + are withdrawn, their sequence numbers issued again. A row with an event a + caller has already cleared cannot be recalculated: the call fails the run, + so an event a caller has taken is never withdrawn or renumbered; +- an open below the last row's fails the run. + +Two calculations that state one open, with no event cleared between them, +are therefore one row, and the bar indices after it run one short of the +calculation count (`tests/test_outputs_recorder.cpp`). The Pine host publishes +strictly increasing opens on every path but one: a daily stream of ticks on a +session that crosses midnight in its time zone can stamp two bars alike. + +A stream can dispatch a calculation for a bar that is not after the last one +the host published: on a `FeedTolerant` stream whose last warm-up bar carries +an off-grid label, the first clock advance calculates the quiet slot that +holds that label. A host that records only what it publishes skips such a +calculation, as the Pine host does (compare `script_bar_open_ms` with the +last open you published); one that calls `output_bar` for it fails the run. +`tests/test_outputs_stream_equivalence.cpp` reproduces the case. + +**The run boundary.** A host that never calls `output_run_begin()` fails its +first recorded bar. One that calls it on its first run only appends the next +run to the old record when that run's bars open later, and fails when they do +not (`tests/test_outputs_run_reuse.cpp`). + +A minimal host: + +```cpp +class Recorder : public pineforge::NativeStrategyHost { +public: + Recorder() { declare_outputs(/*slots*/ 1, /*outputs*/ 1); } + + void on_native_run_begin() override { + output_run_begin(); + last_open_ = std::numeric_limits::min(); + } + + void on_native_bar(const pineforge::Bar& bar, + const pineforge::NativeDecisionContext& context) override { + if (context.script_bar_open_ms <= last_open_) return; // not a new bar + last_open_ = context.script_bar_open_ms; + output_bar(context.script_bar_open_ms, context.script_interval.next_period_open_ms); + output_value(0, bar.close); + if (bar.close > bar.open) output_event(0, bar.close, "up"); + } + +private: + int64_t last_open_ = 0; +}; +``` + +## Reading the record + +| Function | Answers | +|---|---| +| `strategy_outputs_set_enabled(s, on)` | 0, or -1, nothing changed and the refusal coded `outputs_rejected`: reason `not_declared` (turning on a module that declares nothing) or `run_in_progress` (changing the switch while a run is in progress: a batch, or a stream from `strategy_stream_begin` to `strategy_stream_end`). A change clears the record; a call that changes nothing answers 0, also during a run. As with `strategy_set_trace_enabled`, a success leaves `strategy_get_last_error` and its code as they were, and a refusal keeps a failed run's own: read a failed run's error before or after the switch alike. | +| `strategy_outputs_series_count(s)` | slots per row; 0 while recording is off | +| `strategy_outputs_bars_len(s)` | rows | +| `strategy_outputs_bar_times_copy(s, from_bar, open_ms, close_ms, capacity, written)` | the rows' times from `from_bar` on | +| `strategy_outputs_series_copy(s, series, from_bar, out, capacity, written)` | one slot's values from `from_bar` on | +| `strategy_outputs_events_len(s)` | events queued since the run began or the last clear | +| `strategy_outputs_event_get(s, index, out, size_in)` | one event, copied as `pf_output_event_v1_t` | +| `strategy_outputs_events_clear(s)` | drops the queue; rows and the sequence stay | +| `strategy_outputs_constants_copy(s, out, capacity)` | the run constants; returns their count | + +The copies take `from_bar` in `[0, rows]`, a non-negative `capacity` and a +non-NULL `written`; they write `min(capacity, rows - from_bar)` items and +return 0, or return -1 and write nothing. A NULL handle answers -1. +`strategy_outputs_event_get` copies `min(size_in, sizeof(pf_output_event_v1_t))` +bytes and refuses a `size_in` below 8, so an older or newer caller's struct +works; the struct puts every 8-byte field at a multiple of 8, so its 72 bytes +before `message` are laid out alike on every target. `message` is borrowed +until the next call that runs, streams or clears on the handle. + +Every `strategy_outputs_*` call runs on the thread that drives the handle, +between its run and stream calls, never during one; the readers take no +lock. + +A module that records nothing still answers the readers (they are runtime +exports of every module built on this engine): turning recording on answers +-1 with `outputs: this module declares no outputs`, and every reader answers +empty. Three functions are a recording module's own: +`strategy_outputs_api_version` (`PF_OUTPUTS_API_VERSION`), +`strategy_outputs_manifest` and `strategy_signal_safety_receipt`, with the +buffer protocol of `strategy_capabilities_receipt`. Detect a recording module +with `dlsym("strategy_outputs_api_version")`; `PINEFORGE_HAS_OUTPUTS_V1` +tells a C or C++ caller that its `pineforge.h` declares the group. + +## Batches and streams + +A batch caller turns recording on before the run and reads after it; it need +not clear. A streaming caller reads and clears after `strategy_stream_begin` +returns and after each input it pushes. Rows and the sequence carry on across +the warm-up's end; events recorded during the warm-up carry `phase` warm-up, +later ones realtime. A stream records what a batch over the same bars +records, in every value and every event field but `phase` +(`tests/test_outputs_stream_equivalence.cpp`), and a batch cut at bar `t` +records, for every bar up to `t`, what the batch over all the bars records +(`tests/test_outputs_truncation.cpp`). + +## `run_json --outputs` + +`docker/run_json.py --outputs` turns recording on for every run it makes +(with `--bench`, the timed runs too, so a timing then includes recording) and +writes the record as the report's `outputs` key, just before `fingerprint`; +`applied_runtime` and the fingerprint's `provenance.runtime` then hold +`"outputs": true`. Without the flag the report is unchanged. On a module that +records nothing, or one that refuses to record, the harness prints its one +failure line, `{"engine":"pineforge","error":...,"code":"outputs_rejected", +"args":{"reason":"not_declared"}}`, and exits 1. A recording module whose +outputs exports it cannot read (another outputs API version, a missing +export, a manifest that is not a JSON object or does not list what the module +recorded) fails as `strategy_library_incompatible` (reason +`outputs_api_mismatch` or `outputs_manifest_invalid`). + +```json +"outputs": { + "schema_version": "pineforge-outputs/v1", + "message_format": "pineforge/v1", + "manifest_sha256": "", + "manifest": { "...": "the module's manifest" }, + "bars": {"open_ms": [1704067200000], "close_ms": [1704067500000]}, + "series": [{"slot": 0, "output": "o0", "values": [101.25]}], + "constants": [50.0, 2021161215], + "hlines": [{"output": "h0", "price": 50.0}], + "events": [{"sequence": 1, "output": "o2", "bar_index": 0, + "bar_open_ms": 1704067200000, "bar_close_ms": 1704067500000, + "ordinal_in_bar": 0, "phase": "batch", "value": null, + "message": "close=101.25", "freq": "once_per_bar_close"}] +} +``` + +`series` has one entry per slot the manifest lists, in slot order; `constants` +one value per run-constant index; `hlines` one entry per horizontal-level +output, its price the run constant the manifest names (null when it was never +written, as in a run with no rows); an event of an alert output also carries +the output's `freq`. A double that is not finite is null, a time equal to +`INT64_MIN` is null, and a value whose manifest encoding is `rgba-u32` is +written as an integer. `scripts/test_report_outputs_keys.py` pins the keys, +and `scripts/test_run_json_outputs.py` runs the harness end to end. + +## Costs and limits + +- A row costs 8 bytes per slot and is kept for the whole run or stream. +- The event queue holds at most `INT_MAX` events between clears, and a run + at most `INT_MAX` rows; one more fails the run as `outputs_limit`. +- Recording is a branch per writer call while it is off, and generated code + takes that branch once per call site. diff --git a/docs/pages/abi-stability.md b/docs/pages/abi-stability.md index 705e63e1a..51d342722 100644 --- a/docs/pages/abi-stability.md +++ b/docs/pages/abi-stability.md @@ -60,11 +60,13 @@ Three layers: ## Symbol inventory -A built strategy `.so` exposes 80 compiled-strategy `PF_API` declarations -(64 runtime implementations plus sixteen generated exports) plus 43 native-host -declarations: 123 `PF_API` exports in total; a script that declares -`use_bar_magnifier = true` also exports `strategy_declares_bar_magnifier`, the -81st declaration of `pineforge.h`. `nm -gU` also reports libc++'s +The counts below include recorded outputs and describe the unreleased API targeted for 1.5.0; recorded outputs are not part of 1.4.0. + +A built strategy `.so` exposes 89 compiled-strategy `PF_API` declarations +(73 runtime implementations plus sixteen generated exports) plus 43 native-host +declarations: 132 `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, not the complete inventory: @@ -101,9 +103,21 @@ not the complete inventory: | `pf_version_get` | @ref pf_version | | `pf_abi_version` | @ref pf_version | | `pf_version_string` | @ref pf_version | +| `strategy_outputs_set_enabled` | @ref pf_outputs | +| `strategy_outputs_series_count` | @ref pf_outputs | +| `strategy_outputs_bars_len` | @ref pf_outputs | +| `strategy_outputs_bar_times_copy` | @ref pf_outputs | +| `strategy_outputs_series_copy` | @ref pf_outputs | +| `strategy_outputs_events_len` | @ref pf_outputs | +| `strategy_outputs_event_get` | @ref pf_outputs | +| `strategy_outputs_events_clear` | @ref pf_outputs | +| `strategy_outputs_constants_copy` | @ref pf_outputs | +| `strategy_outputs_api_version` | @ref pf_outputs (a recording module's own) | +| `strategy_outputs_manifest` | @ref pf_outputs (a recording module's own) | +| `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 64 runtime implementations, including the +functions. The remaining 73 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. @@ -208,6 +222,21 @@ a run with no symbol data folds nothing new. The C side is append-only -- four functions, each behind its own `PINEFORGE_HAS_…_V1` probe -- so `PF_ABI_VERSION` stays 4. +Recorded outputs are unreleased, targeted for 1.5.0, and not part of 1.4.0. + +Recorded outputs (lane SM-E1, `docs/outputs.md`) adds the recorder to +`BacktestEngine` inside v19, without an epoch: data members outside both +hashed regions and non-virtual members (`declare_outputs`, `output_run_begin`, +`output_bar`, `output_value`, `output_event`, `output_constant` and the +readers behind the C functions); no virtual is added. The layout grows, so a +generated or native C++ object compiled against an earlier v19 header is +rebuilt with the library, as for XSYM-D's virtuals; the script and settlement +ABI matrices accept it unchanged. No value moves: recording is off until a +caller enables it, and nothing a run computes reads the record. The C side is +append-only -- group `pf_outputs`, behind `PINEFORGE_HAS_OUTPUTS_V1`: nine +runtime functions and three a recording module defines -- so `PF_ABI_VERSION` +stays 4 and `pf_report_t` is unchanged. + R5 gap lane P2c gave two TradingView-named public surfaces a generic primary spelling without an epoch, because an alias needs none, and lane REL10 removed the old spellings for 1.0 without one, because removing an alias moves diff --git a/docs/pages/index.md b/docs/pages/index.md index 4a8e5b0e1..6e2867553 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -135,11 +135,13 @@ directly, so a Pine-layer symbol reaching one is a link error. ## API at a glance -The public C surface is **124 `PF_API` declarations** across two headers: +The counts below include recorded outputs and describe the unreleased API targeted for 1.5.0; recorded outputs are not part of 1.4.0. -- `` — **81**: 64 runtime implementations plus seventeen +The public C surface is **136 `PF_API` declarations** across two headers: + +- `` — **93**: 73 runtime implementations plus twenty per-strategy generated exports (`strategy_declares_bar_magnifier` only in a - script that declares `use_bar_magnifier = true`). This is what a compiled + 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 opt-in checked-settings exports (repository guide: `docs/checked-settings.md`) and two execution-capability exports (`docs/strategy-capabilities.md`). diff --git a/docs/pages/native-engine.md b/docs/pages/native-engine.md index 54a8a593e..e085fe0c8 100644 --- a/docs/pages/native-engine.md +++ b/docs/pages/native-engine.md @@ -3967,7 +3967,7 @@ hands the runtime a callback table and gets the same kernel back. 43 additive `PF_API` symbols implemented in `src/native_c_host.cpp` by `CCallbackHost`, a `final NativeStrategyHost` that forwards each existing virtual to the table. No new virtual, no epoch bump, and nothing about the -established C ABI moves: the 64 compiled-strategy runtime symbols and their +established C ABI moves: the compiled-strategy runtime symbols and their counts are untouched, and `scripts/check_c_abi_runtime.py` pins the new set as a second, disjoint inventory. diff --git a/docs/pages/report-schema.md b/docs/pages/report-schema.md index 9860c02d3..560a5990e 100644 --- a/docs/pages/report-schema.md +++ b/docs/pages/report-schema.md @@ -255,6 +255,38 @@ bare native host using the default `HostRecorded` policy has no kernel-owned curve. The array is heap-allocated and freed by #report_free. Note the length field is `int64_t`, not `int`. +## Recorded outputs (outside the report) + +What a module records besides its trades -- values kept per bar, values kept +once per run, events with an optional message -- is not part of +`pf_report_t`, and recording changes no field of it. A caller reads the record +through the `strategy_outputs_*` functions (@ref pf_outputs) after a batch, or +between a stream's inputs; the record's rules are in `docs/outputs.md`. + +`docker/run_json.py --outputs` writes the record into its JSON report as an +`outputs` key, just before `fingerprint`, and marks the run with +`"outputs": true` in `applied_runtime` and in the fingerprint's +`provenance.runtime`; without the flag the report is unchanged: + +| Key | Content | +|---|---| +| `schema_version` | `"pineforge-outputs/v1"` | +| `message_format` | the manifest's message format, `"pineforge/v1"` | +| `manifest_sha256`, `manifest` | the SHA-256 of the manifest bytes the module returned, and the parsed manifest | +| `bars` | `open_ms`, `close_ms`: one entry per recorded row | +| `series` | one `{slot, output, values}` per slot the manifest lists, in slot order | +| `constants` | one value per run-constant index | +| `hlines` | one `{output, price}` per horizontal-level output: the run constant the manifest names | +| `events` | `{sequence, output, bar_index, bar_open_ms, bar_close_ms, ordinal_in_bar, phase, value, message}`, and `freq` on an alert output's events; `phase` is `batch`, `warmup` or `realtime` | + +A double that is not finite is `null`, as everywhere in the report; so is a time +equal to `INT64_MIN` and a run constant never written (an hline price in a run +with no rows). A value whose manifest encoding is `rgba-u32` (a colour) is +written as an integer. With `--bench` the timed runs record too, so the timing +includes recording. `--outputs` on a module that records nothing is the +harness's coded failure line, code `outputs_rejected` (reason `not_declared`), +exit status 1 (@ref run_failure_codes). + ## Lifetime and ownership Every heap pointer in `pf_report_t` is freed by a single call to diff --git a/docs/pages/run-failure-codes.md b/docs/pages/run-failure-codes.md index 14ecc53fd..80720f439 100644 --- a/docs/pages/run-failure-codes.md +++ b/docs/pages/run-failure-codes.md @@ -91,6 +91,8 @@ codes it tested the text alone, so a script stopped by an empty The tables below list the catalog at this tree. +The `outputs_limit` and `outputs_rejected` rows below are unreleased, targeted for 1.5.0, and not part of 1.4.0; the other 38 codes retain their released 1.4.0 status. The `strategy_library_incompatible` row counts the two unreleased 1.5.0 outputs reasons, `outputs_api_mismatch` and `outputs_manifest_invalid`, in addition to its six released 1.4.0 reasons. + ### Classes @@ -121,6 +123,8 @@ The tables below list the catalog at this tree. | `other_symbol_feed_missing` | `no_data` | no | `timeframe` (timeframe, optional) | No feed is installed for another symbol's request at its timeframe. | | `other_symbol_request` | `no_data` | no | `call` (pine_source), `function` (vocab, 2 values), `line` (integer), `symbol` (symbol, optional) | The script read another symbol's request and no bars of that symbol were installed. | | `out_of_memory` | `resource` | yes | none | The run ran out of memory. | +| `outputs_limit` | `strategy_limit` | no | `max` (integer), `reason` (vocab, 2 values) | A recorded-outputs capacity limit was reached during the run. | +| `outputs_rejected` | `input` | no | `reason` (vocab, 3 values) | Recorded outputs were asked of a strategy library that records none, or the recording cannot be honoured as asked. | | `pine_array_error` | `strategy` | no | `collection` (vocab, 2 values, optional), `method` (vocab, 12 values, optional), `reason` (vocab, 5 values) | An array call failed: an index out of bounds, an inverted slice, an empty array, an invalid size, or a modified historical collection. | | `pine_invalid_argument` | `strategy` | no | `argument` (identifier, optional), `function` (identifier, optional), `rule` (vocab, 6 values) | A built-in refused the value of one of its arguments. | | `pine_matrix_error` | `strategy` | no | `function` (vocab, 15 values), `reason` (vocab, 10 values) | A matrix built-in refused its arguments (an index, a dimension, a size or an empty matrix). | @@ -142,7 +146,7 @@ The tables below list the catalog at this tree. | `setting_rejected` | `input` | no | `entrypoint` (vocab, 4 values, optional), `input` (pine_source, optional), `reason` (vocab, 21 values, optional) | A run setting (an input or a strategy() override) was refused. | | `setting_unsupported` | `unsupported` | no | none | A setting this compiled strategy cannot honour. | | `strategy_create_failed` | `engine_fault` | no | none | The compiled strategy could not be created. | -| `strategy_library_incompatible` | `engine_fault` | no | `abi` (integer, optional), `missing` (vocab, 13 values, optional), `reason` (vocab, 6 values) | The compiled strategy library does not match the harness. | +| `strategy_library_incompatible` | `engine_fault` | no | `abi` (integer, optional), `missing` (vocab, 13 values, optional), `reason` (vocab, 8 values) | The compiled strategy library does not match the harness. | | `strategy_runtime_error` | `strategy` | no | none | The script stopped itself with runtime.error(); the message, possibly empty, is the text. | | `strategy_settings_rejected` | `strategy` | no | `field` (vocab, 30 values) | A strategy() declaration setting (or its override) is not a value the engine accepts. | | `stream_input_rejected` | `input` | no | none | A streaming input (bar, tick or auxiliary bar) was refused. | diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 6ff053b97..47f7f4ffa 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -2185,6 +2185,103 @@ class BacktestEngine { void trace(const std::string& name, bool value) { trace(name, value ? 1.0 : 0.0); } void trace(const std::string& name, int value) { trace(name, static_cast(value)); } + // --- Recorded outputs (docs/outputs.md) --- + // Named slots of doubles kept per bar, values kept once per run, and + // events with an optional text, written by a generated module or a C++ + // host and read through the strategy_outputs_* C exports. Off until a + // caller enables recording; no matching, fill, margin, sizing, report or + // hash path reads the record, and none of it is hashed. The host states + // the shape once (declare_outputs), the boundary of every run + // (output_run_begin) and the identity of every row (output_bar): a row is + // a bar the host published, keyed by its open time. A violated + // precondition throws std::logic_error coded engine_invariant; a cap the + // run reached (outputs_limit) or a bar recalculated after its events were + // cleared (outputs_rejected) throws std::runtime_error. Either fails the + // run inside a callback with its text and code. See src/engine_report.cpp. +public: + struct OutputEvent { + uint64_t sequence = 0; // 1-based per run; a cleared one is never issued again + int32_t output_index = 0; + int32_t bar_index = 0; // the row it was recorded on + int64_t bar_open_ms = 0; + int64_t bar_close_ms = 0; + uint32_t ordinal_in_bar = 0; // per output, from 0 within the row + uint32_t phase = 0; // the run's NativeRunPhase when recorded + uint32_t confirmed = 1; // 0 on a calculation of a partially finalized bar + double value = 0.0; + uint64_t message_hash64 = 0; // FNV-1a 64 of the message bytes; 0 without one + bool has_message = false; + std::string message; + }; + + bool outputs_declared() const { return outputs_declared_; } + // false: refused (nothing declared, or a change while the host is Running); + // last_error() says which unless it holds a failed run's error, and a + // success leaves it as it is. A change of the flag clears the record. + bool set_outputs_enabled(bool on); + // The readers behind the C exports. While recording is off the record is + // empty and the counts are 0. + int output_series_count() const { return outputs_enabled_ ? outputs_slots_ : 0; } + int64_t output_bars_len() const { return static_cast(output_open_ms_.size()); } + bool output_bar_times_copy(int64_t from_bar, int64_t* open_ms, int64_t* close_ms, + int64_t capacity, int64_t* written) const; + bool output_series_copy(int slot, int64_t from_bar, double* out, int64_t capacity, + int64_t* written) const; + int output_events_len() const { return static_cast(output_events_.size()); } + const OutputEvent* output_event_at(int index) const; + // Drops the queued events; rows, ordinals and the sequence stay. + void output_events_clear(); + int output_constants_count() const { return outputs_enabled_ ? outputs_constants_ : 0; } + // NaN for na, for a value not written this run, or out of range. + double output_constant_value(int index) const; + +protected: + // Read by generated code before each recorder call, as trace_enabled_ is. + bool outputs_enabled_ = false; + // Once, before the first run: the slots per row, the outputs an event may + // name and the values kept per run. Repeating it repeats the same counts. + // @host-seam (native_c_api.h, COVERAGE / BASE-CLASS SEAMS) + void declare_outputs(int series_slots, int outputs, int run_constants = 0); + // At every run's beginning (a C++ host: on_native_run_begin): an empty + // record, the sequence from 1, every run value unwritten. Idempotent. + // @host-seam (native_c_api.h, COVERAGE / BASE-CLASS SEAMS) + void output_run_begin(); + // Opens the row of the bar being calculated: a new row above the last + // row's open, the last row again (a recalculation) at an equal one; a + // lower open throws. The writers below return at once while recording is + // off. + // @host-seam (native_c_api.h, COVERAGE / BASE-CLASS SEAMS) + void output_bar(int64_t open_ms, int64_t close_ms); + // @host-seam (native_c_api.h, COVERAGE / BASE-CLASS SEAMS) + void output_value(int slot, double value); + // @host-seam (native_c_api.h, COVERAGE / BASE-CLASS SEAMS) + void output_event(int output, double value); + // @host-seam (native_c_api.h, COVERAGE / BASE-CLASS SEAMS) + void output_event(int output, double value, const std::string& message); + // The run's first write of an index stores it; a later one must be equal. + // @host-seam (native_c_api.h, COVERAGE / BASE-CLASS SEAMS) + void output_constant(int index, double value); + +private: + void clear_output_record(); + bool output_host_running() const; + void record_output_event(int output, double value, const std::string* message); + int outputs_slots_ = 0; + int outputs_count_ = 0; + int outputs_constants_ = 0; + bool outputs_declared_ = false; + bool output_run_begun_ = false; + std::vector output_series_; // row-major, rows x slots; NaN unwritten + std::vector output_open_ms_; + std::vector output_close_ms_; + std::vector output_events_; // since the run began or the last clear + uint64_t output_sequence_ = 0; // the last sequence issued + uint64_t output_row_seq_base_ = 0; // its value when the last row opened + uint64_t output_cleared_sequence_ = 0; // the highest sequence a clear dropped + std::vector output_ordinals_; // per output, within the last row + std::vector output_constants_; + std::vector output_constant_written_; + private: // The run spec's quantity tolerance (NativeRunSpec::quantity_tolerance), // 0 when the run declares none, which keeps the settlement exact. The FIFO diff --git a/include/pineforge/native_c_api.h b/include/pineforge/native_c_api.h index aa7b0482f..3aca9e364 100644 --- a/include/pineforge/native_c_api.h +++ b/include/pineforge/native_c_api.h @@ -210,23 +210,23 @@ * * BASE-CLASS SEAMS * ──────────────── - * The rows at the top of this block census NativeStrategyHost's own surface, - * so they cannot see a member of its base. The BacktestEngine members a host - * is documented to call or override from its callbacks are opted in one by - * one: engine.hpp marks each with a `@host-seam` line, and - * scripts/check_native_c_api_surface.py proves this list is exactly the - * marked set, with a C spelling or a reason for each, exactly as it does for - * those rows. A new protected member a host is meant to reach takes the - * marker and a row here. + * The rows at the top of this block census NativeStrategyHost's own surface. A BacktestEngine member a host calls + * or overrides is opted in one by one: engine.hpp marks it `@host-seam`, and scripts/check_native_c_api_surface.py + * proves this list is exactly the marked set, each with a C spelling or a reason. A new one takes the marker and a row. * * [C] declare_opened_lot_entry_bar_mask strategy_native_declare_opened_lot_entry_bar_mask_v1 -- legal * inside on_applied alone; executed by the entry-bar mask * scenario of tests/test_native_c_api.c * [C] hash_host_extension pf_native_callbacks_v1::on_hash_extension -- the host folds a * 64-bit digest of its own state after the kernel's bytes - * [--] hash_source_extension the deprecated spelling of hash_host_extension, kept for C++ - * subclasses written against it; a C host has only the current - * spelling, on_hash_extension + * [--] hash_source_extension the deprecated spelling of hash_host_extension, kept for C++ subclasses + * written against it; a C host has only the current one, on_hash_extension + * [--] declare_outputs C++ only in outputs v1: the record's slots, outputs and run constants, once + * [--] output_run_begin C++ only in outputs v1: the boundary of each run; C reads strategy_outputs_* + * [--] output_bar C++ only in outputs v1: one row per bar the host publishes, keyed by its open + * [--] output_value C++ only in outputs v1: one double in a slot of the open row + * [--] output_event C++ only in outputs v1: an event of the open row, with an optional message + * [--] output_constant C++ only in outputs v1: one value per declared index per run * * HARDENING RULES * ─────────────── diff --git a/include/pineforge/pineforge.h b/include/pineforge/pineforge.h index 0f6029e73..78e39c604 100644 --- a/include/pineforge/pineforge.h +++ b/include/pineforge/pineforge.h @@ -1677,6 +1677,130 @@ PF_API int run_backtest_full_checked(pf_strategy_t s, pf_bar_t* bars, int n, #endif +/** @defgroup pf_outputs Recorded outputs + * What a module records besides trades, read after a run or between the + * inputs of a stream (docs/outputs.md). A module that records declares named + * series slots, outputs and run constants, and per run writes one row per + * bar it publishes: a double per slot (NaN = na), events with an optional + * message, and one value per run constant. Nothing is recorded until + * #strategy_outputs_set_enabled turns recording on, and nothing a run + * computes depends on it: trades, the report, #PF_ABI_VERSION and every + * hash are what they are with recording off. What a slot, an output or a + * constant means is in the manifest the module exports + * (#strategy_outputs_manifest). + * + * Part B, the readers, is implemented by the runtime and present in every + * module built on it; a module that declares nothing answers them as empty. + * Part A is generated per module and present only in one that records: + * detect recording with dlsym("strategy_outputs_api_version"). + * + * Threading: every strategy_outputs_* call runs on the thread that drives + * the handle, never during one of its run or stream calls. A NULL handle + * answers -1 (nothing for a void function). + * @{ */ + +/** Present in a pineforge.h that declares the group. */ +#define PINEFORGE_HAS_OUTPUTS_V1 1 +/** What #strategy_outputs_api_version answers. */ +#define PF_OUTPUTS_API_VERSION 1u + +/** The run phase an event was recorded in. */ +typedef enum pf_output_phase_e { + PF_OUTPUT_PHASE_BATCH = 0, /**< a batch run */ + PF_OUTPUT_PHASE_WARMUP = 1, /**< a stream's warm-up, inside strategy_stream_begin */ + PF_OUTPUT_PHASE_REALTIME = 2 /**< a stream after its warm-up */ +} pf_output_phase_t; +PF_STATIC_ASSERT(sizeof(pf_output_phase_t) == 4); + +/** One recorded event, copied by #strategy_outputs_event_get. Every 8-byte + * field sits at a multiple of 8, so the 72 bytes before `message` are laid + * out alike on every target. */ +typedef struct pf_output_event_v1_s { + uint32_t struct_version; /**< 1 */ + uint32_t size; /**< sizeof(pf_output_event_v1_t) as the runtime compiled it */ + uint64_t sequence; /**< 1-based per run; once cleared, never issued again */ + int32_t output_index; /**< the output, as the manifest's `outputs[].index` */ + int32_t bar_index; /**< the row, 0-based */ + int64_t bar_open_ms; /**< the row's open, Unix ms */ + int64_t bar_close_ms; /**< the row's close, Unix ms; INT64_MIN when unknown */ + uint32_t ordinal_in_bar; /**< per output, from 0 within the row */ + uint32_t phase; /**< a #pf_output_phase_t */ + uint32_t confirmed; /**< 0 when recorded on a partially finalized bar */ + uint32_t reserved0; /**< 0 */ + double value; /**< the event's value; NaN = na */ + uint64_t message_hash64; /**< FNV-1a 64 of every byte the module recorded as the + message; 0 without one */ + const char* message; /**< NUL-terminated, or NULL without one: a message holding a + NUL byte reads to its first NUL, while message_hash64 + covers every recorded byte. Borrowed until the next call + that runs, streams or clears on the handle */ +} pf_output_event_v1_t; + +/** Turn recording on (@p on nonzero) or off. A change clears the record; a + * call that changes nothing changes nothing, even while a run is in progress. + * Returns 0, or -1 with nothing changed and the refusal recorded as code + * `outputs_rejected`: reason `not_declared`, "outputs: this module declares + * no outputs" (turning on a module that records nothing; turning it off + * answers 0), or reason `run_in_progress`, "outputs: recording cannot + * change during a run" (a change while a batch or a stream, from + * #strategy_stream_begin to #strategy_stream_end, is running). As with + * #strategy_set_trace_enabled, a success leaves #strategy_get_last_error + * and its code as they were; a refusal keeps a failed run's own. */ +PF_API int strategy_outputs_set_enabled(pf_strategy_t s, int on); +/** Series slots per row; 0 while recording is off. */ +PF_API int strategy_outputs_series_count(pf_strategy_t s); +/** Rows recorded: one per bar the module published this run. A module that + * opens at most one row per calculation, as a generated one does, never + * records more than #strategy_script_bars_processed. */ +PF_API int64_t strategy_outputs_bars_len(pf_strategy_t s); +/** Copy the open and close times (Unix ms) of rows from @p from_bar on. + * Requires 0 <= @p from_bar <= rows, @p capacity >= 0 and @p written; either + * array may be NULL. Writes min(capacity, rows - from_bar) items, sets + * *written to that count and returns 0; otherwise -1 and nothing written. */ +PF_API int strategy_outputs_bar_times_copy(pf_strategy_t s, int64_t from_bar, + int64_t* open_ms, int64_t* close_ms, + int64_t capacity, int64_t* written); +/** Copy one slot's values (NaN = na) of rows from @p from_bar on, as + * #strategy_outputs_bar_times_copy does; @p out is required when + * @p capacity > 0, and @p series must be below the series count. */ +PF_API int strategy_outputs_series_copy(pf_strategy_t s, int series, int64_t from_bar, + double* out, int64_t capacity, int64_t* written); +/** Events queued since the run began or since the last clear. */ +PF_API int strategy_outputs_events_len(pf_strategy_t s); +/** Copy event @p index into @p out: min(@p size_in, sizeof(pf_output_event_v1_t)) + * bytes, so an older or newer caller's struct works. Returns 0, or -1 with + * nothing written for an index out of range, a NULL @p out, or a @p size_in + * below the 8 bytes of struct_version and size. */ +PF_API int strategy_outputs_event_get(pf_strategy_t s, int index, void* out, size_t size_in); +/** Drop the queued events, in every mode; rows and the sequence are kept. A + * streaming caller clears after #strategy_stream_begin and after each input + * it has read; a batch caller need not clear. */ +PF_API void strategy_outputs_events_clear(pf_strategy_t s); +/** Copy the run constants (NaN = na, or not written this run) into @p out: + * the first min(@p capacity, n) of them. Returns n, the declared count (0 + * while recording is off), or -1 for @p capacity < 0 or a NULL @p out with + * @p capacity > 0. */ +PF_API int strategy_outputs_constants_copy(pf_strategy_t s, double* out, int capacity); + +#ifndef PINEFORGE_NO_STRATEGY_DECLS +/** Part A: #PF_OUTPUTS_API_VERSION. Present only in a module that records. */ +PF_API uint32_t strategy_outputs_api_version(void); +/** Part A: the module's outputs manifest, canonical JSON + * (`pineforge-outputs-manifest/v1`): what every output, slot and constant + * means. The buffer and status protocol of #strategy_capabilities_receipt. */ +PF_API int strategy_outputs_manifest(pf_strategy_t s, char* json, size_t capacity, + size_t* required, char* error, + size_t error_capacity); +/** Part A: the module's signal-safety receipt, canonical JSON + * (`pineforge-signal-safety/v1`), per output. The protocol of + * #strategy_capabilities_receipt. */ +PF_API int strategy_signal_safety_receipt(pf_strategy_t s, char* json, size_t capacity, + size_t* required, char* error, + size_t error_capacity); +#endif + +/** @} */ /* end of pf_outputs */ + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/include/pineforge/run_failure_codes.hpp b/include/pineforge/run_failure_codes.hpp index 159332b5c..24d580518 100644 --- a/include/pineforge/run_failure_codes.hpp +++ b/include/pineforge/run_failure_codes.hpp @@ -23,36 +23,38 @@ enum class RunFailureCode : std::uint16_t { other_symbol_feed_missing = 8, other_symbol_request = 9, out_of_memory = 10, - pine_array_error = 11, - pine_invalid_argument = 12, - pine_matrix_error = 13, - pine_na_reference = 14, - pine_runtime_limit = 15, - pine_string_error = 16, - recalc_cap = 17, - recorded_request_missing = 18, - recorded_request_refused = 19, - request_symbol_invalid = 20, - request_timeframe_invalid = 21, - request_timeframe_unsupported = 22, - request_unsupported = 23, - run_aborted = 24, - run_mode_unsupported = 25, - run_options_rejected = 26, - run_request_invalid = 27, - security_feed_refused = 28, - setting_rejected = 29, - setting_unsupported = 30, - strategy_create_failed = 31, - strategy_library_incompatible = 32, - strategy_runtime_error = 33, - strategy_settings_rejected = 34, - stream_input_rejected = 35, - symbol_feeds_refused = 36, - symbol_metadata_rejected = 37, - syminfo_unreadable = 38, + outputs_limit = 11, + outputs_rejected = 12, + pine_array_error = 13, + pine_invalid_argument = 14, + pine_matrix_error = 15, + pine_na_reference = 16, + pine_runtime_limit = 17, + pine_string_error = 18, + recalc_cap = 19, + recorded_request_missing = 20, + recorded_request_refused = 21, + request_symbol_invalid = 22, + request_timeframe_invalid = 23, + request_timeframe_unsupported = 24, + request_unsupported = 25, + run_aborted = 26, + run_mode_unsupported = 27, + run_options_rejected = 28, + run_request_invalid = 29, + security_feed_refused = 30, + setting_rejected = 31, + setting_unsupported = 32, + strategy_create_failed = 33, + strategy_library_incompatible = 34, + strategy_runtime_error = 35, + strategy_settings_rejected = 36, + stream_input_rejected = 37, + symbol_feeds_refused = 38, + symbol_metadata_rejected = 39, + syminfo_unreadable = 40, }; -inline constexpr std::uint16_t kRunFailureCodeCount = 39; +inline constexpr std::uint16_t kRunFailureCodeCount = 41; } // namespace pineforge diff --git a/scripts/check_c_abi_runtime.py b/scripts/check_c_abi_runtime.py index 1d4a53b1f..a8d42e7ed 100644 --- a/scripts/check_c_abi_runtime.py +++ b/scripts/check_c_abi_runtime.py @@ -91,10 +91,23 @@ "strategy_set_recorded_series", "strategy_get_last_error_code", "strategy_get_last_error_args", + # Recorded outputs (group pf_outputs): the switch and the readers. The + # group's three per-library declarations (strategy_outputs_api_version, + # strategy_outputs_manifest, strategy_signal_safety_receipt) are generated + # per module, so they count as declarations only. + "strategy_outputs_set_enabled", + "strategy_outputs_series_count", + "strategy_outputs_bars_len", + "strategy_outputs_bar_times_copy", + "strategy_outputs_series_copy", + "strategy_outputs_events_len", + "strategy_outputs_event_get", + "strategy_outputs_events_clear", + "strategy_outputs_constants_copy", }) -EXPECTED_PUBLIC_DECLARATIONS = 81 -EXPECTED_RUNTIME_IMPLEMENTATIONS = 64 +EXPECTED_PUBLIC_DECLARATIONS = 93 +EXPECTED_RUNTIME_IMPLEMENTATIONS = 73 # 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 @@ -111,7 +124,7 @@ }) HISTORICAL_ONLY_PHRASE = "stream_begin() fails closed" -# The C-level native host API. Additive to the 64 above: every symbol here is +# The C-level native host API. Additive to the 73 above: every symbol here is # declared in include/pineforge/native_c_api.h and implemented in # src/native_c_host.cpp, and neither file contributes to the two counts above. EXPECTED_NATIVE_C_API = frozenset({ diff --git a/scripts/check_native_c_api_surface.py b/scripts/check_native_c_api_surface.py index e690b9986..91b233d64 100644 --- a/scripts/check_native_c_api_surface.py +++ b/scripts/check_native_c_api_surface.py @@ -140,6 +140,8 @@ "pf_native_failure_code_e": ("native_host.hpp", "NativeFailureCode", {}), "pf_native_failure_operation_e": ("native_host.hpp", "NativeFailureOperation", {}), "pf_native_run_phase_e": ("native_host.hpp", "NativeRunPhase", {}), + # The phase of a recorded event (pineforge.h, group pf_outputs). + "pf_output_phase_e": ("native_host.hpp", "NativeRunPhase", {}), "pf_native_completion_e": ("native_host.hpp", "NativeCompletion", {}), "pf_native_spec_error_e": ("native_run_spec.hpp", "NativeRunSpecError", {}), "pf_native_spec_field_e": ("native_run_spec.hpp", "NativeRunSpecField", {}), diff --git a/scripts/ci_preflight.py b/scripts/ci_preflight.py index bb73d2c90..c60bcdca2 100644 --- a/scripts/ci_preflight.py +++ b/scripts/ci_preflight.py @@ -695,6 +695,10 @@ def check_commands(source: Path) -> list[tuple]: # release.yml's version arithmetic is a tested script. ('report-schema-key-tests', [sys.executable, str(source / 'scripts/test_report_schema_keys.py')]), + # Recorded outputs: the keys, order and value rules of run_json's + # "outputs" block, with no engine (docs/outputs.md). + ('report-outputs-key-tests', + [sys.executable, str(source / 'scripts/test_report_outputs_keys.py')]), ('release-version-tests', [sys.executable, str(source / 'scripts/test_release_version.py')]), # R5 run-failure codes: the catalog diff release.yml stamps is a tested diff --git a/scripts/ci_verify.py b/scripts/ci_verify.py index 05aa72a83..f7c8762d1 100644 --- a/scripts/ci_verify.py +++ b/scripts/ci_verify.py @@ -317,8 +317,8 @@ # +1 test_symbol_calendar (TradingView's symbol calendar as a run input: the # tape converter, the loader, the harness's syminfo-metadata transport) # Both register in release too. 300 registered, 299 run after parser removal: the WebSocket row -# still skips on a system libcurl. -KERNEL_MIN_TESTS = 299 +# still skips on a system libcurl. Lane SM-E1's rows follow EXCLUDED_REGISTERED_MIN below. +KERNEL_MIN_TESTS = 305 # Release-row floor, the same gate for the default profile. Before lane P7 # only the kernel profile had one, so a row that left release alone (a # source-bound TU dropped from TEST_SOURCES, a deleted twin or ABI row) left a @@ -591,8 +591,8 @@ # and needs_aggregation on every run route) # +1 FIX-E1E2 test_aggregated_admission_receipt_tapes (an aggregated # chart's admission receipts in their command's bar space) -# No release row skips, so 792 registered is 792 run. -RELEASE_MIN_TESTS = 792 +# No release row skips, so 792 registered is 792 run (SM-E1: below EXCLUDED_REGISTERED_MIN). +RELEASE_MIN_TESTS = 799 # ADR-0001 ruled-count floors, beside the ctest floors (R5 lane H-DOCGATES, # AUDIT4-opus X13 / docs-a N7). check_kernel_residuals.py counts the rulings # its vocabulary reads -- 174 identifiers and 45 texts on the lane's tree: the @@ -641,7 +641,26 @@ # counted the same way; 773/773/782 with lane FIX-E1E2's two rows of # RELEASE_MIN_TESTS in each, counted the same way. # An excluded run must still discover at least this many rows before -LE. -EXCLUDED_REGISTERED_MIN = {'debug': 773, 'sanitizers': 773, 'native': 782} +EXCLUDED_REGISTERED_MIN = {'debug': 780, 'sanitizers': 779, 'native': 789} +# Lane SM-E1 (recorded outputs, docs/outputs.md) raises the three floors +# above, its comments kept here so that no line cited by line number moves. +# Counted with ctest -N on the lane's tree: +# KERNEL_MIN_TESTS 305 run = those 299 plus six source-free rows: +# +5 test_outputs_recorder, test_outputs_stream_equivalence, +# test_outputs_truncation, test_outputs_run_reuse, +# test_outputs_off_identity +# +1 test_outputs_c_api_c99 (group pf_outputs as strict C99) +# All six register in release too. 306 registered, 305 run: the +# WebSocket row still skips on a system libcurl. +# RELEASE_MIN_TESTS 799 = those 792 plus the six above and one source-bound +# row the kernel profile does not build: +# +1 test_run_json_outputs (docker/run_json.py --outputs as a process, +# over a module that records outputs and a generated one that records +# none) +# No release row skips, so 799 registered is 799 run. +# EXCLUDED_REGISTERED_MIN 780/779/789 = 773/773/782 plus the seven, counted +# the same way; the sanitizers profile builds no module that loads into +# the interpreter, so it registers six of them. # The ctest stage's bound. A full sanitizers run (push to main, a manual # dispatch, the maintainers' verification) ran out of its 30 minutes twice on # main's four-core runner before every row had finished, so it gets an hour; a diff --git a/scripts/test_ci_verify.py b/scripts/test_ci_verify.py index 46db4ae1b..ff8346e46 100644 --- a/scripts/test_ci_verify.py +++ b/scripts/test_ci_verify.py @@ -1053,8 +1053,8 @@ def test_public_and_runtime_export_counts_are_exact(self): ) header = _pf_api_names(ROOT / 'include/pineforge/pineforge.h') runtime = _pf_api_names(ROOT / 'src/c_abi.cpp') - self.assertEqual(EXPECTED_PUBLIC_DECLARATIONS, 81) - self.assertEqual(EXPECTED_RUNTIME_IMPLEMENTATIONS, 64) + self.assertEqual(EXPECTED_PUBLIC_DECLARATIONS, 93) + self.assertEqual(EXPECTED_RUNTIME_IMPLEMENTATIONS, 73) self.assertEqual(len(EXPECTED_RUNTIME), EXPECTED_RUNTIME_IMPLEMENTATIONS) self.assertEqual(len(header), EXPECTED_PUBLIC_DECLARATIONS) self.assertEqual(len(header), len(set(header))) @@ -1504,7 +1504,7 @@ def test_ctest_label_exclusion_is_forwarded(self): def test_pr_exclusion_proves_registered_minus_labelled_equals_ran(self): self.assertEqual(ci_verify.EXCLUDED_REGISTERED_MIN, - {'debug': 773, 'sanitizers': 773, 'native': 782}) + {'debug': 780, 'sanitizers': 779, 'native': 789}) for profile, registered in ci_verify.EXCLUDED_REGISTERED_MIN.items(): with self.subTest(profile=profile): code, summary, scripted, _ = self.run_profile( diff --git a/scripts/test_report_outputs_keys.py b/scripts/test_report_outputs_keys.py new file mode 100644 index 000000000..4782865a3 --- /dev/null +++ b/scripts/test_report_outputs_keys.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""The `outputs` block of the run_json report: keys, order and value rules. + +docker/run_json.py writes an `outputs` block only when --outputs asked for +one (see build_outputs_block). Its shape is a wire format, so these tests pin +it without a compiled engine: build_outputs_block reads the record through a +reader object of callables, which a fake stands in for here. + +Pinned: the block's key order; the keys of a series, constant, hline and +event entry; the phase names; `freq` on the events of an alert output only; +null for a NaN or infinite double, for a time equal to INT64_MIN and for an +hline price no bar wrote (a run with no rows); integers for an `rgba-u32` +slot or constant; `manifest_sha256` over the raw bytes the library returned; +the ctypes mirror of pf_output_event_v1_t; that build_report_dict, which +every run calls, writes no `outputs` key; and that a manifest the harness +cannot read, a slot it does not list as recorded or an event of an output it +does not list is an OutputsError coded strategy_library_incompatible +{reason: outputs_manifest_invalid}, never a traceback or a null id. +""" +from __future__ import annotations + +import ctypes +import hashlib +import json +import math +import sys +import unittest +from pathlib import Path +from types import SimpleNamespace + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "docker")) + +import run_json # noqa: E402 + +BLOCK_KEYS = ["schema_version", "message_format", "manifest_sha256", "manifest", + "bars", "series", "constants", "hlines", "events"] +EVENT_KEYS = ["sequence", "output", "bar_index", "bar_open_ms", "bar_close_ms", + "ordinal_in_bar", "phase", "value", "message"] +INT64_MIN = -(2 ** 63) + +MANIFEST = { + "schema_version": "pineforge-outputs-manifest/v1", + "message_format": "pineforge/v1", + "outputs": [ + {"index": 0, "id": "o0", "kind": "plot", "series": [0], + "colors": {"color": {"slot": 1}}}, + {"index": 1, "id": "o1", "kind": "plotshape", "series": [2], "events": "mark"}, + {"index": 2, "id": "o2", "kind": "alert", "series": [], "events": "per-call", + "freq": "once_per_bar"}, + {"index": 3, "id": "h0", "kind": "hline", "price": {"constant": 0}, + "colors": {"color": {"constant": 1}}}, + {"index": 4, "id": "h1", "kind": "hline", "price": {"constant": 2}}, + ], + "series": [ + {"slot": 2, "output": "o1", "role": "value", "encoding": "bool-as-1-0"}, + {"slot": 0, "output": "o0", "role": "value"}, + {"slot": 1, "output": "o0", "role": "color", "encoding": "rgba-u32"}, + ], + "constants": [ + {"index": 0, "output": "h0", "param": "price"}, + {"index": 1, "output": "h0", "param": "color", "encoding": "rgba-u32"}, + {"index": 2, "output": "h1", "param": "price"}, + ], +} +# Deliberately not the canonical spelling: the digest is over these bytes. +MANIFEST_BYTES = json.dumps(MANIFEST, indent=1).encode() + + +def event(**fields): + base = dict(struct_version=1, size=80, sequence=1, output_index=1, bar_index=0, + bar_open_ms=0, bar_close_ms=300000, ordinal_in_bar=0, phase=0, + confirmed=1, value=1.0, message_hash64=0, message=None) + base.update(fields) + return SimpleNamespace(**base) + + +def reader(*, bars=((0, 300000), (300000, 600000)), series=None, constants=None, + events=()): + opens = [b[0] for b in bars] + closes = [b[1] for b in bars] + if series is None: + series = [[101.5, 102.0], [1286557951.0, 4076222975.0], [1.0, 0.0]] + if constants is None: + constants = [50.0, 2021161215.0, math.nan] + return SimpleNamespace( + manifest=lambda: MANIFEST_BYTES, + bar_times=lambda: (list(opens), list(closes)), + series_count=lambda: len(series), + series=lambda slot: list(series[slot]), + constants=lambda: list(constants), + events=lambda: list(events), + ) + + +class OutputsBlockTests(unittest.TestCase): + def test_block_keys_in_order(self) -> None: + block = run_json.build_outputs_block(reader()) + self.assertEqual(list(block), BLOCK_KEYS) + self.assertEqual(block["schema_version"], "pineforge-outputs/v1") + self.assertEqual(block["message_format"], "pineforge/v1") + self.assertEqual(list(block["bars"]), ["open_ms", "close_ms"]) + + def test_manifest_digest_is_over_the_raw_bytes(self) -> None: + block = run_json.build_outputs_block(reader()) + self.assertEqual(block["manifest_sha256"], hashlib.sha256(MANIFEST_BYTES).hexdigest()) + self.assertNotEqual(block["manifest_sha256"], hashlib.sha256( + json.dumps(MANIFEST, sort_keys=True, separators=(",", ":")).encode()).hexdigest()) + self.assertEqual(block["manifest"], MANIFEST) + + def test_series_entries_in_slot_order_with_their_encoding(self) -> None: + block = run_json.build_outputs_block(reader()) + self.assertEqual([list(entry) for entry in block["series"]], + [["slot", "output", "values"]] * 3) + self.assertEqual([entry["slot"] for entry in block["series"]], [0, 1, 2]) + self.assertEqual(block["series"][0], {"slot": 0, "output": "o0", "values": [101.5, 102.0]}) + colours = block["series"][1]["values"] + self.assertEqual(colours, [1286557951, 4076222975]) + self.assertTrue(all(type(v) is int for v in colours)) + marks = block["series"][2]["values"] + self.assertEqual(marks, [1.0, 0.0]) + self.assertTrue(all(type(v) is float for v in marks)) + + def test_constants_and_hlines(self) -> None: + block = run_json.build_outputs_block(reader()) + self.assertEqual(block["constants"], [50.0, 2021161215, None]) + self.assertIs(type(block["constants"][1]), int) + self.assertEqual(block["hlines"], [{"output": "h0", "price": 50.0}, + {"output": "h1", "price": None}]) + self.assertEqual([list(h) for h in block["hlines"]], [["output", "price"]] * 2) + + def test_a_run_with_no_rows(self) -> None: + block = run_json.build_outputs_block(reader( + bars=(), series=[[], [], []], constants=[math.nan, math.nan, math.nan])) + self.assertEqual(block["bars"], {"open_ms": [], "close_ms": []}) + self.assertEqual([entry["values"] for entry in block["series"]], [[], [], []]) + self.assertEqual(block["constants"], [None, None, None]) + self.assertEqual(block["hlines"], [{"output": "h0", "price": None}, + {"output": "h1", "price": None}]) + self.assertEqual(block["events"], []) + + def test_non_finite_doubles_are_null(self) -> None: + block = run_json.build_outputs_block(reader( + series=[[math.nan, math.inf], [math.nan, -math.inf], [-math.inf, 1.0]], + constants=[math.inf, math.nan, -math.inf], + events=[event(value=math.nan), event(sequence=2, value=math.inf)])) + self.assertEqual([entry["values"] for entry in block["series"]], + [[None, None], [None, None], [None, 1.0]]) + self.assertEqual(block["constants"], [None, None, None]) + self.assertEqual([e["value"] for e in block["events"]], [None, None]) + json.dumps(block, allow_nan=False) + + def test_event_entries(self) -> None: + events = [ + event(sequence=1, output_index=1, bar_index=0, value=1.0, phase=0), + event(sequence=2, output_index=2, bar_index=1, bar_open_ms=300000, + bar_close_ms=INT64_MIN, ordinal_in_bar=1, phase=1, value=math.nan, + message=b"close=101.50", message_hash64=7), + event(sequence=3, output_index=1, bar_index=1, bar_open_ms=INT64_MIN, + phase=2, confirmed=0), + ] + block = run_json.build_outputs_block(reader(events=events)) + first, second, third = block["events"] + self.assertEqual(list(first), EVENT_KEYS) + self.assertEqual(list(second), EVENT_KEYS + ["freq"]) + self.assertEqual(list(third), EVENT_KEYS) + self.assertEqual(first, {"sequence": 1, "output": "o1", "bar_index": 0, + "bar_open_ms": 0, "bar_close_ms": 300000, + "ordinal_in_bar": 0, "phase": "batch", "value": 1.0, + "message": None}) + self.assertEqual(second["output"], "o2") + self.assertEqual(second["phase"], "warmup") + self.assertEqual(second["message"], "close=101.50") + self.assertIsNone(second["bar_close_ms"]) + self.assertIsNone(second["value"]) + self.assertEqual(second["freq"], "once_per_bar") + self.assertEqual(third["phase"], "realtime") + self.assertIsNone(third["bar_open_ms"]) + self.assertNotIn("confirmed", third) + + def assert_manifest_invalid(self, outputs_reader, needle: str) -> None: + with self.assertRaises(run_json.OutputsError) as caught: + run_json.build_outputs_block(outputs_reader) + self.assertEqual(caught.exception.code, "strategy_library_incompatible") + self.assertEqual(caught.exception.code_args, {"reason": "outputs_manifest_invalid"}) + self.assertIn(needle, str(caught.exception)) + + def test_a_manifest_the_harness_cannot_read(self) -> None: + for raw, needle in ((b"{not json", "is not JSON"), + (b"[1, 2]", "is not a JSON object"), + (json.dumps({"outputs": [{"id": "o0"}]}).encode(), + "outputs is not a list of entries with index")): + outputs_reader = reader() + outputs_reader.manifest = lambda raw=raw: raw + self.assert_manifest_invalid(outputs_reader, needle) + + def test_a_slot_the_library_does_not_record(self) -> None: + self.assert_manifest_invalid(reader(series=[[1.0, 2.0], [1.0, 2.0]]), + "names series slot 2, which the library does not record") + + def test_an_event_of_an_output_the_manifest_does_not_list(self) -> None: + self.assert_manifest_invalid(reader(events=[event(output_index=9)]), + "lists no output 9, which an event names") + + def test_mirror_layout_matches_the_c_header(self) -> None: + # pf_output_event_v1_t; src/c_abi.cpp pins the same numbers. + mirror = run_json.OutputEventC + self.assertEqual(ctypes.sizeof(mirror), 80) + self.assertEqual(mirror.size.offset, 4) + self.assertEqual(mirror.sequence.offset, 8) + self.assertEqual(mirror.value.offset, 56) + self.assertEqual(mirror.message_hash64.offset, 64) + self.assertEqual(mirror.message.offset, 72) + + def test_report_dict_writes_no_outputs_key(self) -> None: + class Report: + trades_len = 0 + equity_curve = None + net_profit = 0.0 + input_bars_processed = 0 + script_bars_processed = 0 + magnifier_sub_bars_total = 0 + magnifier_sample_ticks_total = 0 + bar_magnifier_enabled = 0 + metrics = run_json.MetricsC() + + rep = run_json.build_report_dict( + Report(), ohlcv_path="x.csv", n_bars=0, first_ts=0, last_ts=0, + elapsed=0.0, applied_inputs={}, applied_overrides={}, applied_runtime={}) + self.assertNotIn("outputs", rep) + self.assertNotIn("outputs", rep["applied_runtime"]) + + +class ManifestEntryTypesTests(unittest.TestCase): + """Reader-callable witnesses; these do not execute a compiled library.""" + + def assert_invalid(self, manifest) -> None: + outputs_reader = reader(events=[event(output_index=0)]) + outputs_reader.manifest = lambda: json.dumps(manifest).encode() + with self.assertRaises(run_json.OutputsError) as caught: + run_json.build_outputs_block(outputs_reader) + self.assertEqual(caught.exception.code, "strategy_library_incompatible") + self.assertEqual(caught.exception.code_args, + {"reason": "outputs_manifest_invalid"}) + + def test_manifest_entry_indices_are_nonnegative_integers(self) -> None: + for key, field in (("outputs", "index"), ("constants", "index"), + ("series", "slot")): + for value in ([], {}, None, "x", 0.0, True, False, -1): + with self.subTest(key=key, value=value): + manifest = json.loads(MANIFEST_BYTES) + # Leave other valid slots present: mixed types must be + # refused before sorting, not fail in Python's comparison. + manifest[key][0][field] = value + self.assert_invalid(manifest) + + def test_manifest_entry_ids_are_required_nonempty_strings(self) -> None: + for key, field in (("outputs", "id"), ("series", "output")): + for value in (None, "", 0, 1.5, True, False, [], {}): + with self.subTest(key=key, value=value): + manifest = json.loads(MANIFEST_BYTES) + manifest[key][0][field] = value + self.assert_invalid(manifest) + with self.subTest(key=key, missing=field): + manifest = json.loads(MANIFEST_BYTES) + del manifest[key][0][field] + self.assert_invalid(manifest) + + def test_manifest_entry_validation_keeps_valid_values_and_raw_digest(self) -> None: + manifest = json.loads(MANIFEST_BYTES) + # IDs are opaque strings, and indices need not be contiguous here. + manifest["outputs"][0].update(index=17, id="opaque/id:\u03b1") + manifest["series"][1]["output"] = "opaque/id:\u03b1" + manifest["future_metadata"] = {"unconsumed": [True, None]} + raw = json.dumps(manifest, indent=3, ensure_ascii=False).encode() + outputs_reader = reader(events=[event(output_index=17)]) + outputs_reader.manifest = lambda: raw + block = run_json.build_outputs_block(outputs_reader) + self.assertEqual(block["manifest"], manifest) + self.assertEqual(block["manifest_sha256"], hashlib.sha256(raw).hexdigest()) + self.assertEqual(block["events"][0]["output"], "opaque/id:\u03b1") + self.assertEqual(block["series"][0], + {"slot": 0, "output": "opaque/id:\u03b1", + "values": [101.5, 102.0]}) + self.assertEqual([entry["slot"] for entry in block["series"]], [0, 1, 2]) + self.assertEqual(block["constants"], [50.0, 2021161215, None]) + self.assertEqual(block["hlines"], [{"output": "h0", "price": 50.0}, + {"output": "h1", "price": None}]) + + def test_manifest_entry_validation_accepts_empty_optional_collections(self) -> None: + for manifest in ({}, {"outputs": [], "series": [], "constants": []}): + with self.subTest(manifest=manifest): + outputs_reader = reader(bars=(), series=[], constants=[]) + outputs_reader.manifest = lambda: json.dumps(manifest).encode() + block = run_json.build_outputs_block(outputs_reader) + for key in ("series", "constants", "hlines", "events"): + self.assertEqual(block[key], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_run_json_outputs.py b/scripts/test_run_json_outputs.py new file mode 100644 index 000000000..4a243e5d8 --- /dev/null +++ b/scripts/test_run_json_outputs.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""`run_json --outputs` end to end, through docker/run_json.py as a process. + +Usage: test_run_json_outputs.py + + is tests/outputs_fixture_strategy.cpp, a module that records +outputs; is a frozen generated strategy, which records +none. Each case writes an OHLCV CSV, runs run_json.py on it and compares the +JSON it prints with values computed here from the same rows: + + * --outputs writes the `outputs` block: the bars' times, every series slot + the manifest lists, the run constants, the hline read from its price + constant, and every event in sequence order with its phase name; + * without --outputs the report has no new key, and equals the flagged one + less `outputs`, `applied_runtime.outputs`, `fingerprint` and + `elapsed_seconds`; + * --outputs on a library without outputs, or on one whose switch is + refused, is the coded failure line, code outputs_rejected, exit status 1; + * a run that publishes no bar writes empty arrays and a null hline price; + * --bench --outputs is accepted and records the same block. +""" +from __future__ import annotations + +import copy +import csv +import hashlib +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RUN_JSON = ROOT / "docker" / "run_json.py" + +STEP = 5 * 60 * 1000 +T0 = 1704067200000 +GREEN, RED, GRAY = 0xFF4CAF50, 0xFFF23645, 0xFF787B86 + +MANIFEST = { + "changes_trading": [], + "constants": [{"index": 0, "output": "h0", "param": "price"}, + {"encoding": "rgba-u32", "index": 1, "output": "h0", "param": "color"}], + "declaration": {"kind": "indicator", "overlay": True, "title": "Outputs fixture"}, + "message_format": "pineforge/v1", + "not_outputs": [], + "outputs": [ + {"colors": {"color": {"slot": 1}}, "id": "o0", "index": 0, "kind": "plot", "line": 3, + "series": [0], "title": "Close"}, + {"events": "mark", "id": "o1", "index": 1, "kind": "plotshape", "line": 4, + "mark_rule": "bool-true", "series": [2], "title": "Up"}, + {"events": "per-call", "freq": "once_per_bar_close", "id": "o2", "index": 2, + "kind": "alert", "line": 5, "series": []}, + {"id": "o3", "index": 3, "kind": "plot", "line": 6, "series": [3], "title": "Change"}, + {"colors": {"color": {"constant": 1}}, "id": "h0", "index": 4, "kind": "hline", + "line": 7, "price": {"constant": 0, "default": 50}, "title": "Mid"}, + ], + "schema_version": "pineforge-outputs-manifest/v1", + "series": [{"output": "o0", "role": "value", "slot": 0}, + {"encoding": "rgba-u32", "output": "o0", "role": "color", "slot": 1}, + {"encoding": "bool-as-1-0", "output": "o1", "role": "value", "slot": 2}, + {"output": "o3", "role": "value", "slot": 3}], + "source_sha256": "0" * 64, +} +NO_OUTPUTS_ERROR = ("--outputs: this library records no outputs (compile the script as an " + "indicator, or with outputs on)") + + +def rgba(argb: int) -> int: + return ((argb << 8) & 0xFFFFFFFF) | (argb >> 24) + + +def rows(n: int) -> list[tuple[int, float, float, float, float, float]]: + out = [] + price = 100.0 + for i in range(n): + close = price + (1.25 if i % 3 != 1 else -0.75) + out.append((T0 + i * STEP, price, max(price, close) + 0.5, min(price, close) - 0.5, + close, 10.0 + i)) + price = close + return out + + +def expected_block(bars) -> dict: + """What the fixture records over `bars`, computed from the rows.""" + canonical = json.dumps(MANIFEST, sort_keys=True, separators=(",", ":")).encode() + closes = [b[4] for b in bars] + ups = [b[4] > b[1] for b in bars] + events = [] + for k, bar in enumerate(bars): + if ups[k]: + events.append(("o1", k, 1.0, None, None)) + if k % 3 == 2: + events.append(("o2", k, None, f"close={bar[4]:.2f}", "once_per_bar_close")) + ordinals: dict[tuple[str, int], int] = {} + event_rows = [] + for sequence, (output, k, value, message, freq) in enumerate(events, start=1): + ordinal = ordinals.get((output, k), 0) + ordinals[(output, k)] = ordinal + 1 + row = {"sequence": sequence, "output": output, "bar_index": k, + "bar_open_ms": bars[k][0], "bar_close_ms": bars[k][0] + STEP, + "ordinal_in_bar": ordinal, "phase": "batch", "value": value, + "message": message} + if freq is not None: + row["freq"] = freq + event_rows.append(row) + return { + "schema_version": "pineforge-outputs/v1", + "message_format": "pineforge/v1", + "manifest_sha256": hashlib.sha256(canonical).hexdigest(), + "manifest": MANIFEST, + "bars": {"open_ms": [b[0] for b in bars], "close_ms": [b[0] + STEP for b in bars]}, + "series": [ + {"slot": 0, "output": "o0", "values": closes}, + {"slot": 1, "output": "o0", "values": [rgba(GREEN if up else RED) for up in ups]}, + {"slot": 2, "output": "o1", "values": [1.0 if up else 0.0 for up in ups]}, + {"slot": 3, "output": "o3", + "values": [None if k < 2 else closes[k] - closes[k - 1] for k in range(len(bars))]}, + ], + "constants": [50.0, rgba(GRAY)] if bars else [None, None], + "hlines": [{"output": "h0", "price": 50.0 if bars else None}], + "events": event_rows, + } + + +class RunJsonOutputsTests(unittest.TestCase): + outputs_library: Path + generated_library: Path + + @classmethod + def setUpClass(cls) -> None: + cls.tmp = tempfile.TemporaryDirectory(prefix="pf-run-json-outputs-") + cls.bars = rows(9) + cls.csv = Path(cls.tmp.name) / "bars.csv" + with cls.csv.open("w", newline="") as handle: + writer = csv.writer(handle) + writer.writerow(["timestamp", "open", "high", "low", "close", "volume"]) + for bar in cls.bars: + writer.writerow([bar[0], *(repr(v) for v in bar[1:])]) + + @classmethod + def tearDownClass(cls) -> None: + cls.tmp.cleanup() + + def run_json(self, library: Path, *args: str, env: dict | None = None): + command = [sys.executable, str(RUN_JSON), "--so", str(library), "--ohlcv", + str(self.csv), "--input-tf", "5", *args] + done = subprocess.run(command, capture_output=True, text=True, timeout=300, + env={**os.environ, **(env or {})}) + lines = [line for line in done.stdout.splitlines() if line.strip()] + self.assertEqual(len(lines), 1, done.stdout + done.stderr) + self.raw = lines[0] + return done.returncode, json.loads(lines[0]) + + def test_outputs_block(self) -> None: + code, report = self.run_json(self.outputs_library, "--outputs") + self.assertEqual(code, 0, report) + self.assertEqual(report["outputs"], expected_block(self.bars)) + self.assertIs(report["applied_runtime"]["outputs"], True) + self.assertIs(report["fingerprint"]["provenance"]["runtime"]["outputs"], True) + self.assertEqual(list(report)[-1], "fingerprint") + # An rgba-u32 slot is written as JSON integers: the first bar is up + # (green, 0x4CAF50FF), and an integer has no decimal point. + self.assertIn('"slot":1,"output":"o0","values":[1286557951,', self.raw) + + def test_without_the_flag_nothing_else_changes(self) -> None: + code, flagged = self.run_json(self.outputs_library, "--outputs") + self.assertEqual(code, 0, flagged) + code, plain = self.run_json(self.outputs_library) + self.assertEqual(code, 0, plain) + self.assertNotIn("outputs", plain) + self.assertNotIn("outputs", plain["applied_runtime"]) + self.assertNotIn("outputs", plain["fingerprint"]["provenance"]["runtime"]) + stripped = copy.deepcopy(flagged) + del stripped["outputs"] + del stripped["applied_runtime"]["outputs"] + for report in (stripped, plain): + del report["fingerprint"] + del report["elapsed_seconds"] + self.assertEqual(stripped, plain) + + def test_a_library_without_outputs_is_refused(self) -> None: + code, report = self.run_json(self.generated_library, "--outputs") + self.assertEqual(code, 1) + self.assertEqual(report, {"engine": "pineforge", "error": NO_OUTPUTS_ERROR, + "code": "outputs_rejected", + "args": {"reason": "not_declared"}}) + code, report = self.run_json(self.generated_library) + self.assertEqual(code, 0, report) + self.assertNotIn("outputs", report) + + def test_a_refused_switch_is_the_structured_failure(self) -> None: + code, report = self.run_json(self.outputs_library, "--outputs", + env={"PF_OUTPUTS_FIXTURE_UNDECLARED": "1"}) + self.assertEqual(code, 1) + self.assertEqual(report, {"engine": "pineforge", + "error": "--outputs: outputs: this module declares no outputs", + "code": "outputs_rejected", + "args": {"reason": "not_declared"}}) + + def test_a_run_with_no_rows(self) -> None: + code, report = self.run_json(self.outputs_library, "--outputs", + "--inputs", json.dumps({"publish": "false"})) + self.assertEqual(code, 0, report) + self.assertEqual(report["outputs"], expected_block([])) + self.assertEqual(report["outputs"]["hlines"], [{"output": "h0", "price": None}]) + + def test_bench_records_the_same_block(self) -> None: + code, report = self.run_json(self.outputs_library, "--outputs", "--bench", + "--warmup", "1", "--repeats", "2") + self.assertEqual(code, 0, report) + self.assertEqual(report["outputs"], expected_block(self.bars)) + self.assertEqual(len(report["diagnostics"]["timing"]["samples_ns"]), 2) + + +if __name__ == "__main__": + if len(sys.argv) < 3: + sys.exit(f"usage: {sys.argv[0]} ") + RunJsonOutputsTests.outputs_library = Path(sys.argv.pop(1)).resolve() + RunJsonOutputsTests.generated_library = Path(sys.argv.pop(1)).resolve() + unittest.main() diff --git a/src/c_abi.cpp b/src/c_abi.cpp index cd263a6c6..bdb3dc18f 100644 --- a/src/c_abi.cpp +++ b/src/c_abi.cpp @@ -9,13 +9,13 @@ * BEFORE shipping a .so that consumers depend on. * - The runtime-library-side `extern "C"` symbols (the closed-trade * incarnation accessor, setters, strategy_get_last_error, - * strategy_get_last_error_code, strategy_get_last_error_args, - * the security-feed and symbol-data setters, the strategy_stream_* lifecycle, - * native stream bar input/action polling/fingerprint/API-version exports, - * the live-runtime surface (strategy_request_abort, - * strategy_last_run_status, strategy_set_realtime_tail, - * strategy_set_probe_suppress_tail_logic, strategy_set_path_order, - * strategy_last_bar_dual_entry_path, + * strategy_get_last_error_code, strategy_get_last_error_args, the + * security-feed and symbol-data setters, the strategy_stream_* + * lifecycle, the strategy_outputs_* readers, native stream bar + * input/action polling/fingerprint/API-version exports, the live-runtime + * surface (strategy_request_abort, strategy_last_run_status, + * strategy_set_realtime_tail, strategy_set_probe_suppress_tail_logic, + * strategy_set_path_order, strategy_last_bar_dual_entry_path, * strategy_set_broker_state_hash_recording, strategy_broker_state_hash, * strategy_pending_orders_len, strategy_pending_order_get, * strategy_pending_order_layout, strategy_pending_order_fill_qty, @@ -1066,4 +1066,108 @@ PF_API int strategy_set_recorded_series(pf_strategy_t s, const char* key, } #endif +/* Recorded outputs (group pf_outputs, docs/outputs.md): the readers of the + * record BacktestEngine keeps, and its switch. The event copy is size + * prefixed like strategy_pending_order_get: min(size_in, sizeof) bytes, and a + * size_in below the 8-byte header is refused. */ +static_assert(offsetof(pf_output_event_v1_t, size) == 4, + "pf_output_event_v1_t: size follows struct_version"); +static_assert(offsetof(pf_output_event_v1_t, sequence) == 8 + && offsetof(pf_output_event_v1_t, value) == 56 + && offsetof(pf_output_event_v1_t, message_hash64) == 64, + "pf_output_event_v1_t: every 8-byte field at a multiple of 8"); +static_assert(offsetof(pf_output_event_v1_t, message) == 72, + "pf_output_event_v1_t: a 72-byte prefix before the one pointer"); +#if UINTPTR_MAX == UINT64_MAX +static_assert(sizeof(pf_output_event_v1_t) == 80, "pf_output_event_v1_t is 80 bytes on 64-bit"); +#endif +static_assert(static_cast(pineforge::NativeRunPhase::Batch) == PF_OUTPUT_PHASE_BATCH + && static_cast(pineforge::NativeRunPhase::Warmup) == PF_OUTPUT_PHASE_WARMUP + && static_cast(pineforge::NativeRunPhase::Realtime) + == PF_OUTPUT_PHASE_REALTIME, + "pf_output_phase_t mirrors NativeRunPhase"); + +PF_API int strategy_outputs_set_enabled(pf_strategy_t s, int on) { + return pf_cabi_int(s, [&] { + if (!s) return -1; + return static_cast(s)->set_outputs_enabled(on != 0) ? 0 : -1; + }); +} + +PF_API int strategy_outputs_series_count(pf_strategy_t s) { + if (!s) return -1; + return static_cast(s)->output_series_count(); +} + +PF_API int64_t strategy_outputs_bars_len(pf_strategy_t s) { + if (!s) return -1; + return static_cast(s)->output_bars_len(); +} + +PF_API int strategy_outputs_bar_times_copy(pf_strategy_t s, int64_t from_bar, int64_t* open_ms, + int64_t* close_ms, int64_t capacity, + int64_t* written) { + return pf_cabi_int(s, [&] { + if (!s) return -1; + return static_cast(s)->output_bar_times_copy( + from_bar, open_ms, close_ms, capacity, written) + ? 0 : -1; + }); +} + +PF_API int strategy_outputs_series_copy(pf_strategy_t s, int series, int64_t from_bar, + double* out, int64_t capacity, int64_t* written) { + return pf_cabi_int(s, [&] { + if (!s) return -1; + return static_cast(s)->output_series_copy( + series, from_bar, out, capacity, written) + ? 0 : -1; + }); +} + +PF_API int strategy_outputs_events_len(pf_strategy_t s) { + if (!s) return -1; + return static_cast(s)->output_events_len(); +} + +PF_API int strategy_outputs_event_get(pf_strategy_t s, int index, void* out, size_t size_in) { + if (!s || !out) return -1; + if (size_in < offsetof(pf_output_event_v1_t, size) + sizeof(uint32_t)) return -1; + const auto* event = static_cast(s)->output_event_at(index); + if (!event) return -1; + pf_output_event_v1_t tmp; + std::memset(&tmp, 0, sizeof(tmp)); + tmp.struct_version = 1; + tmp.size = static_cast(sizeof(tmp)); + tmp.sequence = event->sequence; + tmp.output_index = event->output_index; + tmp.bar_index = event->bar_index; + tmp.bar_open_ms = event->bar_open_ms; + tmp.bar_close_ms = event->bar_close_ms; + tmp.ordinal_in_bar = event->ordinal_in_bar; + tmp.phase = event->phase; + tmp.confirmed = event->confirmed; + tmp.value = event->value; + tmp.message_hash64 = event->message_hash64; + tmp.message = event->has_message ? event->message.c_str() : nullptr; + std::memcpy(out, &tmp, size_in < sizeof(tmp) ? size_in : sizeof(tmp)); + return 0; +} + +PF_API void strategy_outputs_events_clear(pf_strategy_t s) { + pf_cabi_void([&] { + if (s) static_cast(s)->output_events_clear(); + }); +} + +PF_API int strategy_outputs_constants_copy(pf_strategy_t s, double* out, int capacity) { + return pf_cabi_int(s, [&] { + if (!s || capacity < 0 || (capacity > 0 && !out)) return -1; + const auto* engine = static_cast(s); + const int n = engine->output_constants_count(); + for (int i = 0; i < capacity && i < n; ++i) out[i] = engine->output_constant_value(i); + return n; + }); +} + } /* extern "C" */ diff --git a/src/engine_report.cpp b/src/engine_report.cpp index e24c558e3..1ac012e16 100644 --- a/src/engine_report.cpp +++ b/src/engine_report.cpp @@ -237,3 +237,282 @@ void BacktestEngine::free_report(ReportC* report) { } } // namespace pineforge + + +// --------------------------------------------------------------------------- +// Recorded outputs (engine.hpp, docs/outputs.md). Kept after everything the +// file held before, so no line above moves. The consumer's private header is +// read for three facts only: whether the host is Running, the run's phase and +// the current point's completion. Every refusal carries its run-failure code +// (run_failure.hpp): a broken recorder contract is a host or generated-code +// defect, engine_invariant; a cap the run reached is outputs_limit; the +// two a caller causes (a switch it may not flip, a bar recalculated after it +// took that bar's events) are outputs_rejected. +#include "native_execution_consumer.hpp" + +#include + +#include +#include +#include +#include + +namespace pineforge { + +namespace { + +constexpr double kUnwrittenOutput = std::numeric_limits::quiet_NaN(); + +[[noreturn]] void refuse_output(const std::string& text) { + throw coded(RunFailureCode::engine_invariant, {}, "outputs: " + text); +} + +[[noreturn]] void refuse_output_limit(const char* reason, const char* text) { + throw coded(RunFailureCode::outputs_limit, + {{"reason", reason}, {"max", INT_MAX}}, text); +} + +// A refused switch, recorded as the C boundary records a refused call: a +// handle whose last run failed keeps that run's own text and code. +void note_switch_refusal(BacktestEngine& engine, const char* text, const char* reason) { + if (engine.last_run_status() != 0 + && run_failure_value_of(engine).code != RunFailureCode::none) { + return; + } + note_run_failure(engine, text, RunFailureCode::outputs_rejected, {{"reason", reason}}); +} + +// FNV-1a 64 of the bytes, as stream_state_hash folds them. +uint64_t output_message_hash(const std::string& text) { + uint64_t hash = 1469598103934665603ULL; + for (const unsigned char c : text) { + hash ^= c; + hash *= 1099511628211ULL; + } + return hash; +} + +// Equal bit for bit, or both NaN. +bool same_output_constant(double a, double b) { + if (std::isnan(a) && std::isnan(b)) return true; + uint64_t x = 0; + uint64_t y = 0; + std::memcpy(&x, &a, sizeof(x)); + std::memcpy(&y, &b, sizeof(y)); + return x == y; +} + +} // namespace + +bool BacktestEngine::output_host_running() const { + return execution_consumer_slot_.ptr + && as_native_consumer(*execution_consumer_slot_.ptr).state_kind() + == NativeLifecycleKind::Running; +} + +void BacktestEngine::clear_output_record() { + output_series_.clear(); + output_open_ms_.clear(); + output_close_ms_.clear(); + output_events_.clear(); + output_sequence_ = 0; + output_row_seq_base_ = 0; + output_cleared_sequence_ = 0; + std::fill(output_ordinals_.begin(), output_ordinals_.end(), 0u); + std::fill(output_constants_.begin(), output_constants_.end(), kUnwrittenOutput); + std::fill(output_constant_written_.begin(), output_constant_written_.end(), uint8_t{0}); +} + +bool BacktestEngine::set_outputs_enabled(bool on) { + if (on && !outputs_declared_) { + note_switch_refusal(*this, "outputs: this module declares no outputs", "not_declared"); + return false; + } + if (on != outputs_enabled_) { + if (output_host_running()) { + note_switch_refusal(*this, "outputs: recording cannot change during a run", + "run_in_progress"); + return false; + } + outputs_enabled_ = on; + clear_output_record(); + } + // Success leaves the failure record as it is, as strategy_set_trace_enabled + // and the checked settings setters do: a failed run's text and code survive. + return true; +} + +void BacktestEngine::declare_outputs(int series_slots, int outputs, int run_constants) { + if (series_slots < 0 || outputs < 0 || run_constants < 0) + refuse_output("declared counts must not be negative"); + if (output_host_running()) refuse_output("declare_outputs while a run is in progress"); + if (outputs_declared_) { + if (series_slots != outputs_slots_ || outputs != outputs_count_ + || run_constants != outputs_constants_) { + refuse_output("declare_outputs repeated with other counts"); + } + return; + } + outputs_slots_ = series_slots; + outputs_count_ = outputs; + outputs_constants_ = run_constants; + output_ordinals_.assign(static_cast(outputs), 0u); + output_constants_.assign(static_cast(run_constants), kUnwrittenOutput); + output_constant_written_.assign(static_cast(run_constants), uint8_t{0}); + outputs_declared_ = true; +} + +void BacktestEngine::output_run_begin() { + clear_output_record(); + output_run_begun_ = true; +} + +void BacktestEngine::output_bar(int64_t open_ms, int64_t close_ms) { + if (!outputs_enabled_) return; + if (!output_run_begun_) refuse_output("output_bar before any output_run_begin"); + const size_t slots = static_cast(outputs_slots_); + if (output_open_ms_.empty() || open_ms > output_open_ms_.back()) { + // bar_index is an int32_t, like the C record's. + if (output_open_ms_.size() >= static_cast(INT_MAX)) + refuse_output_limit("too_many_rows", "outputs: the row count is at its limit"); + output_series_.resize(output_series_.size() + slots, kUnwrittenOutput); + output_open_ms_.push_back(open_ms); + output_close_ms_.push_back(close_ms); + std::fill(output_ordinals_.begin(), output_ordinals_.end(), 0u); + output_row_seq_base_ = output_sequence_; + return; + } + if (open_ms < output_open_ms_.back()) { + refuse_output("bar opened at " + std::to_string(open_ms) + + " is before the last recorded bar"); + } + // A recalculation of the last row. Its queued events are retracted and + // their sequences issued again, unless a clear already handed one out. + if (output_cleared_sequence_ > output_row_seq_base_) { + throw coded( + RunFailureCode::outputs_rejected, {{"reason", "recalculated_after_clear"}}, + "outputs: bar " + std::to_string(output_open_ms_.size() - 1) + + " was recalculated after its events were cleared"); + } + std::fill(output_series_.end() - static_cast(slots), output_series_.end(), + kUnwrittenOutput); + output_close_ms_.back() = close_ms; + std::fill(output_ordinals_.begin(), output_ordinals_.end(), 0u); + while (!output_events_.empty() && output_events_.back().sequence > output_row_seq_base_) + output_events_.pop_back(); + output_sequence_ = output_row_seq_base_; +} + +void BacktestEngine::output_value(int slot, double value) { + if (!outputs_enabled_) return; + if (output_open_ms_.empty()) refuse_output("output_value with no open bar"); + if (slot < 0 || slot >= outputs_slots_) + refuse_output("series slot " + std::to_string(slot) + " is out of range"); + output_series_[(output_open_ms_.size() - 1) * static_cast(outputs_slots_) + + static_cast(slot)] = value; +} + +void BacktestEngine::output_event(int output, double value) { + record_output_event(output, value, nullptr); +} + +void BacktestEngine::output_event(int output, double value, const std::string& message) { + record_output_event(output, value, &message); +} + +void BacktestEngine::record_output_event(int output, double value, const std::string* message) { + if (!outputs_enabled_) return; + if (output_open_ms_.empty()) refuse_output("output_event with no open bar"); + if (output < 0 || output >= outputs_count_) + refuse_output("output " + std::to_string(output) + " is out of range"); + if (output_events_.size() >= static_cast(INT_MAX)) + refuse_output_limit("too_many_events", "outputs: the event queue is full"); + if (output_sequence_ == std::numeric_limits::max()) + refuse_output("event sequence overflow"); + OutputEvent event; + event.sequence = output_sequence_ + 1; + event.output_index = output; + event.bar_index = static_cast(output_open_ms_.size() - 1); + event.bar_open_ms = output_open_ms_.back(); + event.bar_close_ms = output_close_ms_.back(); + event.ordinal_in_bar = output_ordinals_[static_cast(output)]; + const NativeExecutionConsumer& consumer = as_native_consumer(execution_consumer()); + event.phase = static_cast(consumer.state_phase()); + const NativeCurrentPointView* point = consumer.current_point(); + event.confirmed = point != nullptr + && point->decision.coordinate.completion == NativeCompletionKind::PartialFinalized + ? 0u : 1u; + event.value = value; + if (message != nullptr) { + event.has_message = true; + event.message = *message; + event.message_hash64 = output_message_hash(*message); + } + output_events_.push_back(std::move(event)); + output_sequence_ = output_events_.back().sequence; + ++output_ordinals_[static_cast(output)]; +} + +void BacktestEngine::output_constant(int index, double value) { + if (!outputs_enabled_) return; + if (!output_run_begun_) refuse_output("output_constant before any output_run_begin"); + if (index < 0 || index >= outputs_constants_) + refuse_output("run constant " + std::to_string(index) + " is out of range"); + const size_t at = static_cast(index); + if (!output_constant_written_[at]) { + output_constants_[at] = value; + output_constant_written_[at] = 1; + return; + } + if (!same_output_constant(output_constants_[at], value)) + refuse_output("run constant " + std::to_string(index) + " changed within a run"); +} + +bool BacktestEngine::output_bar_times_copy(int64_t from_bar, int64_t* open_ms, + int64_t* close_ms, int64_t capacity, + int64_t* written) const { + const int64_t rows = output_bars_len(); + if (written == nullptr || capacity < 0 || from_bar < 0 || from_bar > rows) return false; + const int64_t n = std::min(capacity, rows - from_bar); + const auto first = static_cast(from_bar); + if (open_ms != nullptr) + std::copy(output_open_ms_.begin() + first, output_open_ms_.begin() + first + n, open_ms); + if (close_ms != nullptr) + std::copy(output_close_ms_.begin() + first, output_close_ms_.begin() + first + n, close_ms); + *written = n; + return true; +} + +bool BacktestEngine::output_series_copy(int slot, int64_t from_bar, double* out, + int64_t capacity, int64_t* written) const { + const int64_t rows = output_bars_len(); + if (written == nullptr || slot < 0 || slot >= output_series_count() || capacity < 0 + || (capacity > 0 && out == nullptr) || from_bar < 0 || from_bar > rows) { + return false; + } + const int64_t n = std::min(capacity, rows - from_bar); + const size_t stride = static_cast(outputs_slots_); + for (int64_t i = 0; i < n; ++i) + out[i] = output_series_[static_cast(from_bar + i) * stride + static_cast(slot)]; + *written = n; + return true; +} + +const BacktestEngine::OutputEvent* BacktestEngine::output_event_at(int index) const { + if (index < 0 || index >= output_events_len()) return nullptr; + return &output_events_[static_cast(index)]; +} + +void BacktestEngine::output_events_clear() { + if (output_events_.empty()) return; + output_cleared_sequence_ = std::max(output_cleared_sequence_, output_events_.back().sequence); + output_events_.clear(); +} + +double BacktestEngine::output_constant_value(int index) const { + if (index < 0 || index >= output_constants_count()) return kUnwrittenOutput; + const size_t at = static_cast(index); + return output_constant_written_[at] ? output_constants_[at] : kUnwrittenOutput; +} + +} // namespace pineforge diff --git a/src/run_failure_registry.inc b/src/run_failure_registry.inc index a357f1513..cacf537a4 100644 --- a/src/run_failure_registry.inc +++ b/src/run_failure_registry.inc @@ -39,6 +39,11 @@ static constexpr const char* kRegistryValues[] = { "request.currency_rate", "request.security", "request.security_lower_tf", + "too_many_events", + "too_many_rows", + "not_declared", + "run_in_progress", + "recalculated_after_clear", "array", "matrix", "new", @@ -217,6 +222,8 @@ static constexpr const char* kRegistryValues[] = { "symbol_missing", "setter_missing", "settings_api_mismatch", + "outputs_api_mismatch", + "outputs_manifest_invalid", "initial_capital", "slippage_ticks", "fee_kind", @@ -327,44 +334,47 @@ static constexpr RegistryArg kRegistryArgs[] = { {"function", RunFailureArgKind::vocab, false, 31, 2}, {"line", RunFailureArgKind::integer, false, 33, 0}, {"symbol", RunFailureArgKind::symbol, true, 33, 0}, - {"collection", RunFailureArgKind::vocab, true, 33, 2}, - {"method", RunFailureArgKind::vocab, true, 35, 12}, - {"reason", RunFailureArgKind::vocab, false, 47, 5}, - {"argument", RunFailureArgKind::identifier, true, 52, 0}, - {"function", RunFailureArgKind::identifier, true, 52, 0}, - {"rule", RunFailureArgKind::vocab, false, 52, 6}, - {"function", RunFailureArgKind::vocab, false, 58, 15}, - {"reason", RunFailureArgKind::vocab, false, 73, 10}, - {"object", RunFailureArgKind::vocab, false, 83, 10}, - {"limit", RunFailureArgKind::vocab, false, 93, 4}, - {"max", RunFailureArgKind::integer, true, 97, 0}, - {"reason", RunFailureArgKind::vocab, false, 97, 2}, - {"reason", RunFailureArgKind::vocab, false, 99, 5}, - {"api", RunFailureArgKind::vocab, false, 104, 2}, - {"input_tf", RunFailureArgKind::timeframe, true, 106, 0}, - {"reason", RunFailureArgKind::vocab, false, 106, 7}, - {"script_tf", RunFailureArgKind::timeframe, true, 113, 0}, - {"line", RunFailureArgKind::integer, true, 113, 0}, - {"reason", RunFailureArgKind::vocab, false, 113, 2}, - {"feature", RunFailureArgKind::vocab, false, 115, 10}, - {"input_tf", RunFailureArgKind::timeframe, true, 125, 0}, - {"option", RunFailureArgKind::vocab, false, 125, 11}, - {"script_tf", RunFailureArgKind::timeframe, true, 136, 0}, - {"option", RunFailureArgKind::vocab, false, 136, 20}, - {"reason", RunFailureArgKind::vocab, false, 156, 11}, - {"entrypoint", RunFailureArgKind::vocab, true, 167, 4}, - {"input", RunFailureArgKind::pine_source, true, 171, 0}, - {"reason", RunFailureArgKind::vocab, true, 171, 21}, - {"abi", RunFailureArgKind::integer, true, 192, 0}, - {"missing", RunFailureArgKind::vocab, true, 192, 13}, - {"reason", RunFailureArgKind::vocab, false, 205, 6}, - {"field", RunFailureArgKind::vocab, false, 211, 30}, - {"field", RunFailureArgKind::vocab, true, 241, 6}, - {"input_tf", RunFailureArgKind::timeframe, true, 247, 0}, - {"reason", RunFailureArgKind::vocab, false, 247, 41}, - {"script_tf", RunFailureArgKind::timeframe, true, 288, 0}, - {"field", RunFailureArgKind::vocab, false, 288, 14}, - {"reason", RunFailureArgKind::vocab, false, 302, 4}, + {"max", RunFailureArgKind::integer, false, 33, 0}, + {"reason", RunFailureArgKind::vocab, false, 33, 2}, + {"reason", RunFailureArgKind::vocab, false, 35, 3}, + {"collection", RunFailureArgKind::vocab, true, 38, 2}, + {"method", RunFailureArgKind::vocab, true, 40, 12}, + {"reason", RunFailureArgKind::vocab, false, 52, 5}, + {"argument", RunFailureArgKind::identifier, true, 57, 0}, + {"function", RunFailureArgKind::identifier, true, 57, 0}, + {"rule", RunFailureArgKind::vocab, false, 57, 6}, + {"function", RunFailureArgKind::vocab, false, 63, 15}, + {"reason", RunFailureArgKind::vocab, false, 78, 10}, + {"object", RunFailureArgKind::vocab, false, 88, 10}, + {"limit", RunFailureArgKind::vocab, false, 98, 4}, + {"max", RunFailureArgKind::integer, true, 102, 0}, + {"reason", RunFailureArgKind::vocab, false, 102, 2}, + {"reason", RunFailureArgKind::vocab, false, 104, 5}, + {"api", RunFailureArgKind::vocab, false, 109, 2}, + {"input_tf", RunFailureArgKind::timeframe, true, 111, 0}, + {"reason", RunFailureArgKind::vocab, false, 111, 7}, + {"script_tf", RunFailureArgKind::timeframe, true, 118, 0}, + {"line", RunFailureArgKind::integer, true, 118, 0}, + {"reason", RunFailureArgKind::vocab, false, 118, 2}, + {"feature", RunFailureArgKind::vocab, false, 120, 10}, + {"input_tf", RunFailureArgKind::timeframe, true, 130, 0}, + {"option", RunFailureArgKind::vocab, false, 130, 11}, + {"script_tf", RunFailureArgKind::timeframe, true, 141, 0}, + {"option", RunFailureArgKind::vocab, false, 141, 20}, + {"reason", RunFailureArgKind::vocab, false, 161, 11}, + {"entrypoint", RunFailureArgKind::vocab, true, 172, 4}, + {"input", RunFailureArgKind::pine_source, true, 176, 0}, + {"reason", RunFailureArgKind::vocab, true, 176, 21}, + {"abi", RunFailureArgKind::integer, true, 197, 0}, + {"missing", RunFailureArgKind::vocab, true, 197, 13}, + {"reason", RunFailureArgKind::vocab, false, 210, 8}, + {"field", RunFailureArgKind::vocab, false, 218, 30}, + {"field", RunFailureArgKind::vocab, true, 248, 6}, + {"input_tf", RunFailureArgKind::timeframe, true, 254, 0}, + {"reason", RunFailureArgKind::vocab, false, 254, 41}, + {"script_tf", RunFailureArgKind::timeframe, true, 295, 0}, + {"field", RunFailureArgKind::vocab, false, 295, 14}, + {"reason", RunFailureArgKind::vocab, false, 309, 4}, }; static constexpr RegistryCode kRegistryCodes[] = { @@ -379,35 +389,37 @@ static constexpr RegistryCode kRegistryCodes[] = { {"other_symbol_feed_missing", RunFailureClass::no_data, false, 7, 1}, {"other_symbol_request", RunFailureClass::no_data, false, 8, 4}, {"out_of_memory", RunFailureClass::resource, true, 12, 0}, - {"pine_array_error", RunFailureClass::strategy, false, 12, 3}, - {"pine_invalid_argument", RunFailureClass::strategy, false, 15, 3}, - {"pine_matrix_error", RunFailureClass::strategy, false, 18, 2}, - {"pine_na_reference", RunFailureClass::strategy, false, 20, 1}, - {"pine_runtime_limit", RunFailureClass::strategy_limit, false, 21, 2}, - {"pine_string_error", RunFailureClass::strategy, false, 23, 1}, - {"recalc_cap", RunFailureClass::strategy_limit, false, 24, 0}, - {"recorded_request_missing", RunFailureClass::no_data, false, 24, 0}, - {"recorded_request_refused", RunFailureClass::input, false, 24, 1}, - {"request_symbol_invalid", RunFailureClass::strategy, false, 25, 0}, - {"request_timeframe_invalid", RunFailureClass::strategy, false, 25, 1}, - {"request_timeframe_unsupported", RunFailureClass::unsupported, false, 26, 3}, - {"request_unsupported", RunFailureClass::unsupported, false, 29, 2}, - {"run_aborted", RunFailureClass::engine_fault, true, 31, 0}, - {"run_mode_unsupported", RunFailureClass::unsupported, false, 31, 1}, - {"run_options_rejected", RunFailureClass::input, false, 32, 3}, - {"run_request_invalid", RunFailureClass::input, false, 35, 1}, - {"security_feed_refused", RunFailureClass::input, false, 36, 1}, - {"setting_rejected", RunFailureClass::input, false, 37, 3}, - {"setting_unsupported", RunFailureClass::unsupported, false, 40, 0}, - {"strategy_create_failed", RunFailureClass::engine_fault, false, 40, 0}, - {"strategy_library_incompatible", RunFailureClass::engine_fault, false, 40, 3}, - {"strategy_runtime_error", RunFailureClass::strategy, false, 43, 0}, - {"strategy_settings_rejected", RunFailureClass::strategy, false, 43, 1}, - {"stream_input_rejected", RunFailureClass::input, false, 44, 0}, - {"symbol_feeds_refused", RunFailureClass::symbol_feeds, false, 44, 4}, - {"symbol_metadata_rejected", RunFailureClass::symbol_metadata, true, 48, 1}, - {"syminfo_unreadable", RunFailureClass::symbol_metadata, true, 49, 1}, + {"outputs_limit", RunFailureClass::strategy_limit, false, 12, 2}, + {"outputs_rejected", RunFailureClass::input, false, 14, 1}, + {"pine_array_error", RunFailureClass::strategy, false, 15, 3}, + {"pine_invalid_argument", RunFailureClass::strategy, false, 18, 3}, + {"pine_matrix_error", RunFailureClass::strategy, false, 21, 2}, + {"pine_na_reference", RunFailureClass::strategy, false, 23, 1}, + {"pine_runtime_limit", RunFailureClass::strategy_limit, false, 24, 2}, + {"pine_string_error", RunFailureClass::strategy, false, 26, 1}, + {"recalc_cap", RunFailureClass::strategy_limit, false, 27, 0}, + {"recorded_request_missing", RunFailureClass::no_data, false, 27, 0}, + {"recorded_request_refused", RunFailureClass::input, false, 27, 1}, + {"request_symbol_invalid", RunFailureClass::strategy, false, 28, 0}, + {"request_timeframe_invalid", RunFailureClass::strategy, false, 28, 1}, + {"request_timeframe_unsupported", RunFailureClass::unsupported, false, 29, 3}, + {"request_unsupported", RunFailureClass::unsupported, false, 32, 2}, + {"run_aborted", RunFailureClass::engine_fault, true, 34, 0}, + {"run_mode_unsupported", RunFailureClass::unsupported, false, 34, 1}, + {"run_options_rejected", RunFailureClass::input, false, 35, 3}, + {"run_request_invalid", RunFailureClass::input, false, 38, 1}, + {"security_feed_refused", RunFailureClass::input, false, 39, 1}, + {"setting_rejected", RunFailureClass::input, false, 40, 3}, + {"setting_unsupported", RunFailureClass::unsupported, false, 43, 0}, + {"strategy_create_failed", RunFailureClass::engine_fault, false, 43, 0}, + {"strategy_library_incompatible", RunFailureClass::engine_fault, false, 43, 3}, + {"strategy_runtime_error", RunFailureClass::strategy, false, 46, 0}, + {"strategy_settings_rejected", RunFailureClass::strategy, false, 46, 1}, + {"stream_input_rejected", RunFailureClass::input, false, 47, 0}, + {"symbol_feeds_refused", RunFailureClass::symbol_feeds, false, 47, 4}, + {"symbol_metadata_rejected", RunFailureClass::symbol_metadata, true, 51, 1}, + {"syminfo_unreadable", RunFailureClass::symbol_metadata, true, 52, 1}, }; -static_assert(sizeof(kRegistryCodes) / sizeof(kRegistryCodes[0]) == 39, +static_assert(sizeof(kRegistryCodes) / sizeof(kRegistryCodes[0]) == 41, "the registry must have one row per RunFailureCode"); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 77635eb2b..f5a8a3f87 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1794,6 +1794,18 @@ list(APPEND TEST_SOURCES test_pooc_limit_close_tapes) # (tests/fixtures/aggregated_admission_receipt, E2). Source-bound. list(APPEND TEST_SOURCES test_report_timeframe_ratio) list(APPEND TEST_SOURCES test_aggregated_admission_receipt_tapes) +# Recorded outputs (docs/outputs.md): a bare host's record read through the +# strategy_outputs_* C exports -- hand values, the row and run-constant rules +# and every refusal (recorder); a stream records what a batch records; a +# batch cut at bar t records bars <= t as the whole batch does; the run +# boundary on a reused handle; and three trading hosts whose trades, equity +# and hashes are equal with recording undeclared, off and on. All source-free, +# so they also run in the kernel-only profile. +list(APPEND TEST_SOURCES test_outputs_recorder) +list(APPEND TEST_SOURCES test_outputs_stream_equivalence) +list(APPEND TEST_SOURCES test_outputs_truncation) +list(APPEND TEST_SOURCES test_outputs_run_reuse) +list(APPEND TEST_SOURCES test_outputs_off_identity) # R4-D L4b-fix restores every removed ShortSeed, affordability, sizing and # deferred-close oracle as an individually compiled native-route twin. Keep @@ -2472,6 +2484,40 @@ if(PINEFORGE_BUILD_SOURCE_LAYER AND UNIX ${PROJECT_SOURCE_DIR}/scripts) endif() +# Recorded outputs through docker/run_json.py (--outputs): two strategy +# libraries run as a process, one that records outputs +# (outputs_fixture_strategy.cpp, a bare host with the per-library outputs +# exports) and one frozen generated strategy that records none. Both load +# into the Python interpreter, so neither is built under the sanitizer or +# coverage instrumentation. +if(PINEFORGE_BUILD_SOURCE_LAYER AND UNIX + AND NOT PINEFORGE_ENABLE_SANITIZERS AND NOT PINEFORGE_ENABLE_COVERAGE) + set(_pf_outputs_dir ${CMAKE_BINARY_DIR}/outputs_fixture) + add_library(outputs_fixture_strategy MODULE outputs_fixture_strategy.cpp) + add_library(outputs_fixture_generated MODULE + ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/callback_lifecycle/carried-static-stop/generated.cpp) + target_include_directories(outputs_fixture_generated PRIVATE ${PROJECT_SOURCE_DIR}/src) + target_compile_options(outputs_fixture_generated PRIVATE -O0 -ffp-contract=off + -Wno-unused-but-set-variable -Wno-unused-variable -Wno-unused-parameter + -Wno-unused-function -Wno-sign-compare) + foreach(_pf_outputs_library outputs_fixture_strategy outputs_fixture_generated) + target_link_libraries(${_pf_outputs_library} PRIVATE pineforge) + if(APPLE) + target_link_options(${_pf_outputs_library} PRIVATE + "-Wl,-force_load,$") + else() + target_link_options(${_pf_outputs_library} PRIVATE + "-Wl,--whole-archive" "$" "-Wl,--no-whole-archive") + endif() + set_target_properties(${_pf_outputs_library} PROPERTIES + LIBRARY_OUTPUT_DIRECTORY ${_pf_outputs_dir} PREFIX "" SUFFIX ".so") + endforeach() + add_test(NAME test_run_json_outputs + COMMAND ${Python3_EXECUTABLE} + ${PROJECT_SOURCE_DIR}/scripts/test_run_json_outputs.py + $ $) +endif() + # TradingView's same-side exit tapes (tests/fixtures/same_side_exit): a # global strategy.exit called while a position is held beside an add of the # held side (a resting limit or stop, or a market add at the pyramiding cap). @@ -3692,6 +3738,20 @@ if(PINEFORGE_ENABLE_COVERAGE) endif() pineforge_add_twin_test(test_native_c_api_c99 test_native_c_api_c99) +# Recorded outputs: group pf_outputs of (its runtime +# readers and the per-library declarations) as a strict C99 consumer compiles +# it, the event record's layout pinned at compile time, and a native C host's +# handle answering the readers as a module that declares no outputs. +add_executable(test_outputs_c_api_c99 test_outputs_c_api_c99.c) +set_target_properties(test_outputs_c_api_c99 PROPERTIES + C_STANDARD 99 C_STANDARD_REQUIRED ON C_EXTENSIONS OFF LINKER_LANGUAGE CXX) +target_link_libraries(test_outputs_c_api_c99 PRIVATE pineforge) +target_compile_options(test_outputs_c_api_c99 PRIVATE -UNDEBUG -pedantic-errors -Wall -Wextra) +if(PINEFORGE_ENABLE_COVERAGE) + target_compile_options(test_outputs_c_api_c99 PRIVATE ${_pf_cov_test_flags}) +endif() +pineforge_add_twin_test(test_outputs_c_api_c99 test_outputs_c_api_c99) + # R5 lane H-MEASURE (AUDIT4-codex A4-ACID-COVERAGE): the G1 composite witness. # One ctest row, two translation units, the test_native_c_api pattern. The C++ # half drives one Pine-free NativeStrategyHost through every feature the G1 diff --git a/tests/outputs_fixture_strategy.cpp b/tests/outputs_fixture_strategy.cpp new file mode 100644 index 000000000..9c2a43b85 --- /dev/null +++ b/tests/outputs_fixture_strategy.cpp @@ -0,0 +1,200 @@ +// A hand-written strategy library that records outputs, for +// scripts/test_run_json_outputs.py: the entry points docker/run_json.py binds +// (strategy_create, run_backtest_full, ...) over a bare NativeStrategyHost, +// and the three per-library exports of group pf_outputs that a generated +// module carries (strategy_outputs_api_version, strategy_outputs_manifest, +// strategy_signal_safety_receipt). It stands in for a generated module: the +// manifest is a fixed document in the manifest's canonical bytes (sorted +// keys, no spaces), and the host records: +// +// slot 0 output o0's value: the close +// slot 1 output o0's colour, rgba-u32: up bars green, others red +// slot 2 output o1's mark, bool-as-1-0: close > open; an event when 1 +// slot 3 output o3's value: the change of the close, na on bars 0 and 1 +// output o2: an event with the message "close=" on bars 2, 5, 8, ... +// constant 0: output h0's price, 50; constant 1: its colour, rgba-u32 +// +// Input "publish" = "false" publishes no bar (a run with no rows). With the +// environment variable PF_OUTPUTS_FIXTURE_UNDECLARED set at creation the +// module declares nothing, so switching recording on is refused. +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace pineforge; + +constexpr const char* kManifest = + R"({"changes_trading":[],"constants":[{"index":0,"output":"h0","param":"price"},)" + R"({"encoding":"rgba-u32","index":1,"output":"h0","param":"color"}],)" + R"("declaration":{"kind":"indicator","overlay":true,"title":"Outputs fixture"},)" + R"("message_format":"pineforge/v1","not_outputs":[],)" + R"("outputs":[{"colors":{"color":{"slot":1}},"id":"o0","index":0,"kind":"plot","line":3,"series":[0],"title":"Close"},)" + R"({"events":"mark","id":"o1","index":1,"kind":"plotshape","line":4,"mark_rule":"bool-true","series":[2],"title":"Up"},)" + R"({"events":"per-call","freq":"once_per_bar_close","id":"o2","index":2,"kind":"alert","line":5,"series":[]},)" + R"({"id":"o3","index":3,"kind":"plot","line":6,"series":[3],"title":"Change"},)" + R"({"colors":{"color":{"constant":1}},"id":"h0","index":4,"kind":"hline","line":7,"price":{"constant":0,"default":50},"title":"Mid"}],)" + R"("schema_version":"pineforge-outputs-manifest/v1",)" + R"("series":[{"output":"o0","role":"value","slot":0},{"encoding":"rgba-u32","output":"o0","role":"color","slot":1},)" + R"({"encoding":"bool-as-1-0","output":"o1","role":"value","slot":2},{"output":"o3","role":"value","slot":3}],)" + R"("source_sha256":"0000000000000000000000000000000000000000000000000000000000000000"})"; + +double rgba(std::uint32_t argb) { + return static_cast((argb << 8) | (argb >> 24)); +} + +class FixtureHost final : public NativeStrategyHost { +public: + FixtureHost() { + if (!std::getenv("PF_OUTPUTS_FIXTURE_UNDECLARED")) declare_outputs(4, 5, 2); + } + + bool publish = true; + std::uint64_t runs = 0; + + void on_native_run_begin() override { + output_run_begin(); + bars_ = 0; + previous_close_ = std::numeric_limits::quiet_NaN(); + } + + void on_native_bar(const Bar& bar, const NativeDecisionContext& context) override { + if (!publish) return; + const int k = bars_++; + const bool up = bar.close > bar.open; + if (outputs_enabled_) { + output_bar(context.script_bar_open_ms, context.script_interval.next_period_open_ms); + output_value(0, bar.close); + output_value(1, rgba(up ? 0xFF4CAF50u : 0xFFF23645u)); + output_value(2, up ? 1.0 : 0.0); + if (up) output_event(1, 1.0); + output_value(3, k >= 2 ? bar.close - previous_close_ + : std::numeric_limits::quiet_NaN()); + if (k % 3 == 2) { + char text[64]; + std::snprintf(text, sizeof(text), "close=%.2f", bar.close); + output_event(2, std::numeric_limits::quiet_NaN(), text); + } + output_constant(0, 50.0); + output_constant(1, rgba(0xFF787B86u)); + } + previous_close_ = bar.close; + } + +private: + int bars_ = 0; + double previous_close_ = 0.0; +}; + +FixtureHost* host_of(pf_strategy_t s) { + return static_cast(static_cast(s)); +} + +NativeRunSpec spec_for(std::uint64_t run, const char* input_tf, const char* script_tf) { + NativeRunSpec spec; + spec.identity.session_key = "outputs-fixture"; + spec.identity.run_number = run; + spec.input_tf = input_tf && *input_tf ? input_tf : "5"; + spec.script_tf = script_tf && *script_tf ? script_tf : spec.input_tf; + spec.ticker = "MOCK"; + spec.tickerid = "TEST:MOCK"; + spec.type = "crypto"; + spec.currency = "USDT"; + spec.basecurrency = "ETH"; + 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 = NativeFeeKind::Percent; + spec.fee_value = 0.0; + return spec; +} + +} // namespace + +extern "C" { + +pf_strategy_t strategy_create(const char*) { + try { + return static_cast(new FixtureHost()); + } catch (...) { + return nullptr; + } +} + +void strategy_free(pf_strategy_t s) { + delete host_of(s); +} + +void strategy_set_input(pf_strategy_t s, const char* key, const char* value) { + if (s && key && value && std::strcmp(key, "publish") == 0) + host_of(s)->publish = std::strcmp(value, "false") != 0; +} + +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, int, pf_magnifier_distribution_t, + pf_report_t* out) { + if (out) std::memset(out, 0, sizeof(*out)); + if (!s || n < 0 || (n > 0 && !bars)) return; + try { + FixtureHost* host = host_of(s); + if (host->configure_native(spec_for(++host->runs, input_tf, script_tf)).status + != NativeSetupStatus::Applied) { + return; + } + std::vector input; + for (int i = 0; i < n; ++i) + input.push_back({bars[i].open, bars[i].high, bars[i].low, bars[i].close, + bars[i].volume, bars[i].timestamp}); + host->run(input.data(), n); + if (out) host->fill_report(reinterpret_cast(out)); + } catch (...) { + } +} + +void run_backtest(pf_strategy_t s, pf_bar_t* bars, int n, pf_report_t* out) { + run_backtest_full(s, bars, n, "", "", 0, 4, PF_MAGNIFIER_ENDPOINTS, out); +} + +void report_free(pf_report_t* report) { + BacktestEngine::free_report(reinterpret_cast(report)); +} + +uint32_t strategy_outputs_api_version(void) { + return PF_OUTPUTS_API_VERSION; +} + +int strategy_outputs_manifest(pf_strategy_t s, char* json, size_t capacity, size_t* required, + char* error, size_t error_capacity) { + if (required) *required = 0; + return checked_settings::boundary(error, error_capacity, [&] { + checked_settings::require(s != nullptr, "null strategy"); + checked_settings::receipt(kManifest, json, capacity, required); + }); +} + +int strategy_signal_safety_receipt(pf_strategy_t s, char*, size_t, size_t* required, + char* error, size_t error_capacity) { + if (required) *required = 0; + return checked_settings::boundary(error, error_capacity, [&] { + checked_settings::require(s != nullptr, "null strategy"); + checked_settings::require(false, "the fixture module carries no safety receipt", + PF_SETTINGS_UNSUPPORTED); + }); +} + +} // extern "C" diff --git a/tests/outputs_test_support.hpp b/tests/outputs_test_support.hpp new file mode 100644 index 000000000..1d2106c22 --- /dev/null +++ b/tests/outputs_test_support.hpp @@ -0,0 +1,296 @@ +// Shared by the test_outputs_* rows: a bare NativeStrategyHost that records +// outputs, synthetic bars, and a reader that sees the record only through the +// strategy_outputs_* C exports (include/pineforge/pineforge.h, group +// pf_outputs). The host states its own rows, as the recorder requires: it +// opens one row per calculation whose script bar opens after the last row it +// opened, and skips a calculation that does not (the rule the Pine host keeps +// for a stream's stale carried callback). Source-free. +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace outputs_test { + +using namespace pineforge; + +inline int passed = 0; +inline int failed = 0; + +#define CHECK(cond) \ + do { \ + if (cond) { \ + ++outputs_test::passed; \ + } else { \ + ++outputs_test::failed; \ + std::fprintf(stderr, "CHECK FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \ + } \ + } while (0) + +inline int finish(const char* name) { + std::printf("%s: %d passed, %d failed\n", name, passed, failed); + return failed == 0 ? 0 : 1; +} + +constexpr std::int64_t kT0 = 1704067200000LL; // 2024-01-01 00:00 UTC +constexpr std::int64_t kMinute = 60000; +constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +inline NativeRunSpec make_spec(const char* key, std::uint64_t run_number, + const char* input_tf = "5", const char* script_tf = "5") { + NativeRunSpec spec; + spec.identity.session_key = key; + spec.identity.run_number = run_number; + spec.input_tf = input_tf; + spec.script_tf = script_tf; + spec.ticker = "MOCK"; + spec.tickerid = "TEST:MOCK"; + spec.type = "crypto"; + spec.currency = "USDT"; + spec.basecurrency = "ETH"; + 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 = NativeFeeKind::Percent; + spec.fee_value = 0.0; + return spec; +} + +// n bars `step_ms` apart from `t0`: a deterministic walk with up and down bars. +inline std::vector make_bars(int n, std::int64_t step_ms, std::int64_t t0 = kT0, + double base = 100.0) { + std::vector bars; + bars.reserve(static_cast(n)); + double price = base; + for (int i = 0; i < n; ++i) { + const double drift = ((i * 7) % 5) - 2.0; // -2, 0, 2, -1, 1, ... + const double open = price; + const double close = price + drift * 0.5 + ((i % 3) == 0 ? 0.25 : -0.25); + const double high = std::max(open, close) + 0.75; + const double low = std::min(open, close) - 0.5; + bars.push_back({open, high, low, close, 10.0 + i, t0 + i * step_ms}); + price = close; + } + return bars; +} + +class OutputsHost; +using Script = std::function; + +// The C++ host: the protected recorder calls are forwarded publicly so each +// test states its script as a lambda. +class OutputsHost : public NativeStrategyHost { +public: + struct Shape { + int slots = 0; + int outputs = 0; + int constants = 0; + bool declare = true; + }; + + explicit OutputsHost(Shape shape) { + if (shape.declare) declare_outputs(shape.slots, shape.outputs, shape.constants); + } + + Script script; + std::function on_begin; + bool begin_run = true; // call output_run_begin() in on_native_run_begin + bool skip_stale = true; // the Pine rule: no row for a stale calculation + int calculations = 0; // every on_native_bar the kernel dispatched + int published = 0; // the calculations this host published + int stale = 0; // the calculations it skipped + std::int64_t last_open = std::numeric_limits::min(); + + pf_strategy_t handle() { return static_cast(static_cast(this)); } + + bool recording() const { return outputs_enabled_; } + void declare(int slots, int outputs, int constants = 0) { declare_outputs(slots, outputs, constants); } + void run_begin() { output_run_begin(); } + void bar(std::int64_t open_ms, std::int64_t close_ms) { output_bar(open_ms, close_ms); } + void value(int slot, double v) { output_value(slot, v); } + void event(int output, double v) { output_event(output, v); } + void event(int output, double v, const std::string& message) { output_event(output, v, message); } + void constant(int index, double v) { output_constant(index, v); } + + void on_native_run_begin() override { + calculations = published = stale = 0; + last_open = std::numeric_limits::min(); + if (begin_run) output_run_begin(); + if (on_begin) on_begin(*this); + } + + void on_native_bar(const Bar& b, const NativeDecisionContext& context) override { + ++calculations; + if (skip_stale && context.script_bar_open_ms <= last_open) { + ++stale; + return; + } + last_open = context.script_bar_open_ms; + ++published; + // NativeDecisionContext::script_bar_open_ms is the open a C++ host + // states; the bar's close is its script interval's end. + if (outputs_enabled_) output_bar(context.script_bar_open_ms, context.script_interval.next_period_open_ms); + if (script) script(*this, b, context); + } +}; + +struct EventRecord { + std::uint32_t struct_version = 0; + std::uint32_t size = 0; + std::uint64_t sequence = 0; + std::int32_t output_index = 0; + std::int32_t bar_index = 0; + std::int64_t bar_open_ms = 0; + std::int64_t bar_close_ms = 0; + std::uint32_t ordinal_in_bar = 0; + std::uint32_t phase = 0; + std::uint32_t confirmed = 0; + double value = 0.0; + std::uint64_t message_hash64 = 0; + bool has_message = false; + std::string message; +}; + +struct Record { + int series_count = 0; + std::int64_t bars = 0; + std::vector open_ms, close_ms; + std::vector> series; // [slot][row] + std::vector events; + std::vector constants; +}; + +inline EventRecord read_event(pf_strategy_t s, int index) { + pf_output_event_v1_t raw; + std::memset(&raw, 0, sizeof(raw)); + EventRecord out; + if (strategy_outputs_event_get(s, index, &raw, sizeof(raw)) != 0) { + out.sequence = 0; + return out; + } + out.struct_version = raw.struct_version; + out.size = raw.size; + out.sequence = raw.sequence; + out.output_index = raw.output_index; + out.bar_index = raw.bar_index; + out.bar_open_ms = raw.bar_open_ms; + out.bar_close_ms = raw.bar_close_ms; + out.ordinal_in_bar = raw.ordinal_in_bar; + out.phase = raw.phase; + out.confirmed = raw.confirmed; + out.value = raw.value; + out.message_hash64 = raw.message_hash64; + out.has_message = raw.message != nullptr; + if (raw.message) out.message = raw.message; + return out; +} + +// The events queued now, in queue order. +inline std::vector read_events(pf_strategy_t s) { + std::vector out; + const int n = strategy_outputs_events_len(s); + for (int i = 0; i < n; ++i) out.push_back(read_event(s, i)); + return out; +} + +inline Record read_record(pf_strategy_t s) { + Record r; + r.series_count = strategy_outputs_series_count(s); + r.bars = strategy_outputs_bars_len(s); + if (r.bars > 0) { + r.open_ms.assign(static_cast(r.bars), 0); + r.close_ms.assign(static_cast(r.bars), 0); + std::int64_t written = -1; + if (strategy_outputs_bar_times_copy(s, 0, r.open_ms.data(), r.close_ms.data(), r.bars, + &written) != 0 + || written != r.bars) { + r.open_ms.clear(); + r.close_ms.clear(); + } + } + for (int slot = 0; slot < r.series_count; ++slot) { + std::vector values(static_cast(r.bars), 0.0); + std::int64_t written = -1; + if (strategy_outputs_series_copy(s, slot, 0, values.data(), r.bars, &written) != 0 + || written != r.bars) { + values.clear(); + } + r.series.push_back(std::move(values)); + } + r.events = read_events(s); + const int n = strategy_outputs_constants_copy(s, nullptr, 0); + if (n > 0) { + r.constants.assign(static_cast(n), 0.0); + strategy_outputs_constants_copy(s, r.constants.data(), n); + } + return r; +} + +inline std::uint64_t bits(double v) { + std::uint64_t out; + std::memcpy(&out, &v, sizeof(out)); + return out; +} + +// Bit equality, every NaN equal to every NaN. +inline bool same_value(double a, double b) { + if (std::isnan(a) && std::isnan(b)) return true; + return bits(a) == bits(b); +} + +inline bool same_values(const std::vector& a, const std::vector& b) { + if (a.size() != b.size()) return false; + for (std::size_t i = 0; i < a.size(); ++i) + if (!same_value(a[i], b[i])) return false; + return true; +} + +// Every field but `phase` when `with_phase` is false. +inline bool same_event(const EventRecord& a, const EventRecord& b, bool with_phase) { + return a.struct_version == b.struct_version && a.size == b.size + && a.sequence == b.sequence && a.output_index == b.output_index + && a.bar_index == b.bar_index && a.bar_open_ms == b.bar_open_ms + && a.bar_close_ms == b.bar_close_ms && a.ordinal_in_bar == b.ordinal_in_bar + && (!with_phase || a.phase == b.phase) && a.confirmed == b.confirmed + && same_value(a.value, b.value) && a.message_hash64 == b.message_hash64 + && a.has_message == b.has_message && a.message == b.message; +} + +inline bool same_events(const std::vector& a, const std::vector& b, + bool with_phase) { + if (a.size() != b.size()) return false; + for (std::size_t i = 0; i < a.size(); ++i) + if (!same_event(a[i], b[i], with_phase)) return false; + return true; +} + +// FNV-1a 64 of a message's bytes, the recorder's message_hash64. +inline std::uint64_t fnv1a64(const std::string& text) { + std::uint64_t hash = 1469598103934665603ULL; + for (unsigned char c : text) { + hash ^= c; + hash *= 1099511628211ULL; + } + return hash; +} + +// A colour as an rgba-u32 slot value: 0xAARRGGBB -> 0xRRGGBBAA as a double. +inline double rgba(std::uint32_t argb) { + return static_cast((argb << 8) | (argb >> 24)); +} + +} // namespace outputs_test diff --git a/tests/test_outputs_c_api_c99.c b/tests/test_outputs_c_api_c99.c new file mode 100644 index 000000000..06e3178ee --- /dev/null +++ b/tests/test_outputs_c_api_c99.c @@ -0,0 +1,174 @@ +/* + * test_outputs_c_api_c99.c -- the recorded-outputs group of pineforge.h as a + * strict C99 consumer compiles it. + * + * tests/CMakeLists.txt builds this translation unit as C99 with extensions + * off and -pedantic-errors, so a C11 construct in group pf_outputs (parts A + * and B) fails the build. The event record's layout is pinned at compile time + * -- every 8-byte field at a multiple of 8, the 72-byte prefix before the one + * pointer -- and the three per-library declarations are checked against their + * signatures without referencing them (a runtime library does not define + * them). The body runs a native C host, which declares no outputs, and reads + * every runtime export of the group: recording is refused, every reader + * answers empty, and a NULL handle answers -1. + * + * No assert(): the Release gate defines NDEBUG and would no-op every row. + */ + +#include + +#include +#include +#include +#include + +#if !defined(PINEFORGE_HAS_OUTPUTS_V1) || PINEFORGE_HAS_OUTPUTS_V1 != 1 +#error "pineforge.h does not announce the outputs group" +#endif + +PF_STATIC_ASSERT(PF_OUTPUTS_API_VERSION == 1u); +PF_STATIC_ASSERT(sizeof(pf_output_phase_t) == 4); +PF_STATIC_ASSERT(PF_OUTPUT_PHASE_BATCH == 0); +PF_STATIC_ASSERT(PF_OUTPUT_PHASE_WARMUP == 1); +PF_STATIC_ASSERT(PF_OUTPUT_PHASE_REALTIME == 2); +PF_STATIC_ASSERT(offsetof(pf_output_event_v1_t, struct_version) == 0); +PF_STATIC_ASSERT(offsetof(pf_output_event_v1_t, size) == 4); +PF_STATIC_ASSERT(offsetof(pf_output_event_v1_t, sequence) == 8); +PF_STATIC_ASSERT(offsetof(pf_output_event_v1_t, output_index) == 16); +PF_STATIC_ASSERT(offsetof(pf_output_event_v1_t, bar_index) == 20); +PF_STATIC_ASSERT(offsetof(pf_output_event_v1_t, bar_open_ms) == 24); +PF_STATIC_ASSERT(offsetof(pf_output_event_v1_t, bar_close_ms) == 32); +PF_STATIC_ASSERT(offsetof(pf_output_event_v1_t, ordinal_in_bar) == 40); +PF_STATIC_ASSERT(offsetof(pf_output_event_v1_t, phase) == 44); +PF_STATIC_ASSERT(offsetof(pf_output_event_v1_t, confirmed) == 48); +PF_STATIC_ASSERT(offsetof(pf_output_event_v1_t, reserved0) == 52); +PF_STATIC_ASSERT(offsetof(pf_output_event_v1_t, value) == 56); +PF_STATIC_ASSERT(offsetof(pf_output_event_v1_t, message_hash64) == 64); +PF_STATIC_ASSERT(offsetof(pf_output_event_v1_t, message) == 72); +#if UINTPTR_MAX == UINT64_MAX +PF_STATIC_ASSERT(sizeof(pf_output_event_v1_t) == 80); +#endif + +/* Part A, generated per library: each declaration has exactly this type. A + * conditional expression with operands of incompatible pointer types is a + * constraint violation, and sizeof does not evaluate (or reference) its + * operand. */ +typedef uint32_t (*outputs_api_version_fn)(void); +typedef int (*outputs_json_fn)(pf_strategy_t, char*, size_t, size_t*, char*, size_t); +PF_STATIC_ASSERT(sizeof(1 ? strategy_outputs_api_version : (outputs_api_version_fn)0) + == sizeof(outputs_api_version_fn)); +PF_STATIC_ASSERT(sizeof(1 ? strategy_outputs_manifest : (outputs_json_fn)0) + == sizeof(outputs_json_fn)); +PF_STATIC_ASSERT(sizeof(1 ? strategy_signal_safety_receipt : (outputs_json_fn)0) + == sizeof(outputs_json_fn)); + +static int failures = 0; + +#define CHECK(cond, msg) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, (msg)); \ + ++failures; \ + } \ + } while (0) + +static const pf_bar_t kBars[] = { + {100.0, 102.0, 99.0, 101.0, 4.0, 0}, + {101.0, 103.0, 100.0, 102.0, 4.0, 300000}, + {102.0, 104.0, 101.0, 103.0, 4.0, 600000} +}; + +static int c99_on_bar(void* user, const pf_bar_t* bar, const pf_native_decision_v1* at) { + int* calculations = (int*)user; + (void)bar; + (void)at; + ++*calculations; + return 0; +} + +static void check_undeclared_host(void) { + pf_native_callbacks_v1 callbacks; + pf_native_run_spec_v1 spec; + pf_output_event_v1_t event; + pf_strategy_t handle; + int64_t times[4]; + double values[4]; + int64_t written = -7; + int calculations = 0; + const char* error; + + memset(&callbacks, 0, sizeof(callbacks)); + callbacks.struct_size = (uint32_t)sizeof(callbacks); + callbacks.version = PF_NATIVE_API_VERSION; + callbacks.user = &calculations; + callbacks.on_bar = c99_on_bar; + handle = strategy_native_host_create_v1(&callbacks); + CHECK(handle != NULL, "the callback table was refused"); + if (!handle) return; + + memset(&spec, 0, sizeof(spec)); + spec.struct_size = (uint32_t)sizeof(spec); + spec.session_key = "outputs-c99"; + spec.run_number = 1; + spec.input_tf = "5"; + spec.script_tf = "5"; + spec.ticker = "MOCK"; + spec.tickerid = "TEST:MOCK"; + spec.type = "crypto"; + spec.currency = "USDT"; + spec.basecurrency = "ETH"; + spec.description = ""; + spec.volumetype = ""; + spec.timezone = "UTC"; + spec.session = "24x7"; + spec.chart_timezone = ""; + spec.initial_capital = 10000.0; + spec.point_value = 1.0; + spec.account_fx = 1.0; + spec.price_tick = 0.01; + spec.fee_kind = PF_NATIVE_FEE_PERCENT; + spec.close_execution = PF_NATIVE_CLOSE_EXECUTION_NEXT_ELIGIBLE_POINT; + spec.allowed_open_directions = PF_NATIVE_OPEN_DIRECTIONS_BOTH; + CHECK(strategy_configure_native_v1(handle, &spec) == 0, "the spec was refused"); + CHECK(strategy_native_run_v1(handle, kBars, 3, NULL) == PF_NATIVE_OK, "the run did not complete"); + CHECK(calculations == 3, "the run did not calculate three bars"); + + /* A C host records nothing in outputs v1: it declares no outputs. */ + CHECK(strategy_outputs_set_enabled(handle, 1) == -1, "recording was not refused"); + error = strategy_get_last_error(handle); + CHECK(error != NULL && strcmp(error, "outputs: this module declares no outputs") == 0, + "the refusal text"); + CHECK(strategy_outputs_set_enabled(handle, 0) == 0, "switching off was refused"); + CHECK(strategy_outputs_series_count(handle) == 0, "series count"); + CHECK(strategy_outputs_bars_len(handle) == 0, "bars length"); + CHECK(strategy_outputs_events_len(handle) == 0, "events length"); + CHECK(strategy_outputs_bar_times_copy(handle, 0, times, times + 2, 2, &written) == 0 + && written == 0, + "bar times from bar 0"); + CHECK(strategy_outputs_bar_times_copy(handle, 1, times, NULL, 2, &written) == -1, + "bar times past the end"); + CHECK(strategy_outputs_series_copy(handle, 0, 0, values, 4, &written) == -1, "series copy"); + memset(&event, 0, sizeof(event)); + CHECK(strategy_outputs_event_get(handle, 0, &event, sizeof(event)) == -1, "event copy"); + strategy_outputs_events_clear(handle); + CHECK(strategy_outputs_constants_copy(handle, values, 4) == 0, "constants"); + CHECK(strategy_outputs_constants_copy(handle, NULL, 1) == -1, "constants into NULL"); + strategy_native_host_free(handle); + + CHECK(strategy_outputs_set_enabled(NULL, 0) == -1, "NULL set_enabled"); + CHECK(strategy_outputs_series_count(NULL) == -1, "NULL series_count"); + CHECK(strategy_outputs_bars_len(NULL) == -1, "NULL bars_len"); + CHECK(strategy_outputs_bar_times_copy(NULL, 0, times, NULL, 1, &written) == -1, + "NULL bar_times_copy"); + CHECK(strategy_outputs_series_copy(NULL, 0, 0, values, 1, &written) == -1, "NULL series_copy"); + CHECK(strategy_outputs_events_len(NULL) == -1, "NULL events_len"); + CHECK(strategy_outputs_event_get(NULL, 0, &event, sizeof(event)) == -1, "NULL event_get"); + strategy_outputs_events_clear(NULL); + CHECK(strategy_outputs_constants_copy(NULL, values, 1) == -1, "NULL constants_copy"); +} + +int main(void) { + check_undeclared_host(); + printf("test_outputs_c_api_c99: %s\n", failures == 0 ? "ok" : "FAILED"); + return failures == 0 ? 0 : 1; +} diff --git a/tests/test_outputs_off_identity.cpp b/tests/test_outputs_off_identity.cpp new file mode 100644 index 000000000..2419e9a29 --- /dev/null +++ b/tests/test_outputs_off_identity.cpp @@ -0,0 +1,166 @@ +// Recording changes nothing a strategy run computes. Three trading hosts run +// the same orders over the same bars: one that declares no outputs, one that +// declares them and leaves recording off, and one that records. Against the +// first: the closed trades field for field, the equity curve, the per-bar +// broker-state hash of a batch, and in a stream the stream-state hash and the +// order actions after every input. +// +// Source-free: this TU runs in the kernel-only profile. +#include "outputs_test_support.hpp" + +#include + +using namespace outputs_test; + +namespace { + +constexpr std::int64_t kStep = 5 * kMinute; + +enum class Mode { Undeclared, DeclaredOff, Recording }; + +std::unique_ptr trading_host(Mode mode) { + auto host = std::make_unique(mode == Mode::Undeclared + ? OutputsHost::Shape{0, 0, 0, false} : OutputsHost::Shape{2, 2, 1}); + // Every host makes the same recorder calls; only the third records. + host->script = [](OutputsHost& h, const Bar& b, const NativeDecisionContext&) { + const int k = h.published; + if (k % 5 == 1) { + h.submit_market({order_action::Transact{k % 2 == 0 ? 1.0 : -1.0}, "open", ""}); + h.event(0, b.close, "open"); + } else if (k % 5 == 4) { + h.submit_market({execution::Flatten{}, "flat", ""}); + h.event(1, b.close); + } + h.value(0, b.close); + h.value(1, strategy_current_equity(h.handle())); + h.constant(0, 3.0); + }; + if (mode == Mode::Recording) CHECK(strategy_outputs_set_enabled(host->handle(), 1) == 0); + return host; +} + +// The kernel records the equity curve and, with recording on, the per-bar +// broker-state hash into the report. +NativeRunSpec reporting_spec(const char* key) { + NativeRunSpec spec = make_spec(key, 1); + spec.report_policy = NativeReportPolicy::KernelRecorded; + return spec; +} + +struct Report { + std::vector trades; + std::vector equity; + std::vector hashes; + std::int64_t processed = 0; + double net_profit = 0.0; +}; + +Report report_of(pf_strategy_t s) { + Report out; + pf_report_t rep; + std::memset(&rep, 0, sizeof(rep)); + CHECK(strategy_stream_fill_report(s, &rep) == 0); + out.trades.assign(rep.trades, rep.trades + rep.trades_len); + out.equity.assign(rep.equity_curve, rep.equity_curve + rep.equity_curve_len); + out.hashes.assign(rep.broker_state_hash, rep.broker_state_hash + rep.broker_state_hash_len); + out.processed = rep.script_bars_processed; + out.net_profit = rep.net_profit; + BacktestEngine::free_report(reinterpret_cast(&rep)); + return out; +} + +bool same_trade(const pf_trade_t& a, const pf_trade_t& b) { + return a.entry_time == b.entry_time && a.exit_time == b.exit_time + && same_value(a.entry_price, b.entry_price) && same_value(a.exit_price, b.exit_price) + && same_value(a.pnl, b.pnl) && same_value(a.pnl_pct, b.pnl_pct) && a.is_long == b.is_long + && same_value(a.max_runup, b.max_runup) && same_value(a.max_drawdown, b.max_drawdown) + && same_value(a.qty, b.qty) && same_value(a.commission, b.commission) + && a.entry_bar_index == b.entry_bar_index && a.exit_bar_index == b.exit_bar_index + && a.open_at_end == b.open_at_end; +} + +bool same_report(const Report& a, const Report& b) { + if (a.trades.size() != b.trades.size() || a.equity.size() != b.equity.size()) return false; + for (std::size_t i = 0; i < a.trades.size(); ++i) + if (!same_trade(a.trades[i], b.trades[i])) return false; + for (std::size_t i = 0; i < a.equity.size(); ++i) { + if (a.equity[i].time_ms != b.equity[i].time_ms + || !same_value(a.equity[i].equity, b.equity[i].equity) + || !same_value(a.equity[i].open_profit, b.equity[i].open_profit)) return false; + } + return a.hashes == b.hashes && a.processed == b.processed + && same_value(a.net_profit, b.net_profit); +} + +void batch() { + const auto bars = make_bars(40, kStep); + Report reports[3]; + int k = 0; + for (Mode mode : {Mode::Undeclared, Mode::DeclaredOff, Mode::Recording}) { + auto host = trading_host(mode); + pf_strategy_t s = host->handle(); + strategy_set_broker_state_hash_recording(s, 1); + CHECK(host->configure_native(reporting_spec("outputs-off-identity")).status + == NativeSetupStatus::Applied); + host->run(bars.data(), static_cast(bars.size())); + CHECK(host->native_state().kind == NativeLifecycleKind::Completed); + reports[k] = report_of(s); + CHECK(strategy_outputs_bars_len(s) == (mode == Mode::Recording ? 40 : 0)); + ++k; + } + CHECK(reports[0].trades.size() >= 6); + CHECK(reports[0].hashes.size() == 40); + CHECK(reports[0].equity.size() == 40); + CHECK(same_report(reports[0], reports[1])); + CHECK(same_report(reports[0], reports[2])); +} + +struct StreamTrace { + std::vector state_hashes; // after begin and after every push + std::vector actions; + Report report; +}; + +void stream() { + const auto bars = make_bars(36, kStep); + std::vector c_bars; + for (const Bar& b : bars) c_bars.push_back({b.open, b.high, b.low, b.close, b.volume, b.timestamp}); + StreamTrace traces[3]; + int k = 0; + for (Mode mode : {Mode::Undeclared, Mode::DeclaredOff, Mode::Recording}) { + auto host = trading_host(mode); + pf_strategy_t s = host->handle(); + CHECK(host->configure_native(reporting_spec("outputs-off-identity-stream")).status + == NativeSetupStatus::Applied); + CHECK(strategy_stream_begin(s, c_bars.data(), 12, "", "") == 0); + StreamTrace& trace = traces[k++]; + trace.state_hashes.push_back(strategy_stream_state_hash(s)); + trace.actions.push_back(strategy_stream_order_actions_len(s)); + for (std::size_t i = 12; i < c_bars.size(); ++i) { + CHECK(strategy_stream_push_bar(s, &c_bars[i]) == 0); + trace.state_hashes.push_back(strategy_stream_state_hash(s)); + trace.actions.push_back(strategy_stream_order_actions_len(s)); + strategy_stream_order_actions_clear(s); + strategy_outputs_events_clear(s); + } + CHECK(strategy_stream_end(s, 0) == 0); + trace.report = report_of(s); + CHECK(strategy_outputs_bars_len(s) == (mode == Mode::Recording ? 36 : 0)); + } + CHECK(traces[0].state_hashes.size() == 25); + CHECK(traces[0].report.trades.size() >= 5); + CHECK(traces[0].report.equity.size() == 36); + for (int i = 1; i < 3; ++i) { + CHECK(traces[i].state_hashes == traces[0].state_hashes); + CHECK(traces[i].actions == traces[0].actions); + CHECK(same_report(traces[i].report, traces[0].report)); + } +} + +} // namespace + +int main() { + batch(); + stream(); + return finish("test_outputs_off_identity"); +} diff --git a/tests/test_outputs_recorder.cpp b/tests/test_outputs_recorder.cpp new file mode 100644 index 000000000..2d02a8418 --- /dev/null +++ b/tests/test_outputs_recorder.cpp @@ -0,0 +1,628 @@ +// The outputs recorder against hand values. A bare NativeStrategyHost records +// over 8 synthetic bars; everything is read back through the strategy_outputs_* +// C exports. Proves the values (NaN for a slot not written), the per-row +// ordinals, the 1-based sequence, message bytes and their FNV-1a 64, the +// size-prefixed event copy at 8/16/72/80/96 bytes, every -1 of the C readers, +// the row rules (append, restart, a lower open throws, a write with no row +// throws, a bar before output_run_begin throws, a restart after a clear +// throws, a clear keeps the sequence), the run constants (store, equal +// rewrite, changed throws, NaN, unwritten, reset by a run begin and by a flag +// change, off, index range), two calculations stating one open merging into +// one row, the confirmed flag of a bar a stream finalizes before it closed, +// the run-failure code of every refusal (strategy_get_last_error_code beside +// the text) and of the two caps, the failure record a switch leaves (a success +// keeps it, a refusal keeps a failed run's), and a handle that declares +// nothing. +// +// Source-free: this TU runs in the kernel-only profile. +#include "outputs_test_support.hpp" + +#include + +#include +#include +#include + +using namespace outputs_test; + +namespace { + +constexpr std::int64_t kStep = 5 * kMinute; + +// The call throws: its text holds `needle` and it carries `code`. A broken +// recorder precondition is engine_invariant and a std::logic_error; the +// caps and the caller's refusals are coded as what they are. +bool throws_coded(const std::function& fn, const std::string& needle, + RunFailureCode code = RunFailureCode::engine_invariant) { + try { + fn(); + } catch (const std::exception& e) { + const RunFailureCode carried = classify_run_failure(e).code; + const bool text = std::string(e.what()).find(needle) != std::string::npos; + const bool logic = code != RunFailureCode::engine_invariant + || dynamic_cast(&e) != nullptr; + if (!text || carried != code || !logic) + std::fprintf(stderr, " unexpected: '%s' coded %s\n", e.what(), + run_failure_code_name(carried)); + return text && carried == code && logic; + } + return false; +} + +std::string last_error(pf_strategy_t s) { + const char* text = strategy_get_last_error(s); + return text ? std::string(text) : std::string(); +} + +std::string code_of(pf_strategy_t s) { + const char* code = strategy_get_last_error_code(s); + return code ? std::string(code) : std::string("(null)"); +} + +std::string args_of(pf_strategy_t s) { + const char* args = strategy_get_last_error_args(s); + return args ? std::string(args) : std::string("(null)"); +} + +// A handle that never declared outputs, and a NULL handle. +void undeclared_and_null_handles() { + OutputsHost host({0, 0, 0, false}); + CHECK(host.configure_native(make_spec("outputs-undeclared", 1)).status == NativeSetupStatus::Applied); + host.script = [](OutputsHost& h, const Bar& b, const NativeDecisionContext&) { + h.value(0, b.close); // recording is off: returns at its first branch + h.event(0, 1.0); + }; + const auto bars = make_bars(8, kStep); + host.run(bars.data(), static_cast(bars.size())); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + pf_strategy_t s = host.handle(); + + CHECK(strategy_outputs_set_enabled(s, 1) == -1); + CHECK(last_error(s) == "outputs: this module declares no outputs"); + CHECK(code_of(s) == "outputs_rejected"); + CHECK(args_of(s) == R"({"reason":"not_declared"})"); + // A switch that succeeds leaves the record as it was, as + // strategy_set_trace_enabled does: the refusal stays readable. + CHECK(strategy_outputs_set_enabled(s, 0) == 0); + CHECK(last_error(s) == "outputs: this module declares no outputs"); + CHECK(code_of(s) == "outputs_rejected" && args_of(s) == R"({"reason":"not_declared"})"); + CHECK(strategy_outputs_series_count(s) == 0); + CHECK(strategy_outputs_bars_len(s) == 0); + CHECK(strategy_outputs_events_len(s) == 0); + std::int64_t written = -7; + CHECK(strategy_outputs_bar_times_copy(s, 0, nullptr, nullptr, 0, &written) == 0 && written == 0); + double value = 0.0; + written = -7; + CHECK(strategy_outputs_series_copy(s, 0, 0, &value, 1, &written) == -1 && written == -7); + pf_output_event_v1_t raw; + CHECK(strategy_outputs_event_get(s, 0, &raw, sizeof(raw)) == -1); + strategy_outputs_events_clear(s); + CHECK(strategy_outputs_constants_copy(s, nullptr, 0) == 0); + + CHECK(strategy_outputs_set_enabled(nullptr, 1) == -1); + CHECK(strategy_outputs_set_enabled(nullptr, 0) == -1); + CHECK(strategy_outputs_series_count(nullptr) == -1); + CHECK(strategy_outputs_bars_len(nullptr) == -1); + CHECK(strategy_outputs_bar_times_copy(nullptr, 0, nullptr, nullptr, 0, &written) == -1); + CHECK(strategy_outputs_series_copy(nullptr, 0, 0, &value, 1, &written) == -1); + CHECK(strategy_outputs_events_len(nullptr) == -1); + CHECK(strategy_outputs_event_get(nullptr, 0, &raw, sizeof(raw)) == -1); + strategy_outputs_events_clear(nullptr); + CHECK(strategy_outputs_constants_copy(nullptr, nullptr, 0) == -1); +} + +// The run under test: slot 0 = close; slot 1 = close > open, except bar 2 +// whose slot 1 is never written; output 0 = a mark on every up bar; output 1 +// = two messages on bar 4; constant 0 = 50, constant 1 = a colour. +std::vector recorded_run(OutputsHost& host) { + host.script = [](OutputsHost& h, const Bar& b, const NativeDecisionContext&) { + const int k = h.published - 1; + h.value(0, b.close); + if (k != 2) h.value(1, b.close > b.open ? 1.0 : 0.0); + if (b.close > b.open) h.event(0, 1.0); + if (k == 4) { + h.event(1, kNaN, "first"); + h.event(1, kNaN, std::string("second\0bytes", 12)); + } + h.constant(0, 50.0); + h.constant(1, rgba(0xFF4CAF50u)); + }; + const auto bars = make_bars(8, kStep); + host.run(bars.data(), static_cast(bars.size())); + return bars; +} + +void hand_values() { + OutputsHost host({2, 2, 2}); + pf_strategy_t s = host.handle(); + CHECK(host.configure_native(make_spec("outputs-recorder", 1)).status == NativeSetupStatus::Applied); + CHECK(strategy_outputs_series_count(s) == 0); // declared, still off + CHECK(strategy_outputs_constants_copy(s, nullptr, 0) == 0); + CHECK(strategy_outputs_set_enabled(s, 1) == 0); + CHECK(host.recording()); + CHECK(strategy_outputs_series_count(s) == 2); + CHECK(strategy_outputs_constants_copy(s, nullptr, 0) == 2); + const auto bars = recorded_run(host); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + CHECK(last_error(s).empty()); + + const Record r = read_record(s); + CHECK(r.series_count == 2); + CHECK(r.bars == 8); + CHECK(r.bars <= strategy_script_bars_processed(s)); + CHECK(r.open_ms.size() == 8 && r.close_ms.size() == 8); + for (int i = 0; i < 8 && r.open_ms.size() == 8; ++i) { + CHECK(r.open_ms[i] == kT0 + i * kStep); + CHECK(r.close_ms[i] == kT0 + (i + 1) * kStep); + } + CHECK(r.series.size() == 2 && r.series[0].size() == 8 && r.series[1].size() == 8); + std::vector expected; + std::uint64_t sequence = 0; + for (int i = 0; i < 8 && r.series.size() == 2 && r.series[0].size() == 8; ++i) { + CHECK(bits(r.series[0][i]) == bits(bars[i].close)); + if (i == 2) { + CHECK(std::isnan(r.series[1][i])); + } else { + CHECK(r.series[1][i] == (bars[i].close > bars[i].open ? 1.0 : 0.0)); + } + EventRecord e; + e.struct_version = 1; + e.size = static_cast(sizeof(pf_output_event_v1_t)); + e.bar_index = i; + e.bar_open_ms = kT0 + i * kStep; + e.bar_close_ms = kT0 + (i + 1) * kStep; + e.phase = PF_OUTPUT_PHASE_BATCH; + e.confirmed = 1; + if (bars[i].close > bars[i].open) { + e.sequence = ++sequence; + e.output_index = 0; + e.ordinal_in_bar = 0; + e.value = 1.0; + e.has_message = false; + e.message.clear(); + e.message_hash64 = 0; + expected.push_back(e); + } + if (i == 4) { + const std::string messages[2] = {"first", std::string("second\0bytes", 12)}; + for (std::uint32_t m = 0; m < 2; ++m) { + e.sequence = ++sequence; + e.output_index = 1; + e.ordinal_in_bar = m; + e.value = kNaN; + e.has_message = true; + e.message = messages[m].c_str(); // a C reader sees the text to its first NUL + e.message_hash64 = fnv1a64(messages[m]); + expected.push_back(e); + } + } + } + CHECK(expected.size() >= 3); + CHECK(same_events(r.events, expected, true)); + // A message is NUL-terminated C text: the copy stops at the first NUL, the + // hash covers every recorded byte. + CHECK(r.events.size() == expected.size()); + std::vector messages; + for (const auto& e : r.events) + if (e.output_index == 1) messages.push_back(e); + CHECK(messages.size() == 2); + if (messages.size() == 2) { + CHECK(messages[0].message == "first" && messages[0].ordinal_in_bar == 0); + CHECK(messages[1].message == "second" && messages[1].ordinal_in_bar == 1); + CHECK(messages[1].message_hash64 == fnv1a64(std::string("second\0bytes", 12))); + CHECK(messages[1].sequence == messages[0].sequence + 1); + } + CHECK(r.constants.size() == 2); + if (r.constants.size() == 2) { + CHECK(r.constants[0] == 50.0); + CHECK(r.constants[1] == 1286557951.0); // color green #4CAF50FF + } + + // The size-prefixed copy: refused below 8 bytes, otherwise exactly + // min(size_in, sizeof) bytes. + const int last = strategy_outputs_events_len(s) - 1; + unsigned char buffer[128]; + pf_output_event_v1_t full; + std::memset(&full, 0, sizeof(full)); + CHECK(strategy_outputs_event_get(s, last, &full, sizeof(full)) == 0); + for (std::size_t size_in : {std::size_t{8}, std::size_t{16}, std::size_t{72}, + std::size_t{80}, std::size_t{96}}) { + std::memset(buffer, 0xAB, sizeof(buffer)); + CHECK(strategy_outputs_event_get(s, last, buffer, size_in) == 0); + const std::size_t copied = std::min(size_in, sizeof(pf_output_event_v1_t)); + CHECK(std::memcmp(buffer, &full, copied) == 0); + bool untouched = true; + for (std::size_t i = copied; i < sizeof(buffer); ++i) untouched = untouched && buffer[i] == 0xAB; + CHECK(untouched); + } + std::memset(buffer, 0xAB, sizeof(buffer)); + CHECK(strategy_outputs_event_get(s, last, buffer, 7) == -1); + CHECK(buffer[0] == 0xAB); + CHECK(strategy_outputs_event_get(s, -1, buffer, 80) == -1); + CHECK(strategy_outputs_event_get(s, last + 1, buffer, 80) == -1); + CHECK(strategy_outputs_event_get(s, 0, nullptr, 80) == -1); + CHECK(full.struct_version == 1u && full.size == sizeof(pf_output_event_v1_t)); + + // The bar-time copy. + std::int64_t open[8], close[8], written = -7; + CHECK(strategy_outputs_bar_times_copy(s, 2, open, close, 3, &written) == 0 && written == 3); + CHECK(open[0] == kT0 + 2 * kStep && close[2] == kT0 + 5 * kStep); + CHECK(strategy_outputs_bar_times_copy(s, 6, open, nullptr, 8, &written) == 0 && written == 2); + CHECK(open[1] == kT0 + 7 * kStep); + CHECK(strategy_outputs_bar_times_copy(s, 0, nullptr, nullptr, 8, &written) == 0 && written == 8); + CHECK(strategy_outputs_bar_times_copy(s, 8, open, close, 8, &written) == 0 && written == 0); + written = -7; + CHECK(strategy_outputs_bar_times_copy(s, 9, open, close, 8, &written) == -1 && written == -7); + CHECK(strategy_outputs_bar_times_copy(s, -1, open, close, 8, &written) == -1); + CHECK(strategy_outputs_bar_times_copy(s, 0, open, close, -1, &written) == -1); + CHECK(strategy_outputs_bar_times_copy(s, 0, open, close, 8, nullptr) == -1); + + // The series copy. + double values[8]; + for (double& v : values) v = -1.0; + CHECK(strategy_outputs_series_copy(s, 1, 1, values, 3, &written) == 0 && written == 3); + CHECK(std::isnan(values[1]) && values[3] == -1.0); + CHECK(strategy_outputs_series_copy(s, 0, 5, values, 8, &written) == 0 && written == 3); + CHECK(bits(values[0]) == bits(bars[5].close)); + CHECK(strategy_outputs_series_copy(s, 0, 0, nullptr, 0, &written) == 0 && written == 0); + CHECK(strategy_outputs_series_copy(s, 0, 8, values, 8, &written) == 0 && written == 0); + written = -7; + CHECK(strategy_outputs_series_copy(s, 0, 9, values, 8, &written) == -1 && written == -7); + CHECK(strategy_outputs_series_copy(s, -1, 0, values, 8, &written) == -1); + CHECK(strategy_outputs_series_copy(s, 2, 0, values, 8, &written) == -1); + CHECK(strategy_outputs_series_copy(s, 0, 0, nullptr, 1, &written) == -1); + CHECK(strategy_outputs_series_copy(s, 0, 0, values, -1, &written) == -1); + CHECK(strategy_outputs_series_copy(s, 0, 0, values, 8, nullptr) == -1); + + // The constants copy. + double constants[2] = {-1.0, -1.0}; + CHECK(strategy_outputs_constants_copy(s, constants, 1) == 2); + CHECK(constants[0] == 50.0 && constants[1] == -1.0); + CHECK(strategy_outputs_constants_copy(s, constants, -1) == -1); + CHECK(strategy_outputs_constants_copy(s, nullptr, 1) == -1); + + // A clear drops the queue and nothing else. + strategy_outputs_events_clear(s); + CHECK(strategy_outputs_events_len(s) == 0); + CHECK(strategy_outputs_bars_len(s) == 8); + const Record after = read_record(s); + CHECK(same_values(after.series[0], r.series[0]) && same_values(after.series[1], r.series[1])); + CHECK(after.constants.size() == 2 && after.constants[1] == 1286557951.0); + + // Turning recording off clears the record and answers as undeclared. + CHECK(strategy_outputs_set_enabled(s, 0) == 0); + CHECK(!host.recording()); + CHECK(strategy_outputs_series_count(s) == 0 && strategy_outputs_bars_len(s) == 0); + CHECK(strategy_outputs_constants_copy(s, nullptr, 0) == 0); +} + +// The row rules, the writers' preconditions and the run constants, called +// directly on a declared host outside any run. +void row_rules() { + OutputsHost host({2, 2, 2}); + pf_strategy_t s = host.handle(); + CHECK(strategy_outputs_set_enabled(s, 1) == 0); + + CHECK(throws_coded([&] { host.bar(kT0, kT0 + kStep); }, "before any output_run_begin")); + CHECK(throws_coded([&] { host.constant(0, 1.0); }, "before any output_run_begin")); + host.run_begin(); + host.run_begin(); // idempotent + CHECK(throws_coded([&] { host.value(0, 1.0); }, "with no open bar")); + CHECK(throws_coded([&] { host.event(0, 1.0); }, "with no open bar")); + + host.bar(kT0, kT0 + kStep); // append row 0 + host.value(0, 1.0); + host.value(0, 2.0); // the last write wins + CHECK(throws_coded([&] { host.value(2, 1.0); }, "series slot 2 is out of range")); + CHECK(throws_coded([&] { host.value(-1, 1.0); }, "series slot -1 is out of range")); + CHECK(throws_coded([&] { host.event(2, 1.0); }, "output 2 is out of range")); + host.event(0, 1.0); // sequence 1 + host.bar(kT0 + kStep, kT0 + 2 * kStep); // append row 1 + host.value(1, 3.0); + host.event(0, 1.0); // sequence 2, ordinal 0 + host.event(0, 1.0); // sequence 3, ordinal 1 + host.event(1, 1.0, "x"); // sequence 4, ordinal 0 + // A recalculation of row 1: its values, its close time, its ordinals and + // its queued events start again; its sequences are issued again. + host.bar(kT0 + kStep, kT0 + 2 * kStep + 1); + Record r = read_record(s); + CHECK(r.bars == 2); + CHECK(r.series[0][0] == 2.0 && std::isnan(r.series[1][0])); + CHECK(std::isnan(r.series[0][1]) && std::isnan(r.series[1][1])); + CHECK(r.close_ms[1] == kT0 + 2 * kStep + 1); + CHECK(r.events.size() == 1 && r.events[0].sequence == 1); + host.event(0, 5.0); + r = read_record(s); + CHECK(r.events.size() == 2 && r.events[1].sequence == 2 && r.events[1].ordinal_in_bar == 0 + && r.events[1].value == 5.0 && r.events[1].bar_index == 1 + && r.events[1].bar_close_ms == kT0 + 2 * kStep + 1); + CHECK(throws_coded([&] { host.bar(kT0, kT0 + kStep); }, + "bar opened at 1704067200000 is before the last recorded bar")); + CHECK(read_record(s).bars == 2); + + // A clear raises the cleared sequence; the sequence runs on. + strategy_outputs_events_clear(s); + host.bar(kT0 + 2 * kStep, kT0 + 3 * kStep); // row 2: nothing of it was cleared + host.bar(kT0 + 2 * kStep, kT0 + 3 * kStep); // so it may restart + host.event(1, 7.0, "after clear"); + r = read_record(s); + CHECK(r.events.size() == 1 && r.events[0].sequence == 3 && r.events[0].bar_index == 2); + // Row 2's event is cleared now: recalculating row 2 would retract it. + strategy_outputs_events_clear(s); + CHECK(throws_coded([&] { host.bar(kT0 + 2 * kStep, kT0 + 3 * kStep); }, + "bar 2 was recalculated after its events were cleared", + RunFailureCode::outputs_rejected)); + host.bar(kT0 + 3 * kStep, kT0 + 4 * kStep); + host.event(0, 1.0); + r = read_record(s); + CHECK(r.events.size() == 1 && r.events[0].sequence == 4 && r.events[0].bar_index == 3); + + // Run constants. + Record c = read_record(s); + CHECK(c.constants.size() == 2 && std::isnan(c.constants[0]) && std::isnan(c.constants[1])); + host.constant(0, 50.0); + host.constant(0, 50.0); // an equal rewrite + CHECK(throws_coded([&] { host.constant(0, 50.5); }, "run constant 0 changed within a run")); + CHECK(throws_coded([&] { host.constant(0, -50.0); }, "run constant 0 changed within a run")); + host.constant(1, kNaN); + host.constant(1, -kNaN); // NaN again: equal + CHECK(throws_coded([&] { host.constant(1, 0.0); }, "run constant 1 changed within a run")); + CHECK(throws_coded([&] { host.constant(2, 1.0); }, "run constant 2 is out of range")); + CHECK(throws_coded([&] { host.constant(-1, 1.0); }, "run constant -1 is out of range")); + c = read_record(s); + CHECK(c.constants.size() == 2 && c.constants[0] == 50.0 && std::isnan(c.constants[1])); + // A constant does not need an open row, and a row restart keeps it. + host.bar(kT0 + 3 * kStep, kT0 + 4 * kStep); + CHECK(read_record(s).constants[0] == 50.0); + // A run begin resets them, and so does a flag change. + host.run_begin(); + c = read_record(s); + CHECK(c.bars == 0 && c.events.empty()); + CHECK(c.constants.size() == 2 && std::isnan(c.constants[0])); + host.constant(0, 0.0); // unwritten again: any value stores + CHECK(throws_coded([&] { host.constant(0, -0.0); }, "run constant 0 changed within a run")); + CHECK(read_record(s).constants[0] == 0.0 && !std::signbit(read_record(s).constants[0])); + CHECK(strategy_outputs_set_enabled(s, 0) == 0); + CHECK(strategy_outputs_set_enabled(s, 1) == 0); + c = read_record(s); + CHECK(c.constants.size() == 2 && std::isnan(c.constants[0]) && c.bars == 0); + host.constant(0, 61.0); + CHECK(read_record(s).constants[0] == 61.0); + // A run begin restarts the sequence at 1. + host.run_begin(); + host.bar(kT0, kT0 + kStep); + host.event(0, 1.0); + CHECK(read_record(s).events.size() == 1 && read_record(s).events[0].sequence == 1); + + // Recording off: every writer returns at its first branch. + CHECK(strategy_outputs_set_enabled(s, 0) == 0); + host.bar(kT0 - kStep, kT0); + host.value(9, 1.0); + host.event(9, 1.0, "ignored"); + host.constant(9, 1.0); + CHECK(read_record(s).bars == 0 && strategy_outputs_events_len(s) == 0); + + // declare_outputs: non-negative counts, repeated only with the same counts. + OutputsHost bare({0, 0, 0, false}); + CHECK(throws_coded([&] { bare.declare(-1, 0); }, "declared counts must not be negative")); + CHECK(throws_coded([&] { bare.declare(0, 0, -1); }, "declared counts must not be negative")); + bare.declare(3, 1, 2); + bare.declare(3, 1, 2); + CHECK(throws_coded([&] { bare.declare(3, 1); }, "repeated with other counts")); + CHECK(throws_coded([&] { bare.declare(4, 1, 2); }, "repeated with other counts")); + CHECK(strategy_outputs_set_enabled(bare.handle(), 1) == 0); + CHECK(strategy_outputs_series_count(bare.handle()) == 3); +} + +// Inside a run: a violation fails the run with its text; the switch is +// refused while the host is Running; a stream reads phases 1 and 2. +void in_run() { + { + OutputsHost host({1, 1, 0}); + pf_strategy_t s = host.handle(); + CHECK(host.configure_native(make_spec("outputs-below", 1)).status == NativeSetupStatus::Applied); + CHECK(strategy_outputs_set_enabled(s, 1) == 0); + host.script = [](OutputsHost& h, const Bar&, const NativeDecisionContext& c) { + if (h.published == 3) h.bar(c.script_bar_open_ms - 2 * kStep, c.script_bar_open_ms); + }; + const auto bars = make_bars(6, kStep); + host.run(bars.data(), static_cast(bars.size())); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(last_error(s).find("is before the last recorded bar") != std::string::npos); + CHECK(code_of(s) == "engine_invariant"); + } + { + OutputsHost host({1, 1, 0}); + pf_strategy_t s = host.handle(); + CHECK(host.configure_native(make_spec("outputs-switch", 1)).status == NativeSetupStatus::Applied); + CHECK(strategy_outputs_set_enabled(s, 1) == 0); + host.script = [](OutputsHost& h, const Bar& b, const NativeDecisionContext&) { + h.value(0, b.close); + h.event(0, b.close); + }; + const auto bars = make_bars(6, kStep); + std::vector c_bars; + for (const Bar& b : bars) c_bars.push_back({b.open, b.high, b.low, b.close, b.volume, b.timestamp}); + CHECK(strategy_stream_begin(s, c_bars.data(), 3, "", "") == 0); + // Between the inputs of a stream the host is Running: a change of the + // switch is refused, a call that changes nothing is not. + for (int i = 3; i <= 6; ++i) { + CHECK(strategy_outputs_set_enabled(s, 0) == -1); + CHECK(last_error(s) == "outputs: recording cannot change during a run"); + CHECK(code_of(s) == "outputs_rejected"); + CHECK(args_of(s) == R"({"reason":"run_in_progress"})"); + CHECK(strategy_outputs_set_enabled(s, 1) == 0); + CHECK(code_of(s) == "outputs_rejected"); // a success leaves the record + if (i < 6) CHECK(strategy_stream_push_bar(s, &c_bars[i]) == 0); + } + Record r = read_record(s); + CHECK(r.bars == 6); + CHECK(r.events.size() == 6); + for (const auto& e : r.events) + CHECK(e.phase == (e.bar_index < 3 ? PF_OUTPUT_PHASE_WARMUP : PF_OUTPUT_PHASE_REALTIME)); + CHECK(strategy_stream_end(s, 0) == 0); + CHECK(strategy_outputs_set_enabled(s, 0) == 0); + CHECK(strategy_outputs_bars_len(s) == 0); + } + { + // Two calculations that state one open time, with no event cleared + // between them, are one row: the second recalculates it. Every later + // row's bar_index runs one short of its calculation's count. This + // host states the open itself: calculation 3 repeats bar 2's. + struct EqualStamp : OutputsHost { + using OutputsHost::OutputsHost; + void on_native_bar(const Bar& b, const NativeDecisionContext& c) override { + const int k = calculations++; + const std::int64_t open = k == 3 ? c.script_bar_open_ms - kStep : c.script_bar_open_ms; + bar(open, open + kStep); + value(0, b.close); + event(0, static_cast(k)); + } + }; + EqualStamp merging({1, 1, 0}); + pf_strategy_t m = merging.handle(); + CHECK(merging.configure_native(make_spec("outputs-equal-stamp", 1)).status + == NativeSetupStatus::Applied); + CHECK(strategy_outputs_set_enabled(m, 1) == 0); + const auto bars = make_bars(6, kStep); + merging.run(bars.data(), 6); + CHECK(last_error(m).empty()); + const Record r = read_record(m); + CHECK(merging.calculations == 6); + CHECK(r.bars == 5); + CHECK(r.open_ms.size() == 5 && r.open_ms[2] == kT0 + 2 * kStep && r.open_ms[3] == kT0 + 4 * kStep); + CHECK(r.series.size() == 1 && r.series[0].size() == 5 && bits(r.series[0][2]) == bits(bars[3].close)); + // Calculation 2's event was retracted with its row; calculation 3's + // took its sequence. Calculations 4 and 5 are rows 3 and 4. + CHECK(r.events.size() == 5); + if (r.events.size() == 5) { + CHECK(r.events[2].value == 3.0 && r.events[2].bar_index == 2 && r.events[2].sequence == 3); + CHECK(r.events[3].value == 4.0 && r.events[3].bar_index == 3); + CHECK(r.events[4].value == 5.0 && r.events[4].bar_index == 4 && r.events[4].sequence == 5); + } + } +} + +// After a failed run the switch keeps that run's text and code, whether it +// succeeds (a declared module turned off) or refuses (an undeclared module +// turned on), as the C boundary keeps them for a refused call. +void failed_run_record() { + for (const bool declare : {true, false}) { + OutputsHost host({1, 1, 0, declare}); + pf_strategy_t s = host.handle(); + CHECK(host.configure_native(make_spec("outputs-failed-run", 1)).status + == NativeSetupStatus::Applied); + if (declare) CHECK(strategy_outputs_set_enabled(s, 1) == 0); + host.script = [](OutputsHost& h, const Bar&, const NativeDecisionContext&) { + if (h.published == 3) throw std::runtime_error("host failure"); + }; + const auto bars = make_bars(6, kStep); + host.run(bars.data(), static_cast(bars.size())); + CHECK(host.native_state().kind == NativeLifecycleKind::Failed); + CHECK(strategy_last_run_status(s) != 0); + const std::string text = last_error(s), code = code_of(s), args = args_of(s); + CHECK(text.find("host failure") != std::string::npos); + CHECK(!code.empty() && code != "outputs_rejected"); + if (declare) { + CHECK(strategy_outputs_set_enabled(s, 0) == 0); + CHECK(strategy_outputs_bars_len(s) == 0); + } else { + CHECK(strategy_outputs_set_enabled(s, 1) == -1); + } + CHECK(last_error(s) == text && code_of(s) == code && args_of(s) == args); + } +} + +// The two INT_MAX caps, 2^31 events or rows, are out of a test's reach. Their +// exception as the sites in engine_report.cpp throw it: a std::runtime_error +// read as outputs_limit, the reason naming the cap and max the cap itself. +// The registry checks the arguments where they are raised: a reason outside +// the catalog's list (the last row) reads engine_invariant instead. +void limit_codes() { + struct Row { + const char* reason; + const char* text; + RunFailureCode code; + const char* args; + }; + const Row rows[] = { + {"too_many_events", "outputs: the event queue is full", RunFailureCode::outputs_limit, + R"({"max":2147483647,"reason":"too_many_events"})"}, + {"too_many_rows", "outputs: the row count is at its limit", RunFailureCode::outputs_limit, + R"({"max":2147483647,"reason":"too_many_rows"})"}, + {"output_rows", "outputs: the row count is at its limit", RunFailureCode::engine_invariant, + "{}"}, + }; + for (const Row& row : rows) { + bool thrown = false; + try { + throw coded(RunFailureCode::outputs_limit, + {{"reason", row.reason}, {"max", INT_MAX}}, row.text); + } catch (const std::runtime_error& e) { + thrown = true; + const RunFailureValue value = classify_run_failure(e); + CHECK(value.code == row.code); + CHECK((value.args ? *value.args : std::string("{}")) == row.args); + CHECK(std::string(e.what()) == row.text); + } + CHECK(thrown); + } + CHECK(run_failure_code_class(RunFailureCode::outputs_limit) == RunFailureClass::strategy_limit); + CHECK(!run_failure_code_retryable(RunFailureCode::outputs_limit)); + CHECK(std::string(run_failure_code_name(RunFailureCode::outputs_limit)) == "outputs_limit"); +} + +// A stream ended while its last slot is still forming +// (strategy_stream_end with finalize_partial_input_bar = 1) calculates that +// bar before it closed: its events read confirmed 0, every earlier one 1. +void partially_finalized_bar() { + OutputsHost host({1, 1, 0}); + pf_strategy_t s = host.handle(); + CHECK(host.configure_native(make_spec("outputs-partial", 1)).status == NativeSetupStatus::Applied); + CHECK(strategy_outputs_set_enabled(s, 1) == 0); + host.script = [](OutputsHost& h, const Bar& b, const NativeDecisionContext&) { + h.value(0, b.close); + h.event(0, b.close); + }; + const auto bars = make_bars(3, kStep); + std::vector warmup; + for (const Bar& b : bars) warmup.push_back({b.open, b.high, b.low, b.close, b.volume, b.timestamp}); + CHECK(strategy_stream_begin(s, warmup.data(), 3, "", "") == 0); + pf_trade_tick_t ticks[2]; + std::memset(ticks, 0, sizeof(ticks)); + ticks[0].timestamp = kT0 + 3 * kStep + 10000; + ticks[0].sequence = 1; + ticks[0].price = 101.0; + ticks[0].quantity = 1.0; + ticks[1].timestamp = kT0 + 3 * kStep + 70000; + ticks[1].sequence = 2; + ticks[1].price = 101.5; + ticks[1].quantity = 2.0; + CHECK(strategy_stream_push_ticks(s, ticks, 2) == 0); + CHECK(strategy_outputs_bars_len(s) == 3); // the forming bar is not calculated yet + CHECK(strategy_stream_end(s, 1) == 0); + const Record r = read_record(s); + CHECK(r.bars == 4); + CHECK(r.events.size() == 4); + if (r.events.size() == 4) { + for (int i = 0; i < 3; ++i) + CHECK(r.events[i].confirmed == 1 && r.events[i].phase == PF_OUTPUT_PHASE_WARMUP); + CHECK(r.events[3].confirmed == 0); + CHECK(r.events[3].phase == PF_OUTPUT_PHASE_REALTIME); + CHECK(r.events[3].bar_open_ms == kT0 + 3 * kStep); + CHECK(r.events[3].value == 101.5); + } +} + +} // namespace + +int main() { + undeclared_and_null_handles(); + hand_values(); + row_rules(); + in_run(); + partially_finalized_bar(); + failed_run_record(); + limit_codes(); + return finish("test_outputs_recorder"); +} diff --git a/tests/test_outputs_run_reuse.cpp b/tests/test_outputs_run_reuse.cpp new file mode 100644 index 000000000..e2ecda15a --- /dev/null +++ b/tests/test_outputs_run_reuse.cpp @@ -0,0 +1,192 @@ +// The run boundary on a reused handle. One handle runs a long batch, a short +// batch, a zero-bar batch, a batch with recording switched off and on again, +// and finally a batch whose callback throws; after each completed run it +// records exactly what a fresh handle records over the same bars (the +// sequence starts at 1 again, no row of an earlier run survives). A failed +// run leaves its last error set, which is the caller's signal to read +// nothing. +// +// A C++ host must state the run boundary itself (output_run_begin(), in +// on_native_run_begin). One that never states it fails its first recorded +// bar; one that states it only on its first run appends a later run to the +// old record when the later bars open after it, and fails when they do not. +// +// Source-free: this TU runs in the kernel-only profile. +#include "outputs_test_support.hpp" + +#include + +using namespace outputs_test; + +namespace { + +constexpr std::int64_t kStep = 5 * kMinute; + +void install(OutputsHost& host) { + host.script = [](OutputsHost& h, const Bar& b, const NativeDecisionContext&) { + h.value(0, b.close); + if (b.close > b.open) h.event(0, b.high, "up"); + h.constant(0, b.open > 0.0 ? 7.0 : 0.0); + }; +} + +std::unique_ptr make_host() { + auto host = std::make_unique(OutputsHost::Shape{1, 1, 1}); + install(*host); + return host; +} + +std::string error_of(pf_strategy_t s) { + const char* text = strategy_get_last_error(s); + return text ? text : ""; +} + +bool same_record(const Record& a, const Record& b) { + if (a.bars != b.bars || a.series_count != b.series_count) return false; + if (a.open_ms != b.open_ms || a.close_ms != b.close_ms) return false; + for (std::size_t i = 0; i < a.series.size() && i < b.series.size(); ++i) + if (!same_values(a.series[i], b.series[i])) return false; + return a.series.size() == b.series.size() && same_events(a.events, b.events, true) + && same_values(a.constants, b.constants); +} + +// What a fresh handle records over `bars`. +Record fresh(const std::vector& bars, int n) { + auto host = make_host(); + pf_strategy_t s = host->handle(); + CHECK(host->configure_native(make_spec("outputs-fresh", 1)).status == NativeSetupStatus::Applied); + CHECK(strategy_outputs_set_enabled(s, 1) == 0); + host->run(bars.data(), n); + CHECK(error_of(s).empty()); + return read_record(s); +} + +void reused_handle() { + const auto long_bars = make_bars(20, kStep); + const auto short_bars = make_bars(6, kStep, kT0 + 100 * kStep, 250.0); + auto host = make_host(); + pf_strategy_t s = host->handle(); + CHECK(strategy_outputs_set_enabled(s, 1) == 0); + std::uint64_t run = 0; + auto configure = [&] { + return host->configure_native(make_spec("outputs-reuse", ++run)).status + == NativeSetupStatus::Applied; + }; + + // A long run, then a short one. + CHECK(configure()); + host->run(long_bars.data(), static_cast(long_bars.size())); + CHECK(error_of(s).empty()); + const Record first = read_record(s); + CHECK(first.bars == 20); + CHECK(same_record(first, fresh(long_bars, 20))); + CHECK(configure()); + host->run(short_bars.data(), static_cast(short_bars.size())); + CHECK(error_of(s).empty()); + const Record second = read_record(s); + CHECK(second.bars == 6); + CHECK(!second.events.empty() && second.events.front().sequence == 1); + CHECK(same_record(second, fresh(short_bars, 6))); + + // A zero-bar run completes; its begin cleared the record, so nothing of + // the run before it survives, as on a fresh handle. + CHECK(configure()); + host->run(long_bars.data(), 0); + CHECK(error_of(s).empty()); + CHECK(host->native_state().kind == NativeLifecycleKind::Completed); + const Record zero = read_record(s); + CHECK(zero.bars == 0 && zero.events.empty()); + CHECK(zero.constants.size() == 1 && std::isnan(zero.constants[0])); + CHECK(same_record(zero, fresh(long_bars, 0))); +} + +void off_and_on() { + const auto bars = make_bars(9, kStep); + auto host = make_host(); + pf_strategy_t s = host->handle(); + std::uint64_t run = 0; + auto configure = [&] { + return host->configure_native(make_spec("outputs-off-on", ++run)).status + == NativeSetupStatus::Applied; + }; + CHECK(strategy_outputs_set_enabled(s, 1) == 0); + CHECK(configure()); + host->run(bars.data(), 9); + CHECK(read_record(s).bars == 9); + // Off between runs: the record is cleared and nothing is recorded. + CHECK(strategy_outputs_set_enabled(s, 0) == 0); + CHECK(read_record(s).bars == 0); + CHECK(configure()); + host->run(bars.data(), 9); + CHECK(error_of(s).empty()); + const Record off = read_record(s); + CHECK(off.bars == 0 && off.events.empty() && off.series_count == 0 && off.constants.empty()); + CHECK(host->published == 9); + // On again: the next run records what a fresh handle records. + CHECK(strategy_outputs_set_enabled(s, 1) == 0); + CHECK(configure()); + host->run(bars.data(), 9); + CHECK(error_of(s).empty()); + CHECK(same_record(read_record(s), fresh(bars, 9))); + + // A run whose callback throws on its fourth bar: the run fails and keeps + // its partial record; the caller, seeing the last error, reads nothing. + host->script = [](OutputsHost& h, const Bar& b, const NativeDecisionContext&) { + h.value(0, b.close); + if (h.published == 4) throw std::runtime_error("host failure on bar 4"); + }; + CHECK(configure()); + host->run(bars.data(), 9); + CHECK(host->native_state().kind == NativeLifecycleKind::Failed); + CHECK(error_of(s) == "host failure on bar 4"); + const Record failed_run = read_record(s); + CHECK(failed_run.bars == 4); + CHECK(failed_run.events.empty()); +} + +// The host states the run boundary itself. +void host_without_run_begin() { + const auto bars = make_bars(5, kStep); + { + auto host = make_host(); + host->begin_run = false; + pf_strategy_t s = host->handle(); + CHECK(host->configure_native(make_spec("outputs-no-begin", 1)).status == NativeSetupStatus::Applied); + CHECK(strategy_outputs_set_enabled(s, 1) == 0); + host->run(bars.data(), 5); + CHECK(host->native_state().kind == NativeLifecycleKind::Failed); + CHECK(error_of(s).find("before any output_run_begin") != std::string::npos); + CHECK(read_record(s).bars == 0); + } + { + // output_run_begin on the first run only. + auto host = make_host(); + pf_strategy_t s = host->handle(); + CHECK(strategy_outputs_set_enabled(s, 1) == 0); + CHECK(host->configure_native(make_spec("outputs-once", 1)).status == NativeSetupStatus::Applied); + host->run(bars.data(), 5); + CHECK(read_record(s).bars == 5); + host->begin_run = false; + const auto later = make_bars(3, kStep, kT0 + 10 * kStep); + CHECK(host->configure_native(make_spec("outputs-once", 2)).status == NativeSetupStatus::Applied); + host->run(later.data(), 3); + CHECK(error_of(s).empty()); + const Record appended = read_record(s); + CHECK(appended.bars == 8); + CHECK(appended.open_ms.size() == 8 && appended.open_ms[5] == kT0 + 10 * kStep); + // The same bars again open below the last row: the run fails. + CHECK(host->configure_native(make_spec("outputs-once", 3)).status == NativeSetupStatus::Applied); + host->run(bars.data(), 5); + CHECK(host->native_state().kind == NativeLifecycleKind::Failed); + CHECK(error_of(s).find("is before the last recorded bar") != std::string::npos); + } +} + +} // namespace + +int main() { + reused_handle(); + off_and_on(); + host_without_run_begin(); + return finish("test_outputs_run_reuse"); +} diff --git a/tests/test_outputs_stream_equivalence.cpp b/tests/test_outputs_stream_equivalence.cpp new file mode 100644 index 000000000..5bda50ac4 --- /dev/null +++ b/tests/test_outputs_stream_equivalence.cpp @@ -0,0 +1,294 @@ +// A stream records what a batch over the same bars records. The batch +// runs over [0, T]; the stream begins on [0, W] and takes the rest bar by +// bar, its caller reading and clearing the events after every input. The +// series are equal bit for bit and the events equal in every field but +// `phase` (warm-up 1, realtime 2; a batch 0), for W = 1, T/2 and T-1, on the +// chart timeframe and on an aggregated chart (1m input, 5m script). +// +// The stale carried case: on a FeedTolerant stream whose last warm-up bar has +// an off-grid label, the first stream_advance_time dispatches a quiet carried +// calculation for the calendar slot holding that label -- an open not after +// the last bar the host published. The host skips it (the Pine host's rule), +// so the stream counts more calculations than it has rows, does not fail, and +// still records what a batch over the same bars (the carried flat bars +// included) records. +// +// Negative controls, kept in this file: a host that keys a row to every +// calculation the kernel counts breaks the equality on the row count, and a +// host that states the real open for every calculation fails the stream at +// the recorder's out-of-order refusal. +// +// Source-free: this TU runs in the kernel-only profile. +#include "outputs_test_support.hpp" + +#include +#include + +using namespace outputs_test; + +namespace { + +constexpr std::int64_t kStep = 5 * kMinute; + +struct ScriptState { + std::unique_ptr sma; +}; + +// slot 0 close, slot 1 a kernel SMA(3), slot 2 a colour; output 0 a mark on +// up bars, output 1 a message on every third bar; constants 50 and a colour. +void install_script(OutputsHost& host, std::shared_ptr state) { + host.on_begin = [state](OutputsHost&) { state->sma = std::make_unique(3); }; + host.script = [state](OutputsHost& h, const Bar& b, const NativeDecisionContext&) { + const bool up = b.close > b.open; + h.value(0, b.close); + h.value(1, state->sma->compute(b.close)); + h.value(2, rgba(up ? 0xFF4CAF50u : 0xFFF23645u)); + if (up) h.event(0, b.close); + if (h.published % 3 == 0) { + std::ostringstream text; + text << "bar " << h.published << " close " << b.close; + h.event(1, b.volume, text.str()); + } + h.constant(0, 50.0); + h.constant(1, rgba(0xFF787B86u)); + }; +} + +struct Run { + Record rows; // the record at the end (its queue unused) + std::vector events; // every event the caller read, in order + std::string error; + std::int64_t processed = 0; + std::int64_t warmup_rows = 0; // rows recorded when stream_begin returned +}; + +std::unique_ptr make_host() { + auto host = std::make_unique(OutputsHost::Shape{3, 2, 2}); + install_script(*host, std::make_shared()); + return host; +} + +std::string error_of(pf_strategy_t s) { + const char* text = strategy_get_last_error(s); + return text ? text : ""; +} + +Run batch_run(OutputsHost& host, const NativeRunSpec& spec, const std::vector& bars) { + Run out; + pf_strategy_t s = host.handle(); + if (host.configure_native(spec).status != NativeSetupStatus::Applied + || strategy_outputs_set_enabled(s, 1) != 0) { + out.error = "setup refused"; + return out; + } + host.run(bars.data(), static_cast(bars.size())); + out.error = error_of(s); + out.rows = read_record(s); + out.events = out.rows.events; + out.processed = strategy_script_bars_processed(s); + return out; +} + +void drain(pf_strategy_t s, Run& out) { + for (auto& e : read_events(s)) out.events.push_back(e); + strategy_outputs_events_clear(s); +} + +// Begins on bars[0, warmup) and pushes the rest, then advances the clock to +// each of `advances`; the caller drains after every input. +Run stream_run(OutputsHost& host, const NativeRunSpec& spec, const std::vector& bars, + int warmup, const std::vector& advances = {}) { + Run out; + pf_strategy_t s = host.handle(); + if (host.configure_native(spec).status != NativeSetupStatus::Applied + || strategy_outputs_set_enabled(s, 1) != 0) { + out.error = "setup refused"; + return out; + } + std::vector c_bars; + for (const Bar& b : bars) c_bars.push_back({b.open, b.high, b.low, b.close, b.volume, b.timestamp}); + if (strategy_stream_begin(s, c_bars.data(), warmup, "", "") != 0) { + out.error = error_of(s); + return out; + } + out.warmup_rows = strategy_outputs_bars_len(s); + drain(s, out); + for (std::size_t i = static_cast(warmup); i < c_bars.size(); ++i) { + if (strategy_stream_push_bar(s, &c_bars[i]) != 0) { + out.error = error_of(s); + return out; + } + drain(s, out); + } + for (std::int64_t t : advances) { + if (strategy_stream_advance_time(s, t) != 0) { + out.error = error_of(s); + return out; + } + drain(s, out); + } + out.rows = read_record(s); + out.processed = strategy_script_bars_processed(s); + strategy_stream_end(s, 0); + return out; +} + +// The first difference between a batch and a stream over the same bars, or "". +std::string stream_mismatch(const Run& batch, const Run& stream) { + if (!batch.error.empty()) return "batch failed: " + batch.error; + if (!stream.error.empty()) return "stream failed: " + stream.error; + if (batch.rows.bars != stream.rows.bars) + return "row count " + std::to_string(batch.rows.bars) + " != " + std::to_string(stream.rows.bars); + if (batch.rows.open_ms != stream.rows.open_ms) return "bar open times"; + if (batch.rows.close_ms != stream.rows.close_ms) return "bar close times"; + if (batch.rows.series.size() != stream.rows.series.size()) return "series count"; + for (std::size_t slot = 0; slot < batch.rows.series.size(); ++slot) + if (!same_values(batch.rows.series[slot], stream.rows.series[slot])) + return "series slot " + std::to_string(slot); + if (!same_values(batch.rows.constants, stream.rows.constants)) return "run constants"; + if (batch.events.size() != stream.events.size()) + return "event count " + std::to_string(batch.events.size()) + " != " + + std::to_string(stream.events.size()); + for (std::size_t i = 0; i < batch.events.size(); ++i) { + const EventRecord& b = batch.events[i]; + const EventRecord& s = stream.events[i]; + if (!same_event(b, s, false)) return "event " + std::to_string(i) + " (sequence " + + std::to_string(b.sequence) + " vs " + std::to_string(s.sequence) + ")"; + if (b.phase != PF_OUTPUT_PHASE_BATCH) return "batch event phase"; + const std::uint32_t expected = s.bar_index < stream.warmup_rows ? PF_OUTPUT_PHASE_WARMUP + : PF_OUTPUT_PHASE_REALTIME; + if (s.phase != expected) return "stream event phase at bar " + std::to_string(s.bar_index); + } + return ""; +} + +void chart_timeframe() { + const auto bars = make_bars(24, kStep); + auto reference = make_host(); + const Run batch = batch_run(*reference, make_spec("outputs-d1", 1), bars); + CHECK(batch.error.empty()); + CHECK(batch.rows.bars == 24); + CHECK(batch.events.size() > 8); + for (int warmup : {1, 12, 23}) { + auto host = make_host(); + const Run stream = stream_run(*host, make_spec("outputs-d1", 1), bars, warmup); + const std::string why = stream_mismatch(batch, stream); + if (!why.empty()) std::fprintf(stderr, " W=%d: %s\n", warmup, why.c_str()); + CHECK(why.empty()); + CHECK(stream.warmup_rows == warmup); + bool warm = false, real = false; + for (const auto& e : stream.events) { + warm = warm || e.phase == PF_OUTPUT_PHASE_WARMUP; + real = real || e.phase == PF_OUTPUT_PHASE_REALTIME; + } + CHECK(real); + CHECK(warm == (warmup >= 3)); // the first message event is on bar 3 + } +} + +void aggregated() { + const auto bars = make_bars(60, kMinute); + auto reference = make_host(); + const Run batch = batch_run(*reference, make_spec("outputs-d1-agg", 1, "1", "5"), bars); + CHECK(batch.error.empty()); + CHECK(batch.rows.bars == 12); + if (batch.rows.open_ms.size() == 12) { + CHECK(batch.rows.open_ms[1] == kT0 + kStep && batch.rows.close_ms[1] == kT0 + 2 * kStep); + } + for (int warmup : {1, 30, 59}) { + auto host = make_host(); + const Run stream = stream_run(*host, make_spec("outputs-d1-agg", 1, "1", "5"), bars, warmup); + const std::string why = stream_mismatch(batch, stream); + if (!why.empty()) std::fprintf(stderr, " aggregated W=%d: %s\n", warmup, why.c_str()); + CHECK(why.empty()); + } +} + +NativeRunSpec tolerant_spec(const char* key) { + NativeRunSpec spec = make_spec(key, 1); + spec.slot_label_policy = NativeSlotLabelPolicy::FeedTolerant; + return spec; +} + +// Four warm-up bars, the last labelled one minute into its slot, then the +// clock advanced over three quiet slots. +struct StaleCase { + std::vector warmup; + std::vector advances; + std::vector batch; // the warm-up bars and the three carried flat bars +}; + +StaleCase stale_case() { + StaleCase c; + c.warmup = make_bars(4, kStep); + c.warmup[3].timestamp = kT0 + 3 * kStep + kMinute; + const double last = c.warmup[3].close; + c.advances = {kT0 + 5 * kStep, kT0 + 6 * kStep, kT0 + 7 * kStep}; + c.batch = c.warmup; + for (int k = 4; k <= 6; ++k) c.batch.push_back({last, last, last, last, 0.0, kT0 + k * kStep}); + return c; +} + +void stale_carried() { + const StaleCase c = stale_case(); + auto reference = make_host(); + const Run batch = batch_run(*reference, tolerant_spec("outputs-d1-stale"), c.batch); + CHECK(batch.error.empty()); + CHECK(batch.rows.bars == 7); + CHECK(reference->stale == 0); + + auto host = make_host(); + const Run stream = stream_run(*host, tolerant_spec("outputs-d1-stale"), c.warmup, 4, c.advances); + CHECK(stream.error.empty()); + // The trigger fired: the kernel dispatched a calculation the host did not + // publish, for the slot that holds the last warm-up label. + CHECK(host->stale == 1); + CHECK(stream.processed == 8); + CHECK(stream.rows.bars == 7); + CHECK(stream.processed > stream.rows.bars); + const std::string why = stream_mismatch(batch, stream); + if (!why.empty()) std::fprintf(stderr, " stale carried: %s\n", why.c_str()); + CHECK(why.empty()); + + // Control: a host that keys a row to every calculation the kernel counts + // (keyed by the count). The equality fails on the row count. + struct CountKeyed : OutputsHost { + using OutputsHost::OutputsHost; + Script inner; + void on_native_bar(const Bar& b, const NativeDecisionContext& context) override { + ++calculations; + ++published; + if (outputs_enabled_) output_bar(kT0 + calculations, kT0 + calculations + 1); + if (inner) inner(*this, b, context); + } + }; + CountKeyed counted(OutputsHost::Shape{3, 2, 2}); + { + auto state = std::make_shared(); + install_script(counted, state); + counted.inner = counted.script; + } + const Run control_a = stream_run(counted, tolerant_spec("outputs-d1-stale"), c.warmup, 4, c.advances); + const std::string why_a = stream_mismatch(batch, control_a); + std::fprintf(stderr, " control, rows keyed to the count: %s\n", why_a.c_str()); + CHECK(why_a.rfind("row count 7 != 8", 0) == 0); + + // A host that states the real open for every calculation is refused by + // the recorder at the stale calculation: the stream fails. + auto every = make_host(); + every->skip_stale = false; + const Run control_open = stream_run(*every, tolerant_spec("outputs-d1-stale"), c.warmup, 4, c.advances); + std::fprintf(stderr, " control, every calculation published: %s\n", + control_open.error.c_str()); + CHECK(control_open.error.find("is before the last recorded bar") != std::string::npos); + CHECK(!stream_mismatch(batch, control_open).empty()); +} + +} // namespace + +int main() { + chart_timeframe(); + aggregated(); + stale_carried(); + return finish("test_outputs_stream_equivalence"); +} diff --git a/tests/test_outputs_truncation.cpp b/tests/test_outputs_truncation.cpp new file mode 100644 index 000000000..4c12e1974 --- /dev/null +++ b/tests/test_outputs_truncation.cpp @@ -0,0 +1,162 @@ +// A batch cut at bar t records, for every bar up to t, what the batch +// over the whole input records. Five cut points over 48 five-minute bars; the +// host records the close, a kernel ta:: average, a declared 15-minute +// series (its delivered bucket, NaN until the first) and a colour, plus a +// mark and a message event and a run constant. The rows, their values and +// the events at bars <= t are equal, and so are the run constants. +// +// Negative control: a host value that depends on the batch's last bar (1 on +// the bar it knows is the input's last) breaks the prefix, and the harness names the +// cut bar it differs on. +// +// Source-free: this TU runs in the kernel-only profile. +#include "outputs_test_support.hpp" + +#include + +using namespace outputs_test; + +namespace { + +constexpr std::int64_t kStep = 5 * kMinute; +constexpr int kTotal = 48; + +struct Truncation { + Record rows; + std::string error; +}; + +NativeRunSpec spec_with_series(std::uint64_t run) { + NativeRunSpec spec = make_spec("outputs-d2", run); + NativeTimeframeSubscription fifteen; + fifteen.tf = "15"; + spec.subscriptions.push_back(fifteen); + return spec; +} + +// `last_aware`: the negative control's value, 1 on the bar the host was told +// is the batch's last. +Truncation run_prefix(const std::vector& all, int last_bar, bool last_aware) { + OutputsHost host({5, 2, 1}); + auto sma = std::make_shared>(); + host.on_begin = [sma](OutputsHost&) { *sma = std::make_unique(4); }; + const int n = last_bar + 1; + host.script = [sma, last_aware, n](OutputsHost& h, const Bar& b, const NativeDecisionContext&) { + h.value(0, b.close); + h.value(1, (*sma)->compute(b.close)); + const std::optional bucket = h.native_series_bar(0); + h.value(2, bucket ? bucket->close : kNaN); + if (last_aware) h.value(3, h.published == n ? 1.0 : 0.0); + h.value(4, rgba(b.close >= b.open ? 0xFF4CAF50u : 0xFFF23645u)); + if (b.close < b.open) h.event(0, b.low); + if (bucket && h.published % 4 == 0) h.event(1, bucket->high, "bucket"); + h.constant(0, 21.0); + }; + Truncation out; + pf_strategy_t s = host.handle(); + if (host.configure_native(spec_with_series(1)).status != NativeSetupStatus::Applied + || strategy_outputs_set_enabled(s, 1) != 0) { + out.error = "setup refused"; + return out; + } + host.run(all.data(), n); + const char* text = strategy_get_last_error(s); + out.error = text ? text : ""; + out.rows = read_record(s); + return out; +} + +// The first bar <= cut whose rows or events differ, or -1. `why` names it. +int prefix_mismatch(const Record& cut, const Record& full, int last, std::string& why) { + for (int i = 0; i <= last; ++i) { + if (i >= cut.bars || i >= full.bars) { + why = "missing row"; + return i; + } + if (cut.open_ms[i] != full.open_ms[i] || cut.close_ms[i] != full.close_ms[i]) { + why = "bar times"; + return i; + } + for (std::size_t slot = 0; slot < cut.series.size(); ++slot) { + if (!same_value(cut.series[slot][i], full.series[slot][i])) { + why = "series slot " + std::to_string(slot); + return i; + } + } + } + if (cut.bars != last + 1) { + why = "extra row"; + return last + 1; + } + std::size_t f = 0; + for (const EventRecord& e : cut.events) { + if (f >= full.events.size() || !same_event(e, full.events[f], true)) { + why = "event sequence " + std::to_string(e.sequence); + return e.bar_index; + } + ++f; + } + if (f < full.events.size() && full.events[f].bar_index <= last) { + why = "missing event"; + return full.events[f].bar_index; + } + if (!same_values(cut.constants, full.constants)) { + why = "run constants"; + return last; + } + return -1; +} + +void prefixes_equal() { + const auto bars = make_bars(kTotal, kStep); + const Truncation full = run_prefix(bars, kTotal - 1, false); + CHECK(full.error.empty()); + CHECK(full.rows.bars == kTotal); + // The 15-minute series is delivered on the bar that completes its bucket. + CHECK(full.rows.series.size() == 5); + CHECK(full.rows.constants.size() == 1 && full.rows.constants[0] == 21.0); + if (full.rows.series.size() == 5 && full.rows.bars == kTotal) { + CHECK(full.rows.series[4][0] == rgba(bars[0].close >= bars[0].open ? 0xFF4CAF50u + : 0xFFF23645u)); + CHECK(std::isnan(full.rows.series[2][0]) && std::isnan(full.rows.series[2][1])); + CHECK(full.rows.series[2][2] == bars[2].close); + CHECK(full.rows.series[2][4] == bars[2].close); + CHECK(full.rows.series[2][5] == bars[5].close); + CHECK(std::isnan(full.rows.series[3][0])); // not written in this run + } + CHECK(full.rows.events.size() > 10); + // Bucket ends (t % 3 == 2) and bars inside a bucket. + for (int cut : {0, 8, 19, 44, 46}) { + const Truncation part = run_prefix(bars, cut, false); + CHECK(part.error.empty()); + std::string why; + const int bar = prefix_mismatch(part.rows, full.rows, cut, why); + if (bar >= 0) std::fprintf(stderr, " cut %d: bar %d, %s\n", cut, bar, why.c_str()); + CHECK(bar == -1); + } +} + +void last_bar_control() { + const auto bars = make_bars(kTotal, kStep); + const Truncation full = run_prefix(bars, kTotal - 1, true); + CHECK(full.error.empty()); + int failures = 0; + for (int cut : {0, 8, 19, 44, 46}) { + const Truncation part = run_prefix(bars, cut, true); + std::string why; + const int bar = prefix_mismatch(part.rows, full.rows, cut, why); + std::fprintf(stderr, " control, cut %d: %s at bar %d\n", cut, + bar >= 0 ? why.c_str() : "no difference", bar); + CHECK(bar == cut && why == "series slot 3"); + if (bar >= 0) ++failures; + } + CHECK(failures == 5); +} + +} // namespace + +int main() { + prefixes_equal(); + last_bar_control(); + return finish("test_outputs_truncation"); +}