Skip to content

Commit 309ce2e

Browse files
authored
Merge pull request #33 from pineforge-4pass/feat/backtest-fingerprint
feat: decode-able backtest fingerprint (strategy/input/engine/codegen provenance)
2 parents a6da685 + f97af1e commit 309ce2e

6 files changed

Lines changed: 754 additions & 2 deletions

File tree

docker/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
3232

3333
# Stage the pure-Python transpiler here so the runtime never needs pip or
3434
# network: target-install gives a plain directory tree to COPY across.
35-
RUN pip3 install --break-system-packages --no-cache-dir --target /opt/codegen pineforge-codegen
35+
RUN pip3 install --break-system-packages --no-cache-dir -U --target /opt/codegen pineforge-codegen
3636

3737
WORKDIR /src
3838
# VERSION file is the version source-of-truth inside the build context;

docker/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,40 @@ read-only mounts; the image performs no network I/O at run time.
200200
}
201201
```
202202

203+
## Backtest fingerprint
204+
205+
Every JSON report carries a `fingerprint` recording exactly what produced it —
206+
reversible, no key required:
207+
208+
```json
209+
"fingerprint": {
210+
"token": "<base64 of the canonical provenance JSON>",
211+
"digest": "sha256:<hex>",
212+
"provenance": {
213+
"engine": { "version_string": "...", "major": 0, "minor": 10, "patch": 2, "commit_sha": "..." },
214+
"codegen": { "version": "0.6.4", "generated_cpp_sha256": "...", "transpiled_from_pine": true },
215+
"strategy": { "initial_capital": 1000000.0, "pyramiding": 1, "commission_type": "percent", "...": "all strategy() params, effective" },
216+
"inputs": { "Fast Length": { "type": "int", "default": 9, "value": "8" }, "...": "all input()s, effective" },
217+
"applied": { "inputs": { "Fast Length": "8" }, "overrides": {} },
218+
"runtime": { "input_tf": "", "bar_magnifier": false, "...": "..." }
219+
}
220+
}
221+
```
222+
223+
`strategy` and `inputs` list the **full effective** parameter set — every
224+
`strategy()` field and every `input()` value, with declared defaults, even
225+
when no override was passed. `value` is the applied override if one was given,
226+
otherwise the default. `digest` is a stable id for a run under a given harness and its runtime settings (same inputs + same settings ⇒ same digest).
227+
228+
Decode the token to recover the provenance:
229+
230+
```bash
231+
jq -r '.fingerprint.token' report.json | base64 -d | jq .
232+
```
233+
234+
The provenance is also inlined under `fingerprint.provenance`, so decoding is
235+
only needed to verify the token round-trips.
236+
203237
## Exit codes
204238

205239
| Code | Meaning |

docker/entrypoint.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,10 @@ if [[ -f "${PINE}" ]]; then
9393
echo "[pineforge] transpiling strategy.pine ..." >&2
9494
run_transpile "${PINE}" "${GEN}" # set -e aborts (exit 5) on failure
9595
SRC="${GEN}"
96+
TRANSPILED=true
9697
elif [[ -f "${SRC_CPP}" ]]; then
9798
SRC="${SRC_CPP}"
99+
TRANSPILED=false
98100
else
99101
echo "error: missing input — mount /in/strategy.pine (preferred) or /in/strategy.cpp" >&2
100102
exit 2
@@ -131,4 +133,6 @@ python3 "${PREFIX}/bin/run_json.py" \
131133
--bar-magnifier "${PINEFORGE_BAR_MAGNIFIER:-}" \
132134
--magnifier-samples "${PINEFORGE_MAGNIFIER_SAMPLES:-4}" \
133135
--magnifier-dist "${PINEFORGE_MAGNIFIER_DIST:-endpoints}" \
136+
--generated-cpp "${SRC}" \
137+
--transpiled "${TRANSPILED}" \
134138
|| { echo "[pineforge] backtest failed" >&2; exit 4; }

docker/run_json.py

Lines changed: 270 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,19 @@
5454
"equity_curve": [ # ABI v2: one point per script bar
5555
{ "time_ms": int, "equity": float, "open_profit": float },
5656
...
57-
]
57+
],
58+
"fingerprint": { # decode-able backtest provenance
59+
"token": "<base64(canonical provenance JSON)>", # b64decode -> JSON
60+
"digest": "sha256:<hex>", # stable run id over canonical JSON
61+
"provenance": {
62+
"engine": { version_string, major, minor, patch, commit_sha },
63+
"codegen": { version, generated_cpp_sha256, transpiled_from_pine },
64+
"strategy": { ...all strategy() params, effective... },
65+
"inputs": { "<title>": { type, default, value }, ... },
66+
"applied": { "inputs": {...}, "overrides": {...} }, # user deltas
67+
"runtime": { ...same fields as applied_runtime... }
68+
}
69+
}
5870
}
5971
6072
NaN convention: any metric with an empty/zero denominator is null (JSON has no
@@ -64,16 +76,232 @@
6476
from __future__ import annotations
6577

6678
import argparse
79+
import base64
6780
import csv
6881
import ctypes
82+
import hashlib
6983
import json
7084
import math
85+
import re
7186
import sys
7287
import time
7388
from datetime import datetime, timezone
7489
from pathlib import Path
7590

7691

92+
# >>> fingerprint helpers (DUPLICATED verbatim in scripts/run_strategy.py;
93+
# scripts/ is .dockerignore'd so this cannot be a shared module.
94+
# scripts/fingerprint_self_test.py asserts both copies stay identical.)
95+
try:
96+
from importlib import metadata as _ilmd
97+
except ImportError: # pragma: no cover
98+
_ilmd = None
99+
100+
# Canonical strategy() defaults. Mirrors the engine base-class defaults in
101+
# include/pineforge/engine.hpp (initial_capital_, process_orders_on_close_,
102+
# default_qty_type_, default_qty_value_, pyramiding_, commission_type_,
103+
# commission_value_, slippage_, close_entries_rule_any_). The codegen ctor
104+
# emits only a subset (it omits process_orders_on_close + close_entries_rule),
105+
# so this seed supplies the rest. KEEP IN SYNC with engine.hpp.
106+
STRATEGY_SEED = {
107+
"initial_capital": 1000000.0,
108+
"process_orders_on_close": False,
109+
"default_qty_type": "fixed",
110+
"default_qty_value": 1.0,
111+
"pyramiding": 1,
112+
"commission_type": "percent",
113+
"commission_value": 0.0,
114+
"slippage": 0,
115+
"close_entries_rule": "FIFO",
116+
}
117+
118+
_QTY_TYPE = {"FIXED": "fixed", "PERCENT_OF_EQUITY": "percent_of_equity", "CASH": "cash"}
119+
_COMM_TYPE = {"PERCENT": "percent", "CASH_PER_ORDER": "cash_per_order",
120+
"CASH_PER_CONTRACT": "cash_per_contract"}
121+
122+
# generated.cpp ctor field name -> provenance key.
123+
_STRAT_FIELD_KEY = {
124+
"initial_capital_": "initial_capital",
125+
"process_orders_on_close_": "process_orders_on_close",
126+
"default_qty_type_": "default_qty_type",
127+
"default_qty_value_": "default_qty_value",
128+
"pyramiding_": "pyramiding",
129+
"commission_type_": "commission_type",
130+
"commission_value_": "commission_value",
131+
"slippage_": "slippage",
132+
"close_entries_rule_any_": "close_entries_rule",
133+
}
134+
135+
_INPUT_RE = re.compile(
136+
r'get_input_(\w+)\(\s*"((?:[^"\\]|\\.)*)"\s*,\s*((?:[^();]|\([^()]*\))*?)\s*\)')
137+
138+
139+
def _ctor_body(cpp_text: str) -> str:
140+
"""Return the GeneratedStrategy constructor body, or '' if not found.
141+
142+
Scoping to the ctor is load-bearing: set_strategy_override() also contains
143+
`initial_capital_ = std::stod(value);` lines that must NOT be parsed as
144+
defaults. The member-init list (`_ta_ema_1(5)`) has no `=` so it cannot
145+
false-match the field regex."""
146+
m = re.search(r"GeneratedStrategy\s*\([^)]*\)\s*(?::[^{]*)?\{", cpp_text)
147+
if not m:
148+
return ""
149+
i = m.end() - 1 # index of the opening '{'
150+
depth = 0
151+
for j in range(i, len(cpp_text)):
152+
c = cpp_text[j]
153+
if c == "{":
154+
depth += 1
155+
elif c == "}":
156+
depth -= 1
157+
if depth == 0:
158+
return cpp_text[i + 1:j]
159+
return ""
160+
161+
162+
def _coerce_scalar(rhs: str):
163+
rhs = rhs.strip()
164+
if rhs in ("true", "false"):
165+
return rhs == "true"
166+
if re.fullmatch(r"[+-]?\d+", rhs):
167+
return int(rhs)
168+
try:
169+
f = float(rhs)
170+
return f if (f == f and f not in (float("inf"), float("-inf"))) else rhs
171+
except ValueError:
172+
return rhs
173+
174+
175+
def _unwrap_std_string(expr: str) -> str:
176+
"""Codegen wraps string input defaults as std::string("..."); unwrap to the
177+
inner literal so the recorded default is the value, not the C++ expression."""
178+
m = re.fullmatch(r'std::string\((.*)\)', expr.strip(), re.DOTALL)
179+
return m.group(1).strip() if m else expr
180+
181+
182+
def parse_strategy_params(cpp_text: str) -> dict:
183+
"""Parse strategy() header defaults from the constructor body only."""
184+
out: dict = {}
185+
body = _ctor_body(cpp_text)
186+
for fld, rhs in re.findall(r"(\w+_)\s*=\s*([^;]+);", body):
187+
key = _STRAT_FIELD_KEY.get(fld)
188+
if not key:
189+
continue
190+
rhs = rhs.strip()
191+
if fld == "default_qty_type_":
192+
out[key] = _QTY_TYPE.get(rhs.split("::")[-1], rhs)
193+
elif fld == "commission_type_":
194+
out[key] = _COMM_TYPE.get(rhs.split("::")[-1], rhs)
195+
elif fld == "close_entries_rule_any_":
196+
out[key] = "ANY" if _coerce_scalar(rhs) is True else "FIFO"
197+
else:
198+
out[key] = _coerce_scalar(rhs)
199+
return out
200+
201+
202+
def effective_strategy(cpp_text: str, overrides: dict | None) -> dict:
203+
"""Canonical seed -> ctor-parsed defaults -> user overrides (string wins)."""
204+
s = dict(STRATEGY_SEED)
205+
s.update(parse_strategy_params(cpp_text))
206+
for k, v in (overrides or {}).items():
207+
s[k] = v
208+
return s
209+
210+
211+
def parse_inputs(cpp_text: str) -> dict:
212+
"""Parse every get_input_*("title", default) call; dedup by title (first wins)."""
213+
out: dict = {}
214+
for typ, title, dflt in _INPUT_RE.findall(cpp_text):
215+
if title in out:
216+
continue
217+
d = _unwrap_std_string(dflt.strip())
218+
if d.startswith('"') and d.endswith('"') and len(d) >= 2:
219+
val = d[1:-1]
220+
elif typ == "source":
221+
val = d
222+
else:
223+
val = _coerce_scalar(d)
224+
out[title] = {"type": typ, "default": val}
225+
return out
226+
227+
228+
def effective_inputs(cpp_text: str, inputs_applied: dict | None) -> dict:
229+
"""All declared inputs with {type, default, value}; value = override or default.
230+
Applied inputs with no matching declaration are appended best-effort."""
231+
applied = inputs_applied or {}
232+
out: dict = {}
233+
for title, meta in parse_inputs(cpp_text).items():
234+
out[title] = {
235+
"type": meta["type"],
236+
"default": meta["default"],
237+
"value": applied.get(title, meta["default"]),
238+
}
239+
for title, v in applied.items():
240+
if title not in out:
241+
out[title] = {"type": "unknown", "default": None, "value": v}
242+
return out
243+
244+
245+
def _sha256_file(path) -> str | None:
246+
try:
247+
h = hashlib.sha256()
248+
with open(path, "rb") as f:
249+
for chunk in iter(lambda: f.read(65536), b""):
250+
h.update(chunk)
251+
return h.hexdigest()
252+
except OSError:
253+
return None
254+
255+
256+
def _codegen_version() -> str:
257+
if _ilmd is None:
258+
return "unknown"
259+
try:
260+
return _ilmd.version("pineforge-codegen")
261+
except Exception:
262+
return "unknown"
263+
264+
265+
def build_provenance(engine: dict, cpp_path, transpiled: bool,
266+
inputs_applied: dict, overrides_applied: dict,
267+
runtime: dict | None) -> dict:
268+
cpp_text = ""
269+
cpp_sha = None
270+
if cpp_path:
271+
cpp_sha = _sha256_file(cpp_path)
272+
try:
273+
with open(cpp_path, "r", encoding="utf-8", errors="replace") as f:
274+
cpp_text = f.read()
275+
except OSError:
276+
cpp_text = ""
277+
return {
278+
"engine": engine,
279+
"codegen": {
280+
"version": _codegen_version(),
281+
"generated_cpp_sha256": cpp_sha,
282+
"transpiled_from_pine": bool(transpiled),
283+
},
284+
"strategy": effective_strategy(cpp_text, overrides_applied),
285+
"inputs": effective_inputs(cpp_text, inputs_applied),
286+
"applied": {
287+
"inputs": dict(inputs_applied or {}),
288+
"overrides": dict(overrides_applied or {}),
289+
},
290+
"runtime": runtime or {},
291+
}
292+
293+
294+
def build_fingerprint(provenance: dict) -> dict:
295+
canonical = json.dumps(provenance, sort_keys=True, separators=(",", ":"))
296+
raw = canonical.encode("utf-8")
297+
return {
298+
"token": base64.b64encode(raw).decode("ascii"),
299+
"digest": "sha256:" + hashlib.sha256(raw).hexdigest(),
300+
"provenance": provenance,
301+
}
302+
# <<< fingerprint helpers
303+
304+
77305
# --- ctypes mirror of <pineforge/pineforge.h> -------------------------
78306

79307
class BarC(ctypes.Structure):
@@ -203,6 +431,29 @@ class ReportC(ctypes.Structure):
203431
]
204432

205433

434+
class PfVersionC(ctypes.Structure):
435+
"""Mirror of pf_version_t (returned by value from pf_version_get)."""
436+
_fields_ = [("major", ctypes.c_int), ("minor", ctypes.c_int),
437+
("patch", ctypes.c_int), ("commit_sha", ctypes.c_char_p)]
438+
439+
440+
def engine_version(lib: ctypes.CDLL) -> dict:
441+
"""Read engine version+sha from the .so (whole-archive exports). The
442+
fields are hasattr-guarded so an older .so degrades to blanks."""
443+
eng = {"version_string": "", "major": None, "minor": None,
444+
"patch": None, "commit_sha": ""}
445+
if hasattr(lib, "pf_version_string"):
446+
lib.pf_version_string.restype = ctypes.c_char_p
447+
s = lib.pf_version_string()
448+
eng["version_string"] = s.decode("utf-8", "replace") if s else ""
449+
if hasattr(lib, "pf_version_get"):
450+
lib.pf_version_get.restype = PfVersionC
451+
v = lib.pf_version_get()
452+
eng["major"], eng["minor"], eng["patch"] = int(v.major), int(v.minor), int(v.patch)
453+
eng["commit_sha"] = v.commit_sha.decode("utf-8", "replace") if v.commit_sha else ""
454+
return eng
455+
456+
206457
# pf_report_t is CALLER-allocated: a .so built against a different ABI
207458
# writes past (or short of) our ReportC buffer. Assert version up front.
208459
EXPECTED_PF_ABI = 2
@@ -463,6 +714,13 @@ def main() -> int:
463714
ap.add_argument("--magnifier-dist", default="endpoints",
464715
help="Sample distribution: uniform, cosine, triangle, "
465716
"endpoints (default), front_loaded, back_loaded.")
717+
ap.add_argument("--generated-cpp", type=Path, default=None,
718+
help="Path to the compiled generated.cpp; hashed and parsed "
719+
"for the report fingerprint (strategy()/input() provenance).")
720+
ap.add_argument("--transpiled", default="",
721+
help="'true' if generated.cpp came from a .pine transpile this "
722+
"run, 'false' if a user-supplied .cpp. Recorded in the "
723+
"fingerprint as codegen.transpiled_from_pine.")
466724
args = ap.parse_args()
467725

468726
inputs = parse_kv_json(args.inputs, "--inputs")
@@ -516,6 +774,17 @@ def main() -> int:
516774
}
517775
out = build_report_dict(report, args.ohlcv, n, first_ts, last_ts,
518776
elapsed, inputs, overrides, applied_runtime)
777+
try:
778+
out["fingerprint"] = build_fingerprint(build_provenance(
779+
engine_version(lib),
780+
args.generated_cpp,
781+
parse_bool(args.transpiled),
782+
inputs,
783+
overrides,
784+
applied_runtime,
785+
))
786+
except Exception:
787+
out["fingerprint"] = None
519788
json.dump(out, sys.stdout, separators=(",", ":"))
520789
sys.stdout.write("\n")
521790
finally:

0 commit comments

Comments
 (0)