diff --git a/CHANGELOG.md b/CHANGELOG.md index bd2933cf..90179e7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ 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, 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 + 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 84a27271..7927c276 100644 --- a/docker/README.md +++ b/docker/README.md @@ -137,6 +137,41 @@ header) are configured via separate env vars: | `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` | +### Instrument metadata (`PINEFORGE_SYMINFO`) + +`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. + +- `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 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 + 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 container surfaces these as `{"engine":"pineforge","error":"..."}` on diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index a04b89d6..c6aa7da8 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 f1047de1..2b95e2d2 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 16b64665..e48f0818 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,214 @@ 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} + 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"]) +@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 + + +def test_syminfo_error_is_a_value_error(): + assert issubclass(run_json.SyminfoError, ValueError) + + +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) + 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): + 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)