Tutorial / reference image. Same spirit as
tutorial/— a minimal, end-to-end example you can copy and adapt. Production deployments will want their own image (smaller base, pinned libpineforge version, hardened entrypoint, image signing, etc.). Use this one to learn the inputs/outputs and as a starting point.
Self-contained image for backtesting a PineScript v6 strategy against an
OHLCV CSV. The image bundles the pineforge-codegen transpiler (pip,
source-available), so you can mount a strategy.pine directly — it
transpiles → compiles → runs locally, no hosted API, no API key, source
never leaves the container. A pre-transpiled strategy.cpp is still
accepted for back-compat. JSON report on stdout, build/transpile noise on
stderr.
This checkout documents the 1.4.0 release (2026-10-08), pairing engine 1.4.0 with codegen 1.4.0. The release adds typed/unresolved provenance and coded failures, and the harness uses checked settings when the strategy exports that API. Use the same-version engine/codegen pair, regenerate the C++ and relink; an older image keeps its own harness behavior.
This repository's workflows publish no image. The prebuilt image of this
harness is the release hub's, ghcr.io/pineforge-4pass/pineforge-release:
pineforge-release builds it from an engine release's static-lib tarball
with a pinned pineforge-codegen (the engine<E>-codegen<C> tag names the
pair; same-version pairing starts at 1.0.0), for Linux amd64 and arm64.
docker pull ghcr.io/pineforge-4pass/pineforge-release:latest
# or pin a hub release (its own X.Y.Z) or an engine/codegen pair:
docker pull ghcr.io/pineforge-4pass/pineforge-release:X.Y.Z
docker pull ghcr.io/pineforge-4pass/pineforge-release:engine1.0.0-codegen1.0.0A stable release is tagged X.Y.Z, X.Y, latest,
engine<E>-codegen<C> and sha-<short>; a release candidate only its exact
version (for example 1.0.0-rc.1), engine<E>-codegen<C> and
sha-<short>, never latest.
This tree keeps the harness the image runs (docker/entrypoint.sh,
docker/run_json.py) but no Dockerfile: the engine is a library and its
release builds no image (#38). The image's Dockerfile is pineforge-release's
docker/Dockerfile. It vendors this harness from the pinned engine release,
fetches that release's static-lib tarball, and adds g++, Eigen, python3
and the pineforge-codegen transpiler; clone that repository to build the
image yourself.
The examples below call the image pineforge: tag the pulled one that way
(docker tag ghcr.io/pineforge-4pass/pineforge-release:latest pineforge) or
write its full name.
Release 1.8.0 adds two diagnostics to every successful executed
report from this harness, including ordinary and selected array reports,
named-sink reports and FD small reports:
diagnostics.run_profile is exactly "unjailed" or "jailed", frozen at
startup from --profile or PINEFORGE_RUN_JSON_PROFILE (CLI wins; default
unjailed). diagnostics.scratch_arena_jailed is boolean true only when
the host used the jailed profile and native arena admission succeeded;
unjailed runs report false even with an attached arena.
diagnostics.warnings is present only when the library's optional
strategy_get_run_warnings getter returns a nonempty list of objects with
code (string) and args (object). It includes
request_timeframe_not_multiple with input_tf, requested_tf and sec_id,
and preserves unknown warning codes and their arguments. Older libraries and
runs without warnings keep the earlier report shape and bytes. These warnings
do not enter sink records or fingerprints.
The marker records the checked host profile and successful native admission.
It is not independent OS jail proof or a fresh backend query at report time;
off Linux admission accepts any attached arena. Neither arena sizes nor
PF_SCRATCH_ARENA_JAILED=1 sets it. Consumers requiring the policy must
require exact "jailed" and boolean true, refusing absent or malformed
values. Older reports remain readable by the replay converter without
inferring these fields. Capabilities, validation-only documents and failure
lines carry neither field.
For direct run_json.py --report-sink-fd N execution, these diagnostics stay
in the small success JSON on stdout; native pf-report-sink/v1 records go to
the inherited stream N. The launcher supplies and drains that endpoint;
Python closes its owned descriptor before publishing the success document.
The new fields do not change sink records, receipts or fingerprint versions.
docker run --rm \
-v $(pwd)/strategy.pine:/in/strategy.pine:ro \
-v $(pwd)/ohlcv.csv:/in/ohlcv.csv:ro \
pineforge > report.jsonMount points (provide exactly one of strategy.pine / strategy.cpp):
| Host path | Container path | Required | Notes |
|---|---|---|---|
strategy.pine |
/in/strategy.pine |
preferred | PineScript v6 source; transpiled in-container |
strategy.cpp |
/in/strategy.cpp |
back-compat | Pre-transpiled PineForge translation unit (used only if no .pine). Refused unless PINEFORGE_STRATEGY_CPP_SHA256 holds the sha256 of exactly these bytes, or PINEFORGE_ALLOW_UNATTESTED_CPP=1 (local trust only; a hosted caller must never set it) |
ohlcv.csv |
/in/ohlcv.csv |
yes | timestamp,open,high,low,close,volume |
Set PINEFORGE_BUILD_ONLY to exactly 1 or true and
PINEFORGE_WORK_DIR to the absolute path of an existing empty directory
created by the caller. Phase 1 performs the normal source resolution,
attestation, translation, compilation and selected-window inventory binding,
then writes engine-step.json and exits 0. Its stdout is empty; stderr ends
with [pineforge] build complete; engine step not started. The engine has
not run. The caller owns this directory and its eventual cleanup. The
build-only checks and manifest publication live inside entrypoint.sh,
using inline Python and its standard-library JSON module.
Other build-mode values, including unset, empty, 0, false and yes, leave
build mode off. A set PINEFORGE_WORK_DIR with that mode off is refused with
status 4 and one run_request_invalid JSON line, option work_dir.
Build mode requires an absolute existing empty directory that is writable
and searchable by the caller and is not a symlink: a relative path,
file, missing directory or directory containing even a hidden entry is
refused before building, with status 4 and one run_request_invalid JSON
line whose args.option is work_dir. Nothing in that directory is replaced.
Both build mode and PINEFORGE_TRANSPILE_ONLY being on is refused with
status 4, run_request_invalid, option arguments, naming both variables.
Transpile mode is on for exactly 1 or true too.
The manifest is one ASCII-escaped JSON object with schema
pineforge-engine-step/v1, atomically published with mode 0600. Its argv
is the single Bash array assembled for the ordinary engine invocation.
argv[0] is the absolute result of resolving python3 during the build;
phase 2 performs no interpreter lookup through PATH. An interpreter
resolution that is relative, or a manifest publication failure, returns
status 4 with harness_internal_error. Other phase-1 failures retain their
ordinary statuses: missing input or C++ attestation 2, compilation or binding
3, compile-guard refusal 4, translation 5.
The caller must execute every element of the manifest's argv, in order,
without shell splitting, adding flags or reconstructing the command.
The following table describes how the entrypoint constructs it; the manifest
is authoritative.
| Arguments | Source and inclusion rule |
|---|---|
Interpreter, run_json.py |
Resolved python3, then ${PINEFORGE_PREFIX:-/opt/pineforge}/bin/run_json.py |
--so, --ohlcv |
Built library, then the input directory's ohlcv.csv |
--inputs, --overrides |
PINEFORGE_INPUTS, PINEFORGE_OVERRIDES; always passed, empty when unset |
--input-tf, --script-tf, --bar-magnifier |
Corresponding PINEFORGE_INPUT_TF, PINEFORGE_SCRIPT_TF, PINEFORGE_BAR_MAGNIFIER; always passed, empty when unset |
--magnifier-samples, --magnifier-dist |
PINEFORGE_MAGNIFIER_SAMPLES and PINEFORGE_MAGNIFIER_DIST; unset or empty uses 4 and endpoints |
--generated-cpp, --transpiled |
The compiled source path and whether it was translated from Pine in this invocation |
--trade-start-ms, --chart-tz |
Nonempty PINEFORGE_TRADE_START_MS, PINEFORGE_CHART_TZ, in that order |
--magnifier-volume-weighted |
PINEFORGE_MAGNIFIER_VOLUME_WEIGHTED is 1, true, yes or on |
--syminfo, --symbol-feeds |
Nonempty PINEFORGE_SYMINFO, PINEFORGE_SYMBOL_FEEDS, in that order |
--max-symbol-feeds |
PINEFORGE_MAX_SYMBOL_FEEDS is set, including empty or 0 |
--tv-compat, --max-inventory-bytes |
On the plain g++ branch (including zero-only compile-guard settings), compatibility flag when PINEFORGE_TV_COMPAT is 1, true, yes or on; then the inventory value when PINEFORGE_MAX_INVENTORY_BYTES is set, including empty or 0. Neither flag is forwarded on the compile-guard branch |
--report-sink-fd, --run-id |
Corresponding PINEFORGE_REPORT_SINK_FD, PINEFORGE_RUN_ID is set; empty strings are retained |
--bench, --warmup, --repeats |
PINEFORGE_BENCH is 1, true, yes or on; warmup/repeats use PINEFORGE_WARMUP/PINEFORGE_REPEATS, with unset or empty values defaulting to 3/20 |
--report-policy, window arguments |
Nonempty PINEFORGE_REPORT_POLICY, followed by each nonempty PINEFORGE_WINDOW_START_MS, PINEFORGE_WINDOW_END_MS, PINEFORGE_PREROLL_BARS, PINEFORGE_FED_START_MS as --window-start-ms, --window-end-ms, --preroll-bars, --fed-start-ms |
--request-feed-inventory |
Last, when the selected path produced a bound inventory |
Run phase 2 in the manifest's cwd, the entrypoint's working directory.
In particular, relative syminfo and symbol-feed index paths resolve there.
Preserve the phase-1 environment unchanged, as inherits.environment states.
The engine reads PINEFORGE_RUN_JSON_PROFILE,
PINEFORGE_COMMAND_SCRATCH_MAX_BYTES, PINEFORGE_COMMAND_SCRATCH_DIR,
TMPDIR, PF_SCRATCH_ARENA_FD, PF_SCRATCH_ARENA_BYTES,
PF_SCRATCH_ARENA_JAILED, PF_SCRATCH_WINDOW_BYTES, PATH and locale
settings. Build mode exports or clears none of them.
Preserve stdin and the inherited descriptors. Stdout carries the JSON report;
stderr carries diagnostics. If PINEFORGE_REPORT_SINK_FD is set,
inherits.descriptors records its value verbatim and --report-sink-fd
passes it to the engine. The caller opens that descriptor before phase 1 and
keeps the same descriptor open through phase 2. The engine validates it;
an empty value is forwarded for that validation. A launcher's outcome
descriptor belongs to the wrapper, must not alias the report sink, and is
neither opened nor selected by this contract.
The plain engine step is one process and can be the target of a launcher
that denies child-process creation. Its report-channel dispatch is excluded.
Build mode refuses any set value, including empty, for each of these
eleven names because their dispatch starts additional processes:
PINEFORGE_REPORT_CHANNEL, PINEFORGE_TERMINAL_STATUS_FD,
PINEFORGE_REPORT_MAX_BYTES, PINEFORGE_DIAGNOSTIC_MAX_BYTES,
PINEFORGE_CHANNEL_WALL_MS, PINEFORGE_CHANNEL_STALL_MS,
PINEFORGE_CHANNEL_POST_EXIT_DRAIN_MS, PINEFORGE_CHANNEL_STOP_GRACE_MS,
PINEFORGE_REPORT_PLANNED_SINK_BYTES, PINEFORGE_REPORT_ATTEMPT_ID,
PINEFORGE_REPORT_HOST_ATTESTATION_FD. The manifest records them in
forbidden_environment_spawning.
It also refuses PINEFORGE_REPORT_PRODUCER,
PINEFORGE_REPORT_CANDIDATE_FD and PINEFORGE_REPORT_NATIVE_SINK_FD,
recorded in forbidden_environment_producer. These three select or configure
the producer side of a channel, which has a different destination and
descriptor contract. Each refusal is status 4, run_request_invalid, option
arguments, naming the first offending variable. Ordinary full runs retain
their channel behavior.
No forbidden environment name may be introduced in phase 2. None of these
twelve forbidden_flags may be added to its argv:
--report-channel, --terminal-status-fd, --report-max-bytes,
--diagnostic-max-bytes, --channel-wall-ms, --channel-stall-ms,
--post-exit-drain-ms, --stop-grace-ms, --planned-sink-bytes,
--attempt-id, --host-attestation-fd, --test-only-host-attestation-fd.
Build mode checks every argv token, including option values, against these
flags, including the --flag=value form. A matching token is refused with
status 4, run_request_invalid, option arguments, naming that token;
the compiled library is removed and no manifest is published.
The plain PINEFORGE_REPORT_SINK_FD remains allowed.
The launcher observes the engine's own exit status. The engine step returns
0, 1 or 2, plus death by a signal: 0 means the report was written and the run
completed; 1 means a failed run, harness fault or report that could not be
written whole; 2 means argparse refused the command line or explicit request
validation failed. The module describes failures and incomplete publication
at docker/run_json.py:117 and docker/run_json.py:136; main() describes
its status contract at docker/run_json.py:5314 and propagates typed failure
statuses at docker/run_json.py:5336 and docker/run_json.py:5345.
The argparse error exits 2 at docker/run_json.py:4583; explicit sink-option
refusals also use 2 at docker/run_json.py:6040. The standalone csv2bin
refusal returns 2 at docker/run_json.py:5304, but the entrypoint never
selects that subcommand. _host_main() returns 1 on report-channel setup
failure at docker/run_json.py:8718, propagates the run status at
docker/run_json.py:8751, and the process exits through
sys.exit(_host_main()) at docker/run_json.py:9582.
Statuses 3 (compile or bind failure) and 5 (transpile failure) are
build-phase-only; the engine step never returns them. Status 4 is only the
entrypoint's mapping, or a usage/limit failure of an opt-in compile guard or
of build-only (including a manifest publication failure). The entrypoint's
2 means missing input or C++ attestation failure; the engine step's argparse
2 has a different meaning despite sharing the number.
The entrypoint maps every nonzero engine status to 4, with
[pineforge] backtest failed on stderr. The manifest's structured
status_mapping records engine_step_statuses, engine_step_meaning,
build_phase_only, entrypoint_only_codes, full_run_mapping and signals;
the mapping and its entrypoint code use the same ENGINE_FAILURE_STATUS
constant. A process terminated by a signal has no status of its own; the
caller reports it, conventionally as 128 plus the signal number.
A wrapper needing the entrypoint's mapping must apply it itself, after
interpreting its launcher's own outcomes and signals.
Phase 2 reads the library, CSV chart and generated source named in
artifacts, the bound inventory when present, syminfo and symbol-feed
indexes when requested, and the CSVs those indexes name. The manifest hashes
the library, compiled source and bound inventory as left by phase 1.
artifacts.ohlcv.sha256 is null: the CSV mount is read during phase 2 and is
not hashed by the build. artifacts.request_feed_inventory is null when no
inventory was bound. Keep these inputs and the installation's interpreter,
modules and native dependencies readable until the engine exits.
The library, inventory files and manifest persist in work_dir, as does
the compiled source except on the non-selected path with
PINEFORGE_ALLOW_UNATTESTED_CPP=1: that path leaves the source at
/in/strategy.cpp (or the configured input directory), so artifacts.cpp
and --generated-cpp refer to the caller-mutable input mount. Keep that
source readable and verify its hash before phase 2 too. The existing
inventory binder may also retain its temporary publication file.
Phase 2 must finish before the caller removes these files. Engine writes go
to stdout, the inherited report descriptor when selected, and temporary
report-sink, report-replay and command-store files under TMPDIR (otherwise
/tmp); PINEFORGE_COMMAND_SCRATCH_DIR can select a separate command-store
directory. The entrypoint passes no named report-output, replay-output or
spill-directory flags. Provide writable scratch and sink destinations
separately from the read-only build directory.
The wrapper has three mandatory duties at the trust boundary:
- Terminate every process started by phase 1 before reading anything from the work directory, using its process containment mechanism. Waiting for the entrypoint alone is insufficient.
- In addition to checking the artifact hashes, obtain and verify the manifest's own sha256 over a channel only the wrapper owns. Hashes stored inside the manifest do not establish its integrity.
- Mount the work directory read-only for phase 2, and verify the artifact hashes before executing the manifest argv.
The entrypoint runs inside phase 1 and cannot establish those guarantees for its caller.
The entrypoint can apply cooperative limits to compilation on Linux. The
feature is off when all three settings are unset. Once any setting opts it in,
an unset dimension and a dimension set to 0 are both unlimited. With no
positive setting, the entrypoint keeps its original compiler and
inventory-binding commands, environment, output and exit statuses after
validating the supplied values.
| Environment setting | Unit and scope | Value resolution |
|---|---|---|
PINEFORGE_COMPILE_WALL_MS |
Milliseconds from before compiler launch through all helpers and selected inventory binding | Unset or 0: unlimited; positive: exact |
PINEFORGE_COMPILE_AS_BYTES |
Per-process address space, installed as inherited RLIMIT_AS with equal soft and hard limits before compiler or binder execution |
Unset or 0: unlimited; positive: exact |
PINEFORGE_COMPILE_DIAGNOSTIC_MAX_BYTES |
Combined raw stdout and stderr bytes from the compiler, its helpers and the binder | Unset or 0: unlimited; positive: exact |
A set value must contain decimal digits only and fit 0..9007199254740990.
An omitted setting is unlimited once the feature is opted in, and 0 is also
unlimited. A positive value is used exactly. A positive address-space request
above the inherited hard limit is refused before launch. Empty strings, signs,
whitespace, fractions and values outside the range are usage errors: one
run_request_invalid failure line with
args: {"option":"arguments"} on stdout and exit 4.
For example, these settings allow 30 seconds, 2 GiB of address space per compiler process, and 64 KiB of compiler/binder diagnostics:
docker run --rm \
-e PINEFORGE_COMPILE_WALL_MS=30000 \
-e PINEFORGE_COMPILE_AS_BYTES=2147483648 \
-e PINEFORGE_COMPILE_DIAGNOSTIC_MAX_BYTES=65536 \
-v "$(pwd)/strategy.pine:/in/strategy.pine:ro" \
-v "$(pwd)/ohlcv.csv:/in/ohlcv.csv:ro" \
pineforge > report.jsonThe wall deadline is absolute and includes waiting for descendants after
the driver exits. Progress and binder launch do not renew it. On expiry,
the guard stops and reaps descendants, including detached orphans adopted
through Linux child-subreaper support, and returns exit 4 with
run_deadline_exceeded and args: {"stage":"compile"}. Cleanup has a
1,000 ms grace; the hosting layer must retain ownership if a task cannot
be reaped within it.
The diagnostic collector reads at most the byte cap plus one probe byte.
It forwards at most the cap to stderr, without decoding bytes or adding a
truncation marker. Observing the probe stops compilation and returns exit 4
with diagnostic_budget_exceeded and
args: {"cap_bytes":B,"used_bytes":B,"requested_bytes":1}. Exact fit followed
by EOF is allowed. Both compiler streams share one allowance, which is also
shared with the binder. Fixed entrypoint progress and failure messages are
separate from this compiler-output cap.
An ordinary compiler or binding failure still exits 3. Address-space
allocation failures also retain that status: the guard does not infer a
memory-limit cause from diagnostics, allocation errors or child signals.
The compiler flags, including -ffp-contract=off and whole-archive linking,
are unchanged.
A compile that stops because of a limit outside these three settings, such as a file-size, open-file or process-count limit that the host applies, is not typed either. The entrypoint decodes only the wait status of the compiler driver it started, and any nonzero status that is not one of the typed stops above is an ordinary compile failure, exit 3. Measured with the compiler of the 1.10.0 image (g++ 14.2.0, Debian 13, arm64), the driver ended with exit 4 for a file-size limit (its own internal-error status: it reports the death of its helper itself) and with exit 1 for an open-file, process-count or address-space limit, the same status as a syntax error. None of them reached the entrypoint as a signal. The status therefore names no cause, and the entrypoint does not read the compiler's text for one. Telling such a stop apart needs evidence from whoever enforces the limit, not the compiler's status or output.
These controls are engine guards. They do not establish namespaces, cgroups, RSS or aggregate memory quotas, cumulative tree CPU limits, temporary-storage quotas, task quotas, or signed installation evidence. The host supplies those controls and owns the outer process lifetime and report channels. These settings apply to compilation and binding; transpilation and backtesting have their own controls. A requested guard requires Linux procfs and child-subreaper support; an unavailable mechanism is a usage refusal.
docker/pineforge_report_supervise.py is an opt-in, separate-process report
channel (--report-channel supervised/v1 --terminal-status-fd M ..., or the
environment names listed in its module docstring). It is OFF by default: with
no channel option or variable set, run_json.py behaves exactly as before. It
is not yet host-attested: an ordinary invocation refuses with
run_isolation_unavailable{reason:"policy"} before starting a producer, and
only an explicit test attestation can run it. For now the full report needs an
external sink descriptor; the internal snapshot handoff is pending. It does not
forward the host run-quota marker, so it refuses to start when any variable
whose name starts with PF_RUN_QUOTA_ is set.
Limits. Report, diagnostic, wall and stall limits are unlimited when unset or
0; a positive value is exact and a positive host grant can only constrain it
(a request above the grant is refused, never clamped). Two cleanup graces have a
default: --post-exit-drain-ms (how long diagnostic and receipt pipes may stay
open after their owners exit) and --stop-grace-ms (the wait between the
terminate request and the forced kill), both 1000 ms by default and unlimited
at 0. A value of 0 for --stop-grace-ms disables the forced kill, so the
supervisor then waits for the producer's process group to exit. The same
unsigned-decimal grammar applies to every limit.
Terminal record. Each handled outcome produces one pf-run-terminal/v1 record
with the members schema, version, contract, attempt_id, invocation,
complete, exit_class, failure, exit_status, diagnostics, producer,
limits, report_charged_bytes, channels, sink_verification, usage and
cleanup. diagnostics is a list of text strings, each cut at 1,024 bytes;
refusals and expired graces name their parameter there. Each entry of limits
(including post_exit_drain_ms and stop_grace_ms) reports requested,
granted and effective, with effective null for an unlimited dimension.
complete stays false while the native execution domain is not measured empty,
so a test-attested run records complete: false.
Sink validation. Unjailed runs validate the native sink in file mode with an
optional cap. Under the jailed profile the validator runs in provisional stdout
mode with a positive bound: --planned-sink-bytes
(PINEFORGE_REPORT_PLANNED_SINK_BYTES) or the positive report allowance, and the
run is refused before launch when neither exists. The supervisor holds up to
that bound in memory until the validator exits 0 with a complete receipt, so an
operator who sets a very large report allowance on a jailed profile lets the
supervisor grow to that size.
Set PINEFORGE_TRANSPILE_ONLY=1 to emit the generated C++ on stdout and exit
— no OHLCV needed:
docker run --rm -e PINEFORGE_TRANSPILE_ONLY=1 \
-v $(pwd)/strategy.pine:/in/strategy.pine:ro \
pineforge > strategy.cppOptional env vars apply parameter overrides before the backtest runs:
| Env var | Maps to | Example |
|---|---|---|
PINEFORGE_INPUTS |
strategy_set_input(k, v) |
'{"Fast Length": "8", "Slow Length": "21"}' |
PINEFORGE_OVERRIDES |
strategy_set_override(k, v) |
'{"default_qty_value": "5", "commission_value": "0.04"}' |
PINEFORGE_INPUTS / PINEFORGE_OVERRIDES are JSON objects of
{string: string} (quote numeric values too; the runtime parses on its side).
A JSON number is still accepted and passed as Python spells it (5 as "5",
1e3 as "1000.0"); a boolean, null, an array or an object is refused before
the run (run_request_invalid), so spell a boolean "true" / "false".
Empty / unset → defaults from the original strategy(...) and input.*() calls.
Each key maps to a single argument of the Pine strategy(...) call
and may be set independently. The runtime applies only the keys you
provide; everything else stays at the strategy's compiled-in default.
| Key | Type | Allowed values / range | Notes |
|---|---|---|---|
initial_capital |
number | > 0 |
Starting equity in account currency. |
pyramiding |
integer | >= 0 |
Max same-direction entries before further entries are blocked. |
slippage |
integer | >= 0 |
Per-fill slippage in ticks (mintick units). |
commission_value |
number | >= 0 |
Commission magnitude. Units depend on commission_type. |
commission_type |
enum | percent, cash_per_order, cash_per_contract |
Selects how commission_value is interpreted. |
default_qty_value |
number | >= 0 (the checked settings API refuses a negative value) |
Default order size, interpreted per default_qty_type. |
default_qty_type |
enum | fixed, percent_of_equity, cash |
Default sizing mode for strategy.entry/order calls. |
process_orders_on_close |
boolean | true / false (or 1 / 0) |
When true, market orders fill at bar close instead of next open. |
close_entries_rule |
enum | ANY, FIFO |
How strategy.close(id) selects entries (FIFO is the default). |
Example combining several keys:
docker run --rm \
-v $(pwd)/strategy.cpp:/in/strategy.cpp:ro -e PINEFORGE_ALLOW_UNATTESTED_CPP=1 \
-v $(pwd)/ohlcv.csv:/in/ohlcv.csv:ro \
-e 'PINEFORGE_OVERRIDES={
"initial_capital":"100000",
"default_qty_type":"percent_of_equity",
"default_qty_value":"10",
"commission_type":"percent",
"commission_value":"0.04",
"slippage":"2",
"pyramiding":"0",
"process_orders_on_close":"true",
"close_entries_rule":"ANY"
}' \
pineforge | jq '.applied_overrides'Runtime args (passed to run_backtest_full rather than the strategy
header) are configured via separate env vars:
| Env var | Default | Notes |
|---|---|---|
PINEFORGE_INPUT_TF |
auto-detect | Chart bar timeframe: '1', '5', '15', '60', 'D', 'W', ... |
PINEFORGE_SCRIPT_TF |
= input_tf | Strategy timeframe; must be ≥ input_tf (engine throws otherwise) |
PINEFORGE_BAR_MAGNIFIER |
false |
true enables intra-bar OHLC path sampling for stop/limit fills |
PINEFORGE_MAGNIFIER_SAMPLES |
4 |
Sub-bar sample count when magnifier is on (≥2) |
PINEFORGE_MAGNIFIER_DIST |
endpoints |
uniform, cosine, triangle, endpoints, front_loaded, back_loaded |
PINEFORGE_REPORT_SINK_FD |
unset | Raw --report-sink-fd value; set-empty is forwarded |
PINEFORGE_RUN_ID |
"" |
Raw --run-id for the sink header; set-empty is forwarded as empty (a caller that needs an id refuses empty itself); unset keeps the empty default |
The caller must open an inheritable descriptor numbered from 3 through INT_MAX, excluding 5 and any phase descriptor; run_json.py closes its copy after capture.
An invalid value is refused like --report-sink-fd (entrypoint exit 4), and the failure line or small success document stays on stdout.
PINEFORGE_SYMINFO (the harness's --syminfo) names a JSON file holding the
instrument's metadata, either a flat object or {"syminfo": {...}}; an object
with extra keys (for example a full instrument record) can be passed as is, keys
the harness does not use are ignored. It applies mincontract first, then
series_origin_ms when declared, then
mintick, pointvalue, timezone and session, each through the strategy
library's strategy_set_syminfo_* setters.
-
mincontractis the instrument's lot size (TradingView'ssyminfo.mincontract). It is set as the engine'sqty_stepmetadata, so order quantities are floored to that grid, and asmincontractmetadata, so a script'ssyminfo.mincontractreads return the same value. -
Absent or
null: no lot grid, and the run is the same as without the key. -
Any other value that is not a positive finite JSON number (
0,-1,"0.001",true,NaN,Infinity, a list or an object) fails the run before it starts: one line{"engine":"pineforge","error":"syminfo.mincontract must be a positive finite number, got <value>"}on stdout, where<value>is the parsed value re-encoded as JSON (so1e-400shows as0.0) cut to 80 characters (got true,got "0.001"), harness exit status 1, entrypoint exit 4 (codelot_grid_rejected). A strategy library withoutstrategy_set_syminfo_metadatafails the same way whenmincontractis set (codestrategy_library_incompatible): the harness never runs without the grid it was given. A file that cannot be read, is not valid JSON or holds no JSON object, and amintick/pointvalue/timezone/sessionthe setters cannot take, fail the same way before any setter runs, with codesyminfo_unreadableandargs.reasonio,not_json,not_objectorvalue_type(see "The failure line" below). -
An applied grid is recorded as
applied_runtime.syminfo({"qty_step": <v>, "mincontract": <v>}) and so infingerprint.provenance.runtime: its fingerprint digest differs from the gridless run's. Without a grid or a declared series origin the key is absent and the report, fingerprint included, is what it was before these keys were supported, apart fromelapsed_seconds(and, with--bench, the timing samples).mintick,pointvalue,timezoneandsessionare not recorded. -
series_origin_msdeclares the open time in epoch milliseconds of the first bar of the series the reference ran on, which may differ from the feed start or the report window. It must be a positive JSON integer;0, negative values, floats, strings, booleans,NaNandInfinityfail withsyminfo_unreadable,args.reasonvalue_type, and a message namingseries_origin_ms. Absent ornullapplies nothing: existing behaviour, report and fingerprint are unchanged. A library withoutstrategy_set_syminfo_metadatafails withstrategy_library_incompatible, as formincontract. -
Declaring this origin enables both chart EMA warmup (
ta.ema, and the embedded EMAs inta.macd,ta.kcandta.tsi:nafor the firstlength - 1inputs, then an SMA seed) andsecurity_range_start_na_warmup.request.securitystarts at the first whole bucket opening at or after the origin; plain security expressions staynauntil the first completed higher-timeframe bar. The low-level raw keys remain available; a raw key set afterwards wins for its context. -
Applied metadata echoes
{"series_origin_ms": <v>}inapplied_runtime.syminfoandfingerprint.provenance.runtime. Once the first fed chart bar is known, it also carriesseries_origin_first_bar_msandseries_origin_offset_ms(first bar minus origin). Origins equal to or earlier than the first fed bar are accepted, including fractional-bar offsets. A later origin is also accepted and adds the typed warning field"series_origin_warning": "origin_after_first_bar". A wrong declared origin gives wrong values; the declaration does not recover missing history. This option has no defined meaning in live runs and must not be set there.
docker/run_json.py is vendored: pineforge-release copies it from the engine tag
at every release, so the metadata handling (mincontract, series_origin_ms)
lives in this file.
A script that calls request.security on another symbol reads that symbol's
own bars, never the chart's. Without them, or when the index below lacks the
requested symbol string or timeframe, the run stops where the request's value
is read (request.security(...) at line N: no data is pinned for this request, and its value was read, exit 4). PINEFORGE_SYMBOL_FEEDS (the
harness's --symbol-feeds) names a JSON index of those bars, installed through
the library's strategy_set_symbol_facts and strategy_set_symbol_feed (engine
1.0.0 and later):
{"symbols": {
"BINANCE:ETHUSDT": {
"syminfo": {"tickerid": "BINANCE:ETHUSDT", "type": "crypto", "currency": "USDT",
"mintick": 0.01, "session": "24x7", "timezone": "UTC"},
"feeds": {"240": "ethusdt-240.csv", "1D": "ethusdt-1D.csv"}}}}docker run --rm \
-v $(pwd)/strategy.pine:/in/strategy.pine:ro \
-v $(pwd)/btcusdt-240.csv:/in/ohlcv.csv:ro \
-v $(pwd)/symbols:/in/symbols:ro \
-e PINEFORGE_SYMBOL_FEEDS=/in/symbols/symbols.json \
pineforge > report.json- A symbol key is the exact string the script passes at run time, exchange
prefix and suffix included:
BINANCE:ETHUSDT,ETHUSDTandBINANCE:ETHUSDT.Pare three symbols. Forinput.symbolit is the input's value (its default, or thePINEFORGE_INPUTSoverride). A string naming the chart's own market is another symbol too: the harness does not set the chart'ssyminfo.tickerid. - One feed per timeframe the script requests, keyed in the engine's spelling:
whole minutes as a bare integer (
"240", never"4h"), else<n>D|W|M|S; a bareD/W/M/Sis folded to1D/1W/1M/1S. A request attimeframe.period(or"") reads the feed at the chart's timeframe. - A feed is a CSV like
ohlcv.csv(timestamp,open,high,low,close,volume; an empty volume is a symbol that publishes none), paths relative to the index. Each bar's close is its open plus the timeframe (calendar months forM), right for a 24x7 symbol; give a session-bound symbol atime_closecolumn (unix ms; an empty cell falls back to open plus timeframe). Bars before the chart's first bar are delivered as history on it; bars after its last are never read. A header-only feed installs the symbol without bars: its requests read na on every bar (a symbol with no bars in the window). syminfois the symbol's catalog object, flat or{"syminfo": {...}}. Itstype,timezone,session,currencyandmintickare set as the symbol's facts, whichsyminfo.*reads inside the request;tickeridis set as itscanonicalfact, which nosyminfo.*reads; other keys are ignored. Inside the requestsyminfo.tickeridis always the key andsyminfo.tickerthe key after its last:. Withoutsyminfo,syminfo.mintickreads NaN and those four strings read empty.- Merge rule (TradingView's): with
lookaheadoff a chart bar reads the latest requested bar whose close is at or before the chart bar's close; with it on, the latest that opened at or before the chart bar's open. A missing requested bar carries the last value forward (gapsoff) or reads na (gapson). - Symbol and total feed counts default to no limit.
--max-symbol-feeds N(PINEFORGE_MAX_SYMBOL_FEEDS) applies N to both counts; unset or0means unlimited.--max-inventory-bytes N(PINEFORGE_MAX_INVENTORY_BYTES) applies the same rule to inventory bytes in both the binder and runner. Empty, negative and non-integer values are usage errors.--tv-compat(PINEFORGE_TV_COMPAT=1) opts into defaults of 256 symbols/feeds and 8 MiB inventory from the runner's one compatibility set; explicit limits, including0, win. The chart must be its own input (PINEFORGE_SCRIPT_TFunset or equal to the input timeframe), else the run fails withrequest.security of another symbol needs the chart's own bars as input; input '<i>' aggregated to chart '<s>' is not supported. The run is historical only. Each feed is a full pass of its request's expression over its bars.request.security_lower_tfon another symbol reads no feed. - A feed must be at the timeframe it serves. Nothing aggregates another
symbol's bars: the engine looks a feed up by the exact symbol string and
timeframe, and
strategy_set_symbol_feedinstalls the bars as given. A240feed does not serve aDrequest, and a1feed serves only a request at1, so give each requested timeframe its own bars. - An index or feed the harness cannot install (for example: not JSON, a bad
timeframe spelling, two feeds at one timeframe, a CSV without
close, timestamps that do not strictly increase, a close after the next bar's open, a non-positivemintick, exceeding an explicit or compatibility feed limit, a library without the setters, a feed the engine refuses) fails the run before it starts: one line{"engine":"pineforge","error":"--symbol-feeds: ...","code":"symbol_feeds_refused",...}on stdout, harness exit 1, entrypoint exit 4 (see "The failure line" below). - What was installed is recorded as
applied_runtime.symbol_feeds(each symbol's facts, and per feed its bar count, first and last open and a hash of its values), so the fingerprint digest differs from a run without it. Unset, or an index naming no symbol: the key is absent and the report is what it was before this variable existed, apart fromelapsed_seconds.
Engine 1.6.1 (released without a hub image) declares selected-window contract
revision 1.3-parity through its run_json.py --so <strategy.so> --capabilities-json probe: it
emits the four typed codes (strategy_value_invalid,
engine_command_value_invalid, report_result_invalid and
report_post_execution_failed), with value parity with the 1.4.0 reference on
tested cases, and no completeness claim (the published 1.6.0 image declares
1.3).
From 1.6.2, the engine/codegen image's run_json.py --so <strategy.so> --capabilities-json probe declares revision 1.3: with the
section-4 producers and manifest present and value ownership complete
(the completeness proof), it emits the four typed codes (strategy_value_invalid,
engine_command_value_invalid, report_result_invalid and
report_post_execution_failed). A build declaring 1.3 pairs only with a
complete receipt; 1.3-parity remains the parity build's literal, and a
tuple holding both is invalid (the published 1.5.0 image declared
1.3-parity, earlier images carried no revision member; the published 1.6.0
image declared 1.3 without a completeness proof; and engine 1.6.1, released
without a hub image, declares 1.3-parity).
| Capability member | Supported runtime | Unsupported runtime |
|---|---|---|
selected_window_contract_revisions |
["1.3"] |
[] |
This member belongs to the probe's capabilities object. The selected-window
wire version remains 1, the phase version 1 and the fingerprint/schema version 2.
This section describes support released in 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
the coded form and the earlier text-only form. The container exits 4
(the harness returns 1; the entrypoint maps any harness failure to 4).
Every failure of docker/run_json.py prints exactly one line on stdout and no
report, whatever failed: the run, the request, a file, the strategy library or
the harness itself. The one exception is a report stdout cannot take whole (a
closed pipe, a full disk): exit status 1, the reason on stderr, and no line
after the part written.
{"engine":"pineforge","error":"<English text>","code":"<code>","args":{...}}engineanderrorlead, so the line starts with{"engine":"pineforge","error":"as before;erroris the English text, unchanged wherever a text existed before codes.codeis a stable code from the closed vocabularydocker/run_failure_codes.json(schemapineforge-run-failure-catalog/v1: each code's class, retryable flag, arguments and English templates).argsholds its typed arguments: strings, integers, numbers. A code is never read from a text: a script'sruntime.error("<any text>")is alwaysstrategy_runtime_error.- The run's own failure takes its code from
strategy_get_last_error_codeand its arguments fromstrategy_get_last_error_args(re-parsed; anything but a JSON object of scalars becomes{}). A strategy library without the code getter (built before it) prints nocodeand noargs: a run error with a text is the earlier{"engine":"pineforge","error":"<text>"}line, byte for byte, and a run status of 1 without a text is that line with the textthe run did not complete and the engine reported no error. - A run fails when the engine reports a text, a code, or a run status of 1
(
strategy_last_run_status).runtime.error()with an empty message therefore fails ("error":"", codestrategy_runtime_error) instead of reporting the bars it reached as a result. With the code getter, a status of 1 with neither a text nor a code isengine_unclassified_error, textthe run did not complete and the engine reported no error. - Caps keep the line inside the first 64 KiB a reader takes:
erroris cut at 16 KiB and each string argument at 1 KiB of UTF-8, on a character boundary; a line still over 60 KiB (text JSON escapes heavily, such as control characters) has its text cut further, after its arguments are dropped if they alone are over (the code stays). A non-UTF-8 byte Python kept from the command line or a path (a lone surrogate) is printed as U+FFFD, never as an escape a strict JSON parser rejects. - Exit status 1, or 2 for a command line argparse refuses (its usage still goes to stderr); the entrypoint maps both to 4.
The harness's own failures and their codes:
The executed 1.3.0/candidate contrasts
show the migration boundary: the candidate refuses three input settings the
old pair let run: a value for a title two inputs share, a color input given
as text, and an integer input outside its declared options. Non-numeric and
negative initial capital already failed
on the old pair; they now have coded setting refusals. All five candidate
setting failures are setting_rejected, catalog class input; the explicit
runtime.error self-stop is strategy_runtime_error, class strategy.
Both have entrypoint exit 4, so classify by the code/catalog rather than exit
alone. The original stdout/stderr and exact source/build identities were
retained; no old failure was rewritten to add new keys.
| Failure | code |
args |
|---|---|---|
--inputs / --overrides not a JSON object or holding a boolean, null, array or object value, --magnifier-dist unknown, --input-tf / --script-tf / --chart-tz not UTF-8, a command line argparse refuses (exit 2) |
run_request_invalid |
option: the flag (inputs, magnifier_samples, ...) or arguments |
--ohlcv unreadable, a missing column, a value that is not a number, a first or last timestamp outside the calendar (years 1 to 9999), no bars |
chart_bars_unreadable |
reason: io, columns, value, empty |
--syminfo unreadable or unusable (above) |
syminfo_unreadable |
reason: io, not_json, not_object, value_type |
syminfo.mincontract not a positive finite number |
lot_grid_rejected |
|
--symbol-feeds index or feed refused (one reason per refusal) |
symbol_feeds_refused |
reason: timeframe_invalid, feed_not_increasing, ... |
| a feed the engine refuses | the engine's code when it reports one, else symbol_feeds_refused |
the engine's, else reason: engine_refused |
| the strategy library cannot be loaded, has another ABI, lacks an export or a setter, exports part of the checked settings API or another version of it | strategy_library_incompatible |
reason: load_failed, abi_missing, abi_mismatch, symbol_missing, setter_missing, settings_api_mismatch; missing; abi |
strategy_create returns no strategy (checked before any setter) |
strategy_create_failed |
|
| a setting the strategy refuses (below) | setting_rejected |
entrypoint, reason, and input (the input's title) when the key is a title the strategy declares |
| a declared input the compiled strategy cannot honour | setting_unsupported |
|
| anything else (a harness bug; its traceback on stderr) | harness_internal_error |
Settings: a strategy library exporting the checked settings API
(strategy_settings_api_version() == 1, see docs/checked-settings.md) is created
with strategy_create_checked and configured with strategy_set_input_checked /
strategy_set_override_checked. A setting it refuses (an unknown input title or
override key, an enum or option outside its list, a value that does not parse or
is out of range) fails the run before it starts, with the text
strategy_set_input: <message> or strategy_set_override: <message> and code
setting_rejected, args.reason naming the message (unknown_key,
invalid_input_option, expected_integer, ...). A library exporting only part
of that API (strategy_settings_api_version, strategy_create_checked,
strategy_set_input_checked, strategy_set_override_checked) or another version
of it is refused before any setter (strategy_library_incompatible, reason
settings_api_mismatch). A library with none of it keeps the legacy setters,
which accepted some settings, ignored others and already failed some with
text-only errors. The six measured requests illustrate this change; they
are not an exhaustive list of refusals.
The checked setters enforce the declared settings before the run, so send values the strategy declares:
- a number outside the input's declared
minval/maxval, or an override outside its range (a negativeinitial_capital):setting_rejected,reasonvalue_below_minimumorvalue_above_maximum. Some legacy requests ran with out-of-range values, but the measured negative-capital request already failed later with a text-only run-spec error; - an input this compiled strategy cannot honour (a default the transpiler could
not resolve):
setting_unsupported(the legacy getter took the value); - a title two inputs share:
setting_rejected,reasonambiguous_key(the legacy setter set both). Give the inputs unique titles in the Pine source; - a color input given as text:
setting_rejectedwith an integer-parse reason. The measured"color.blue"request reportsexpected_integer; source inspection givesinvalid_integer_or_trailing_bytesfor"#FF0000","0xFF0000"and"black". These additional spellings were not run. Send the packed0xAARRGGBBvalue as a decimal integer instead, for example opaque red as"4294901760"(see checked settings); - an input value outside its declared
options:setting_rejected, reasoninvalid_input_optionfor numeric/string inputs, orinvalid_enum_optionfor enum inputs. Choose one of the declared options.
Known diagnostic limit: a setting_rejected refusal of a strategy
override carries args.entrypoint: "strategy_set_override" and
args.reason, but no override key. Its text also omits the key: the
non-numeric capital request reads
strategy_set_override: expected a finite decimal number. args.input
names an input only when the strategy declares that title, so an unknown
input title is not named. setting_unsupported carries args: {}. The
harness stops at the first refused setting, processing all inputs before
overrides. A host sending several overrides cannot derive the refused key
from the failure line alone.
Both inputs and overrides accept a JSON object whose values are strings or numbers; express booleans as lowercase "true" or "false" strings, because native JSON booleans, null, arrays and objects are rejected.
A value the checked setter cannot parse reads its own message, such as
strategy_set_override: expected a finite decimal number, where a legacy setter
that threw left strategy_set_override: stod. Whatever the library, a boolean,
null, array or object value in --inputs / --overrides is refused before any
setter (run_request_invalid, above) instead of reaching it as Python spells it
(True, None).
Mount a strategy.pine and the bundled pineforge-codegen
(source-available,
pip install pineforge-codegen) transpiles it in-container. Advanced users may
instead mount a pre-transpiled strategy.cpp (the C++ must export the PineForge
C ABI in <pineforge/pineforge.h> — i.e. compile unchanged against
libpineforge.a into a strategy .so exporting the per-strategy C ABI). Inputs are
read-only mounts; the image performs no network I/O at run time.
{
"engine": "pineforge",
"input": {
"ohlcv": "/in/ohlcv.csv",
"bars": 672,
"first_ts": 1777486500000,
"last_ts": 1778090400000,
"first_time": "2026-04-29 18:15 UTC",
"last_time": "2026-05-06 18:00 UTC"
},
"applied_inputs": {},
"applied_overrides": {},
"applied_runtime": {
"input_tf": "",
"script_tf": "",
"input_tf_seconds": 900,
"script_tf_seconds": 900,
"script_tf_ratio": 1,
"needs_aggregation": false,
"bar_magnifier": false,
"magnifier_samples": 4,
"magnifier_dist": "endpoints"
},
"elapsed_seconds": 0.0042,
"summary": {
"total_trades": 50,
"wins": 17,
"losses": 33,
"win_rate_pct": 34.0,
"net_pnl": 569.97,
"avg_trade": 11.3994,
"best_trade": 1149.00,
"worst_trade": -1111.97,
"max_drawdown": -4045.15,
"bars_processed": 672
},
"trades": [
{
"n": 1,
"side": "long",
"entry_time": 1777488300000,
"exit_time": 1777505400000,
"entry_price": 75242.42,
"exit_price": 75771.20,
"qty": 1,
"pnl": 528.78,
"pnl_pct": 0.7028,
"max_runup": 846.48,
"max_drawdown": 17.42
}
]
}Every JSON report carries a fingerprint recording exactly what produced it —
reversible, no key required:
This is a schematic of the 1.4.0 provenance shape, not a measured
before/after result. Certified values are typed; unresolved rows use the
resolution records described below. The top-level applied_inputs and
applied_overrides remain wire-string echoes.
"fingerprint": {
"token": "<base64 of the canonical provenance JSON>",
"digest": "sha256:<hex>",
"provenance": {
"engine": { "version_string": "...", "major": 1, "minor": 0, "patch": 0, "commit_sha": "..." },
"feed": { "canonicalization": "pf-ohlcv-barc-le-v1", "source_values_sha256": "..." },
"codegen": { "version": "...", "generated_cpp_sha256": "...", "transpiled_from_pine": true },
"strategy": { "initial_capital": 1000000.0, "pyramiding": 1, "commission_type": "percent", "...": "all strategy() params, effective" },
"inputs": { "Fast Length": { "type": "int", "default": 12, "value": 8 }, "...": "declared inputs or explicit unresolved rows" },
"applied": { "inputs": { "Fast Length": 8 }, "overrides": {} },
"runtime": { "input_tf": "", "bar_magnifier": false, "...": "..." }
}
}strategy and inputs describe the effective parameter set where it can be
certified, with explicit unresolved records otherwise. A certified value
is the native effective value after setting overrides, or the default when
none was applied. digest identifies the full provenance document under the
given harness and build identities; it is not stable across release pairs
whose types, values, resolution records or identities differ.
feed.source_values_sha256 identifies the primary OHLCV source by hashing a
versioned domain prefix followed by every parsed source row, in original order,
as little-endian <5dq> records (open, high, low, close, volume,
timestamp). The source identity is computed before validation-only start/end
slicing, so slice bounds remain a separate runtime concern. Paths, CSV newline
style, header order, and equivalent numeric spellings do not affect the hash.
token is base64 of the canonical provenance JSON bytes; digest is
sha256: plus the hex SHA-256 of those same bytes. Encoding is a direct
RFC 8785 / JCS-style canonical writer over the accepted value tree (not plain
JSON.stringify, which does not sort keys or implement full JCS). Over values
parsed under a JavaScript / IEEE-754 binary64 number model, the token bytes
match a JCS direct encoder for the types we accept:
| Rule | Behavior |
|---|---|
| Objects | Keys sorted by UTF-16 code unit order (JCS / ECMAScript), not Unicode code point; no whitespace. Each key is normalized once via base str.__str__ to a plain built-in str; duplicate normalized names fail closed (ValueError) so distinct str-subclass keys that coexist only via custom __eq__/__hash__ cannot emit the same JSON name twice (JS would silently drop one). Non-str keys raise TypeError. Own keys such as "10", "2", and "__proto__" are retained in lexical order (a naive object-rebuild + JSON.stringify reorders array-index keys and can drop __proto__). |
| Arrays | Element order preserved; no whitespace |
| Floats | ECMAScript NumberToString form for every finite IEEE-754 value (no trailing .0 on integral floats, ±0 → 0, scientific notation at ES thresholds: exponent < -6 or ≥ 21). Float subclasses are normalized via base float.__float__ first so hostile __float__/__abs__/comparison/__repr__ hooks cannot alter math or emission. |
| Integers | Decimal digits only for exact integers inside the product safe-integer domain [-(2⁵³-1), 2⁵³-1] (= Number.MIN/MAX_SAFE_INTEGER). This is a strict accepted-type policy guaranteeing unique lossless integer identity across generic ECMAScript consumers — not a claim that every larger individual value must round as binary64. Exact integers outside the domain (e.g. int 2⁵³) are rejected (ValueError); isolated binary64 values such as float 2⁵³ remain legal on the float path. Int subclasses (e.g. IntEnum) are normalized via base int.__index__ to a plain built-in int before domain checks and digit emission, so hostile/Enum.NAME __index__/__int__/comparison/__str__/__repr__/__format__ hooks cannot change the value or bypass rejection. Booleans are not integers. |
| Non-finite numbers | Rejected (ValueError) — not encoded as null |
| Strings | JSON control escapes + "/\; raw valid Unicode (no ensure_ascii \uXXXX for non-ASCII); U+2028/U+2029 and non-BMP stay as UTF-8 code points. Unpaired UTF-16 surrogates: rejected. Str subclasses are normalized via base str.__str__ first so hostile __str__/__iter__/encode hooks cannot change emission, key order, or bypass surrogate rejection. |
| Bools / null | true / false / null |
The {token,digest,provenance} object shape and canonical encoder are
unchanged in 1.4.0. Typed provenance normalization changes the values
fed to that encoder, including explicit uncertainty; those changes can
change the token and digest.
Verification (authoritative path): base64-decode token to the canonical
UTF-8 JSON bytes and hash those bytes. digest is exactly sha256: plus the
hex SHA-256 of that same byte string — no re-serialization is required.
Decoded canonical token bytes are authoritative; the inlined
fingerprint.provenance is a convenience view of the same data.
Re-canonicalization is not free-form json.loads / plain
JSON.stringify: if a verifier rebuilds canonical JSON from a decoded
value tree, it must use an RFC 8785 / JCS direct encoder whose JSON
number model is IEEE-754 binary64 (ECMAScript Number). Default Python
json.loads is not sufficient for that path: canonical tokens such as
float 1e20 serialize as 100000000000000000000, and plain json.loads
yields a Python int outside the product integer domain, which this helper
correctly rejects rather than rounding. Project tests that exercise
verifier reconstruction use json.loads(text, parse_int=float) so
integral-looking tokens re-enter as binary64 floats before re-encoding.
Out-of-domain provenance yields no fingerprint under existing callers:
build_fingerprint raises, docker/run_json.py sets fingerprint to
null, and scripts/run_strategy.py skips writing a fingerprint file.
Residual intentional fail-closed cases: non-finite numbers, exact
integers outside the product safe-integer domain, unpaired surrogates
(invalid I-JSON / UTF-8), and object keys that collide after str.__str__
normalization (duplicate normalized JSON names).
Legacy declaration normalization runs before hashing and does not evaluate C++
expressions or perform C++ name lookup. The source scanner certifies only the
producer subset emitted from Pine during this run. The entrypoint records that
internal fact as codegen.transpiled_from_pine; generated-looking C++, comments
and caller assertions do not establish origin. A supplied C++ source requires
a unique supported native receipt row that agrees on type, default and effective
value without coercion before an input can be certified. Strategy settings also
require a unique supported native row agreeing on type and effective value.
Without that confirmation the row is foreign_unverified_source. Existing
scanner refusals still apply, and native duplicate titles retain their reason
and distinct-input count. The scanner is not a general C++ certification parser.
For supplied C++, strategy_get_effective_settings belongs to the strategy
translation unit, so its native receipt is self-attested rather than independent
verification.
The translation unit is read lexically: comments, ordinary and prefixed string
and character literals and raw string literals (R"delim(...)delim", with
u8R/uR/UR/LR) are delimited exactly. Code context excludes preprocessor
lines and every conditional group except the first branch of
#ifdef PF_SETTINGS_API_VERSION after the checked_settings.hpp include
(the macro is defined in pineforge.h, so this include order is a proxy for
the producer's layout) and
the #ifdef branch and the #else branch of
#ifdef PINEFORGE_HAS_SYMBOL_SECURITY_EVAL_V1 (its request of another symbol).
The recognized directives are
#include <pineforge/...hpp> or a bare standard header, a #define line
identical to one the producer emits (its replacement list included),
#error and conditionals; the producer's leading-comma macros
PF_PINE_TIME_SESSION_DAY_ARGS and PF_VWAP_SESSION_ANCHOR_ARGS are invoked
only as NAME(syminfo_.timezone, syminfo_.session);
any other directive or invocation of those macros, mismatched brackets, a quote
continuing a number (the producer emits no digit separators), a line splice anywhere, a backslash outside a literal
(a universal-character name), a digraph, or a relied-on name used other than
the producer does (std and checked_settings only qualifying, pineforge
only qualifying or in using namespace pineforge;) leaves no row of that unit
certified.
| Relied-on token | Recognized form (anything else refuses) |
|---|---|
| Getter call | One of get_input_int, _int64, _double, _bool, _string, _source, in code context, after an expression token or a producer C-style cast (int), (double), (int64_t) (the cast is not part of the input), as name("title", default) with an unprefixed literal title and exactly one default argument, and not in a spelling that can declare a local of that name (after a |
statement-level comma or one inside for/if/switch/while parentheses, |
|
or wrapped only in parentheses after > or a statement-initial name). Every other get_input_* occurrence (other casts included) refuses the unit. A getter call inside a recognized macro invocation (the producer's macros and listed standard-library spellings such as assert; a replacement list may drop or repeat the argument) is never certified: its title is macro_argument. This list does not cover arbitrary toolchain macros. |
|
| Title | The producer's narrow-literal escapes, decoded to the native first-NUL identity. When the getters of one title disagree on getter type or default, or a getter sits in a security-guard branch that the other branch does not repeat (a guard without its own #else, whose implicit empty branch runs no getter, counts as not repeating), the native receipt arbitrates: a supported row whose type and default (each parsed by its own type) match exactly one distinct pair certifies that pair; otherwise ambiguous_binding (unsupported_default when no getter's default is readable at all). Never first-wins. |
| Numeric/bool/string default | A decimal literal (optional sign), true/false, or a plain or std::string("...") literal. |
| Symbolic numeric default | Exactly one file-scope [static] const|constexpr int NAME = <decimal int32>;, and every other occurrence in this closed list of pure reads: a whole getter default; the metadata default {"title", "enum", ::pineforge::checked_settings::number(NAME),; auto _pna_l = (NAME); or auto _pna_r = (NAME); (the na-aware relational temporaries); (__switch_val_<n> == NAME) { (the switch lowering). Any other occurrence, in any position, refuses. The value also needs a supported native receipt row that agrees. |
| Source default | _src_<selector>_ for a selector in the native vocabulary open high low close volume hl2 hlc3 ohlc4 hlcc4, recorded as the selector; that identifier may otherwise appear only before .. An override is certified only when it is such a selector; anything else (the native getter falls back) is refused, never echoed. |
| Native receipt row | Compared independently, each side parsed by its own declared type: the receipt type must be the getter's (int/enum, int, float, bool, string, source), and a supported row's default and effective value must equal the certified ones exactly. |
| strategy() default | One parameterless GeneratedStrategy() constructor whose top-level statements are, outside any conditional except the adapter hooks, one pineforge::source::PineStrategyConfig cfg{}; declaration first, cfg.<field> = <rhs>; assignments to the producer's 13 members (margin_long, |
margin_short and src_series_active are recognized but not provenance), and |
|
one final configure_pine_strategy(cfg); (besides the producer's adapter |
|
hook calls, nothing else may stand in the constructor). Pre-R4-C <field>_ = <rhs>; member writes are refused: nothing establishes which member they reach. Numbers are decimal literals (integers within the native int width), booleans true/false, and enums static_cast<int>(QtyType::NAME), QtyType::NAME (likewise CommissionType) or the index 0-2, where every occurrence of QtyType/CommissionType in the unit is followed by :: and a canonical member. |
An input whose default cannot be proved stays in the fingerprint with its
declared type, default: null, value: null, and this explicit refusal:
"resolution": {
"status": "unresolved",
"reason": "ambiguous_binding",
"raw_default": "Side__long_"
}Here null means unresolved, not a claimed native value. The raw token is the
actual declared default expression. Its applied input value remains the original
wire string, even when an override was provided; the applied key set and top-level
wire echoes are unchanged. Unknown inputs also remain strings. A strategy()
default refused the same way is null in strategy, with its record under
strategy_resolution, unless an applied override sets the field and satisfies
the origin/receipt rule (the override is then the effective value). When a literal or comment context cannot be
delimited at all, every applied input key is an unknown row with value: null
and an unsupported_binding refusal (raw_default: null), and every strategy()
default is refused. The refusal is part of the canonical token and digest, so
these cases retain a fingerprint.
| Reason | Refused class |
|---|---|
foreign_unverified_source |
Supplied C++ has no unique supported native receipt confirming the row; source appearance cannot establish producer origin. Applied values keep their wire strings. |
ambiguous_binding |
An identifier with an occurrence outside its one recognized declaration and the closed read list (shadows, local declarators, other script-body uses); getters of one title (or security-guard branches) that disagree where the receipt does not pick exactly one; or disagreement with the native receipt's type, default or value, for inputs and for strategy() values alike. |
duplicate_title |
On the legacy-settings path, the native receipt lists the title more than once with two or more distinct (type, default) pairs, compared as written: several inputs share it, so no single value belongs to the title. The record adds "distinct_native_inputs", the number of distinct pairs. Takes precedence over every other input reason on that path. |
macro_argument |
A getter of the title sits inside a macro argument (see "Getter call"). |
unsupported_binding |
No recognized declaration, an unrecognized lexical or preprocessor context, an unrecognized getter-family token, an undelimitable literal or comment, or an unrecognized strategy() constructor flow. |
unsupported_default |
Unrecognized default expressions, definitions, numeric spellings/ranges, source expressions, enum spellings outside the canonical vocabulary and non-true/false booleans; no expression is evaluated to guess a value. |
unsupported_override |
A source override outside the native selector vocabulary. |
receipt_unavailable |
A symbolic default without a usable, supported native settings receipt row. |
Conservative refusal includes valid C++ forms outside the recognized producer
subset. Every concrete scalar the resolver knows (a recognized literal, an
emulated legacy override, or a numeric value in the native receipt) passes the
unchanged numeric/Unicode domain checks above before any refusal (a receipt
integer is parsed by its own type, leading zeros included): an
out-of-domain value keeps its existing refusal (no fingerprint), and null never
erases it. A malformed checked receipt or a non-canonical checked source selector
also makes the complete fingerprint null. The checked-settings path
rejects a receipt with any repeated title as ambiguous and likewise emits
fingerprint: null, even if the backtest itself succeeds; it does not emit
the legacy path's per-title duplicate record.
For the demonstrated repeated-title script, giving the two inputs distinct
titles is the source-derived remedy and is expected to restore the
checked-path fingerprint. The retained sample did not execute the renamed
variant. An unresolved legacy row can still belong to a non-null
fingerprint; the two cases are different.
run_json_codes_test.py retains six recorded pre-release fingerprints
(BASE_FINGERPRINTS). These checks cover the specific certified values and
resolution records in those examples; changed reasons or counts also change
the fingerprint, because those fields are hashed.
Those pins do not assert fingerprint equality with released 1.3.0: the
release adds typed values and explicit uncertainty, and build/version inputs
also differ.
Decode the token to inspect the canonical provenance JSON:
jq -r '.fingerprint.token' report.json | base64 -d | jq .To verify digest without rebuilding JSON, hash the decoded token bytes
directly (for example base64 -d | shasum -a 256).
| Code | Meaning |
|---|---|
0 |
Success — JSON report, C++ in transpile-only mode, or empty stdout after a build-only run |
2 |
Missing input mount (/in/strategy.pine or /in/strategy.cpp, and /in/ohlcv.csv), or a mounted strategy.cpp the host did not vouch for (one typed run_request_invalid line on stdout) |
3 |
g++ compile or selected inventory binding failed, including a stop by a limit the host applied (no cause is inferred) |
4 |
Backtest aborted at runtime, a compile guard refused its settings or stopped compilation, or a build-only request or manifest failed |
5 |
Transpile failed (unsupported Pine construct or syntax error) |
The repo's tutorial/macd/strategy.pine and
tutorial/data/btcusdt_15m_7d.csv make a convenient smoke pair:
docker run --rm \
-v $(pwd)/tutorial/macd/strategy.pine:/in/strategy.pine:ro \
-v $(pwd)/tutorial/data/btcusdt_15m_7d.csv:/in/ohlcv.csv:ro \
pineforge | jq '.summary'- Linux-only image (Debian bookworm-slim). Run on macOS / Windows via Docker Desktop or any other amd64/arm64 OCI runtime.
- The image bundles
libeigen3-devfor the matrix-typed PineScript surface, even when your strategy does not touch it;<Eigen/...>must resolve at compile time because<pineforge/engine.hpp>transitively includes it. - Reproducibility: the libpineforge inside the image is pinned by the
image SHA, and its
io.pineforge.engine.version/io.pineforge.codegen.versionlabels name the pair. Pull a release tag (ghcr.io/pineforge-4pass/pineforge-release:X.Y.Z) for stable backtests.