From 67de43c09a7dc53083ebbda862118b77b246bfad Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sun, 4 Oct 2026 06:20:16 +0800 Subject: [PATCH 1/2] Apply syminfo.mincontract as the engine's lot grid in the Docker harness The harness applied only mintick, pointvalue, timezone and session from the --syminfo file, so a percent-of-equity strategy traded sub-lot quantities that the instrument does not allow. A positive finite mincontract is now set as the engine's qty_step metadata (order quantities are floored to it) and as the mincontract metadata, before the other setters. Absent or null changes nothing; any other value, or a library without the metadata setter, fails the run with the structured error line, which shows the value's JSON text. An applied grid is recorded as applied_runtime.syminfo and so in the fingerprint. pineforge-release vendors docker/run_json.py from the engine tag at every release, so the change has to live here to reach a published image: this is the same file as pineforge-release#21, whose copy the next sync overwrites. docker/README.md, the docker/entrypoint.sh header comment and CHANGELOG.md document it; docker/run_json_syminfo_test.py covers it. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 12 ++ docker/README.md | 33 +++++ docker/entrypoint.sh | 10 +- docker/run_json.py | 121 ++++++++++++++---- docker/run_json_syminfo_test.py | 213 +++++++++++++++++++++++++++++++- 5 files changed, 360 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd2933cf6..982a3c30a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ version number follows semantic versioning over the surfaces the ## Unreleased +- **Harness lot grid:** `docker/run_json.py` applies `mincontract` from the + `--syminfo` (`PINEFORGE_SYMINFO`) file as the engine's lot grid, the metadata key + `qty_step` plus the `mincontract` key a script's `syminfo.mincontract` reads, so a + percent-of-equity strategy no longer trades sub-lot quantities. Absent or `null` + changes nothing: the report and its fingerprint are as before, apart from + `elapsed_seconds`. A value that is not a positive finite number, or a strategy + library without `strategy_set_syminfo_metadata`, fails the run (one + `{"engine":"pineforge","error":...}` line, harness exit 1, entrypoint exit 4) + instead of running without the grid. An applied grid is recorded as + `applied_runtime.syminfo`, so a gridded run has its own fingerprint digest. The + engine library is unchanged. + - **Native runner routing and delivery:** webhooks are optional; `--webhook-routes` adds first-match per-action targets and payload `pineforge-native-order-action/v2`. New `actions`, `status` and offline `redeliver` commands expose the journal and diff --git a/docker/README.md b/docker/README.md index 84a272715..4166fcf68 100644 --- a/docker/README.md +++ b/docker/README.md @@ -136,6 +136,39 @@ header) are configured via separate env vars: | `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_SYMINFO` | none | Path to the instrument's metadata JSON (the harness's `--syminfo`); see below | + +### Instrument metadata (`PINEFORGE_SYMINFO`) + +`PINEFORGE_SYMINFO` names a JSON file holding the instrument's metadata, either a +flat object or `{"syminfo": {...}}`; a catalog object can be passed as is, keys +the harness does not use are ignored. It applies `mincontract` first, then +`mintick`, `pointvalue`, `timezone` and `session`, each through the strategy +library's `strategy_set_syminfo_*` setters. + +- `mincontract` is the instrument's lot size (TradingView's + `syminfo.mincontract`). It is set as the engine's `qty_step` metadata, so order + quantities are floored to that grid, and as `mincontract` metadata, so a + script's `syminfo.mincontract` reads 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 "}` on stdout, where `` is + the value's JSON text cut to 80 characters (`got true`, `got "0.001"`), + harness exit status 1, entrypoint exit 4. A strategy library without + `strategy_set_syminfo_metadata` fails the same way when `mincontract` is set: + the harness never runs without the grid it was given. +- An applied grid is recorded as `applied_runtime.syminfo` + (`{"qty_step": , "mincontract": }`) and so in + `fingerprint.provenance.runtime`: its fingerprint digest differs from the + gridless run's. Without a grid the key is absent and the report, fingerprint + included, is what it was before this key was supported, apart from + `elapsed_seconds` (and, with `--bench`, the timing samples). `mintick`, + `pointvalue`, `timezone` and `session` are not recorded. + +`docker/run_json.py` is vendored: pineforge-release copies it from the engine tag +at every release, so the lot-grid handling (`mincontract`) lives in this file. The engine catches every error (TF mismatch, unsupported emulation flags, unknown-input-TF, etc.) into `strategy_get_last_error()`; the diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index a04b89d64..c6aa7da81 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -36,6 +36,14 @@ # PINEFORGE_MAGNIFIER_DIST Sample distribution: uniform / cosine / triangle / # endpoints (default) / front_loaded / back_loaded. # +# Optional env var (instrument metadata): +# PINEFORGE_SYMINFO Path to a syminfo JSON file, a flat object or {"syminfo": {...}}. +# Keys applied: mincontract (the lot size: order quantities are +# floored to it), then mintick, pointvalue, timezone, session; other +# keys are ignored. mincontract absent or null: no lot grid. Any other +# mincontract that is not a positive finite number fails the run +# (exit 4, one {"engine":"pineforge","error":...} line on stdout). +# # Exit codes: # 0 success (JSON report, or C++ in transpile-only mode, on stdout) # 2 missing input mount @@ -131,7 +139,7 @@ echo "[pineforge] running backtest ..." >&2 # PINEFORGE_TRADE_START_MS unix-ms; suppress orders before it # PINEFORGE_CHART_TZ IANA tz for date builtins # PINEFORGE_MAGNIFIER_VOLUME_WEIGHTED 1/true → vw magnifier (needs BAR_MAGNIFIER) -# PINEFORGE_SYMINFO path to a syminfo.json +# PINEFORGE_SYMINFO path to a syminfo.json (see the header) # PINEFORGE_BENCH (+_WARMUP/_REPEATS) 1/true → timing mode extra=() [[ -n "${PINEFORGE_TRADE_START_MS:-}" ]] && extra+=(--trade-start-ms "${PINEFORGE_TRADE_START_MS}") diff --git a/docker/run_json.py b/docker/run_json.py index f1047de12..2b95e2d23 100755 --- a/docker/run_json.py +++ b/docker/run_json.py @@ -74,6 +74,13 @@ } } +applied_runtime (and so provenance.runtime) also holds +"syminfo": {"qty_step": float, "mincontract": float} when --syminfo set a lot +grid from syminfo.mincontract; without one the key is absent. + +A run error or a --syminfo the harness rejects (see apply_syminfo) prints one +line {"engine": "pineforge", "error": ""} instead, exit status 1. + NaN convention: any metric with an empty/zero denominator is null (JSON has no NaN); a real computed 0 stays 0. See the report-schema + metrics reference docs for the per-field meaning of every metrics.* key. @@ -834,6 +841,10 @@ def load_strategy(so_path: Path) -> ctypes.CDLL: for _n in ("strategy_set_syminfo_timezone", "strategy_set_syminfo_session"): if hasattr(lib, _n): getattr(lib, _n).argtypes = [ctypes.c_void_p, ctypes.c_char_p] + if hasattr(lib, "strategy_set_syminfo_metadata"): + lib.strategy_set_syminfo_metadata.argtypes = [ + ctypes.c_void_p, ctypes.c_char_p, ctypes.c_double] + lib.strategy_set_syminfo_metadata.restype = None # Validation-parity setters mirrored from scripts/run_strategy.py. All # hasattr-guarded: trade_start_time + chart_timezone are runtime PF exports; @@ -854,16 +865,52 @@ def load_strategy(so_path: Path) -> ctypes.CDLL: return lib +class SyminfoError(ValueError): + """A --syminfo file the harness cannot apply as given. main() reports it as + the structured {"engine", "error"} failure (exit 1), never as a traceback.""" + + +# This file is vendored: pineforge-release copies it from the pineforge-engine +# tag at every release. Keep the lot-grid handling (mincontract) identical in +# both repos, or a sync drops it. def apply_syminfo(lib, strat, syminfo_path): """Apply syminfo.json (data-worker schema) via strategy_set_syminfo_*. - Tolerant: missing keys skipped. Accepts {"syminfo": {...}} or a flat dict.""" + Tolerant: missing keys skipped. Accepts {"syminfo": {...}} or a flat dict. + + mincontract (TradingView's syminfo.mincontract, the instrument's lot size) + is strict: absent or null applies nothing; anything else must be a positive + finite JSON number, else SyminfoError before any setter runs. A valid one is + set first, as the metadata key qty_step (the engine floors order quantities + to that grid) and as mincontract (what syminfo.mincontract reads return). + Returns what main() records in applied_runtime["syminfo"]: + {"qty_step": v, "mincontract": v}, or {} when no grid was applied.""" import json doc = json.loads(open(syminfo_path).read()) si = doc.get("syminfo", doc) + applied = {} + lot = si.get("mincontract") + if lot is not None: + try: + v = (float(lot) if isinstance(lot, (int, float)) + and not isinstance(lot, bool) else math.nan) + except OverflowError: # an int beyond binary64 + v = math.nan + if not (math.isfinite(v) and v > 0): + raise SyminfoError( + "syminfo.mincontract must be a positive finite number, got " + + json.dumps(lot)[:80]) + if not hasattr(lib, "strategy_set_syminfo_metadata"): + raise SyminfoError( + "the strategy library has no strategy_set_syminfo_metadata, " + "so syminfo.mincontract cannot be applied") + lib.strategy_set_syminfo_metadata(strat, b"qty_step", v) + lib.strategy_set_syminfo_metadata(strat, b"mincontract", v) + applied = {"qty_step": v, "mincontract": v} if "mintick" in si: lib.strategy_set_syminfo_mintick(strat, float(si["mintick"])) if "pointvalue" in si: lib.strategy_set_syminfo_pointvalue(strat, float(si["pointvalue"])) if si.get("timezone"): lib.strategy_set_syminfo_timezone(strat, str(si["timezone"]).encode()) if si.get("session"): lib.strategy_set_syminfo_session(strat, str(si["session"]).encode()) + return applied def fmt_utc(ms: int) -> str: @@ -1149,17 +1196,28 @@ def main() -> int: # Volume-weighted magnifier only meaningful when the magnifier is on. vw_on = bool(args.magnifier_volume_weighted) and bar_magnifier == 1 + # The lot grid the last _make_state() applied (the body run's), for + # applied_runtime["syminfo"]; {} when none. + syminfo_applied: dict = {} + def _make_state(): """Create + fully configure a fresh strategy state — everything EXCEPT the timed run_backtest_full call. Mirrors scripts/run_strategy.py's setup so the engine behaves identically to the ctypes validation harness.""" + nonlocal syminfo_applied st = lib.strategy_create(b"{}") for k, v in inputs.items(): lib.strategy_set_input(st, k.encode(), v.encode()) for k, v in overrides.items(): lib.strategy_set_override(st, k.encode(), v.encode()) if args.syminfo: - apply_syminfo(lib, st, args.syminfo) + try: + # A replacement apply_syminfo may return None (or another non-dict). + r = apply_syminfo(lib, st, args.syminfo) + syminfo_applied = r if isinstance(r, dict) else {} + except SyminfoError: + lib.strategy_free(st) + raise if args.trade_start_ms is not None and hasattr(lib, "strategy_set_trade_start_time"): lib.strategy_set_trade_start_time(st, int(args.trade_start_ms)) if args.chart_tz and hasattr(lib, "strategy_set_chart_timezone"): @@ -1181,31 +1239,38 @@ def _run(st, rep): # isolates the engine hot loop (closest to the GBench harness). dlopen # already happened above (load_strategy), outside any loop. timing = None - if args.bench: - warmup = max(0, int(args.warmup)) - repeats = max(1, int(args.repeats)) - for _ in range(warmup): - st = _make_state(); rep = ReportC() - try: - _run(st, rep) - finally: - lib.report_free(ctypes.byref(rep)); lib.strategy_free(st) - samples_ns: list[int] = [] - for _ in range(repeats): - st = _make_state(); rep = ReportC() - try: - t0 = time.perf_counter_ns(); _run(st, rep); t1 = time.perf_counter_ns() - samples_ns.append(t1 - t0) - finally: - lib.report_free(ctypes.byref(rep)); lib.strategy_free(st) - timing = _timing_block( - samples_ns, warmup=warmup, repeats=repeats, - bar_magnifier=bar_magnifier, magnifier_samples=magnifier_samples, - magnifier_dist=args.magnifier_dist.strip().lower() or "endpoints", - volume_weighted=vw_on) - - # --- Body run: one configured run for trades / metrics / diagnostics. --- - state = _make_state() + try: + if args.bench: + warmup = max(0, int(args.warmup)) + repeats = max(1, int(args.repeats)) + for _ in range(warmup): + st = _make_state(); rep = ReportC() + try: + _run(st, rep) + finally: + lib.report_free(ctypes.byref(rep)); lib.strategy_free(st) + samples_ns: list[int] = [] + for _ in range(repeats): + st = _make_state(); rep = ReportC() + try: + t0 = time.perf_counter_ns(); _run(st, rep); t1 = time.perf_counter_ns() + samples_ns.append(t1 - t0) + finally: + lib.report_free(ctypes.byref(rep)); lib.strategy_free(st) + timing = _timing_block( + samples_ns, warmup=warmup, repeats=repeats, + bar_magnifier=bar_magnifier, magnifier_samples=magnifier_samples, + magnifier_dist=args.magnifier_dist.strip().lower() or "endpoints", + volume_weighted=vw_on) + + # --- Body run: one configured run for trades / metrics / diagnostics. --- + state = _make_state() + except SyminfoError as e: + # A rejected --syminfo: the structured failure, before any stdout. + json.dump({"engine": "pineforge", "error": str(e)}, + sys.stdout, separators=(",", ":")) + sys.stdout.write("\n") + return 1 report = ReportC() started = time.time() try: @@ -1234,6 +1299,8 @@ def _run(st, rep): "trade_start_ms": args.trade_start_ms, "chart_tz": args.chart_tz or "", } + if syminfo_applied: + applied_runtime["syminfo"] = syminfo_applied incarnation_accessor = getattr( lib, "strategy_closed_trade_entry_incarnation", None) trade_entry_incarnations = ( diff --git a/docker/run_json_syminfo_test.py b/docker/run_json_syminfo_test.py index 16b64665f..c3a82ab4d 100644 --- a/docker/run_json_syminfo_test.py +++ b/docker/run_json_syminfo_test.py @@ -1,4 +1,13 @@ -import json, types +import base64 +import contextlib +import ctypes +import io +import json +import sys +import types +from pathlib import Path + +import pytest import run_json # docker/ is on sys.path in the engine test env @@ -15,3 +24,205 @@ def test_apply_syminfo_calls_setters(tmp_path): "timezone": "UTC", "session": "24x7"}})) run_json.apply_syminfo(lib, object(), p) assert calls == {"mintick": 0.5, "pointvalue": 2.0, "tz": b"UTC", "session": b"24x7"} + + +# syminfo.mincontract (the instrument's lot size) is the engine's lot grid: set as the +# metadata keys qty_step and mincontract before the other setters; absent or null changes +# nothing; anything but a positive finite JSON number is a structured failure. + +SETTERS = ("strategy_set_syminfo_metadata", "strategy_set_syminfo_mintick", + "strategy_set_syminfo_pointvalue", "strategy_set_syminfo_timezone", + "strategy_set_syminfo_session") +CORE = ("pf_abi_version", "strategy_create", "strategy_set_input", "strategy_set_override", + "run_backtest_full", "strategy_free", "report_free") +FOUR = {"mintick": 0.5, "pointvalue": 2, "timezone": "UTC", "session": "24x7"} +ST = 7 +FOUR_CALLS = [("strategy_set_syminfo_mintick", ST, 0.5), + ("strategy_set_syminfo_pointvalue", ST, 2.0), + ("strategy_set_syminfo_timezone", ST, b"UTC"), + ("strategy_set_syminfo_session", ST, b"24x7")] +BAD_LOT = "syminfo.mincontract must be a positive finite number, got " + + +class FakeLib: + """A strategy library stand-in: each named symbol records (name, *args); a name + left out fails hasattr like a missing export.""" + + def __init__(self, names, returns=None): + self.calls = [] + for name in names: + setattr(self, name, self._fn(name, (returns or {}).get(name))) + + def _fn(self, name, result): + def call(*args): + self.calls.append((name,) + args) + return result + return call + + +def write(tmp_path, doc, name="syminfo.json"): + p = tmp_path / name + p.write_text(json.dumps(doc)) + return p + + +def fake_lib(setters=SETTERS): + return FakeLib(CORE + tuple(setters), {"pf_abi_version": run_json.EXPECTED_PF_ABI}) + + +@pytest.mark.parametrize("doc,lot", [ + (dict(FOUR, mincontract=0.25), 0.25), + ({"syminfo": dict(FOUR, mincontract=1e-05)}, 1e-05), + (dict(FOUR, mincontract=1), 1.0), +], ids=["flat", "wrapped", "integer"]) +def test_mincontract_sets_the_lot_grid_before_the_other_setters(tmp_path, doc, lot): + lib = FakeLib(SETTERS) + got = run_json.apply_syminfo(lib, ST, write(tmp_path, doc)) + assert lib.calls == [("strategy_set_syminfo_metadata", ST, b"qty_step", lot), + ("strategy_set_syminfo_metadata", ST, b"mincontract", lot)] + FOUR_CALLS + assert all(type(c[3]) is float for c in lib.calls[:2]) + assert got == {"qty_step": lot, "mincontract": lot} + + +@pytest.mark.parametrize("setters", [SETTERS, SETTERS[1:]], ids=["setter", "no-setter"]) +@pytest.mark.parametrize("doc", [ + dict(FOUR), + dict(FOUR, mincontract=None), + {"syminfo": dict(FOUR, mincontract=None)}, +], ids=["absent", "null", "wrapped-null"]) +def test_mincontract_absent_or_null_applies_no_grid(tmp_path, doc, setters): + lib = FakeLib(setters) + assert run_json.apply_syminfo(lib, ST, write(tmp_path, doc)) == {} + assert lib.calls == FOUR_CALLS + + +HUGE_INT = "1" + "0" * 400 +LONG_STR = '"' + "x" * 1000 + '"' + + +@pytest.mark.parametrize("raw,shown", [ + ("0", "0"), ("-1", "-1"), ("-0.0", "-0.0"), ("1e-400", "0.0"), + ('"0.001"', '"0.001"'), ("true", "true"), ("false", "false"), + ("NaN", "NaN"), ("Infinity", "Infinity"), ("-Infinity", "-Infinity"), + ("[]", "[]"), ("{}", "{}"), + (HUGE_INT, HUGE_INT[:80]), (LONG_STR, LONG_STR[:80]), +], ids=["zero", "negative", "negative-zero", "underflow", "string", "true", "false", + "nan", "infinity", "negative-infinity", "array", "object", "huge-int", "long-string"]) +def test_mincontract_not_a_positive_finite_number_is_rejected(tmp_path, raw, shown): + bad = tmp_path / "bad.json" + bad.write_text(json.dumps(FOUR)[:-1] + ', "mincontract": ' + raw + "}") + lib = FakeLib(SETTERS) + with pytest.raises(run_json.SyminfoError) as e: + run_json.apply_syminfo(lib, ST, bad) + assert str(e.value) == BAD_LOT + shown + assert lib.calls == [] + + +def test_mincontract_without_the_metadata_setter_is_rejected(tmp_path): + lib = FakeLib(SETTERS[1:]) + with pytest.raises(run_json.SyminfoError, match="strategy_set_syminfo_metadata"): + run_json.apply_syminfo(lib, ST, write(tmp_path, dict(FOUR, mincontract=0.25))) + assert lib.calls == [] + + +def test_load_strategy_declares_the_metadata_setter_signature(monkeypatch): + monkeypatch.setattr(ctypes, "CDLL", lambda path: fake_lib()) + meta = run_json.load_strategy(Path("fake.so")).strategy_set_syminfo_metadata + assert meta.argtypes == [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_double] + assert meta.restype is None + + +# main() against the fake library. + +@pytest.fixture +def harness(tmp_path, monkeypatch): + tape = tmp_path / "tape.csv" + tape.write_text("open,high,low,close,volume,timestamp\n" + "1,2,0.5,1.5,10,1000\n1.5,3,1,2.5,20,2000\n") + + def run(lib, *extra, syminfo=None): + argv = ["run_json.py", "--so", "fake.so", "--ohlcv", str(tape), *extra] + if syminfo is not None: + argv += ["--syminfo", str(write(tmp_path, syminfo, "s.json"))] + monkeypatch.setattr(ctypes, "CDLL", lambda path: lib) + monkeypatch.setattr(sys, "argv", argv) + out = io.StringIO() + with contextlib.redirect_stdout(out): + status = run_json.main() + return status, out.getvalue() + + return run + + +def report_of(text): + rep = json.loads(text) + rep["elapsed_seconds"] = 0 + return rep + + +def count(lib, name): + return [c[0] for c in lib.calls].count(name) + + +@pytest.mark.parametrize("doc", [dict(FOUR), dict(FOUR, mincontract=None)], + ids=["absent", "null"]) +def test_main_without_a_grid_reports_as_without_syminfo(harness, doc): + _, plain = harness(fake_lib()) + status, out = harness(fake_lib(), syminfo=doc) + assert status == 0 + assert report_of(out) == report_of(plain) + assert "syminfo" not in report_of(out)["applied_runtime"] + + +def test_main_records_the_grid_in_the_report_and_the_fingerprint(harness): + _, plain = harness(fake_lib()) + status, out = harness(fake_lib(), syminfo=dict(FOUR, mincontract=0.25)) + rep = report_of(out) + prov = json.loads(base64.b64decode(rep["fingerprint"]["token"])) + grid = {"qty_step": 0.25, "mincontract": 0.25} + assert status == 0 + assert rep["applied_runtime"]["syminfo"] == grid + assert prov["runtime"]["syminfo"] == grid + assert rep["fingerprint"]["digest"] != report_of(plain)["fingerprint"]["digest"] + + +def test_main_bench_applies_the_grid_to_every_state(harness): + lib = fake_lib() + status, out = harness(lib, "--bench", "--warmup", "2", "--repeats", "3", + syminfo=dict(FOUR, mincontract=0.25)) + states = 2 + 3 + 1 + assert status == 0 + assert report_of(out)["applied_runtime"]["syminfo"] == {"qty_step": 0.25, "mincontract": 0.25} + assert count(lib, "strategy_create") == count(lib, "strategy_free") == states + assert [c[2] for c in lib.calls if c[0] == "strategy_set_syminfo_metadata"] \ + == [b"qty_step", b"mincontract"] * states + + +@pytest.mark.parametrize("extra", [ + [], + ["--bench", "--warmup", "1", "--repeats", "1"], + ["--bench", "--warmup", "0", "--repeats", "1"], +], ids=["body", "bench-warmup", "bench-repeats"]) +def test_main_rejects_a_bad_mincontract_with_one_structured_line(harness, extra): + lib = fake_lib() + status, out = harness(lib, *extra, syminfo=dict(FOUR, mincontract=-1)) + assert status == 1 + assert out == '{"engine":"pineforge","error":"' + BAD_LOT + '-1"}\n' + assert count(lib, "run_backtest_full") == 0 + assert count(lib, "strategy_create") == count(lib, "strategy_free") == 1 + + +def test_main_with_a_grid_and_no_metadata_setter_fails(harness): + status, out = harness(fake_lib(SETTERS[1:]), syminfo=dict(FOUR, mincontract=0.25)) + assert status == 1 + assert json.loads(out)["error"].startswith( + "the strategy library has no strategy_set_syminfo_metadata") + + +@pytest.mark.parametrize("value", [None, True], ids=["None", "True"]) +def test_main_ignores_a_non_dict_from_a_replacement_apply_syminfo(harness, monkeypatch, value): + _, plain = harness(fake_lib()) + monkeypatch.setattr(run_json, "apply_syminfo", lambda lib, strat, path: value) + status, out = harness(fake_lib(), syminfo=dict(FOUR, mincontract=0.25)) + assert status == 0 + assert report_of(out) == report_of(plain) From 3f9dedf58a4589dc88c8f68dfaa83f99ece3d026 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sun, 4 Oct 2026 06:49:39 +0800 Subject: [PATCH 2/2] Address review of the lot-grid harness change Tests: restore the checks the port from pineforge-release dropped (SyminfoError is a ValueError; the grid sits in the digested token bytes in canonical form and the plain token has no syminfo) and pin the recorded grid to floats, so an integer mincontract is not recorded as an int. Docs: the error text shows the parsed value re-encoded as JSON, not the file's text; a file that is not JSON or not an object still ends in a traceback; a library without the metadata setter fails the run only when mincontract is set (CHANGELOG); the PINEFORGE_SYMINFO row leaves the "runtime args" table (it is applied through the syminfo setters and has its own section); the extra-keys object is named plainly. docker/run_json.py and docker/entrypoint.sh are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 5 +++-- docker/README.md | 16 +++++++++------- docker/run_json_syminfo_test.py | 11 ++++++++++- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 982a3c30a..90179e7a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,9 @@ version number follows semantic versioning over the surfaces the `qty_step` plus the `mincontract` key a script's `syminfo.mincontract` reads, so a percent-of-equity strategy no longer trades sub-lot quantities. Absent or `null` changes nothing: the report and its fingerprint are as before, apart from - `elapsed_seconds`. A value that is not a positive finite number, or a strategy - library without `strategy_set_syminfo_metadata`, fails the run (one + `elapsed_seconds`. A value that is not a positive finite number, or, when + `mincontract` is set, a strategy library without + `strategy_set_syminfo_metadata`, fails the run (one `{"engine":"pineforge","error":...}` line, harness exit 1, entrypoint exit 4) instead of running without the grid. An applied grid is recorded as `applied_runtime.syminfo`, so a gridded run has its own fingerprint digest. The diff --git a/docker/README.md b/docker/README.md index 4166fcf68..7927c2763 100644 --- a/docker/README.md +++ b/docker/README.md @@ -136,12 +136,12 @@ header) are configured via separate env vars: | `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_SYMINFO` | none | Path to the instrument's metadata JSON (the harness's `--syminfo`); see below | ### Instrument metadata (`PINEFORGE_SYMINFO`) -`PINEFORGE_SYMINFO` names a JSON file holding the instrument's metadata, either a -flat object or `{"syminfo": {...}}`; a catalog object can be passed as is, keys +`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 `mintick`, `pointvalue`, `timezone` and `session`, each through the strategy library's `strategy_set_syminfo_*` setters. @@ -155,10 +155,12 @@ library's `strategy_set_syminfo_*` setters. `"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 "}` on stdout, where `` is - the value's JSON text cut to 80 characters (`got true`, `got "0.001"`), - harness exit status 1, entrypoint exit 4. A strategy library without - `strategy_set_syminfo_metadata` fails the same way when `mincontract` is set: - the harness never runs without the grid it was given. + the parsed value re-encoded as JSON (so `1e-400` shows as `0.0`) cut to 80 + characters (`got true`, `got "0.001"`), harness exit status 1, entrypoint exit + 4. A strategy library without `strategy_set_syminfo_metadata` fails the same + way when `mincontract` is set: the harness never runs without the grid it was + given. A file that is not valid JSON, or not a JSON object, is not covered by + this: the harness ends in a Python traceback, as before. - An applied grid is recorded as `applied_runtime.syminfo` (`{"qty_step": , "mincontract": }`) and so in `fingerprint.provenance.runtime`: its fingerprint digest differs from the diff --git a/docker/run_json_syminfo_test.py b/docker/run_json_syminfo_test.py index c3a82ab4d..e48f0818f 100644 --- a/docker/run_json_syminfo_test.py +++ b/docker/run_json_syminfo_test.py @@ -82,6 +82,7 @@ def test_mincontract_sets_the_lot_grid_before_the_other_setters(tmp_path, doc, l ("strategy_set_syminfo_metadata", ST, b"mincontract", lot)] + FOUR_CALLS assert all(type(c[3]) is float for c in lib.calls[:2]) assert got == {"qty_step": lot, "mincontract": lot} + assert all(type(v) is float for v in got.values()) # an integer mincontract is recorded as a float @pytest.mark.parametrize("setters", [SETTERS, SETTERS[1:]], ids=["setter", "no-setter"]) @@ -96,6 +97,10 @@ def test_mincontract_absent_or_null_applies_no_grid(tmp_path, doc, setters): assert lib.calls == FOUR_CALLS +def test_syminfo_error_is_a_value_error(): + assert issubclass(run_json.SyminfoError, ValueError) + + HUGE_INT = "1" + "0" * 400 LONG_STR = '"' + "x" * 1000 + '"' @@ -178,12 +183,16 @@ def test_main_records_the_grid_in_the_report_and_the_fingerprint(harness): _, plain = harness(fake_lib()) status, out = harness(fake_lib(), syminfo=dict(FOUR, mincontract=0.25)) rep = report_of(out) - prov = json.loads(base64.b64decode(rep["fingerprint"]["token"])) + token = base64.b64decode(rep["fingerprint"]["token"]) + prov = json.loads(token) grid = {"qty_step": 0.25, "mincontract": 0.25} assert status == 0 assert rep["applied_runtime"]["syminfo"] == grid assert prov["runtime"]["syminfo"] == grid assert rep["fingerprint"]["digest"] != report_of(plain)["fingerprint"]["digest"] + # The digested bytes carry the grid in canonical form, and only when one was applied. + assert b'"syminfo":{"mincontract":0.25,"qty_step":0.25}' in token + assert b"syminfo" not in base64.b64decode(report_of(plain)["fingerprint"]["token"]) def test_main_bench_applies_the_grid_to_every_state(harness):