|
54 | 54 | "equity_curve": [ # ABI v2: one point per script bar |
55 | 55 | { "time_ms": int, "equity": float, "open_profit": float }, |
56 | 56 | ... |
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 | + } |
58 | 70 | } |
59 | 71 |
|
60 | 72 | NaN convention: any metric with an empty/zero denominator is null (JSON has no |
|
64 | 76 | from __future__ import annotations |
65 | 77 |
|
66 | 78 | import argparse |
| 79 | +import base64 |
67 | 80 | import csv |
68 | 81 | import ctypes |
| 82 | +import hashlib |
69 | 83 | import json |
70 | 84 | import math |
| 85 | +import re |
71 | 86 | import sys |
72 | 87 | import time |
73 | 88 | from datetime import datetime, timezone |
74 | 89 | from pathlib import Path |
75 | 90 |
|
76 | 91 |
|
| 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 | + |
77 | 305 | # --- ctypes mirror of <pineforge/pineforge.h> ------------------------- |
78 | 306 |
|
79 | 307 | class BarC(ctypes.Structure): |
@@ -203,6 +431,29 @@ class ReportC(ctypes.Structure): |
203 | 431 | ] |
204 | 432 |
|
205 | 433 |
|
| 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 | + |
206 | 457 | # pf_report_t is CALLER-allocated: a .so built against a different ABI |
207 | 458 | # writes past (or short of) our ReportC buffer. Assert version up front. |
208 | 459 | EXPECTED_PF_ABI = 2 |
@@ -463,6 +714,13 @@ def main() -> int: |
463 | 714 | ap.add_argument("--magnifier-dist", default="endpoints", |
464 | 715 | help="Sample distribution: uniform, cosine, triangle, " |
465 | 716 | "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.") |
466 | 724 | args = ap.parse_args() |
467 | 725 |
|
468 | 726 | inputs = parse_kv_json(args.inputs, "--inputs") |
@@ -516,6 +774,17 @@ def main() -> int: |
516 | 774 | } |
517 | 775 | out = build_report_dict(report, args.ohlcv, n, first_ts, last_ts, |
518 | 776 | 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 |
519 | 788 | json.dump(out, sys.stdout, separators=(",", ":")) |
520 | 789 | sys.stdout.write("\n") |
521 | 790 | finally: |
|
0 commit comments