From 1e490f11f7dbed9a49f153ca10fb2b62c31a7109 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 20 Oct 2026 11:30:37 +0800 Subject: [PATCH 1/3] report: fail with report_result_invalid when a report metric overflows; annualise cagr proportionally under one day, with a named form flag (CR-202, owner W-73) Squash of the CR-202 work on engine main 0212cabe2d. Its single parent is engine main, as the GitHub squash writes it. What changes - A report metric whose computed value overflows (buy_hold_return, every other report metric) fails the run with the typed failure report_result_invalid, reason nonfinite, instead of writing null. - Proportional annualisation (owner ruling W-73; docs/design/selected-window-wire-v1.3-revision-3.1.md, docs/pages/report-schema.md). span_years = (last bar time - first bar time) / (365.25 * 86400 * 1000 ms), as before. Under one day (span_ms < 86 400 000) cagr = (E/E0 - 1) * (1/span_years) * 100, the simple proportional form, always, and calmar = cagr / max_equity_drawdown_pct follows. From one day on cagr is the compounded form, unchanged; only if that value is not finite the simple form is used instead (a rare ranking inversion across the one-day boundary, documented). sharpe_bar and sortino_bar use the same formulas on every span and are never absent by span; sharpe_tv and sortino_tv are unchanged. No null for these four on a valid run; every other non-finite value is the typed report_result_invalid. - The public report names the form under diagnostics.annualization (outside the 135 digest leaves): cagr = "simple_proportional_subday" or "simple_proportional_overflow", and calmar carries the same string when it is not null. The writer derives it from the curve it holds; for a run of one day or more whose compounded form is not finite and whose first mark is not proven to be the initial capital the cagr is still the simple value and only the overflow flag is absent (documented; a runtime export of the capital is carded for a later release). The frozen report structures gain no member. - C host (src/native_c_host.cpp): a report failure after a completed run is recorded and returns E_STATE, a report failure after an earlier latched failure returns E_RUN_FAILED with that failure; in both the caller's report struct is defined (zeroed local report freed on a throw) instead of half written. - The native runner keeps its own report format: undefined metrics stay the string "NaN" (runner/README.md); only an infinite value is the typed failure there. - Disclosed behaviour: an infinite first-open or last-close price and a non-finite selected anchor are now report_result_invalid (feed admission makes the first unreachable). - kNeutralReportDigest in tests/test_native_anchored_legs.cpp is a lab harvest of the neutral 15 one-minute bar run (its cagr is the simple proportional value on a 14-minute span); the value is taken from the lab log under the fixture authority of CORE-TOP. Documentation sentences reworded or replaced by this change, each named for scripts/check_doc_reverts.py (the sentence's introducing commit, then its first words): - 1bd075c048 (Trading metrics + equity curve in backtest report, #31): "See the per-field doxygen in `` for the exact rule on every field." in docs/pages/report-schema.md, now "in `` for the existing field domains; the rule below is authoritative" for cagr on a span under one day and when its compounded form overflows. - 1b08c612fd (EN-133, #384): "net_pnl, avg_trade, best_trade, worst_trade and max_drawdown, must be finite or the run fails with report_result_invalid, field naming the corresponding trades. or summary. and reason equal to nonfinite." in docs/pages/report-schema.md, extended with win_rate_pct and the equity-curve equity and open_profit leaves. - 5691220cfd (ADR-0001 rewritten against HEAD): "compute_equity_stats answers its all-NaN value with no error" in docs/adr/0001-kernel-adapter-boundary.md, now "answers its defined empty-curve metrics with no error". - 375795a787 (shard the ownership manifest-controls test, CR-196): "(9, 9, 9 and 8 for shards 1 to 4 of 4: 8, 8, 8 and 7 cases plus the guard)" and "The separate cheap test_shards_partition_every_case_exactly_once guard runs in every shard" in tests/ownership/README.md, now "(10, 10, 10 and 10 for shards 1 to 4 of 4: 9 cases plus the guard in each)" and "Each shard also runs the cheap test_shards_partition_every_case_exactly_once guard". - file:line citation digits only, wording kept: ADR-0001 (the "inclusive bar metrics" bullet, the "Reporting." bullet, the strategy_configure_native_v1 row, the magnifier_sub_bars_total row), native-feature-parity rows RP2, RP4, S1b and "A zoned chart-day memo", native-refactor-progress (the quiet-bar cost check and the every_gate_is_taken_both_ways citation), native-engine ("MUST mirror" citation of compute_equity_stats). Documentation sentences the history of this branch introduced and later reworded or removed (none of them is deleted by this squash against main): - c9ba50751e introduced "Report arithmetic that produces a nonfinite value, including overflow, fails with `report_result_invalid` and reason `nonfinite`." and "The engine checks computed values before they can become a metric absence." in docs/pages/report-schema.md, both kept. - eba8561ef0 (revision 3.1) introduced "The constant policy list is in `docker/report_annualization.def`, shared by the native metric walk and public report writer." in docs/design/selected-window-wire-v1.3-revision-3.1.md; e963e5e728 (the earlier squash of this work) introduced the revision 3.1 note's null-rule sentences ("The short-span absence applies to cagr, calmar, sharpe_bar and sortino_bar", "Calmar carries the short-span reason with CAGR even when the curve also has zero drawdown", "span_too_short_to_annualize") and the report-schema.md ones; the owner ruling W-73 withdrew that null rule and they are reworded or replaced in this change. Co-Authored-By: Claude Sonnet 5.5 --- docker/report_annualization.def | 2 + docker/run_json.py | 137 ++- docker/selected_window_report.py | 6 +- docs/adr/0001-kernel-adapter-boundary.md | 12 +- docs/design/native-feature-parity.md | 4 +- .../selected-window-wire-v1.3-revision-3.1.md | 194 ++++ docs/native-refactor-progress.md | 6 +- docs/pages/metrics.md | 5 + docs/pages/native-engine.md | 2 +- docs/pages/report-schema.md | 133 ++- runner/report.hpp | 38 +- scripts/check_selected_window_ownership.py | 58 +- src/engine_metrics.cpp | 159 ++-- src/engine_report.cpp | 27 +- src/metrics_annualization.hpp | 11 + src/native_c_host.cpp | 43 +- src/report_value_invalid.hpp | 41 + tests/CMakeLists.txt | 4 + tests/ownership/README.md | 15 +- tests/ownership/manifest.v1.json | 144 ++- .../report/buy_hold_return_overflow/NOTES.md | 35 + .../report/buy_hold_return_overflow/bars.csv | 41 + .../buy_hold_return_overflow/expect.json | 12 + .../buy_hold_return_overflow/probe.pine | 4 + .../report/buy_hold_return_overflow/run.json | 5 + .../buy_hold_return_overflow/syminfo.json | 7 + tests/ownership/test_manifest_checker.py | 46 +- tests/report_failure_checks.hpp | 68 ++ tests/test_adapter_quiet_bar.cpp | 49 + tests/test_metrics_l4d.cpp | 13 +- tests/test_metrics_overflow.cpp | 836 ++++++++++++++++++ tests/test_native_anchored_legs.cpp | 6 +- tests/test_native_c_host_report_failure.cpp | 241 +++++ tests/test_run_json_nonfinite_report.py | 515 ++++++++++- tests/test_run_json_selected_window.py | 13 +- tests/test_selected_report_view.cpp | 25 +- tests/test_utc_month_memo.cpp | 15 +- 37 files changed, 2823 insertions(+), 149 deletions(-) create mode 100644 docker/report_annualization.def create mode 100644 docs/design/selected-window-wire-v1.3-revision-3.1.md create mode 100644 src/metrics_annualization.hpp create mode 100644 src/report_value_invalid.hpp create mode 100644 tests/ownership/probes/report/buy_hold_return_overflow/NOTES.md create mode 100644 tests/ownership/probes/report/buy_hold_return_overflow/bars.csv create mode 100644 tests/ownership/probes/report/buy_hold_return_overflow/expect.json create mode 100644 tests/ownership/probes/report/buy_hold_return_overflow/probe.pine create mode 100644 tests/ownership/probes/report/buy_hold_return_overflow/run.json create mode 100644 tests/ownership/probes/report/buy_hold_return_overflow/syminfo.json create mode 100644 tests/report_failure_checks.hpp create mode 100644 tests/test_metrics_overflow.cpp create mode 100644 tests/test_native_c_host_report_failure.cpp diff --git a/docker/report_annualization.def b/docker/report_annualization.def new file mode 100644 index 000000000..5d7cd5201 --- /dev/null +++ b/docker/report_annualization.def @@ -0,0 +1,2 @@ +// Sub-day boundary for simple proportional CAGR, included by the kernel. +PF_ANNUALIZATION_SPAN_MS(86400000) diff --git a/docker/run_json.py b/docker/run_json.py index 3312c7255..059349926 100755 --- a/docker/run_json.py +++ b/docker/run_json.py @@ -54,7 +54,7 @@ "longs": { ...pf_trade_stats_t... }, # long trades only "shorts": { ...pf_trade_stats_t... }, # short trades only "equity": { ...pf_equity_stats_t... } # sharpe/sortino/cagr/calmar/... - }, # any NaN statistic -> null (see _num) + }, # defined metric absence -> null (see _num) "equity_curve": [ # ABI v2: one point per script bar { "time_ms": int, "equity": float, "open_profit": float }, ... @@ -129,7 +129,8 @@ 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. +for the per-field meaning of every metrics.* key. Overflow and other computed +nonfinite report values fail as report_result_invalid, reason nonfinite. Selected-window mode (selected-window wire v1.3, full/v1 report, no digest). Opt in with --report-policy selected-window/v1, --window-start-ms T, --window-end-ms E, @@ -2210,6 +2211,13 @@ def failure_line(text, code=None, code_args=None) -> str: (_cut_utf8(_no_surrogates(value), ERROR_ARG_TEXT_MAX) if isinstance(value, str) else value) for name, value in (code_args or {}).items()} + if code == "report_result_invalid": + # Translate native metric fields at the public wire boundary. + field = doc["args"].get("field") + if isinstance(field, str): + prefix, separator, name = field.rpartition(".") + if prefix == "metrics.equity": + doc["args"]["field"] = prefix + separator + EQUITY_REPORT_KEYS.get(name, name) line = _dump_line(doc) if len(line) > ERROR_LINE_MAX: if code is not None and len(_dump_line({**doc, "error": ""})) > ERROR_LINE_MAX: @@ -3543,12 +3551,13 @@ def fmt_utc(ms: int) -> str: ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC") -def _num(x): - """JSON-safe float. The engine's metric NaN convention (empty / zero - denominator -> NaN, never 0) cannot survive JSON: json.dump emits a bare - `NaN` token that a strict downstream JSON.parse (the MCP layer) rejects. - Collapse every non-finite double to null so the report stays valid JSON.""" +def _num(x, *, report_metric=False): + """Native metric NaN is the producer's explicit defined-absence sentinel. + Preserve infinity until _validate_report_result can raise the typed R. + Recorded script outputs have their separate nonfinite-to-null convention.""" f = float(x) + if report_metric: + return None if math.isnan(f) else f return f if math.isfinite(f) else None @@ -3559,16 +3568,79 @@ def _num(x): EQUITY_REPORT_KEYS = {"sharpe_monthly": "sharpe_tv", "sortino_monthly": "sortino_tv"} +# Proportional annualisation (revision 3.1): a span under one day annualises cagr +# by the simple proportional form; one day or more keeps the compounded form. The +# bound is docker/report_annualization.def's; run_json.py ships alone, so +# tests/test_run_json_nonfinite_report.py pins this copy to the native one. +MIN_ANNUALIZATION_SPAN_MS = 86_400_000 + + +def _annualization_diagnostics(curve, metrics, trades=(), window_anchor=False): + """Name the simple proportional form of cagr, and of the calmar made from it. + + No span makes cagr, calmar, sharpe_bar or sortino_bar null, so the writer + names the form from what it holds. Under one day the kernel always takes + the simple form: a finite cagr on a positive sub-day span is flagged. From + one day on it takes the compounded form unless that is not finite, so the + writer repeats the kernel's arithmetic on the curve's own marks: the ratio + of the last mark to the initial capital. The initial capital is the first + mark only when the rows prove it untouched: the selected-window anchor + (window_anchor), or no open profit and no trade entered at it. With no such + proof the writer makes no claim and names nothing. calmar is cagr over the + drawdown percent, so it carries the same flag as the cagr it was computed + from: its key is present exactly when the cagr key is and calmar is not + null (zero drawdown leaves it null: then no calmar key). The bar ratios use + one formula on every span and have no reason. + Python's integer subtraction is exact across the full int64 timestamp + domain; convert the difference to double just as the kernel does. + """ + cagr = metrics["equity"]["cagr"] + if not curve or cagr is None or not math.isfinite(cagr): + return {} + first, last = curve[0]["time_ms"], curve[-1]["time_ms"] + span_ms = float(last - first) if last >= first else -float(first - last) + if not span_ms > 0: + return {} + if span_ms < MIN_ANNUALIZATION_SPAN_MS: + form = "simple_proportional_subday" + else: + if not window_anchor and (curve[0]["open_profit"] != 0 + or any(row["entry_bar_index"] <= 0 for row in trades)): + return {} + capital, final = curve[0]["equity"], curve[-1]["equity"] + if not (math.isfinite(capital) and capital > 0 and math.isfinite(final) and final > 0): + return {} + span_years = span_ms / (365.25 * 86400.0 * 1000.0) + try: + compounded = (math.pow(final / capital, 1.0 / span_years) - 1.0) * 100.0 + except OverflowError: + compounded = math.inf + if math.isfinite(compounded): + return {} + form = "simple_proportional_overflow" + if metrics["equity"]["calmar"] is None: + return {"cagr": form} + return {"cagr": form, "calmar": form} + + +def _annualization_block(curve, metrics, trades=(), window_anchor=False) -> dict: + """The diagnostics entry for revision 3.1: present only when cagr used a simple form.""" + reasons = _annualization_diagnostics(curve, metrics, trades, window_anchor) + return {"annualization": reasons} if reasons else {} + + def _stats_dict(s) -> dict: """Serialize a pf_trade_stats_t / pf_equity_stats_t ctypes struct to a dict, keying off each field's ctype: integer counters stay ints, every double is - sanitized through _num. Driven by _fields_ so it tracks the struct verbatim; + normalized through _num's metric mode. Computed nonfinite values must + already have failed native M checks; infinities also fail the C backstop. + Driven by _fields_ so it tracks the struct verbatim; an equity struct's two monthly ratios take their EQUITY_REPORT_KEYS.""" keys = EQUITY_REPORT_KEYS if isinstance(s, EquityStatsC) else {} out = {} for name, ctype in s._fields_: v = getattr(s, name) - out[keys.get(name, name)] = _num(v) if ctype is ctypes.c_double else int(v) + out[keys.get(name, name)] = _num(v, report_metric=True) if ctype is ctypes.c_double else int(v) return out @@ -3578,7 +3650,10 @@ def build_report_dict(report: ReportC, ohlcv_path: Path, applied_inputs: dict[str, str], applied_overrides: dict[str, str], applied_runtime: dict[str, object] | None = None, - trade_entry_incarnations: list[int] | None = None) -> dict: + trade_entry_incarnations: list[int] | None = None, + window_anchor: bool = False) -> dict: + # window_anchor: the curve's first point is the selected-window anchor, the + # engine's own initial capital by construction (_annualization_diagnostics). trades = [] pnls: list[float] = [] for i in range(report.trades_len): @@ -3613,9 +3688,18 @@ def build_report_dict(report: ReportC, ohlcv_path: Path, cum, peak, max_dd = 0.0, 0.0, 0.0 for p in pnls: + if not math.isfinite(p): + raise RunFailure("the report result trades.pnl is not finite", + "report_result_invalid", + {"field": "trades.pnl", "reason": "nonfinite"}) cum += p peak = max(peak, cum) - max_dd = min(max_dd, cum - peak) + drawdown = cum - peak + if not math.isfinite(cum) or not math.isfinite(drawdown): + raise RunFailure("the report result summary.max_drawdown is not finite", + "report_result_invalid", + {"field": "summary.max_drawdown", "reason": "nonfinite"}) + max_dd = min(max_dd, drawdown) # Computed trading metrics (ABI v2): all/longs/shorts trade stats + the # equity-curve-derived block (sharpe/sortino/cagr/calmar/...). See the @@ -3636,8 +3720,8 @@ def build_report_dict(report: ReportC, ohlcv_path: Path, p = report.equity_curve[i] equity_curve.append({ "time_ms": int(p.time_ms), - "equity": _num(p.equity), - "open_profit": _num(p.open_profit), + "equity": float(p.equity), + "open_profit": float(p.open_profit), }) return { @@ -3674,6 +3758,7 @@ def build_report_dict(report: ReportC, ohlcv_path: Path, "magnifier_sub_bars_total": int(report.magnifier_sub_bars_total), "magnifier_sample_ticks_total": int(report.magnifier_sample_ticks_total), "bar_magnifier_enabled": bool(report.bar_magnifier_enabled), + **_annualization_block(equity_curve, metrics, trades, window_anchor), }, "trades": trades, "metrics": metrics, @@ -3682,9 +3767,9 @@ def build_report_dict(report: ReportC, ohlcv_path: Path, def _validate_report_result(out: dict) -> None: - """Canonical trade and summary doubles have no defined non-finite absence. - Keep the failure fields as engine-owned literals; metrics, equity and - recorded outputs retain their existing _num-to-null convention.""" + """Required results are finite; only native metric absence is nullable. + Metric keys come from the producer's ctypes descriptors and alias map, + after native computation has distinguished absence from computed NaN.""" for trade in out["trades"]: for key, field in ( ("entry_price", "trades.entry_price"), @@ -3701,6 +3786,7 @@ def _validate_report_result(out: dict) -> None: "report_result_invalid", {"field": field, "reason": "nonfinite"}) for key, field in ( + ("win_rate_pct", "summary.win_rate_pct"), ("net_pnl", "summary.net_pnl"), ("avg_trade", "summary.avg_trade"), ("best_trade", "summary.best_trade"), @@ -3711,6 +3797,22 @@ def _validate_report_result(out: dict) -> None: raise RunFailure(f"the report result {field} is not finite", "report_result_invalid", {"field": field, "reason": "nonfinite"}) + for block in ("all", "longs", "shorts", "equity"): + for name, value in out["metrics"][block].items(): + if value is not None and not math.isfinite(value): + metric_field = f"metrics.{block}.{name}" + raise RunFailure(f"the report result {metric_field} is not finite", + "report_result_invalid", + {"field": metric_field, "reason": "nonfinite"}) + for point in out["equity_curve"]: + for key, field in ( + ("equity", "equity_curve.equity"), + ("open_profit", "equity_curve.open_profit"), + ): + if not math.isfinite(point[key]): + raise RunFailure(f"the report result {field} is not finite", + "report_result_invalid", + {"field": field, "reason": "nonfinite"}) def _request_invalid(text: str, option: str, exit_status: int = 1) -> RunFailure: @@ -4387,7 +4489,8 @@ def _make_state(): if incarnation_accessor is not None else None) out = build_report_dict( report, args.ohlcv, fed_count, plan["fed_first_data_ms"], plan["fed_last_data_ms"], - elapsed, inputs, overrides, applied_runtime, trade_entry_incarnations) + elapsed, inputs, overrides, applied_runtime, trade_entry_incarnations, + window_anchor=True) _validate_report_result(out) out["report_window"] = window out["report_shape"] = swr.report_shape_full(len(out["equity_curve"]), diff --git a/docker/selected_window_report.py b/docker/selected_window_report.py index 03c8f6798..880a44680 100644 --- a/docker/selected_window_report.py +++ b/docker/selected_window_report.py @@ -595,7 +595,7 @@ def _established_number(value: Any) -> Optional[float]: def _finite_actual(value: Any) -> bool: """Whether value, as the engine produced it, is a finite number: an int or a float that is - not a bool, not None (the report writes a non-finite double as null), not text, and not NaN + not a bool, not None (executed curve marks must be finite), not text, and not NaN or an infinity. Nothing is converted or read from text.""" if isinstance(value, bool) or not isinstance(value, (int, float)): return False @@ -619,7 +619,9 @@ def check_selected_curve(curve: Sequence[Mapping[str, Any]], start_ms: int, with the capital is skipped. No capital is guessed or written back, and every other check above, the finiteness of the actual anchor equity included, is made in both cases. When the provenance does establish a finite capital (a number or text spelling one), the anchor - equity must equal it, or the run is an engine invariant failure.""" + equity must equal it, or the run is an engine invariant failure. The anchor is also the + initial capital the report writer recomputes the compounded cagr from, with or without an + established capital (run_json._annualization_diagnostics, window_anchor).""" if len(curve) != window_script_bars + 1: raise _invariant(f"the selected curve has {len(curve)} points, the plan says " f"{window_script_bars + 1}") diff --git a/docs/adr/0001-kernel-adapter-boundary.md b/docs/adr/0001-kernel-adapter-boundary.md index 7110412c6..24f597bdc 100644 --- a/docs/adr/0001-kernel-adapter-boundary.md +++ b/docs/adr/0001-kernel-adapter-boundary.md @@ -86,7 +86,7 @@ summary of it. `NativeCoordinate::interval_index` (`market_driver.hpp:74`) is the sequential SCRIPT-bar index even when `input_tf` is finer than `script_tf`. The same script coordinate books `PyramidEntry::entry_bar_index`, trade-row indices and - the inclusive bar metrics (`engine_orders.cpp:113-114`, `engine_metrics.cpp:140`). + the inclusive bar metrics (`engine_orders.cpp:113-114`, `engine_metrics.cpp:158`). `NativeCoordinate::input_interval_index` (`market_driver.hpp:79`) names the input slot that supplied the point for a host that needs input cadence. Both coordinate values are folded by `hash_coordinate` @@ -172,7 +172,7 @@ summary of it. (`guard_native_mutation` `engine_aux_security.cpp:83`). Indicators are `ta_*.cpp`, session/calendar `session_time.cpp` / `native_calendar.cpp`, FX `configure_native_fx_curve` (`native_host.hpp:1202`). -- **Reporting.** `fill_report` (`engine.hpp:1859`, `src/engine_report.cpp:56`) gives a bare host +- **Reporting.** `fill_report` (`engine.hpp:1859`, `src/engine_report.cpp:58`) gives a bare host trade stats, and the equity curve is a run-spec choice rather than a host chore. `NativeReportPolicy::HostRecorded` (the default) leaves the series to the host, whose `record_equity_point` / `update_equity_extremes` remain protected @@ -181,8 +181,8 @@ summary of it. `KernelRecordedAtHostMarks` records the same series at the host's own marks. `report_open_position_at_end` books the still-open position as a mark-to-market row under `KernelRecorded`. Under `HostRecorded` an unrecorded curve still degenerates — - `compute_equity_stats` answers its all-NaN value with no error (`src/engine_metrics.cpp:203`, - consumed by `fill_metrics_section`, `src/engine_report.cpp:138-145`) — which is why the policy exists. + `compute_equity_stats` answers its defined empty-curve metrics with no error (`src/engine_metrics.cpp:222`, + consumed by `fill_metrics_section`, `src/engine_report.cpp:205`) — which is why the policy exists. - **Reproducibility.** Two digests, one wrapping the other. `native_continuation_hash()` (`native_host.hpp:1376`) returns the consumer's event/continuation hash. **A digest names the run's inputs** (rule 2): the timezone identity enters it as the zone's *content* — @@ -761,10 +761,10 @@ number names its symbol on this tree. | `NativeReportPolicy::KernelRecordedAtHostMarks` (`native_run_spec.hpp:64`) | **C++-only** (lane E22's ruling, restated) | Under it the host names each report point from inside its own callbacks, and the C table has no call that marks one: a C host could only declare a series nobody records. `pf_native_report_policy_e` (`native_c_api.h:661`) leaves its value unnamed, and the enum guard holds that exclusion. A C++ host now reaches the same kernel producer through `NativeStrategyHost::mark_native_report_point`; a mark outside a running HostMarks run returns false. | | `strategy_set_path_order` (`pineforge.h:948`) clamps an out-of-range mode to AUTO | **retained** | A `void` ABI v4 setter cannot answer a refusal. Its values are now documented as `pf_native_path_order_e`'s (`native_c_api.h:904`); a C host that wants an unknown word refused declares `path_order` in `pf_native_run_spec_ext_v1`, which answers `PF_NATIVE_E_TAG`. | | the enumerated `int32` fields of `pf_pending_order_v1_t` (`strategy_pending_order_get` `pineforge.h:1096`) | **no C names in the header; generator-owned** | The mirror is generated from the Pine source host's resting-order record by `scripts/gen_pending_order_mirror.py` (held by its `--check` source guard) and self-described at run time by `strategy_pending_order_layout`. Its enumerated fields are the source layer's order vocabulary, not a kernel enumeration. A C name for them belongs in that generator, which owns the struct, so that it cannot drift from the mirror — never hand-written beside it. | -| `strategy_configure_native_v1` (`c_abi.cpp:980`) versus `strategy_configure_native_ext_result_v1` (`native_c_host.cpp:4066`) | **retained legacy base plus typed additive route** | A base specification the kernel rejects still returns `-1` and latches `Failed`, preserving its frozen caller contract; a Ready misuse retains that Contract latch. The C boundary refuses arguments, a Running phase and a Completed hash hook without entering kernel configure, so a read cannot reconfigure its host. The extended result call validates before its first configure, writes `pf_native_spec_error_t` / `pf_native_spec_field_t`, and names reused-host session-key and run-number refusals; no old signature or layout moved. | +| `strategy_configure_native_v1` (`c_abi.cpp:980`) versus `strategy_configure_native_ext_result_v1` (`native_c_host.cpp:4107`) | **retained legacy base plus typed additive route** | A base specification the kernel rejects still returns `-1` and latches `Failed`, preserving its frozen caller contract; a Ready misuse retains that Contract latch. The C boundary refuses arguments, a Running phase and a Completed hash hook without entering kernel configure, so a read cannot reconfigure its host. The extended result call validates before its first configure, writes `pf_native_spec_error_t` / `pf_native_spec_field_t`, and names reused-host session-key and run-number refusals; no old signature or layout moved. | | `strategy_configure_native_fx_curve_v1` (`pineforge.h:546`) | **typed additive route** | `strategy_configure_native_fx_curve_ext_v1` writes the C twin of `NativeFxCurveValidation` and its offending index. The old `-1` route remains unchanged, while a C caller can now distinguish a decreasing timestamp, non-positive rate, allocation failure or wrong phase. | | `on_native_timeframe_bar` (`native_host.hpp:885`) and `NativeTimeframeBarContext::interval` (`native_host.hpp:809`) | **query routed** | The callback signature stays frozen. `strategy_native_timeframe_bar_interval_v1` is valid only during that callback and copies the kernel calendar interval without deriving it from the delivered bar or delivery timestamp. | -| `magnifier_sub_bars_total` / `magnifier_sample_ticks_total` (`engine_report.cpp:75-76`) | **kernel-owned report facts** | The generic intrabar driver already increments `NativeDriverStatistics::sub_bars_processed` / `sample_ticks_processed` for every retained path (`native_execution_consumer.cpp:8681-8693`); the kernel now publishes those same counters for bare hosts. They are report fields, not new durable state: the existing `native_continuation_hash` fold of `NativeDriverStatistics` (`native_execution_consumer.cpp:1125-1130`) sees the same values, so no hash contract moves. | +| `magnifier_sub_bars_total` / `magnifier_sample_ticks_total` (`engine_report.cpp:77-78`) | **kernel-owned report facts** | The generic intrabar driver already increments `NativeDriverStatistics::sub_bars_processed` / `sample_ticks_processed` for every retained path (`native_execution_consumer.cpp:8681-8693`); the kernel now publishes those same counters for bare hosts. They are report fields, not new durable state: the existing `native_continuation_hash` fold of `NativeDriverStatistics` (`native_execution_consumer.cpp:1125-1130`) sees the same values, so no hash contract moves. | | `pf_native_lot_excursion_v1` (`native_c_api.h:1912`) | **additive fee tail** | `entry_commission` carries the C++ `ClosedLotExcursionFacts::entry_commission` in account currency. The callback table publishes a trailing layout marker so F4 callers still receive the base facts length; current callers receive the fee tail without changing `PF_NATIVE_API_VERSION`. | | `strategy_outputs_set_enabled` and the eight `strategy_outputs_*` readers (`pineforge.h`, group `pf_outputs`) | **additive runtime exports** | Recording is off by default, and nothing a run computes reads the record. The event copy is size-prefixed like `strategy_pending_order_get`, and `PF_ABI_VERSION` stays 4. The writers are C++ only in this version (the BASE-CLASS SEAMS rows of `native_c_api.h`): a C host declares no outputs, so its handle answers every reader as empty. | diff --git a/docs/design/native-feature-parity.md b/docs/design/native-feature-parity.md index 2442b75c8..4d183bcfb 100644 --- a/docs/design/native-feature-parity.md +++ b/docs/design/native-feature-parity.md @@ -156,9 +156,9 @@ Columns: **Feature** (adapter mechanism, cited) · **Native today** (`yes` / `pa | ID | Feature (adapter mechanism) | Native today | Class | Lane | Sources | Notes | |---|---|---|---|---|---|---| | RP1 | Per-lot MFE / MAE, kernel-sampled or host-owned | **yes** — sampler `apply_excursion` native_execution_consumer.cpp:3280, closing-row fold `max_runup` engine_orders.cpp:157-171; `owns_lot_excursions` native_host.hpp:1021-1025 | K+A | — | F:H1 O:I1 O:I2 S:X1 | **Disagree:** S partial + its P8 excursion-mode enum (single-source, not adopted, Appendix A.9). **Lowered by R5 lane H-THIN:** the Pine host keeps the kernel's `owns_lot_excursions()` default, so the sampler is the Pine excursion; the host model, its entry-bar masks and its caches are deleted, and the source layer's fold no longer carries them. **Measured against TradingView first (R5 lane H-MEASURE, `tests/test_e19_excursion_tape.cpp`):** the host-owned model was `ab9714be`'s, not TradingView's — where it and the kernel sampler differ the kernel's number is TradingView's (entry-bar over-mask, exit-bar over-fold after an at-open exit, a `calc_on_order_fills` scratch TradingView reports as 0 / 0); both miss a bar's open print ahead of a same-bar stop fill. On the corpus the two models differ on 341 of 431 390 trades, and of the 326 of those a TradingView export identifies, the kernel's number is TradingView's on 315 and the host's on none. The lowering moved the cells of 335 trades in 19 corpus probes: 324 now equal TradingView's, 11 moved closer, none farther; the all-in residual's own entry-bar call is the tape of the private fixture set `e19_allin_trim`. | -| RP2 | Closed-trade list, trade statistics, report struct | **yes** — `fill_report` engine_report.cpp:39-85, `get_trade` engine.hpp:1792-1829, `compute_trade_stats` engine_metrics.cpp:122 | K | — | F:H2 O:I3 S:X2 | — | +| RP2 | Closed-trade list, trade statistics, report struct | **yes** — `fill_report` engine_report.cpp:58-105, `get_trade` engine.hpp:1792-1829, `compute_trade_stats` engine_metrics.cpp:133 | K | — | F:H2 O:I3 S:X2 | — | | RP3 | Equity curve | **no** — `update_equity_extremes` engine.hpp:1362 / `record_equity_point` engine.hpp:1380 are protected and were called only by the adapter **Closed:** `NativeReportPolicy::KernelRecorded` native_run_spec.hpp:63 has the consumer fold the extremes and append one point per script calculation, labelled with the script interval's open; `KernelRecordedAtHostMarks` records the same series at the host's own marks (`tests/test_native_report_truth.cpp`). | K | L2 | F:H3 O:I4 S:X2 | S's "partial" folds RP2 in | -| RP4 | Equity metrics (drawdown, run-up, Sharpe / Sortino, CAGR, time in market) | **no** — `compute_equity_stats` exits early on an empty curve engine_metrics.cpp:203; `fill_metrics_section` engine_report.cpp:138-145 **Closed:** with a recorded curve the drawdown / run-up walk is finite and every equity metric is computed over a real series; `report_open_position_at_end` adds the still-open position as a mark-to-market row (`tests/test_native_report_truth.cpp`). | K | L2 | F:H3 O:I5 | silent degenerate metrics, no error (O) | +| RP4 | Equity metrics (drawdown, run-up, Sharpe / Sortino, CAGR, time in market) | **no** — `compute_equity_stats` exits early on an empty curve engine_metrics.cpp:222; `fill_metrics_section` engine_report.cpp:205 **Closed:** with a recorded curve the drawdown / run-up walk is finite and every equity metric is computed over a real series; `report_open_position_at_end` adds the still-open position as a mark-to-market row (`tests/test_native_report_truth.cpp`). | K | L2 | F:H3 O:I5 | silent degenerate metrics, no error (O) | | RP5 | Range-end row for a position still open at the end (`scheduler_record_range_end` pine_strategy_host.cpp:1982) | **yes, shared** — one generic producer `NativeExecutionConsumer::append_open_position_report_rows`, called by the kernel's run-end `record_open_position_report_rows` (`KernelRecorded` + `report_open_position_at_end`) and by the adapter at TradingView's mark; `report_trade_count()` engine.hpp:1831 includes the rows | K + A report shape | L2, Q6 | F:H4 O:I6 S:X2 | **Disagree:** S says kernel range-end rows exist (storage only). The ROWS are now the kernel's (§3.7 part 1); TV report shape — the equity re-mark, the extreme re-fold, the same-bar re-sort, three marks — stays A, measured in §3.7 part 2. The short-seed swaps (`project_short_seed_report_rows` pine_strategy_host.cpp:1444) stay A as well: a projection of two rows' `entry_incarnation` onto `ab9714be`'s numbering, which TradingView does not export; measured against the kernel's own lot identities by `tests/test_short_seed_report_swap.cpp` (R5 lane H-MEASURE), and pinned by `test_native_oracle_short_seed_percent_full_l2`, whose two incarnation checks fail when the swap is skipped. The swap runs only when a later close of the seed id closes a remnant; a flat collision keeps the kernel's numbering. | | RP6 | Per-trade accessors | **partial** — `get_trade` is public; the `closed_trade_*` family is protected engine.hpp:1391 **Closed:** `closed_trade_count()` / `closed_trade(i)` and `report_trade_count()` / `get_report_trade(i)` are public, and `closed_trade_close_cause(i)` engine.hpp:1849 answers the typed `execution::CloseCause` the closer recorded (`tests/test_native_report_truth.cpp`). | K | L2 | F:H6 | (single-source) | | RP7 | Live order-action stream (runner ledger / webhook) | **yes** — `stream_observe_actions_` engine_execution.cpp:1383, enabled `stream_observe_actions_` native_execution_consumer.cpp:10589, C ABI `strategy_stream_order_actions_len` c_abi.cpp:737-754 | K | — | F:H5 | (single-source) | diff --git a/docs/design/selected-window-wire-v1.3-revision-3.1.md b/docs/design/selected-window-wire-v1.3-revision-3.1.md new file mode 100644 index 000000000..290cb07fa --- /dev/null +++ b/docs/design/selected-window-wire-v1.3-revision-3.1.md @@ -0,0 +1,194 @@ +# Selected-window wire v1.3 — revision 3.1 clarification + +The earlier reading of this note, under which cagr, calmar, sharpe_bar and +sortino_bar are null on a measured span under one day, is withdrawn. +Proportional annualization replaces it: on a valid run no span makes any of the +four null, `cagr` takes its simple proportional form under one day, and the +public wire names the simple form wherever `cagr` used it, under +`diagnostics.annualization.cagr` and, when `calmar` is not null, under +`diagnostics.annualization.calmar`. + +Revision 3 Appendix A's equity-metric row (line 83) permits a finite double or +a defined null, with metric-domain checks and otherwise +`report_result_invalid`. Its closing paragraph (line 93) permits typed null +only for a defined absence, never as a catch-all for infinity or arithmetic +overflow. This clarification defines proportional annualization, which adds no +absence, and keeps the exhaustive 135 scalar leaf paths and every other +ownership rule unchanged. + +## Annualization domain + +The proportional rule applies to **cagr**, and reaches calmar through it; +**sharpe_bar and sortino_bar** keep one formula on every span. The complete +annualized-field inventory of `compute_equity_stats` +(`src/engine_metrics.cpp:222`), with exact public paths and reason keys, is +below. No field other than `cagr` and `calmar` receives an annualization +reason, and `calmar` carries the same reason as the `cagr` it was computed from. + +In the table `E` is the final equity (the last equity-curve mark), `E0` the +initial capital and `span_years = span_ms / (365.25 * 86400 * 1000)`. A valid +run has `span_years > 0`, `E0 > 0` and `E > 0`. + +| Field (public wire key) | Path | C ABI field | Rule | Reason key path | Reason value | +| --- | --- | --- | --- | --- | --- | +| `cagr`, span under one day | `metrics.equity.cagr` (`metrics.equity` only; absent from `summary` and `metrics.all`). | `pf_equity_stats_t::cagr` | Simple proportional form `(E/E0 - 1) * (1/span_years) * 100`, always, when `0 < span_ms < 86 400 000` on a valid run; a finite number, never `null` on account of the span. | `diagnostics.annualization.cagr` | `simple_proportional_subday` | +| `cagr`, one day or more | `metrics.equity.cagr` (`metrics.equity` only; absent from `summary` and `metrics.all`). | `pf_equity_stats_t::cagr` | Compounded form `(pow(E/E0, 1/span_years) - 1) * 100`, unchanged, when `span_ms >= 86 400 000` (exactly one day included) and it is finite. | None. | None; no reason. | +| `cagr`, one day or more, compounded form not finite | `metrics.equity.cagr` (`metrics.equity` only; absent from `summary` and `metrics.all`). | `pf_equity_stats_t::cagr` | The simple proportional form above, used when `span_ms >= 86 400 000` and the compounded form is not finite (overflow). | `diagnostics.annualization.cagr` | `simple_proportional_overflow` | +| `calmar`, span under one day | `metrics.equity.calmar` (`metrics.equity` only; absent from `summary` and `metrics.all`). | `pf_equity_stats_t::calmar` | `cagr / max_equity_drawdown_pct` with the simple proportional `cagr` above; `null` at zero drawdown as before. The reason key is present exactly when the `cagr` reason key is present and `calmar` is not `null`. | `diagnostics.annualization.calmar` | `simple_proportional_subday` | +| `calmar`, one day or more | `metrics.equity.calmar` (`metrics.equity` only; absent from `summary` and `metrics.all`). | `pf_equity_stats_t::calmar` | `cagr / max_equity_drawdown_pct` with the compounded `cagr` above; `null` at zero drawdown as before. | None. | None; no reason. | +| `calmar`, one day or more, compounded form not finite | `metrics.equity.calmar` (`metrics.equity` only; absent from `summary` and `metrics.all`). | `pf_equity_stats_t::calmar` | `cagr / max_equity_drawdown_pct` with the simple proportional `cagr` used because the compounded form is not finite; `null` at zero drawdown as before. The reason key is present exactly when the `cagr` reason key is present and `calmar` is not `null`. | `diagnostics.annualization.calmar` | `simple_proportional_overflow` | +| `sharpe_bar` | `metrics.equity.sharpe_bar` (`metrics.equity` only; absent from `summary` and `metrics.all`). | `pf_equity_stats_t::sharpe_bar` | The observed-bar-density formula on every span; never `null` on account of the span. | None. | None; no reason. | +| `sortino_bar` | `metrics.equity.sortino_bar` (`metrics.equity` only; absent from `summary` and `metrics.all`). | `pf_equity_stats_t::sortino_bar` | The same density scaling with downside deviation, on every span; never `null` on account of the span. | None. | None; no reason. | +| `sharpe_tv`, `sortino_tv` (not in the set) | `metrics.equity.sharpe_tv`, `metrics.equity.sortino_tv` (`metrics.equity` only; absent from `summary` and `metrics.all`). | `pf_equity_stats_t::sharpe_monthly`, `pf_equity_stats_t::sortino_monthly` | Calendar-month returns with fixed sqrt(12), independent of `span_ms`; existing null domain. | None. | None; no reason. | + +In native `ReportC` and C ABI `pf_report_t`, these members are inside +`metrics.equity`; a defined absence is a NaN and carries no reason, and neither +form of `cagr` or of `calmar` carries one. The +separate runner JSON uses `metrics.equity.cagr`, `metrics.equity.calmar`, +`metrics.equity.sharpe_bar` and `metrics.equity.sortino_bar` for the four +fields, encoding an absence as the string `"NaN"` with no reason. +Its monthly keys are `metrics.equity.sharpe_monthly` and +`metrics.equity.sortino_monthly`, with the same string encoding for an absence. + +The constructions and span treatments are: + +- `cagr`: Compound annual growth, using the measured span in years, from one day on; the simple proportional form under one day, and in place of a compounded form that is not finite. +- `calmar`: CAGR divided by maximum equity drawdown percent, with whichever CAGR applies; it carries the reason of that CAGR when it is not null, and is null, with no reason, at zero drawdown. +- `sharpe_bar`: Observed bar density `(n-1)/span_years`, scaled by its square root, on every span, with strict overflow checks. +- `sortino_bar`: The same density scaling with downside deviation, on every span, with strict overflow checks. +- `sharpe_monthly` (`sharpe_tv`): Calendar-month returns in chart timezone, scaled by fixed sqrt(12); existing behavior and overflow checks. +- `sortino_monthly` (`sortino_tv`): Calendar-month downside construction with fixed sqrt(12); existing behavior and overflow checks. + +The monthly pair is not annualized from the measured span. A sub-day curve +already has too few monthly returns, so these nulls receive no extra reason. + +Recovery factor, both buy/hold fields, time in market, open profit, all equity +extrema and all trade metrics are not annualized. They gain no new absence. +The one-day bound is in `docker/report_annualization.def`, included by the +native metric walk; the public report writer, which ships as one file, keeps a +copy of the same bound, and a test pins the two together. No field list exists +in either copy: no span makes a metric null. + +The measured span (`src/engine_metrics.cpp:283`) is +`span_ms = equity_curve[-1].time_ms - equity_curve[0].time_ms`: the last +equity-curve point's time minus the first point's time. These are the +script-bar OPEN timestamps in UTC epoch milliseconds, including the selected +anchor when present. This uses the existing unsigned integer difference, converted to +double after subtraction, with its existing signed reverse-span branch. +In selected-window mode, the first point is the anchor at the requested start +T, so the measured span starts at T, including when the first window bar is +later. It does not use elapsed wall time, chart timezone, bar count, or +the timezone-adjusted month calendar. + +A positive span **strictly less than 86,400,000 ms** +(`0 < span_ms < 86 400 000`) gives `cagr` its simple proportional form, +`(E/E0 - 1) * (1/span_years) * 100`, always and only that form: the compounded +power is not evaluated, so it cannot overflow there, and the metric is a finite +number with the reason `simple_proportional_subday`. A span of **86,400,000 ms +or more**, exactly one day included, keeps the compounded form unchanged, +`(pow(E/E0, 1/span_years) - 1) * 100`, with no reason. If that compounded value +is not finite, the kernel uses the simple form instead and the reason is +`simple_proportional_overflow`. `calmar` is `cagr / max_equity_drawdown_pct` +of whichever form applies and carries the same reason as that `cagr` whenever +it is not null; at zero drawdown it is null and has no reason. `sharpe_bar` and +`sortino_bar` use their one formula on every span. No span makes any of the +four null on a valid run. Existing empty-curve, nonpositive-span, +nonpositive-capital and nonpositive-final-equity absences remain, with no +reason, and so do the bar ratios' own domains (fewer than two returns, zero +deviation) and zero drawdown for `calmar`. Any other computed nonfinite result, +including a simple form that is itself not finite, still raises +`report_result_invalid` with reason `nonfinite` and the exact metric field, +for example `metrics.equity.cagr`. Any overflow outside `cagr` stays strict +on every span. + +The two forms are not on one scale, and the switch between them has a known, +accepted consequence: a ranking of runs by `cagr` can invert against a ranking +by return across a switch. For the same return the compounded form is far above +the simple form on spans under a year, so a run just over one day can outrank a +run just under one day that gained more; and a run whose compounded form just +overflows takes the simple form, many orders of magnitude below the compounded +value of a run with a slightly smaller return. The reason key tells a reader +which form produced a value. The inversion is rare in practice. + +## Reason on the public wire + +The public JSON writer emits the optional reason object alongside the metric +leaves. For a sub-day run, the relevant JSON members are: + +```json +{ + "metrics": { + "equity": { + "cagr": 601097.14, + "calmar": 300548.57 + } + }, + "diagnostics": { + "annualization": { + "cagr": "simple_proportional_subday", + "calmar": "simple_proportional_subday" + } + } +} +``` + +The curve of this example is seven minutes long, falls from 10,000 to 9,800 and +rises to 10,800, so `cagr` is `0.08 * (1 / span_years) * 100`, about 601,097 +percent per year, the maximum equity drawdown is 2 percent and `calmar` is +`cagr / 2`, about 300,549 (both rounded here). The other metrics keep their +definitions and domains. + +The object is present only for a valid run whose `cagr` took a simple form. Its +key `cagr` is `simple_proportional_subday` for a positive span under one day and +`simple_proportional_overflow` for a span of one day or more whose compounded +form is not finite. Its key `calmar` is present exactly when the key `cagr` is +present and `metrics.equity.calmar` is not null, and carries the same string: +the form of the `cagr` that `calmar` was computed from. `calmar` is null at zero +drawdown, and then has no key. No reason is attached to the existing absence +cases or to any other field; in particular `sharpe_bar` and `sortino_bar` have +none on any span. If `cagr` took its compounded form, +`diagnostics.annualization` is absent, whatever `calmar` is. + +`_annualization_diagnostics` (`docker/run_json.py:3578`) derives the span from +the same first and last curve times. Under one day a finite `cagr` on a positive +span is enough, since the kernel makes `cagr` null only in the existing +domains. From one day on the writer repeats the kernel's arithmetic, `(pow(E/E0, +1/span_years) - 1) * 100`, on the curve's own marks, with `E` the last mark, and +names the overflow form when that value is not finite (a power that overflows +counts, as `pow` returns infinity in C). `E0` is the first mark only where the +run proves it is the initial capital: the selected-window anchor, which is the +engine's initial capital by construction, or, in an ordinary run, a first mark +with no open profit and no trade entered at it (nothing is then realized or +marked, so the mark is the capital). Where it cannot prove the initial capital +the writer cannot recompute the compounded form, guesses none and names nothing. +In that case (a run of one day or more whose compounded form is not finite, with an unproven first mark) +the reported `cagr` is still the simple proportional value that the kernel computed; only +the `simple_proportional_overflow` flag is absent. A later release may remove the +gap by reading the capital from the engine. +It adds the `calmar` key beside the `cagr` key from `metrics.equity.calmar` +alone: the same string when that value is not null, no key when it is null. +No kernel state, public POD member, header ABI, or runtime export is added. + +Revision 3 explicitly places diagnostics/timings outside the digest. The +reason stays there; metrics, summary, trade and curve keys remain unchanged. +The digest still has exactly 135 scalar leaves. +The public writer's canonical-result validation and selected-window placement +checks retain their existing scopes; `report_shape` is unchanged. The native +runner's `native_report_json` (`runner/report.hpp:104`) exposes the separate +native report format and has no existing diagnostics object designated +outside this digest. It keeps undefined metrics as the string "NaN" in its +own format, outside revision 3. The runner and C ABI carry no named reason; +the named reason is emitted where the public JSON wire is built. + +## Examples + +| Measured curve | Result | +| --- | --- | +| Seven minutes, equity 10,000 to 10,800 | Completes; cagr is the simple proportional form, about 601,097 percent per year, with the reason `simple_proportional_subday`. The curve never falls, so calmar is null at zero drawdown and has no reason key; sharpe_bar and sortino_bar keep their definitions and are not null on account of the span. Other fields retain their definitions, including monthly ratios that are undefined with too few months. | +| Seven minutes, equity 10,000 down to 9,800 and up to 10,800 | Completes; cagr is as above and calmar is `cagr / 2` (the maximum equity drawdown is 2 percent): both carry the reason `simple_proportional_subday`, under `diagnostics.annualization.cagr` and `diagnostics.annualization.calmar`. | +| Exactly one day, equity ratio 1e30 | Completes; the compounded form overflows, so cagr is the simple proportional form, with the reason `simple_proportional_overflow`; calmar, when the curve has a drawdown, is `cagr / max_equity_drawdown_pct` and carries the same reason. | +| One day or several days, equity ratio 1e300 | Completes when all other results are valid; the compounded form overflows and the simple form is finite, so cagr is the simple form, with the reason `simple_proportional_overflow`. | +| Exactly one day, equity ratio 1e305 | Fails with `report_result_invalid`, field `metrics.equity.cagr`, reason `nonfinite`: the compounded form overflows and the simple form is not finite either. | +| 86,399,999 ms, equity ratio 1e300 | Completes when all other results are valid; cagr is the simple proportional form, with the reason `simple_proportional_subday`; calmar, when it is not null, carries the same reason. | +| Exactly one day with an ordinary gain | Annualizes with the existing formula and arithmetic order: compounded, no reason under `cagr` and none under `calmar`. | +| Buy/hold product overflow, including on a sub-day span | Fails with `report_result_invalid`, field `metrics.equity.buy_hold_return`, reason `nonfinite`. | diff --git a/docs/native-refactor-progress.md b/docs/native-refactor-progress.md index fd9c33a3f..42cc4ce63 100644 --- a/docs/native-refactor-progress.md +++ b/docs/native-refactor-progress.md @@ -175,7 +175,7 @@ them for the record only. "Linux aarch64" is the maintainers' Cortex-A725 host. | --- | --- | --- | --- | --- | --- | | D2-B, the quiet-bar protocol and one receipt read per bar | PERF-D2's lane: a per-bar bar-open request, a bulk fold of quiet driver points, the adapter's quiet-open predicate with lazy open bookkeeping, and a gate before the receipt poll. D2-A landed the hook declarations and the O(1) `has_command_after` (`src/native_execution_consumer.hpp:321`). PERF-L4 gated the read inside `observe_terminal_receipts` (`src/source/pine_adapter.cpp:6446`), which still runs about three times a bar | PERF-D2, Linux aarch64 (Cortex-A725), callgrind and GBench: about ×0.88 on the 14 probes (an estimate from a measured −13 to −14 % floor bound for the bar-open request alone); after PB the receipt poll still costs about 250 instructions a bar on the no-order floor. On this tree the quiet-bar probe asks the receipt gate 40,996 times and each once-per-bar-open gate 13,248 times: 3.09 reads a bar, 82 % of them stopped at the gate (macOS arm64, AppleClang 17, Release) | "R5 PERF-D2 DONE 2026-09-24 19:56" (D2-B after V19-D); "R5 SUPERVISOR RULING 2026-09-25 00:15 Taipei": "D2-B (quiet-bar protocol) waits for PERF-D3's verdict"; TRIAGE1 §D | An unpublished prototype branch and its patches. PERF-D3's S1 (next row) prototyped the request and the lazy opening | A post-release performance epoch. Done when the receipt gate is asked as often as the once-per-bar gates | | S1a, kernel quiet-bar driving (PERF-D3) | A bar with nothing live, a flat book and a declined opening is driven in a quiet form: the kernel writes the bookkeeping its four price points would leave (ordinals, the last two driver marks, the floor, the point epochs) without walking them or calling the bar-open callback. Additive host cadence declarations; C hosts declare from their callback table; one C symbol under the PF_API checklist | PERF-D3, Linux aarch64 (Cortex-A725), GBench time and `perf stat` instructions, on the L3 tree `b33ea659`, S1a with S1b: no-order floor 0.764 / 0.660 in time (0.718 / 0.656 in instructions), 14 probes 0.958 / 0.921 (0.892 / 0.856), 100 slots 0.972 / 0.956 per slot. A bare kernel host that opts in: idle 0.545× (35 % fewer instructions a bar), market 0.81×, bracket 0.92×. Byte-identical: corpus 312/312, the 132-configuration battery with its hash columns | "FINDING PERF-D3 (filed for a post-release epoch per the owner's cap)" (ledger, 2026-09-24 16:58 UTC); "OWNER DECISION 2026-09-25 00:20 Taipei"; TRIAGE1 §D: "PERF-D3 S1/S2 (post-release epoch)" | An unpublished prototype branch (five commits on L3's tree) and its patches | The post-release epoch, with the owner calls PERF-D3 lists (the API under the existing ruling, the restated gate count) | -| S1b, the Pine lazy opening | The Pine host records what its skipped opening and its uncalled input callback would have written, at the bar's first host callback. Member functions only, no layout change | Measured with S1a. A quiet bar then asks 15 gates, not 30, so the quiet-bar cost check `CHECK(asked >= 20 * o.boundary.size())` (`tests/test_adapter_quiet_bar.cpp:1054`) must be restated | As S1a | As S1a. The in-script hash witnesses caught the prototype's one miss (`awaiting_legacy_script_open_ms_`) | As S1a | +| S1b, the Pine lazy opening | The Pine host records what its skipped opening and its uncalled input callback would have written, at the bar's first host callback. Member functions only, no layout change | Measured with S1a. A quiet bar then asks 15 gates, not 30, so the quiet-bar cost check `CHECK(asked >= 20 * o.boundary.size())` (`tests/test_adapter_quiet_bar.cpp:1103`) must be restated | As S1a | As S1a. The in-script hash witnesses caught the prototype's one miss (`awaiting_legacy_script_open_ms_`) | As S1a | | S1c, the held-position wake band | S1 for bars that hold a position with nothing live, 41 % of the median public slot's bars. The host declares a price band, and the kernel calls the opening only when the bar's range leaves it. The adapter derives the band from its margin model and declines when it cannot bound it | PERF-D3, a measurement-only upper bound without the band, which is not sound in general: 100 slots 0.776 / 0.716 per slot, 14 probes 0.830 / 0.761, cumulative against L3 with S1, S2 and D2-A | As S1a; the owner call "Fund the price band" | An unpublished measurement-only script | As S1a. Size L, parity risk high: TradingView's margin money rules | | S2 and S2e, the codegen feature manifest | Codegen states which per-bar hooks and publications a strategy can reach, and the engine skips the others; no manifest means every feature. A median 39 of 52 are statically unreachable, over the 312 corpus strategies and the 100 slots alike. S2e adds two gates that stop the Pine risk ledgers of a strategy with no risk rule | PERF-D3, on top of S1: floor −14 % / −15 % in time (−6.4 % in instructions), 14 probes 0.943 / 0.934, 100 slots 0.942 / 0.945. The nine S2 gates are byte-identical. S2e adds about 2–3 % and moves the adapter hash domain `pineforge-source-adapter/v4` to its next version | As S1a; the supervisor's cap: "any structural S1/S2 redesign is a post-release epoch" | In PERF-D3's unpublished prototype (a regex-based manifest scanner) | A codegen and engine co-change with a corpus re-emission; S2e also needs an owner decision on the hash-domain step | | The kernel quiet-run loop and the Pine quiet calculation | A loop for consecutive quiet bars of a pumped batch at the chart's own timeframe (label and gap checks in the batch preflight, precomputed intervals and session facts, one calculation frame); and skipping a flat bar's post-script hooks when its script issued nothing | Estimated, not prototyped (PERF-D3's report, section 3.7): about 1,000 instructions a bar for the loop and 800 for the quiet calculation, taking the floor from 3,726 instructions (S1, S2 and D2-A) to about 1,900, or 180–200 ns a bar | As S1a (PERF-D3's follow-up row) | The PERF-D3 report only | After S1a and S1b | @@ -183,7 +183,7 @@ them for the record only. "Linux aarch64" is the maintainers' Cortex-A725 host. | Compact journal records and a history ring buffer (L3) | Smaller command records and a ring buffer for the request core's history. `WorkingRequestCore::history` (`include/pineforge/native_order.hpp:1709`) hands out the whole vector and the core holds `history_` (`include/pineforge/native_order.hpp:2280`) inside `native_order_v7`, so both are a layout and API change | L3, Linux aarch64 (Cortex-A725), `perf` call graphs, inclusive shares: at most 3.68 % of bracket, 11.99 % of re-issue, 4.26 % of market and 3.42 % of the 14 probes (retiring the history alone: 1.64, 4.51, 2.53 and 1.00 %); only part is realisable, since records must still be written | "R5 L3 DONE + ACCEPTED 2026-09-24 22:10 Taipei", ruling 1: "DEFERRED to the PERF-D3 structural plan"; TRIAGE1 §D: "compact journal records" | Lane L3's code, merged by INT22 (#285); its profiles are unpublished | A `native_order` layout epoch | | A checked-values handoff for executions (L3) | `execution_values` runs twice, in `check_execution` (`src/native_order.cpp:4887`) and again in `apply_execution` (`src/native_order.cpp:4910`); handing the checked values across needs a public type, an API addition with no layout change | L3, as above: 2.21 % of bracket for the two together, about 1.1 % recoverable | L3 ruling 2: "a public checked-values handoff for executions (~1.1% bracket) -> DEFERRED" | As above | An owner decision on the API addition | | A chunked `trades_` store (L3) | Closed trades in chunks rather than one vector; `trades_` (`include/pineforge/engine.hpp:546`) is protected `BacktestEngine` state, so script ABI | L3, as above: on bracket, vector growth 0.64 %, `Trade` moves 0.74 % and `record_close_trade` 0.43 % (at most about 1.4 %); at most 0.35 % on the 14 probes | L3 ruling 4: "chunked trades_ store (ABI) -> DEFERRED" | As above | An `engine_script_run` epoch that changes the engine's protected layout | -| A zoned chart-day memo (D2-D finding 4) | On a chart with a timezone, `libc_chart_day_key` (`src/source/pine_adapter.cpp:17351`) takes the process timezone lock through `ScopedTimezone` and calls `localtime_r` on every bar, because `on_bar_open` (`src/source/pine_adapter.cpp:22250`) reads `chart_day_key` (`src/source/pine_adapter.cpp:22487`) unconditionally. The non-UTC monthly Sharpe/Sortino walk, `month_key_local` (`src/engine_metrics.cpp:33`), pays one `localtime_r` per equity point. UTC charts use D2-D's memo, `civil_chart_day_key` (`src/source/pine_adapter.cpp:17335`) | Not measured on a zoned chart. The UTC path cost 134 instructions a bar before D2-D's memo and 68 after (D2-D, Linux aarch64, callgrind on the floor). PERF-K1: `ScopedTimezone` copies the zone name, which allocates for names of 16 or more characters under libstdc++ | TRIAGE1 §D: "D2-D finding 4 (the timezone `localtime_r` memo)", under the owner's cap. D2-D's report: an exact zoned memo "would need the zone's transition table, which belongs in `timezone.cpp`" | None; the UTC memo's witness is `tests/test_chart_day_memo.cpp` | A lane that owns `src/timezone.cpp` and builds an exact zone-transition memo (PERF-K1's Sitka case shows why a shortcut is not exact), byte-identical, with the zoned keys of `test_chart_day_memo` still equal to the computed ones | +| A zoned chart-day memo (D2-D finding 4) | On a chart with a timezone, `libc_chart_day_key` (`src/source/pine_adapter.cpp:17351`) takes the process timezone lock through `ScopedTimezone` and calls `localtime_r` on every bar, because `on_bar_open` (`src/source/pine_adapter.cpp:22250`) reads `chart_day_key` (`src/source/pine_adapter.cpp:22487`) unconditionally. The non-UTC monthly Sharpe/Sortino walk, `month_key_local` (`src/engine_metrics.cpp:35`), pays one `localtime_r` per equity point. UTC charts use D2-D's memo, `civil_chart_day_key` (`src/source/pine_adapter.cpp:17335`) | Not measured on a zoned chart. The UTC path cost 134 instructions a bar before D2-D's memo and 68 after (D2-D, Linux aarch64, callgrind on the floor). PERF-K1: `ScopedTimezone` copies the zone name, which allocates for names of 16 or more characters under libstdc++ | TRIAGE1 §D: "D2-D finding 4 (the timezone `localtime_r` memo)", under the owner's cap. D2-D's report: an exact zoned memo "would need the zone's transition table, which belongs in `timezone.cpp`" | None; the UTC memo's witness is `tests/test_chart_day_memo.cpp` | A lane that owns `src/timezone.cpp` and builds an exact zone-transition memo (PERF-K1's Sitka case shows why a shortcut is not exact), byte-identical, with the zoned keys of `test_chart_day_memo` still equal to the computed ones | | Thread-local reads that remain per call | D2-C moved the three scopes' writes onto the pump's block, `pump_ambient` (`src/native_execution_consumer.hpp:496`). A reader still reaches the block through `tl_runtime_ambient` (`src/ta_extremes_volume.cpp:29`): `bar_context` (`src/ta_extremes_volume.cpp:82`), read by every `ExtremeRing::update` (`src/ta_extremes_volume.cpp:117`), which backs `ta.highest`, `ta.lowest`, `ta.highestbars` and `ta.lowestbars` and, through an embedded `Highest` and `Lowest`, `ta.stoch`, `ta.wpr` and `ta.range`; and `matching_day_partition` (`src/timeframe.cpp:628`), read by the default-anchored `ta.vwap` through `session_day_index`, by `crosses_boundary` on a DAY period (`timeframe.change("D")`) and by the session-period helpers. `decompose_ms_local` (`src/session_time.cpp:250`) reads two thread-locals per call on a zoned clock, and the prepared order storage's `free_blocks` (`src/native_order.cpp:620`) one per prepared mutation. `ema_na_warmup_flag` is read once per EMA, at its first compute | One thread-local access per reader call. AUDIT4, macOS arm64, a dlopen'd tutorial-MACD probe, counted by `_tlv_get_addr` breakpoints and by descriptor patching: 1.000 a bar for one `ta::Highest` and for one default `ta::VWAP`, 1.999 for one `pine_hour` on a New York clock, 0.000 on the no-order floor and with orders. On Linux a TLS-descriptor call costs about 20 instructions: D2-C's 13 → 4 calls a bar was 260 → 80 instructions (Linux aarch64, GCC 13, callgrind). INT23: 2.00 a bar on the zoned probe 064, 0.08 on probe 008 | None for the TA and day-partition readers: "RULING: INT23 ACCEPTED" (ledger, 2026-09-24 23:30 UTC) reads "dlopen TLS 13->0/bar", and INT23's report names only the timezone and order-core residuals. This row is their record | Unpublished (AUDIT4's scratch) | A byte-identical lane that hands these readers the pump's block without a thread-local read. `ta::bar_context()` is script ABI, so its public declaration stays as it is | | "audit-off mode", "LTO", "data-oriented layout" | Named in "OWNER DECISION 2026-09-25 00:20 Taipei" among the post-release structural items: "S1 bulk quiet bars, S2 per-strategy specialization, audit-off mode, LTO+PGO, data-oriented layout". No engine or campaign document defines, designs or measures the three | None | The owner decision | None | Struck from this inventory until a design note defines one of them | @@ -212,7 +212,7 @@ brief asked it to "read the receipts ONCE per bar instead of three times". The lane gated the read instead: `observe_terminal_receipts` (`src/source/pine_adapter.cpp:6446`) returns at its receipt gate when no command lies above its cursor. `every_gate_is_taken_both_ways` -(`tests/test_adapter_quiet_bar.cpp:1061`) prints +(`tests/test_adapter_quiet_bar.cpp:1110`) prints `gate ReceiptRead asked 40996 quiet 33552 live 7444`, against `asked 13248` for every gate asked once per bar open, so the adapter reads 3.09 times a bar and 82 % of those reads stop at the gate (macOS arm64, this tree). PERF-L4's diff --git a/docs/pages/metrics.md b/docs/pages/metrics.md index 8ea689d07..f83c0de2b 100644 --- a/docs/pages/metrics.md +++ b/docs/pages/metrics.md @@ -83,6 +83,11 @@ every **script-bar close**, timestamped with the script-bar **open** time | `time_in_market_pct` | `100 ·` script bars with an open position at close `/ total script bars` | empty curve | | `open_pl` | Mark-to-market open profit at the final bar | — | +@note `cagr` takes its simple proportional form, `100 · (final_equity/initial_capital − 1) / years`, +when the measured span is under one day, and in place of the compounded value above when that value is +not finite; the report names the form used (see @ref report_schema). `calmar` divides that `cagr`, so the +report names the same form under its own key whenever `calmar` is defined. + @note Metrics are only meaningful when `strategy_get_last_error()` returns an empty string: `run()` captures exceptions, so a failed run yields a truncated curve and metrics over the truncated prefix. diff --git a/docs/pages/native-engine.md b/docs/pages/native-engine.md index 7d3aa9d09..c25537a72 100644 --- a/docs/pages/native-engine.md +++ b/docs/pages/native-engine.md @@ -2099,7 +2099,7 @@ per script calculation — after the callback returns, and after the with the **script interval's open**, at the same instant as that calculation's extremes fold, so the curve is identical with and without an intrabar path and a re-walk of it reproduces the scalars above bit for bit -(`compute_equity_stats`, `engine_metrics.cpp:203`, "MUST mirror +(`compute_equity_stats`, `engine_metrics.cpp:222`, "MUST mirror update_equity_extremes"). The result is one point per script bar, a finite drawdown/run-up walk, and metrics computed over a real series. diff --git a/docs/pages/report-schema.md b/docs/pages/report-schema.md index 552a144e7..f9ea34a16 100644 --- a/docs/pages/report-schema.md +++ b/docs/pages/report-schema.md @@ -131,9 +131,11 @@ currency, net of entry fees — already scaled by `qty` and the point value. In JSON reports, each trade's `entry_price`, `exit_price`, `qty`, `pnl`, `pnl_pct`, `max_runup`, `max_drawdown` and `commission`, and the summary's -`net_pnl`, `avg_trade`, `best_trade`, `worst_trade` and `max_drawdown`, must be -finite or the run fails with `report_result_invalid`, `field` naming the -corresponding `trades.` or `summary.` and `reason` equal to `nonfinite`. +`win_rate_pct`, `net_pnl`, `avg_trade`, `best_trade`, `worst_trade` and +`max_drawdown`, and each equity-curve point's `equity` and `open_profit`, must +be finite or the run fails with `report_result_invalid`, `field` naming the +corresponding `trades.`, `summary.`, `equity_curve.equity` or +`equity_curve.open_profit` and `reason` equal to `nonfinite`. In ordinary mode without `--run-phase-fd`, a non-finite double elsewhere in the report document fails with `report_result_invalid`, `field` equal to `report.document` and `reason` equal to `nonfinite`. @@ -261,7 +263,130 @@ this schema has to change (`scripts/test_report_schema_keys.py` pins the loss, `avg_win` with no winning trades, `sharpe_monthly` with fewer than two monthly returns or zero deviation, `calmar` with zero drawdown. A `0.0` in the report is always a real computed zero. See the per-field doxygen -in `` for the exact rule on every field. +in `` for the existing field domains; the rule below +is authoritative for `cagr` on a span under one day and when its compounded +form overflows, which that doxygen does not describe. + +Report arithmetic that produces a nonfinite value, including overflow, fails with `report_result_invalid` and reason `nonfinite`. +The engine checks computed values before they can become a metric absence. + +For `cagr`, a measured span under one day is annualized by its simple +proportional form: `cagr` is a finite number, and the wire carries +`simple_proportional_subday` under `diagnostics.annualization.cagr`. From one +day on `cagr` keeps its compounded form unless that is not finite; the simple +form is then used and the wire carries `simple_proportional_overflow` under the +same key. `calmar` is computed from that `cagr` and carries the same string +under `diagnostics.annualization.calmar` whenever it is not null. `calmar`, +`sharpe_bar` and `sortino_bar` are not null on account of the span, and +`sharpe_bar` and `sortino_bar` have no reason key. + +The exact public paths, native members and reason keys are, with `E` the final +equity (the last equity-curve mark), `E0` the initial capital and +`span_years = span_ms / (365.25 * 86400 * 1000)`; a valid run has +`span_years > 0`, `E0 > 0` and `E > 0`: + +| Field (public wire key) | Path | C ABI field | Rule | Reason key path | Reason value | +| --- | --- | --- | --- | --- | --- | +| `cagr`, span under one day | `metrics.equity.cagr` (`metrics.equity` only; absent from `summary` and `metrics.all`). | `pf_equity_stats_t::cagr` | Simple proportional form `(E/E0 - 1) * (1/span_years) * 100`, always, when `0 < span_ms < 86 400 000` on a valid run; a finite number, never `null` on account of the span. | `diagnostics.annualization.cagr` | `simple_proportional_subday` | +| `cagr`, one day or more | `metrics.equity.cagr` (`metrics.equity` only; absent from `summary` and `metrics.all`). | `pf_equity_stats_t::cagr` | Compounded form `(pow(E/E0, 1/span_years) - 1) * 100`, unchanged, when `span_ms >= 86 400 000` (exactly one day included) and it is finite. | None. | None; no reason. | +| `cagr`, one day or more, compounded form not finite | `metrics.equity.cagr` (`metrics.equity` only; absent from `summary` and `metrics.all`). | `pf_equity_stats_t::cagr` | The simple proportional form above, used when `span_ms >= 86 400 000` and the compounded form is not finite (overflow). | `diagnostics.annualization.cagr` | `simple_proportional_overflow` | +| `calmar`, span under one day | `metrics.equity.calmar` (`metrics.equity` only; absent from `summary` and `metrics.all`). | `pf_equity_stats_t::calmar` | `cagr / max_equity_drawdown_pct` with the simple proportional `cagr` above; `null` at zero drawdown as before. The reason key is present exactly when the `cagr` reason key is present and `calmar` is not `null`. | `diagnostics.annualization.calmar` | `simple_proportional_subday` | +| `calmar`, one day or more | `metrics.equity.calmar` (`metrics.equity` only; absent from `summary` and `metrics.all`). | `pf_equity_stats_t::calmar` | `cagr / max_equity_drawdown_pct` with the compounded `cagr` above; `null` at zero drawdown as before. | None. | None; no reason. | +| `calmar`, one day or more, compounded form not finite | `metrics.equity.calmar` (`metrics.equity` only; absent from `summary` and `metrics.all`). | `pf_equity_stats_t::calmar` | `cagr / max_equity_drawdown_pct` with the simple proportional `cagr` used because the compounded form is not finite; `null` at zero drawdown as before. The reason key is present exactly when the `cagr` reason key is present and `calmar` is not `null`. | `diagnostics.annualization.calmar` | `simple_proportional_overflow` | +| `sharpe_bar` | `metrics.equity.sharpe_bar` (`metrics.equity` only; absent from `summary` and `metrics.all`). | `pf_equity_stats_t::sharpe_bar` | The observed-bar-density formula on every span; never `null` on account of the span. | None. | None; no reason. | +| `sortino_bar` | `metrics.equity.sortino_bar` (`metrics.equity` only; absent from `summary` and `metrics.all`). | `pf_equity_stats_t::sortino_bar` | The same density scaling with downside deviation, on every span; never `null` on account of the span. | None. | None; no reason. | +| `sharpe_tv`, `sortino_tv` (not in the set) | `metrics.equity.sharpe_tv`, `metrics.equity.sortino_tv` (`metrics.equity` only; absent from `summary` and `metrics.all`). | `pf_equity_stats_t::sharpe_monthly`, `pf_equity_stats_t::sortino_monthly` | Calendar-month returns with fixed sqrt(12), independent of `span_ms`; existing null domain. | None. | None; no reason. | + +No field other than `cagr` and `calmar` receives an annualization reason. +In native `ReportC` and C ABI `pf_report_t`, these members are inside +`metrics.equity`; a defined absence is a NaN and carries no reason, and neither +form of `cagr` or of `calmar` carries one. The +separate runner JSON uses `metrics.equity.cagr`, `metrics.equity.calmar`, +`metrics.equity.sharpe_bar` and `metrics.equity.sortino_bar` for the four +fields, encoding an absence as the string `"NaN"` with no reason. +Its monthly keys are `metrics.equity.sharpe_monthly` and +`metrics.equity.sortino_monthly`, with the same string encoding for an absence. + +The span is `span_ms = equity_curve[-1].time_ms - equity_curve[0].time_ms`: +the last equity-curve point's `time_ms` minus the first point's, using the +script-bar OPEN timestamps in UTC epoch milliseconds and an unsigned +difference before conversion to double. The clock is independent of elapsed +wall time and the chart timezone. In selected-window mode, point 0 is the +anchor at the requested start T, so the span starts at T even if the first +window bar is later. A positive span strictly less than one day +(`0 < span_ms < 86 400 000`) gives `cagr` its simple proportional form; +exactly one day and more keep the compounded form unchanged, with no reason +unless that form is not finite. Empty curves, nonpositive spans, nonpositive +capital and nonpositive final equity keep their existing absences without +a reason. The reason object is present only when `cagr` took a simple form and is +outside the canonical result digest, which still has exactly +135 scalar leaves. It has the key `cagr` and, when `metrics.equity.calmar` is +not null, the key `calmar` with the same string; `calmar` is null at zero +drawdown, and then has no key. `sharpe_bar` and `sortino_bar` have none on any +span. If `cagr` took its compounded form, `diagnostics.annualization` is absent, +whatever `calmar` is. + +On a valid run no span makes `cagr`, `calmar`, `sharpe_bar` or `sortino_bar` +null: `calmar` and the bar ratios keep their own existing domains (zero +drawdown; fewer than two returns, or zero deviation), which carry no reason. +The sub-day form evaluates no power, so it cannot overflow. From one day on the +compounded form is evaluated first and, when it is not finite, the simple form is +used. Any other computed nonfinite result, including a simple form that is +itself not finite, remains the typed failure `report_result_invalid`, reason +`nonfinite`, naming the exact field, for example `metrics.equity.cagr`. + +The two forms are not on one scale, and the switch between them has a known, +accepted consequence: a ranking of runs by `cagr` can invert against a ranking +by return across a switch. For the same return the compounded form is far above +the simple form on spans under a year, so a run just over one day can outrank a +run just under one day that gained more; and a run whose compounded form just +overflows takes the simple form, many orders of magnitude below the compounded +value of a run with a slightly smaller return. The reason key tells a reader +which form produced a value. The inversion is rare in practice. + +For a sub-day run, the relevant JSON members are (a seven-minute curve that +falls from 10,000 to 9,800 and rises to 10,800, so `cagr` is +`0.08 * (1 / span_years) * 100`, the maximum equity drawdown is 2 percent and +`calmar` is `cagr / 2`, both rounded here): + +```json +{ + "metrics": { + "equity": { + "cagr": 601097.14, + "calmar": 300548.57 + } + }, + "diagnostics": { + "annualization": { + "cagr": "simple_proportional_subday", + "calmar": "simple_proportional_subday" + } + } +} +``` + +The report writer names the form from the curve it holds: under one day a finite +`cagr` on a positive span is flagged, and from one day on it repeats the kernel's +compounded arithmetic on the curve's marks and flags the run when that value +is not finite. The initial capital it needs is the first mark only where the run +proves it (the selected-window anchor; in an ordinary run a first mark with no +open profit and no trade entered at it); with no proof it names nothing. In that +case (a run of one day or more whose compounded form is not finite, with an +unproven first mark) the reported `cagr` is still the simple proportional value +the kernel computed; only the `simple_proportional_overflow` flag is absent. It adds +the `calmar` key beside the `cagr` key from `metrics.equity.calmar` alone: the +same string when that value is not null, no key when it is null. + +`sharpe_bar` / `sortino_bar` use their observed-density construction on every +span, and `sharpe_tv` / `sortino_tv` retain calendar-month +resampling with fixed sqrt(12), independent of the measured span. The monthly +pair is already null on sub-day spans and receives no extra reason. Any +overflow outside `cagr`, and a `cagr` overflow that the simple form does not +absorb, fail with `report_result_invalid` and +reason `nonfinite`, naming the exact field. An executed trade or equity mark +must be finite. See the [revision 3.1 clarification](../design/selected-window-wire-v1.3-revision-3.1.md) +for the field set and wire placement. ## Equity curve (ABI v2) diff --git a/runner/report.hpp b/runner/report.hpp index 8d7add243..6b98cef97 100644 --- a/runner/report.hpp +++ b/runner/report.hpp @@ -2,6 +2,7 @@ #include "json.hpp" #include "report_delta.hpp" +#include "../src/report_value_invalid.hpp" #include #include #include @@ -25,9 +26,22 @@ template inline Json report_number(Value value) { } } +// Metrics alone may carry an explicit native NaN absence. Executed trade, +// summary and curve values have no such sentinel. Trace values keep their +// independent report_number representation above. +template +inline Json report_result_number(Value value, const char* field, + bool nullable = false, const char* prefix = nullptr) { + if constexpr (!std::is_integral_v) { + if (nullable && std::isnan(value)) return report_number(value); + pineforge::report::finite_value(value, field, prefix); + } + return report_number(value); +} + inline Json report_trade(const pf_trade_t& trade) { Json result = Json::object({}); -#define PF_REPORT_FIELD(field) result.members[#field] = report_number(trade.field) +#define PF_REPORT_FIELD(field) result.members[#field] = report_result_number(trade.field, "trades." #field) PF_REPORT_FIELD(entry_time); PF_REPORT_FIELD(exit_time); PF_REPORT_FIELD(entry_price); PF_REPORT_FIELD(exit_price); PF_REPORT_FIELD(pnl); PF_REPORT_FIELD(pnl_pct); PF_REPORT_FIELD(is_long); @@ -39,9 +53,9 @@ inline Json report_trade(const pf_trade_t& trade) { return result; } -inline Json report_stats(const pf_trade_stats_t& stats) { +inline Json report_stats(const pf_trade_stats_t& stats, const char* prefix = "metrics.all") { Json result = Json::object({}); -#define PF_REPORT_FIELD(field) result.members[#field] = report_number(stats.field) +#define PF_REPORT_FIELD(field) result.members[#field] = report_result_number(stats.field, #field, true, prefix) PF_REPORT_FIELD(num_trades); PF_REPORT_FIELD(num_wins); PF_REPORT_FIELD(num_losses); PF_REPORT_FIELD(num_even); PF_REPORT_FIELD(percent_profitable); PF_REPORT_FIELD(net_profit); PF_REPORT_FIELD(net_profit_pct); @@ -62,11 +76,14 @@ inline Json report_stats(const pf_trade_stats_t& stats) { inline Json report_equity_stats(const pf_equity_stats_t& stats) { Json result = Json::object({}); -#define PF_REPORT_FIELD(field) result.members[#field] = report_number(stats.field) +#define PF_REPORT_FIELD(field) result.members[#field] = report_result_number(stats.field, "metrics.equity." #field, true) PF_REPORT_FIELD(max_equity_drawdown); PF_REPORT_FIELD(max_equity_drawdown_pct); PF_REPORT_FIELD(max_equity_runup); PF_REPORT_FIELD(max_equity_runup_pct); PF_REPORT_FIELD(buy_hold_return); PF_REPORT_FIELD(buy_hold_return_pct); - PF_REPORT_FIELD(sharpe_monthly); PF_REPORT_FIELD(sortino_monthly); + result.members["sharpe_monthly"] = report_result_number( + stats.sharpe_monthly, "metrics.equity.sharpe_tv", true); + result.members["sortino_monthly"] = report_result_number( + stats.sortino_monthly, "metrics.equity.sortino_tv", true); PF_REPORT_FIELD(sharpe_bar); PF_REPORT_FIELD(sortino_bar); PF_REPORT_FIELD(cagr); PF_REPORT_FIELD(calmar); PF_REPORT_FIELD(recovery_factor); PF_REPORT_FIELD(time_in_market_pct); PF_REPORT_FIELD(open_pl); @@ -94,7 +111,7 @@ inline Json native_report_json(const pf_report_t& report, return report_array(values ? values + offset : nullptr, count - offset, serialize); }; #define PF_REPORT_FIELD(field) result.members[#field] = report_number(report.field) - PF_REPORT_FIELD(total_trades); PF_REPORT_FIELD(trades_len); PF_REPORT_FIELD(net_profit); + PF_REPORT_FIELD(total_trades); PF_REPORT_FIELD(trades_len); PF_REPORT_FIELD(input_bars_processed); PF_REPORT_FIELD(script_bars_processed); PF_REPORT_FIELD(security_feeds_total); PF_REPORT_FIELD(security_complete_total); PF_REPORT_FIELD(security_partial_total); PF_REPORT_FIELD(magnifier_sub_bars_total); @@ -104,11 +121,13 @@ inline Json native_report_json(const pf_report_t& report, PF_REPORT_FIELD(security_diag_len); PF_REPORT_FIELD(trace_len); PF_REPORT_FIELD(trace_names_len); PF_REPORT_FIELD(equity_curve_len); PF_REPORT_FIELD(broker_state_hash_len); #undef PF_REPORT_FIELD + result.members["net_profit"] = report_result_number(report.net_profit, "summary.net_pnl"); result.members["trades"] = array("trades", report.trades, report.trades_len, report_trade); result.members["equity_curve"] = array("equity_curve", report.equity_curve, report.equity_curve_len, [](const pf_equity_point_t& point) { return Json::object({ - {"time_ms", report_number(point.time_ms)}, {"equity", report_number(point.equity)}, - {"open_profit", report_number(point.open_profit)}}); }); + {"time_ms", report_number(point.time_ms)}, + {"equity", report_result_number(point.equity, "equity_curve.equity")}, + {"open_profit", report_result_number(point.open_profit, "equity_curve.open_profit")}}); }); result.members["security_diag"] = array("security_diag", report.security_diag, report.security_diag_len, [](const pf_security_diag_t& diag) { return Json::object({ {"sec_id", report_number(diag.sec_id)}, {"feed_count", report_number(diag.feed_count)}, @@ -123,7 +142,8 @@ inline Json native_report_json(const pf_report_t& report, result.members["broker_state_hash"] = array("broker_state_hash", report.broker_state_hash, report.broker_state_hash_len, [](std::uint64_t hash) { return report_number(hash); }); result.members["metrics"] = Json::object({{"all", report_stats(report.metrics.all)}, - {"longs", report_stats(report.metrics.longs)}, {"shorts", report_stats(report.metrics.shorts)}, + {"longs", report_stats(report.metrics.longs, "metrics.longs")}, + {"shorts", report_stats(report.metrics.shorts, "metrics.shorts")}, {"equity", report_equity_stats(report.metrics.equity)}}); return result; } diff --git a/scripts/check_selected_window_ownership.py b/scripts/check_selected_window_ownership.py index d606f1ba5..8da5667dc 100644 --- a/scripts/check_selected_window_ownership.py +++ b/scripts/check_selected_window_ownership.py @@ -14,6 +14,7 @@ import argparse import ast +import hashlib import json import re import sys @@ -418,6 +419,26 @@ def py_values(node, domains): raise InputError("unresolved Python producer argument") +def metric_result_fields(tree, validator): + # The loop labels producer-owned descriptor keys. Resolve its domain from + # the live ctypes/alias declarations, independently of manifest paths. + text = ast.unparse(validator) + if not all(part in text for part in ( + "for block in ('all', 'longs', 'shorts', 'equity'):", + "for name, value in out['metrics'][block].items():", + "metric_field = f'metrics.{block}.{name}'")): + raise InputError("unrecognized metric result guard; update the independent reconstruction") + aliases = ast.literal_eval(py_assignments(tree)["EQUITY_REPORT_KEYS"]) + paths = set() + for block, kind in ctypes_fields(tree, "MetricsC"): + for name, ctype in ctypes_fields(tree, kind): + if ctype != "ctypes.c_double": + continue # Native integer counters cannot hold a nonfinite double. + key = aliases.get(name, name) if kind == "EquityStatsC" else name + paths.add(f"metrics.{block}.{key}") + return paths + + def python_producers(root, findings): rows = [] for path in sorted((root / "docker").glob("*.py")): @@ -429,6 +450,8 @@ def python_producers(root, findings): phases = ast.literal_eval(assignments.get("_POST_BOUNDARY_PHASES", ast.Tuple(elts=[], ctx=ast.Load()))) for function in [n for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]: domains = py_domains(function) + if path.name == "run_json.py" and function.name == "_validate_report_result": + domains["metric_field"] = metric_result_fields(tree, function) domains["phase"].update(phases) grouped, post_reasons, post_phases = defaultdict(list), set(), set() for node in ast.walk(function): @@ -888,7 +911,7 @@ def reconstruct_schema(root, findings): # serializer is a new reconstruction obligation, not an automatic pass. stats_text = ast.unparse(stats) if not all(fragment in stats_text for fragment in - ("s._fields_", "keys.get(name, name)", "_num(v) if ctype is ctypes.c_double else int(v)", + ("s._fields_", "keys.get(name, name)", "_num(v, report_metric=True) if ctype is ctypes.c_double else int(v)", "EQUITY_REPORT_KEYS if isinstance(s, EquityStatsC) else {}")): raise InputError("unrecognized _stats_dict serializer; update the independent reconstruction") builder = function(tree, "build_report_dict") @@ -1025,9 +1048,42 @@ def check(root, manifest): check_descriptors(doc, cpp_files, findings) reconstructed = reconstruct_schema(root, findings) check_schema(doc, reconstructed, findings) + check_probe_pins(root, doc, findings) return sorted(findings.lines) +def check_probe_pins(root, doc, findings): + # Earlier probe groups predate the hash registry. All report probes enter + # this registry; discovery is from disk, never a hard-coded probe name. + rows = doc.get("probes") + if not isinstance(rows, list) or any( + not isinstance(row, dict) or set(row) != {"id", "sha256"} + or not isinstance(row["id"], str) + or not re.fullmatch(r"report/[a-z0-9_]+", row["id"]) for row in rows): + raise InputError("probes must be a list of report probe ids and sha256 objects") + registered = unique_rows(rows, "id", "duplicate-probe", findings) + directory = root / "tests/ownership/probes" + discovered = {path.parent.relative_to(directory).as_posix() + for path in (directory / "report").glob("*/probe.pine")} + for name in sorted(discovered - registered.keys()): + findings.add("probe-pins", f"unregistered {name}") + for name in sorted(registered.keys() - discovered): + findings.add("probe-pins", f"stale {name}") + required = {"probe.pine", "bars.csv", "syminfo.json", "run.json", "expect.json", "NOTES.md"} + for name, probe in registered.items(): + hashes = probe["sha256"] + if not isinstance(hashes, dict) or set(hashes) != required: + findings.add("probe-pins", f"{name}: pinned file set differs") + continue + for file, expected in hashes.items(): + path = directory / name / file + if not path.is_file(): + findings.add("probe-pins", f"{name}: missing {file}") + elif (not isinstance(expected, str) or not re.fullmatch(r"[0-9a-f]{64}", expected) + or hashlib.sha256(path.read_bytes()).hexdigest() != expected): + findings.add("probe-pins", f"{name}: {file} sha256 differs") + + def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=ROOT) diff --git a/src/engine_metrics.cpp b/src/engine_metrics.cpp index 34faf0b53..d5ff1c7d2 100644 --- a/src/engine_metrics.cpp +++ b/src/engine_metrics.cpp @@ -15,6 +15,8 @@ #include #include "metrics_month_memo.hpp" +#include "metrics_annualization.hpp" +#include "report_value_invalid.hpp" #include "timezone.hpp" namespace pineforge { @@ -43,24 +45,33 @@ inline int month_key_local(int64_t ts_ms) { // call ONLY under ScopedTimezone // population downside deviation vs rf for Sortino (arbitration item — see // spec; corpus probe pins it). inline void sharpe_sortino(const std::vector& r, double rf_period, - double ann, double* sharpe, double* sortino) { + double ann, double* sharpe, double* sortino, + const char* sharpe_field, const char* sortino_field) { *sharpe = kNaN; *sortino = kNaN; const size_t n = r.size(); if (n < 2) return; + report::finite_value(rf_period, sharpe_field); + report::finite_value(ann, sharpe_field); double mean = 0.0; - for (double x : r) mean += x; - mean /= (double)n; + for (double x : r) { + report::finite_value(x, sharpe_field); + mean = report::finite_value(mean + x, sharpe_field); + } + mean = report::finite_value(mean / (double)n, sharpe_field); double var = 0.0, down = 0.0; for (double x : r) { - var += (x - mean) * (x - mean); - const double d = std::min(0.0, x - rf_period); - down += d * d; + const double delta = report::finite_value(x - mean, sharpe_field); + var = report::finite_value(var + delta * delta, sharpe_field); + const double d = std::min(0.0, report::finite_value(x - rf_period, sortino_field)); + down = report::finite_value(down + d * d, sortino_field); } - const double sd = std::sqrt(var / (double)(n - 1)); - const double dd = std::sqrt(down / (double)n); - if (sd > 0.0) *sharpe = (mean - rf_period) / sd * ann; - if (dd > 0.0) *sortino = (mean - rf_period) / dd * ann; + const double sd = report::finite_value(std::sqrt(var / (double)(n - 1)), sharpe_field); + const double dd = report::finite_value(std::sqrt(down / (double)n), sortino_field); + if (sd > 0.0) + *sharpe = report::finite_value((mean - rf_period) / sd * ann, sharpe_field); + if (dd > 0.0) + *sortino = report::finite_value((mean - rf_period) / dd * ann, sortino_field); } } // namespace @@ -121,6 +132,12 @@ int keep_utc_month(UtcMonthMemo& memo, int64_t ts_ms) { pf_trade_stats_t compute_trade_stats(const TradeC* trades, int n, TradeFilter filter, double initial_capital) { + const char* prefix = filter == TradeFilter::ALL ? "metrics.all" : + filter == TradeFilter::LONG ? "metrics.longs" : "metrics.shorts"; + const auto check = [prefix](double value, const char* field) { + return report::finite_value(value, field, prefix); + }; + check(initial_capital, "net_profit_pct"); pf_trade_stats_t s{}; double pct_sum = 0.0, win_pct_sum = 0.0, loss_pct_sum = 0.0; double bars_sum = 0.0, win_bars_sum = 0.0, loss_bars_sum = 0.0; @@ -131,19 +148,21 @@ pf_trade_stats_t compute_trade_stats(const TradeC* trades, int n, for (int i = 0; i < n; ++i) { const TradeC& t = trades[i]; if (!match(t, filter)) continue; + report::validate_trade(t); ++s.num_trades; - s.net_profit += t.pnl; - s.commission_paid += t.commission; - pct_sum += t.pnl_pct; + s.net_profit = check(s.net_profit + t.pnl, "net_profit"); + s.commission_paid = check(s.commission_paid + t.commission, "commission_paid"); + pct_sum = check(pct_sum + t.pnl_pct, "avg_trade_pct"); // TV counts bars-in-trade inclusively of the entry bar (TV=9 vs the // exclusive diff 8, all blocks; validated 2026-06-12 vs TV export). - const double bars = (double)(t.exit_bar_index - t.entry_bar_index) + 1.0; - bars_sum += bars; + const double bars = (double)(static_cast(t.exit_bar_index) + - static_cast(t.entry_bar_index)) + 1.0; + bars_sum = check(bars_sum + bars, "avg_bars_in_trade"); if (t.pnl > 0.0) { ++s.num_wins; - s.gross_profit += t.pnl; - win_pct_sum += t.pnl_pct; - win_bars_sum += bars; + s.gross_profit = check(s.gross_profit + t.pnl, "gross_profit"); + win_pct_sum = check(win_pct_sum + t.pnl_pct, "avg_win_pct"); + win_bars_sum = check(win_bars_sum + bars, "avg_bars_in_wins"); streak_l = 0; if (++streak_w > s.max_consecutive_wins) s.max_consecutive_wins = streak_w; // TV "Largest profit %" is the independent max of per-trade %, @@ -153,9 +172,9 @@ pf_trade_stats_t compute_trade_stats(const TradeC* trades, int n, if (t.pnl_pct > largest_win_pct) largest_win_pct = t.pnl_pct; } else if (t.pnl < 0.0) { ++s.num_losses; - s.gross_loss += -t.pnl; // positive magnitude - loss_pct_sum += -t.pnl_pct; - loss_bars_sum += bars; + s.gross_loss = check(s.gross_loss + -t.pnl, "gross_loss"); // positive magnitude + loss_pct_sum = check(loss_pct_sum + -t.pnl_pct, "avg_loss_pct"); + loss_bars_sum = check(loss_bars_sum + bars, "avg_bars_in_losses"); streak_w = 0; if (++streak_l > s.max_consecutive_losses) s.max_consecutive_losses = streak_l; // Independent maxima, mirroring the win side. pnl_pct is net @@ -171,32 +190,32 @@ pf_trade_stats_t compute_trade_stats(const TradeC* trades, int n, } const bool cap_ok = initial_capital > 0.0; - s.net_profit_pct = cap_ok ? s.net_profit / initial_capital * 100.0 : kNaN; - s.gross_profit_pct = cap_ok ? s.gross_profit / initial_capital * 100.0 : kNaN; - s.gross_loss_pct = cap_ok ? s.gross_loss / initial_capital * 100.0 : kNaN; + s.net_profit_pct = cap_ok ? check(s.net_profit / initial_capital * 100.0, "net_profit_pct") : kNaN; + s.gross_profit_pct = cap_ok ? check(s.gross_profit / initial_capital * 100.0, "gross_profit_pct") : kNaN; + s.gross_loss_pct = cap_ok ? check(s.gross_loss / initial_capital * 100.0, "gross_loss_pct") : kNaN; s.percent_profitable = s.num_trades > 0 - ? (double)s.num_wins / (double)s.num_trades * 100.0 : kNaN; - s.profit_factor = s.gross_loss > 0.0 ? s.gross_profit / s.gross_loss : kNaN; - s.avg_trade = s.num_trades > 0 ? s.net_profit / s.num_trades : kNaN; - s.avg_trade_pct = s.num_trades > 0 ? pct_sum / s.num_trades : kNaN; - s.avg_win = s.num_wins > 0 ? s.gross_profit / s.num_wins : kNaN; - s.avg_win_pct = s.num_wins > 0 ? win_pct_sum / s.num_wins : kNaN; - s.avg_loss = s.num_losses > 0 ? s.gross_loss / s.num_losses : kNaN; - s.avg_loss_pct = s.num_losses > 0 ? loss_pct_sum / s.num_losses : kNaN; + ? check((double)s.num_wins / (double)s.num_trades * 100.0, "percent_profitable") : kNaN; + s.profit_factor = s.gross_loss > 0.0 ? check(s.gross_profit / s.gross_loss, "profit_factor") : kNaN; + s.avg_trade = s.num_trades > 0 ? check(s.net_profit / s.num_trades, "avg_trade") : kNaN; + s.avg_trade_pct = s.num_trades > 0 ? check(pct_sum / s.num_trades, "avg_trade_pct") : kNaN; + s.avg_win = s.num_wins > 0 ? check(s.gross_profit / s.num_wins, "avg_win") : kNaN; + s.avg_win_pct = s.num_wins > 0 ? check(win_pct_sum / s.num_wins, "avg_win_pct") : kNaN; + s.avg_loss = s.num_losses > 0 ? check(s.gross_loss / s.num_losses, "avg_loss") : kNaN; + s.avg_loss_pct = s.num_losses > 0 ? check(loss_pct_sum / s.num_losses, "avg_loss_pct") : kNaN; s.ratio_avg_win_avg_loss = (s.num_wins > 0 && s.num_losses > 0 && s.avg_loss > 0.0) - ? s.avg_win / s.avg_loss : kNaN; + ? check(s.avg_win / s.avg_loss, "ratio_avg_win_avg_loss") : kNaN; s.largest_win = s.num_wins > 0 ? largest_win : kNaN; s.largest_win_pct = s.num_wins > 0 ? largest_win_pct : kNaN; s.largest_loss = s.num_losses > 0 ? largest_loss : kNaN; s.largest_loss_pct = s.num_losses > 0 ? largest_loss_pct : kNaN; s.expectancy = s.num_trades > 0 - ? ((double)s.num_wins / s.num_trades) * (s.num_wins > 0 ? s.avg_win : 0.0) - - ((double)s.num_losses / s.num_trades) * (s.num_losses > 0 ? s.avg_loss : 0.0) + ? check(((double)s.num_wins / s.num_trades) * (s.num_wins > 0 ? s.avg_win : 0.0) + - ((double)s.num_losses / s.num_trades) * (s.num_losses > 0 ? s.avg_loss : 0.0), "expectancy") : kNaN; - s.avg_bars_in_trade = s.num_trades > 0 ? bars_sum / s.num_trades : kNaN; - s.avg_bars_in_wins = s.num_wins > 0 ? win_bars_sum / s.num_wins : kNaN; - s.avg_bars_in_losses = s.num_losses > 0 ? loss_bars_sum / s.num_losses : kNaN; + s.avg_bars_in_trade = s.num_trades > 0 ? check(bars_sum / s.num_trades, "avg_bars_in_trade") : kNaN; + s.avg_bars_in_wins = s.num_wins > 0 ? check(win_bars_sum / s.num_wins, "avg_bars_in_wins") : kNaN; + s.avg_bars_in_losses = s.num_losses > 0 ? check(loss_bars_sum / s.num_losses, "avg_bars_in_losses") : kNaN; return s; } @@ -206,24 +225,32 @@ pf_equity_stats_t compute_equity_stats(const pf_equity_point_t* curve, int64_t n double first_open, double last_close, int64_t bars_in_market, double net_profit, int64_t observation_count) { + const auto check = [](double value, const char* field) { + return report::finite_value(value, field, "metrics.equity"); + }; + check(initial_capital, "buy_hold_return"); + report::finite_value(net_profit, "summary.net_pnl"); pf_equity_stats_t e{}; e.sharpe_monthly = kNaN; e.sortino_monthly = kNaN; e.sharpe_bar = kNaN; e.sortino_bar = kNaN; e.cagr = kNaN; e.calmar = kNaN; e.recovery_factor = kNaN; e.buy_hold_return = kNaN; e.buy_hold_return_pct = kNaN; e.time_in_market_pct = kNaN; e.open_pl = 0.0; + // NaN is the existing missing-price sentinel. Infinity is never missing. + if (std::isinf(first_open)) check(first_open, "buy_hold_return_pct"); + if (std::isinf(last_close)) check(last_close, "buy_hold_return_pct"); if (std::isfinite(first_open) && first_open > 0.0 && std::isfinite(last_close)) { - e.buy_hold_return_pct = (last_close / first_open - 1.0) * 100.0; - e.buy_hold_return = initial_capital * (last_close / first_open - 1.0); + e.buy_hold_return_pct = check((last_close / first_open - 1.0) * 100.0, "buy_hold_return_pct"); + e.buy_hold_return = check(initial_capital * (last_close / first_open - 1.0), "buy_hold_return"); } if (n <= 0 || curve == nullptr) return e; - e.open_pl = curve[n - 1].open_profit; + e.open_pl = report::finite_value(curve[n - 1].open_profit, "equity_curve.open_profit"); // Exposure is over observations, not curve points: a selected-window curve // carries its window anchor as point 0 (metrics.hpp), and the anchor is no // bar. Every other statistic below walks the n curve points. e.time_in_market_pct = observation_count > 0 - ? (double)bars_in_market / (double)observation_count * 100.0 : kNaN; + ? check((double)bars_in_market / (double)observation_count * 100.0, "time_in_market_pct") : kNaN; // --- Drawdown / runup walk. MUST mirror update_equity_extremes // (engine.hpp): trough resets to eq on every new peak. The walk over the @@ -232,33 +259,49 @@ pf_equity_stats_t compute_equity_stats(const pf_equity_point_t* curve, int64_t n // peak (dd) / trough (runup) in effect when the extreme was hit. double peak = curve[0].equity, trough = curve[0].equity; for (int64_t i = 0; i < n; ++i) { - const double eq = curve[i].equity; + const double eq = report::finite_value(curve[i].equity, "equity_curve.equity"); + report::finite_value(curve[i].open_profit, "equity_curve.open_profit"); if (eq > peak) { peak = eq; trough = eq; } if (eq < trough) trough = eq; - const double dd = peak - eq; + const double dd = check(peak - eq, "max_equity_drawdown"); if (dd > e.max_equity_drawdown) { e.max_equity_drawdown = dd; - e.max_equity_drawdown_pct = peak > 0.0 ? dd / peak * 100.0 : kNaN; + e.max_equity_drawdown_pct = peak > 0.0 ? check(dd / peak * 100.0, "max_equity_drawdown_pct") : kNaN; } - const double ru = eq - trough; + const double ru = check(eq - trough, "max_equity_runup"); if (ru > e.max_equity_runup) { e.max_equity_runup = ru; - e.max_equity_runup_pct = trough > 0.0 ? ru / trough * 100.0 : kNaN; + e.max_equity_runup_pct = trough > 0.0 ? check(ru / trough * 100.0, "max_equity_runup_pct") : kNaN; } } - // --- Calendar span (ms -> years) --- - const double span_years = - (double)(curve[n - 1].time_ms - curve[0].time_ms) / (365.25 * 86400.0 * 1000.0); + // --- Measured curve span on the UTC epoch-ms bar clock (ms -> years) --- + // Unsigned differences avoid signed overflow even for the full int64 + // timestamp domain. For a representable old difference the conversion + // and the floating-point operation order are unchanged. + const int64_t first_time = curve[0].time_ms, last_time = curve[n - 1].time_ms; + const double span_ms = last_time >= first_time + ? (double)(static_cast(last_time) - static_cast(first_time)) + : -(double)(static_cast(first_time) - static_cast(last_time)); + const double span_years = check(span_ms / (365.25 * 86400.0 * 1000.0), "cagr"); if (span_years > 0.0 && initial_capital > 0.0 && curve[n - 1].equity > 0.0) { - e.cagr = (std::pow(curve[n - 1].equity / initial_capital, 1.0 / span_years) - - 1.0) * 100.0; + const double ratio = check(curve[n - 1].equity / initial_capital, "cagr"); + if (span_ms < static_cast(detail::kMinAnnualizationSpanMs)) { + e.cagr = check((ratio - 1.0) * (1.0 / span_years) * 100.0, "cagr"); + } else { + const double compounded = (std::pow(ratio, 1.0 / span_years) - 1.0) * 100.0; + // A nonfinite compounded value alone permits the proportional + // fallback. The simple/compounded switch can cause a rare ranking + // inversion across the one-day boundary (simple > compounded). + e.cagr = check(std::isfinite(compounded) ? compounded + : (ratio - 1.0) * (1.0 / span_years) * 100.0, "cagr"); + } } if (e.max_equity_drawdown > 0.0) { - e.recovery_factor = net_profit / e.max_equity_drawdown; + e.recovery_factor = check(net_profit / e.max_equity_drawdown, "recovery_factor"); if (!std::isnan(e.cagr) && e.max_equity_drawdown_pct > 0.0) - e.calmar = e.cagr / e.max_equity_drawdown_pct; + e.calmar = check(e.cagr / e.max_equity_drawdown_pct, "calmar"); } // --- Monthly-resampled Sharpe/Sortino (sharpe_monthly / sortino_monthly, @@ -290,20 +333,22 @@ pf_equity_stats_t compute_equity_stats(const pf_equity_point_t* curve, int64_t n std::vector r; for (size_t i = 1; i < month_end.size(); ++i) if (month_end[i - 1] > 0.0) r.push_back(month_end[i] / month_end[i - 1] - 1.0); - sharpe_sortino(r, 0.02 / 12.0, std::sqrt(12.0), &e.sharpe_monthly, &e.sortino_monthly); + sharpe_sortino(r, 0.02 / 12.0, std::sqrt(12.0), &e.sharpe_monthly, &e.sortino_monthly, + "metrics.equity.sharpe_monthly", "metrics.equity.sortino_monthly"); } // --- Per-bar variant: density-annualized (observed bars/year, NOT the // calendar tf formula — that inflates Sharpe ~sqrt(5) on non-24/7 // sessions; see spec). if (n >= 3 && span_years > 0.0) { - const double bars_per_year = (double)(n - 1) / span_years; + const double bars_per_year = check((double)(n - 1) / span_years, "sharpe_bar"); std::vector r; r.reserve((size_t)(n - 1)); for (int64_t i = 1; i < n; ++i) if (curve[i - 1].equity > 0.0) r.push_back(curve[i].equity / curve[i - 1].equity - 1.0); sharpe_sortino(r, 0.02 / bars_per_year, std::sqrt(bars_per_year), - &e.sharpe_bar, &e.sortino_bar); + &e.sharpe_bar, &e.sortino_bar, + "metrics.equity.sharpe_bar", "metrics.equity.sortino_bar"); } return e; } diff --git a/src/engine_report.cpp b/src/engine_report.cpp index 9a9e75116..a5b4cdfd5 100644 --- a/src/engine_report.cpp +++ b/src/engine_report.cpp @@ -7,6 +7,8 @@ #include +#include "report_value_invalid.hpp" + #include #include #include @@ -53,7 +55,7 @@ void BacktestEngine::trace(const std::string& name, double value) { // release. strategy_stream_fill_report, the explicit snapshot, runs this same // function asking for the rows' own owner (NativeExecutionConsumer:: // fill_snapshot_report), so a live stream's rows survive such a refusal. -void BacktestEngine::fill_report(ReportC* out) const { +void BacktestEngine::fill_report(ReportC* out) const try { // A read from inside a capture or an observer call is refused before any // gate: the empty report, latched for the terminal helper, and no recursive // present_report. @@ -92,6 +94,14 @@ void BacktestEngine::fill_report(ReportC* out) const { out->broker_state_hash_len = hn; out->broker_state_hash = hn > 0 ? new uint64_t[hn] : nullptr; if (hn > 0) std::copy(broker_state_hashes_.begin(), broker_state_hashes_.end(), out->broker_state_hash); +} catch (const std::exception& error) { + // A C wrapper can ask for partial rows after execution already failed. + // A later invalid metric must not replace that latched typed failure. + if (!last_error().empty() + && classify_run_failure(error).code == RunFailureCode::report_result_invalid) { + throw coded(run_failure_value_of(*this), last_error()); + } + throw; } @@ -103,8 +113,9 @@ void BacktestEngine::fill_report(ReportC* out) const { void BacktestEngine::fill_trades_section(ReportC* out) const { const int n_closed = (int)trades_.size(); const int n = n_closed + (int)range_end_trades_.size(); - out->total_trades = n; - out->trades_len = n; + out->total_trades = 0; + out->trades_len = 0; + out->net_profit = 0.0; if (n > 0) { out->trades = new TradeC[n]; @@ -113,6 +124,7 @@ void BacktestEngine::fill_trades_section(ReportC* out) const { for (int i = 0; i < n; i++) { const Trade& t = (i < n_closed) ? trades_[i] : range_end_trades_[i - n_closed]; + report::validate_trade(t); out->trades[i].entry_time = t.entry_time; out->trades[i].exit_time = t.exit_time; out->trades[i].entry_price = t.entry_price; @@ -127,13 +139,14 @@ void BacktestEngine::fill_trades_section(ReportC* out) const { out->trades[i].entry_bar_index = t.entry_bar_index; out->trades[i].exit_bar_index = t.exit_bar_index; out->trades[i].open_at_end = t.open_at_end ? 1 : 0; - net_profit += t.pnl; + // A partial report keeps each fully initialized row, including + // this one if only the following aggregate overflows. + out->trades_len = out->total_trades = i + 1; + net_profit = report::finite_value(net_profit + t.pnl, "summary.net_pnl"); + out->net_profit = net_profit; } - - out->net_profit = net_profit; } else { out->trades = nullptr; - out->net_profit = 0.0; } } diff --git a/src/metrics_annualization.hpp b/src/metrics_annualization.hpp new file mode 100644 index 000000000..bc39f3e6b --- /dev/null +++ b/src/metrics_annualization.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace pineforge::metrics::detail { + +#define PF_ANNUALIZATION_SPAN_MS(value) inline constexpr std::uint64_t kMinAnnualizationSpanMs = value; +#include "../docker/report_annualization.def" +#undef PF_ANNUALIZATION_SPAN_MS + +} // namespace pineforge::metrics::detail diff --git a/src/native_c_host.cpp b/src/native_c_host.cpp index e59cc1c14..1b47f9550 100644 --- a/src/native_c_host.cpp +++ b/src/native_c_host.cpp @@ -3460,6 +3460,7 @@ PF_API int strategy_native_run_v1(pf_strategy_t s, const pf_bar_t* bars, int n, auto* host = host_of(s); if (!host) return PF_NATIVE_E_HANDLE; if (n < 0 || (n > 0 && !bars)) return c_refuse(host, PF_NATIVE_E_ARGUMENT); + if (out) *out = pf_report_t{}; /* OK answers a run THIS call carried to Completed. A call the engine * refuses without beginning (a Completed handle nothing re-armed, a * Failed or not-yet-Ready handle, a refused bar array or run option) @@ -3473,7 +3474,47 @@ PF_API int strategy_native_run_v1(pf_strategy_t s, const pf_bar_t* bars, int n, * begin. */ const std::uint64_t high_water = host->native_consumed_high_water(); host->run(reinterpret_cast(bars), n); - if (out) host->fill_report(reinterpret_cast(out)); + if (out) { + // A failed execution may have a partial curve whose report metrics + // overflow. fill_report rethrows that execution's earlier typed + // failure, not R. Preserve its run-failed status rather than routing + // the rethrow through guarded()'s generic E_STATE exception case. + const bool failed = host->native_state().kind == pineforge::NativeLifecycleKind::Failed; + const auto earlier = pineforge::run_failure_value_of(*host); + pineforge::ReportC local{}; + static_assert(sizeof(local) == sizeof(*out), "native report layouts must match"); + std::unique_ptr + cleanup(&local, &pineforge::BacktestEngine::free_report); + try { + host->fill_report(&local); + } catch (const std::exception& error) { + const auto carried = pineforge::classify_run_failure(error); + if (!failed && carried.code == pineforge::RunFailureCode::report_result_invalid) { + // A completed execution with an invalid report answers + // E_STATE with the recorded R and an empty, freeable out. + // cleanup releases every allocation made before the throw. + pineforge::note_run_failure(*host, error.what(), carried); + return PF_NATIVE_E_STATE; + } + if (!failed || host->last_error().empty() + || earlier.code == pineforge::RunFailureCode::none + || dynamic_cast(&error) == nullptr + || carried.code != earlier.code || carried.args != earlier.args + || host->last_error() != error.what()) { + throw; + } + // native_c_api.h promises partial rows when execution began + // and failed. Publish the zero-initialized local, whose trade + // length names only initialized rows; later arrays stay null. + // The caller may have supplied an uninitialized out: overwrite + // it without reading or freeing its previous contents. + std::memcpy(out, &local, sizeof(local)); + (void)cleanup.release(); + return PF_NATIVE_E_RUN_FAILED; + } + std::memcpy(out, &local, sizeof(local)); + (void)cleanup.release(); + } return host->native_state().kind == pineforge::NativeLifecycleKind::Completed && host->native_consumed_high_water() > high_water ? PF_NATIVE_OK diff --git a/src/report_value_invalid.hpp b/src/report_value_invalid.hpp new file mode 100644 index 000000000..79c02ba22 --- /dev/null +++ b/src/report_value_invalid.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include + +#include +#include +#include + +namespace pineforge::report { + +// Call only on a value actually computed/copied into a finite result domain. +// An explicit out-of-domain metric sentinel is chosen by its caller before +// this check. In particular, a computed NaN is never an absence. +inline double finite_value(double value, const char* field, const char* prefix = nullptr) { + if (!std::isfinite(value)) { + const std::string path = prefix ? std::string(prefix) + "." + field : field; + throw coded( + RunFailureCode::report_result_invalid, + RunFailureArgs{{"field", path}, {"reason", "nonfinite"}}, + "The report producer generated an invalid canonical result."); + } + return value; +} + +// A missed execution invariant is still an R at the report boundary. This +// does not replace the earlier engine-owned settlement/admission guards. +template +inline void validate_trade(const Trade& trade) { +#define PF_FINITE_TRADE(field) (void)finite_value(trade.field, "trades." #field) + PF_FINITE_TRADE(entry_price); + PF_FINITE_TRADE(exit_price); + PF_FINITE_TRADE(qty); + PF_FINITE_TRADE(pnl); + PF_FINITE_TRADE(pnl_pct); + PF_FINITE_TRADE(max_runup); + PF_FINITE_TRADE(max_drawdown); + PF_FINITE_TRADE(commission); +#undef PF_FINITE_TRADE +} + +} // namespace pineforge::report diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1f09381df..fd57b7a91 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3688,6 +3688,10 @@ endif() list(REMOVE_ITEM TEST_SOURCES ${_pf_private_fixture_rows}) +# Report arithmetic and C-host failure ownership, with source-free includes. +list(APPEND TEST_SOURCES test_metrics_overflow) +list(APPEND TEST_SOURCES test_native_c_host_report_failure) + foreach(test_name ${TEST_SOURCES}) pineforge_add_unit_test(${test_name}) endforeach() diff --git a/tests/ownership/README.md b/tests/ownership/README.md index d82592c75..23ecb1f73 100644 --- a/tests/ownership/README.md +++ b/tests/ownership/README.md @@ -49,6 +49,14 @@ an empty set. Passing one probe directory as `--probes` is also supported. No CTest registration is added: the public no-fixture profile has no installed-codegen prerequisite; the supervisor runs this script on a box. +Report probes are also registered by relative `id` in the manifest's `probes` +array. Each entry pins the SHA256 of all six files: `probe.pine`, `bars.csv`, +`syminfo.json`, `run.json`, `expect.json`, and `NOTES.md`. The manifest checker +discovers `probes/report/*/probe.pine` independently and rejects unregistered, +stale or duplicate ids, missing pins/files and changed bytes. Earlier groups +predate this registry. The driver continues to discover every group normally; +the registry adds a source guard and does not imply a runtime verdict. + Each directory has `probe.pine`, `bars.csv`, `expect.json`, and `NOTES.md`. The CSV header is `timestamp,open,high,low,close,volume`, with timestamps in Unix milliseconds. Use 40..200 synthetic bars and document their hand-written @@ -290,9 +298,10 @@ python3 -I -B tests/ownership/test_manifest_checker.py --shard 1/4 `--shard I/N` requires integers with `1 <= I <= N` and selects `sorted(loader case ids)[I-1::N]`, passes those ids to unittest by name and -exits non-zero unless it ran exactly the number of tests it selected (9, 9, 9 -and 8 for shards 1 to 4 of 4: 8, 8, 8 and 7 cases plus the guard). The separate cheap `test_shards_partition_every_case_exactly_once` -guard runs in every shard: for each shard count from 1 through 8 it rebuilds +exits non-zero unless it ran exactly the number of tests it selected +(10, 10, 10 and 10 for shards 1 to 4 of 4: 9 cases plus the guard in each). Each +shard also runs the cheap `test_shards_partition_every_case_exactly_once` +guard: for each shard count from 1 through 8 it rebuilds the assignment from the loader's ids with index residues (the same loader expression, a different assignment rule) and checks the exact assignments, disjointness and full coverage, with at least 30 cases discovered. diff --git a/tests/ownership/manifest.v1.json b/tests/ownership/manifest.v1.json index acc3dca27..71278b043 100644 --- a/tests/ownership/manifest.v1.json +++ b/tests/ownership/manifest.v1.json @@ -2,6 +2,19 @@ "format": "pineforge-selected-window-ownership/v1", "contract": "selected-window wire v1.3 revision 3 section 4, Appendices A-D; D1-D4 and D6 rulings", "scope": "Ownership and source reconstruction, not a digest implementation or an executed parity verdict.", + "probes": [ + { + "id": "report/buy_hold_return_overflow", + "sha256": { + "probe.pine": "d2a45ca5b0bcb62c985c40dbf76855bc7d31cf079efa87998e6cf2568f7fd789", + "bars.csv": "c4b0d57cab8fb1c6d2febdb5a55892eae9166b587aca07e58da0722188e012e9", + "syminfo.json": "9b0a94d4a3c6a3d7e59aceef79008b56bb0ece06528803883f4d76cbc1e448e3", + "run.json": "70dc9aee310653010c226e983e009b7a52b2ba8be9fb45b79ff500190ea61e37", + "expect.json": "43896518e1d2d254a024a87365f1a154e704909ab5087d50009e5bf3585e4ea2", + "NOTES.md": "cd3f14bdab8ccc2e1cb1ed35688bfe1f4c4bd8d7fe81e136999fc36089a50bef" + } + } + ], "vocabulary": { "strategy_value_invalid": { "fields": [ @@ -28,7 +41,37 @@ "report_result_invalid": { "fields": ["trades.entry_price", "trades.exit_price", "trades.qty", "trades.pnl", "trades.pnl_pct", "trades.max_runup", "trades.max_drawdown", "trades.commission", "summary.net_pnl", "summary.avg_trade", "summary.best_trade", - "summary.worst_trade", "summary.max_drawdown", "report.document"], + "summary.worst_trade", "summary.max_drawdown", "report.document", + "summary.win_rate_pct", "equity_curve.equity", "equity_curve.open_profit", + "metrics.all.percent_profitable", "metrics.all.net_profit", "metrics.all.net_profit_pct", + "metrics.all.gross_profit", "metrics.all.gross_profit_pct", "metrics.all.gross_loss", + "metrics.all.gross_loss_pct", "metrics.all.profit_factor", "metrics.all.avg_trade", + "metrics.all.avg_trade_pct", "metrics.all.avg_win", "metrics.all.avg_win_pct", + "metrics.all.avg_loss", "metrics.all.avg_loss_pct", "metrics.all.ratio_avg_win_avg_loss", + "metrics.all.largest_win", "metrics.all.largest_win_pct", "metrics.all.largest_loss", + "metrics.all.largest_loss_pct", "metrics.all.commission_paid", "metrics.all.expectancy", + "metrics.all.avg_bars_in_trade", "metrics.all.avg_bars_in_wins", "metrics.all.avg_bars_in_losses", + "metrics.longs.percent_profitable", "metrics.longs.net_profit", "metrics.longs.net_profit_pct", + "metrics.longs.gross_profit", "metrics.longs.gross_profit_pct", "metrics.longs.gross_loss", + "metrics.longs.gross_loss_pct", "metrics.longs.profit_factor", "metrics.longs.avg_trade", + "metrics.longs.avg_trade_pct", "metrics.longs.avg_win", "metrics.longs.avg_win_pct", + "metrics.longs.avg_loss", "metrics.longs.avg_loss_pct", "metrics.longs.ratio_avg_win_avg_loss", + "metrics.longs.largest_win", "metrics.longs.largest_win_pct", "metrics.longs.largest_loss", + "metrics.longs.largest_loss_pct", "metrics.longs.commission_paid", "metrics.longs.expectancy", + "metrics.longs.avg_bars_in_trade", "metrics.longs.avg_bars_in_wins", "metrics.longs.avg_bars_in_losses", + "metrics.shorts.percent_profitable", "metrics.shorts.net_profit", "metrics.shorts.net_profit_pct", + "metrics.shorts.gross_profit", "metrics.shorts.gross_profit_pct", "metrics.shorts.gross_loss", + "metrics.shorts.gross_loss_pct", "metrics.shorts.profit_factor", "metrics.shorts.avg_trade", + "metrics.shorts.avg_trade_pct", "metrics.shorts.avg_win", "metrics.shorts.avg_win_pct", + "metrics.shorts.avg_loss", "metrics.shorts.avg_loss_pct", "metrics.shorts.ratio_avg_win_avg_loss", + "metrics.shorts.largest_win", "metrics.shorts.largest_win_pct", "metrics.shorts.largest_loss", + "metrics.shorts.largest_loss_pct", "metrics.shorts.commission_paid", "metrics.shorts.expectancy", + "metrics.shorts.avg_bars_in_trade", "metrics.shorts.avg_bars_in_wins", "metrics.shorts.avg_bars_in_losses", + "metrics.equity.max_equity_drawdown", "metrics.equity.max_equity_drawdown_pct", "metrics.equity.max_equity_runup", + "metrics.equity.max_equity_runup_pct", "metrics.equity.buy_hold_return", "metrics.equity.buy_hold_return_pct", + "metrics.equity.sharpe_tv", "metrics.equity.sortino_tv", "metrics.equity.sharpe_bar", + "metrics.equity.sortino_bar", "metrics.equity.cagr", "metrics.equity.calmar", + "metrics.equity.recovery_factor", "metrics.equity.time_in_market_pct", "metrics.equity.open_pl"], "reasons": ["nonfinite", "invalid_unicode", "column_schema", "type_schema"] }, "report_post_execution_failed": { @@ -110,7 +153,100 @@ {"field": "trades.max_drawdown", "reasons": ["nonfinite"]}, {"field": "trades.commission", "reasons": ["nonfinite"]}, {"field": "summary.net_pnl", "reasons": ["nonfinite"]}, {"field": "summary.avg_trade", "reasons": ["nonfinite"]}, {"field": "summary.best_trade", "reasons": ["nonfinite"]}, {"field": "summary.worst_trade", "reasons": ["nonfinite"]}, - {"field": "summary.max_drawdown", "reasons": ["nonfinite"]} + {"field": "summary.max_drawdown", "reasons": ["nonfinite"]}, + {"field": "summary.win_rate_pct", "reasons": ["nonfinite"]}, + {"field": "equity_curve.equity", "reasons": ["nonfinite"]}, + {"field": "equity_curve.open_profit", "reasons": ["nonfinite"]}, + {"field": "metrics.all.percent_profitable", "reasons": ["nonfinite"]}, + {"field": "metrics.all.net_profit", "reasons": ["nonfinite"]}, + {"field": "metrics.all.net_profit_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.all.gross_profit", "reasons": ["nonfinite"]}, + {"field": "metrics.all.gross_profit_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.all.gross_loss", "reasons": ["nonfinite"]}, + {"field": "metrics.all.gross_loss_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.all.profit_factor", "reasons": ["nonfinite"]}, + {"field": "metrics.all.avg_trade", "reasons": ["nonfinite"]}, + {"field": "metrics.all.avg_trade_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.all.avg_win", "reasons": ["nonfinite"]}, + {"field": "metrics.all.avg_win_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.all.avg_loss", "reasons": ["nonfinite"]}, + {"field": "metrics.all.avg_loss_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.all.ratio_avg_win_avg_loss", "reasons": ["nonfinite"]}, + {"field": "metrics.all.largest_win", "reasons": ["nonfinite"]}, + {"field": "metrics.all.largest_win_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.all.largest_loss", "reasons": ["nonfinite"]}, + {"field": "metrics.all.largest_loss_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.all.commission_paid", "reasons": ["nonfinite"]}, + {"field": "metrics.all.expectancy", "reasons": ["nonfinite"]}, + {"field": "metrics.all.avg_bars_in_trade", "reasons": ["nonfinite"]}, + {"field": "metrics.all.avg_bars_in_wins", "reasons": ["nonfinite"]}, + {"field": "metrics.all.avg_bars_in_losses", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.percent_profitable", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.net_profit", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.net_profit_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.gross_profit", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.gross_profit_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.gross_loss", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.gross_loss_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.profit_factor", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.avg_trade", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.avg_trade_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.avg_win", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.avg_win_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.avg_loss", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.avg_loss_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.ratio_avg_win_avg_loss", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.largest_win", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.largest_win_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.largest_loss", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.largest_loss_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.commission_paid", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.expectancy", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.avg_bars_in_trade", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.avg_bars_in_wins", "reasons": ["nonfinite"]}, + {"field": "metrics.longs.avg_bars_in_losses", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.percent_profitable", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.net_profit", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.net_profit_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.gross_profit", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.gross_profit_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.gross_loss", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.gross_loss_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.profit_factor", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.avg_trade", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.avg_trade_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.avg_win", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.avg_win_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.avg_loss", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.avg_loss_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.ratio_avg_win_avg_loss", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.largest_win", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.largest_win_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.largest_loss", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.largest_loss_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.commission_paid", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.expectancy", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.avg_bars_in_trade", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.avg_bars_in_wins", "reasons": ["nonfinite"]}, + {"field": "metrics.shorts.avg_bars_in_losses", "reasons": ["nonfinite"]}, + {"field": "metrics.equity.max_equity_drawdown", "reasons": ["nonfinite"]}, + {"field": "metrics.equity.max_equity_drawdown_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.equity.max_equity_runup", "reasons": ["nonfinite"]}, + {"field": "metrics.equity.max_equity_runup_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.equity.buy_hold_return", "reasons": ["nonfinite"]}, + {"field": "metrics.equity.buy_hold_return_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.equity.sharpe_tv", "reasons": ["nonfinite"]}, + {"field": "metrics.equity.sortino_tv", "reasons": ["nonfinite"]}, + {"field": "metrics.equity.sharpe_bar", "reasons": ["nonfinite"]}, + {"field": "metrics.equity.sortino_bar", "reasons": ["nonfinite"]}, + {"field": "metrics.equity.cagr", "reasons": ["nonfinite"]}, + {"field": "metrics.equity.calmar", "reasons": ["nonfinite"]}, + {"field": "metrics.equity.recovery_factor", "reasons": ["nonfinite"]}, + {"field": "metrics.equity.time_in_market_pct", "reasons": ["nonfinite"]}, + {"field": "metrics.equity.open_pl", "reasons": ["nonfinite"]} + ]}, + {"id": "report.build_report_dict", "file": "docker/run_json.py", "anchor": "build_report_dict", "producer": "report_result_invalid", "stage": null, "fields": [ + {"field": "trades.pnl", "reasons": ["nonfinite"]}, {"field": "summary.max_drawdown", "reasons": ["nonfinite"]} ]}, {"id": "report._main", "file": "docker/run_json.py", "anchor": "_main", "producer": "report_result_invalid", "stage": null, "fields": [{"field": "report.document", "reasons": ["nonfinite"]}]}, {"id": "post._post_boundary_failure", "file": "docker/run_json.py", "anchor": "_post_boundary_failure", "producer": "report_post_execution_failed", "stage": null, "fields": [], "phases": ["result_assembly", "results_digest", "serialization"], "reasons": ["resource", "io", "exception"]}, @@ -457,8 +593,8 @@ {"path": "trades[*].max_drawdown", "kind": "number", "null_allowed": false, "owner": "producer", "detection": ["T", "C"], "reasons": ["nonfinite"]}, {"path": "trades[*].commission", "kind": "number", "null_allowed": false, "owner": "producer", "detection": ["T", "C"], "reasons": ["nonfinite"]}, {"path": "equity_curve[*].time_ms", "kind": "integer", "null_allowed": false, "owner": "producer", "detection": ["E", "C"], "reasons": ["type_schema"]}, - {"path": "equity_curve[*].equity", "kind": "number", "null_allowed": true, "owner": "producer", "detection": ["E", "C"], "reasons": ["nonfinite"]}, - {"path": "equity_curve[*].open_profit", "kind": "number", "null_allowed": true, "owner": "producer", "detection": ["E", "C"], "reasons": ["nonfinite"]} + {"path": "equity_curve[*].equity", "kind": "number", "null_allowed": false, "owner": "producer", "detection": ["E", "C"], "reasons": ["nonfinite"]}, + {"path": "equity_curve[*].open_profit", "kind": "number", "null_allowed": false, "owner": "producer", "detection": ["E", "C"], "reasons": ["nonfinite"]} ] } } diff --git a/tests/ownership/probes/report/buy_hold_return_overflow/NOTES.md b/tests/ownership/probes/report/buy_hold_return_overflow/NOTES.md new file mode 100644 index 000000000..af267c2b2 --- /dev/null +++ b/tests/ownership/probes/report/buy_hold_return_overflow/NOTES.md @@ -0,0 +1,35 @@ +# TNX-CX c18_3: buy_hold_return overflow + +Pinned before the producer edits from selected-window wire v1.3 revision 3, +Appendix A lines 74, 83 and 93: a computed equity metric must be finite or +have an existing defined absence. Overflow requires R(nonfinite), whose +catalog entry is revision 2 section 8, line 149. No alternate buy/hold or +CAGR calculation is defined by those clauses. + +One fixed long entry, qty 0.5, at 1, with pointvalue 1e300 and margin_long=100. +Initial capital 1e301 funds the entry without leverage. mincontract=0.5 +admits the exact fractional quantity. Every price and input is finite. +The 40 synthetic minute bars start at 1704067200000: the first two have all +OHLC prices 1, and the remaining 38 have all prices 2.5e8. Volume is 1. +The entry submitted on bar 0 fills at bar 1's open; the position remains +open until the one range-end report row. + +Expected base behavior (to be verified by the lab): completed, exactly one +long trade/open-at-end row, finite settlement and final equity, finite +buy_hold_return_pct = 24999999900, and null buy_hold_return and cagr. The +account profit is 0.5 * 1e300 * (2.5e8 - 1), below DBL_MAX; the buy/hold +metric uses all capital, 1e301 * (2.5e8 / 1 - 1), above DBL_MAX. The CAGR +pow also overflows over this short span. The first failing metric in the +existing computation order is buy_hold_return. + +The expectation is failed/report_result_invalid with field +metrics.equity.buy_hold_return and reason nonfinite. min_trades is 0 because +driver.py requires it on failed rows, which have no success report/trade +array. The base receipt must separately show one trade; this is not a +zero-trade control. tests/test_metrics_overflow.cpp isolates both buy/hold +and CAGR failures and also preserves finite and defined-absence controls. + +Authored only: no compiler, executable, probe, checker or test was run here. +The supervisor must retain both base-red and fixed-green receipts with the +same probe bytes, toolchain and engine build freshness evidence. A source +construction refusal or an earlier execution failure is not the intended red. diff --git a/tests/ownership/probes/report/buy_hold_return_overflow/bars.csv b/tests/ownership/probes/report/buy_hold_return_overflow/bars.csv new file mode 100644 index 000000000..52b334da9 --- /dev/null +++ b/tests/ownership/probes/report/buy_hold_return_overflow/bars.csv @@ -0,0 +1,41 @@ +timestamp,open,high,low,close,volume +1704067200000,1,1,1,1,1 +1704067260000,1,1,1,1,1 +1704067320000,250000000,250000000,250000000,250000000,1 +1704067380000,250000000,250000000,250000000,250000000,1 +1704067440000,250000000,250000000,250000000,250000000,1 +1704067500000,250000000,250000000,250000000,250000000,1 +1704067560000,250000000,250000000,250000000,250000000,1 +1704067620000,250000000,250000000,250000000,250000000,1 +1704067680000,250000000,250000000,250000000,250000000,1 +1704067740000,250000000,250000000,250000000,250000000,1 +1704067800000,250000000,250000000,250000000,250000000,1 +1704067860000,250000000,250000000,250000000,250000000,1 +1704067920000,250000000,250000000,250000000,250000000,1 +1704067980000,250000000,250000000,250000000,250000000,1 +1704068040000,250000000,250000000,250000000,250000000,1 +1704068100000,250000000,250000000,250000000,250000000,1 +1704068160000,250000000,250000000,250000000,250000000,1 +1704068220000,250000000,250000000,250000000,250000000,1 +1704068280000,250000000,250000000,250000000,250000000,1 +1704068340000,250000000,250000000,250000000,250000000,1 +1704068400000,250000000,250000000,250000000,250000000,1 +1704068460000,250000000,250000000,250000000,250000000,1 +1704068520000,250000000,250000000,250000000,250000000,1 +1704068580000,250000000,250000000,250000000,250000000,1 +1704068640000,250000000,250000000,250000000,250000000,1 +1704068700000,250000000,250000000,250000000,250000000,1 +1704068760000,250000000,250000000,250000000,250000000,1 +1704068820000,250000000,250000000,250000000,250000000,1 +1704068880000,250000000,250000000,250000000,250000000,1 +1704068940000,250000000,250000000,250000000,250000000,1 +1704069000000,250000000,250000000,250000000,250000000,1 +1704069060000,250000000,250000000,250000000,250000000,1 +1704069120000,250000000,250000000,250000000,250000000,1 +1704069180000,250000000,250000000,250000000,250000000,1 +1704069240000,250000000,250000000,250000000,250000000,1 +1704069300000,250000000,250000000,250000000,250000000,1 +1704069360000,250000000,250000000,250000000,250000000,1 +1704069420000,250000000,250000000,250000000,250000000,1 +1704069480000,250000000,250000000,250000000,250000000,1 +1704069540000,250000000,250000000,250000000,250000000,1 diff --git a/tests/ownership/probes/report/buy_hold_return_overflow/expect.json b/tests/ownership/probes/report/buy_hold_return_overflow/expect.json new file mode 100644 index 000000000..544e4676d --- /dev/null +++ b/tests/ownership/probes/report/buy_hold_return_overflow/expect.json @@ -0,0 +1,12 @@ +{ + "outcome": "failed", + "code": "report_result_invalid", + "args": { + "field": "metrics.equity.buy_hold_return", + "reason": "nonfinite" + }, + "min_trades": 0, + "label": "R", + "pinned_by": "revision 3 Appendix A lines 74,83,93; Appendix D line 200; revision 2 section 8 report_result_invalid", + "native_boundary_control": "tests/test_metrics_overflow.cpp" +} diff --git a/tests/ownership/probes/report/buy_hold_return_overflow/probe.pine b/tests/ownership/probes/report/buy_hold_return_overflow/probe.pine new file mode 100644 index 000000000..9a7d045ec --- /dev/null +++ b/tests/ownership/probes/report/buy_hold_return_overflow/probe.pine @@ -0,0 +1,4 @@ +//@version=6 +strategy("TNX-CX c18_3 report overflow", initial_capital=1e301, default_qty_type=strategy.fixed, default_qty_value=0.5, margin_long=100, margin_short=100, commission_value=0, slippage=0) +if bar_index == 0 + strategy.entry("L", strategy.long, qty=0.5) diff --git a/tests/ownership/probes/report/buy_hold_return_overflow/run.json b/tests/ownership/probes/report/buy_hold_return_overflow/run.json new file mode 100644 index 000000000..a70048851 --- /dev/null +++ b/tests/ownership/probes/report/buy_hold_return_overflow/run.json @@ -0,0 +1,5 @@ +{ + "input_tf": "1", + "script_tf": "1", + "options": ["--syminfo", "syminfo.json"] +} diff --git a/tests/ownership/probes/report/buy_hold_return_overflow/syminfo.json b/tests/ownership/probes/report/buy_hold_return_overflow/syminfo.json new file mode 100644 index 000000000..9188111ee --- /dev/null +++ b/tests/ownership/probes/report/buy_hold_return_overflow/syminfo.json @@ -0,0 +1,7 @@ +{ + "mintick": 1, + "mincontract": 0.5, + "pointvalue": 1e300, + "timezone": "UTC", + "session": "24x7" +} diff --git a/tests/ownership/test_manifest_checker.py b/tests/ownership/test_manifest_checker.py index 38010aa95..7255d32d3 100644 --- a/tests/ownership/test_manifest_checker.py +++ b/tests/ownership/test_manifest_checker.py @@ -43,7 +43,9 @@ def emitted_schema(root: Path) -> dict: """ tree = ast.parse((root / "docker/run_json.py").read_text(encoding="utf-8")) classes = {"TradeC", "TradeStatsC", "EquityStatsC", "MetricsC", "EquityPointC"} - functions = {"_num", "_stats_dict", "fmt_utc", "build_report_dict"} + functions = {"_num", "_stats_dict", "fmt_utc", "build_report_dict", + "_annualization_diagnostics", "_annualization_block"} + assigned = {"EQUITY_REPORT_KEYS", "MIN_ANNUALIZATION_SPAN_MS"} selected = [ast.ImportFrom(module="__future__", names=[ast.alias(name="annotations")], level=0)] found = set() for node in tree.body: @@ -53,11 +55,12 @@ def emitted_schema(root: Path) -> dict: elif isinstance(node, ast.FunctionDef) and node.name in functions: selected.append(node) found.add(node.name) - elif isinstance(node, ast.Assign) and any( - isinstance(t, ast.Name) and t.id == "EQUITY_REPORT_KEYS" for t in node.targets): - selected.append(node) - found.add("EQUITY_REPORT_KEYS") - if found != classes | functions | {"EQUITY_REPORT_KEYS"}: + elif isinstance(node, ast.Assign): + names = {t.id for t in node.targets if isinstance(t, ast.Name)} & assigned + if names: + selected.append(node) + found |= names + if found != classes | functions | assigned: raise AssertionError(f"independent D4 builder extraction is incomplete: {sorted(found)}") namespace = {"ctypes": ctypes, "math": math, "Path": Path, "datetime": datetime, "timezone": timezone} @@ -181,6 +184,7 @@ def copy_tree(self): for directory in ("src", "include", "docker"): shutil.copytree(ROOT / directory, self.root / directory, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + shutil.copytree(ROOT / "tests/ownership/probes", self.root / "tests/ownership/probes") def reject(self, check, detail, *, copied_tree=False): result = self.run_checker(copied_tree=copied_tree) @@ -197,6 +201,36 @@ def test_integrated_tree_is_green(self): self.assertEqual(result.stdout, "") self.assertEqual(result.stderr, "") + def test_report_probe_pin_removed_is_rejected(self): + self.doc["probes"] = [] + self.reject("probe-pins", "unregistered report/buy_hold_return_overflow") + + def test_report_probe_file_hash_changed_is_rejected(self): + self.copy_tree() + path = self.root / "tests/ownership/probes/report/buy_hold_return_overflow/bars.csv" + path.write_bytes(path.read_bytes().replace(b"250000000", b"250000001", 1)) + self.reject("probe-pins", "report/buy_hold_return_overflow: bars.csv sha256 differs", + copied_tree=True) + + def test_report_probe_file_pin_missing_is_rejected(self): + del self.doc["probes"][0]["sha256"]["run.json"] + self.reject("probe-pins", "report/buy_hold_return_overflow: pinned file set differs") + + def test_report_probe_duplicate_is_rejected(self): + self.doc["probes"].append(copy.deepcopy(self.doc["probes"][0])) + self.reject("duplicate-probe", "report/buy_hold_return_overflow") + + def test_report_metric_field_binding_changed_is_rejected(self): + self.copy_tree() + path = self.root / "docker/run_json.py" + text = path.read_text(encoding="utf-8") + original = 'metric_field = f"metrics.{block}.{name}"' + self.assertEqual(text.count(original), 1) + path.write_text(text.replace(original, 'metric_field = f"metrics.{name}.{block}"'), + encoding="utf-8") + self.reject("input", "unrecognized metric result guard; update the independent reconstruction", + copied_tree=True) + def test_kept_branch_anchor_removed_is_rejected(self): self.copy_tree() cases = ( diff --git a/tests/report_failure_checks.hpp b/tests/report_failure_checks.hpp new file mode 100644 index 000000000..9ee3d138f --- /dev/null +++ b/tests/report_failure_checks.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +namespace report_failure_checks { + +// Independent proportional expectation from the inputs and the frozen walk. +// The bar ratios and every other field keep the reference's exact values. +inline void apply_proportional_annualization(pf_equity_stats_t& reference, + const pf_equity_point_t* curve, std::int64_t n, + double initial_capital) { + if (!curve || n <= 0 || initial_capital <= 0.0 || curve[n - 1].equity <= 0.0) return; + const auto first = curve[0].time_ms; + const auto last = curve[n - 1].time_ms; + if (last <= first) return; + constexpr std::uint64_t day_ms = 24ULL * 60 * 60 * 1000; + const auto span_ms = static_cast(last) - static_cast(first); + if (span_ms < day_ms || !std::isfinite(reference.cagr)) { + const double span_years = static_cast(span_ms) / (365.25 * 86400.0 * 1000.0); + const double ratio = curve[n - 1].equity / initial_capital; + reference.cagr = (ratio - 1.0) * (1.0 / span_years) * 100.0; + if (reference.max_equity_drawdown > 0.0 && reference.max_equity_drawdown_pct > 0.0) + reference.calmar = reference.cagr / reference.max_equity_drawdown_pct; + } +} + +// Remaining overflow is a typed fault on every span, in computation order. +// `reference` must come from the frozen, unguarded equity_stats_reference +// walk with the input-derived proportional rule applied above. +inline const char* annualization_overflow(const pf_equity_stats_t& reference) { + if (std::isinf(reference.cagr)) return "metrics.equity.cagr"; + if (std::isinf(reference.calmar)) return "metrics.equity.calmar"; + if (std::isinf(reference.sharpe_bar)) return "metrics.equity.sharpe_bar"; + if (std::isinf(reference.sortino_bar)) return "metrics.equity.sortino_bar"; + return nullptr; +} + +inline bool is_nonfinite(const std::exception& error, const std::string& field) { + const auto failure = pineforge::classify_run_failure(error); + return dynamic_cast(&error) != nullptr + && failure.code == pineforge::RunFailureCode::report_result_invalid + && pineforge::run_failure_code_class(failure.code) + == pineforge::RunFailureClass::engine_fault + && !pineforge::run_failure_code_retryable(failure.code) + && failure.args + && *failure.args == "{\"field\":\"" + field + "\",\"reason\":\"nonfinite\"}"; +} + +// Returning successfully fails the expectation. An unrelated exception still +// escapes to the test's own failure handler; it cannot satisfy this check. +template +inline bool rejects_nonfinite(const char* field, Compute&& compute) { + try { + compute(); + } catch (const std::exception& error) { + if (!is_nonfinite(error, field)) throw; + return true; + } + return false; +} + +} // namespace report_failure_checks diff --git a/tests/test_adapter_quiet_bar.cpp b/tests/test_adapter_quiet_bar.cpp index 56a5ce327..2cdead21a 100644 --- a/tests/test_adapter_quiet_bar.cpp +++ b/tests/test_adapter_quiet_bar.cpp @@ -59,6 +59,10 @@ // allocator. `allocations` below reads its count; `count_allocations` says // whether a bar records it. #include "global_allocation_replacement.hpp" +#ifndef PINEFORGE_QUIET_BAR_HARVEST +#include "equity_stats_reference.hpp" +#include "report_failure_checks.hpp" +#endif #include #include @@ -230,6 +234,17 @@ class QuietHost final : public source::PineStrategyHost { } const std::vector& rows() const { return trades_; } +#ifndef PINEFORGE_QUIET_BAR_HARVEST + pf_equity_stats_t reference_equity(const ReportC& report) const { + auto reference = equity_stats_reference::compute_equity_stats( + report.equity_curve, report.equity_curve_len, initial_capital_, chart_timezone_, + first_bar_open_, current_bar_.close, bars_in_market_, report.net_profit); + report_failure_checks::apply_proportional_annualization( + reference, report.equity_curve, report.equity_curve_len, initial_capital_); + return reference; + } +#endif + const std::vector& recorded_hashes() const { return broker_state_hashes_; } std::vector boundary; std::vector quiet_allocations; @@ -787,9 +802,43 @@ Observed observe(const Scenario& s, bool magnifier, bool recording, bool portabl out.error = host.last_error(); out.boundary = host.boundary; ReportC report{}; +#ifdef PINEFORGE_QUIET_BAR_HARVEST + // The frozen harvest headers predate the typed report-failure API. host.fill_report(&report); for (std::int64_t i = 0; i < report.broker_state_hash_len; ++i) out.rows.push_back(report.broker_state_hash[i]); +#else + const auto expected_failure = [&]() { + return report_failure_checks::annualization_overflow(host.reference_equity(report)); + }; + bool rejected = false; + try { + host.fill_report(&report); + CHECK(expected_failure() == nullptr); + } catch (const std::exception& error) { + // After proportional annualization, the input-derived frozen walk + // must independently prove the exact remaining overflow field. + const char* field = expected_failure(); + if (!field || !report_failure_checks::is_nonfinite(error, field)) { + BacktestEngine::free_report(&report); + throw; + } + CHECK(report_failure_checks::is_nonfinite(error, field)); + rejected = true; + } + if (rejected) { + // Metric assembly precedes the report's hash-array copy. Read the + // recorded execution rows directly after checking the report failure; + // all original digest words, counts and continuation checks remain. + out.rows = host.recorded_hashes(); + } else { + CHECK(equity_stats_reference::first_difference( + report.metrics.equity, host.reference_equity(report)) == nullptr); + for (std::int64_t i = 0; i < report.broker_state_hash_len; ++i) + out.rows.push_back(report.broker_state_hash[i]); + CHECK(out.rows == host.recorded_hashes()); + } +#endif BacktestEngine::free_report(&report); out.final_hash = host.broker_state_hash(); if (!portable) out.continuation = host.continuation(); diff --git a/tests/test_metrics_l4d.cpp b/tests/test_metrics_l4d.cpp index 7e21d12e0..509ab9e3b 100644 --- a/tests/test_metrics_l4d.cpp +++ b/tests/test_metrics_l4d.cpp @@ -35,6 +35,8 @@ #include #include +#include "equity_stats_reference.hpp" + using namespace pineforge; static int tests_passed = 0; @@ -318,7 +320,16 @@ static void test_equity_stats_drawdown_walk() { CHECK(std::fabs(e.max_equity_runup - 200.0) < 1e-12); CHECK(std::fabs(e.max_equity_runup_pct - 200.0 / 9.0) < 1e-9); CHECK(std::fabs(e.recovery_factor - 100.0 / 300.0) < 1e-12); - CHECK(!std::isnan(e.cagr)); + auto reference = equity_stats_reference::compute_equity_stats( + c, 4, 1000.0, "", 100.0, 110.0, 0, 100.0); + const double span_years = static_cast(c[3].time_ms - c[0].time_ms) + / (365.25 * 86400.0 * 1000.0); + const double ratio = c[3].equity / 1000.0; + reference.cagr = (ratio - 1.0) * (1.0 / span_years) * 100.0; + reference.calmar = reference.cagr / reference.max_equity_drawdown_pct; + CHECK(std::isfinite(e.cagr) && std::isfinite(e.calmar)); + CHECK(std::isfinite(e.sharpe_bar) && std::isfinite(e.sortino_bar)); + CHECK(equity_stats_reference::first_difference(e, reference) == nullptr); CHECK(std::isnan(e.sharpe_monthly)); // single month bucket -> <2 returns } diff --git a/tests/test_metrics_overflow.cpp b/tests/test_metrics_overflow.cpp new file mode 100644 index 000000000..201ef34cf --- /dev/null +++ b/tests/test_metrics_overflow.cpp @@ -0,0 +1,836 @@ +// CAGR is proportional below one day and on compounded overflow. +// Every remaining report overflow is R(nonfinite). +#include +#include +#include + +#include "../runner/report.hpp" +#include "equity_stats_reference.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace pineforge; + +namespace { +int failures = 0; +const char* row = ""; +constexpr double kNaN = std::numeric_limits::quiet_NaN(); +constexpr double kInf = std::numeric_limits::infinity(); +constexpr double kMax = std::numeric_limits::max(); +constexpr std::int64_t kYear = 31'557'600'000LL; +constexpr std::int64_t kDay = 86'400'000LL; +constexpr std::int64_t kTime = 1704067200000LL; + +#define CHECK(expr) do { if (!(expr)) { \ + std::fprintf(stderr, "FAIL [%s] %s:%d %s\n", row, __FILE__, __LINE__, #expr); \ + ++failures; \ +} } while (0) + +void nonfinite(const char* name, const std::string& field, + const std::function& compute) { + row = name; + try { + compute(); + CHECK(false); // Returning (including NaN/null/zero) is the base defect. + } catch (const std::exception& error) { + const auto failure = classify_run_failure(error); + CHECK(failure.code == RunFailureCode::report_result_invalid); + CHECK(run_failure_code_class(failure.code) == RunFailureClass::engine_fault); + CHECK(!run_failure_code_retryable(failure.code)); + CHECK(failure.args && *failure.args == + "{\"field\":\"" + field + "\",\"reason\":\"nonfinite\"}"); + } +} + +bool same_bits(double left, double right) { + std::uint64_t a = 0, b = 0; + std::memcpy(&a, &left, sizeof a); + std::memcpy(&b, &right, sizeof b); + return a == b; +} + +TradeC trade(double pnl, double pct = 1.0, double fee = 0.0) { + TradeC t{}; + t.entry_price = 1.0; + t.exit_price = 2.0; + t.qty = 0.5; + t.pnl = pnl; + t.pnl_pct = pct; + t.commission = fee; + t.is_long = 1; + t.entry_bar_index = 0; + t.exit_bar_index = 1; + return t; +} + +pf_trade_stats_t stats(const std::vector& trades, double capital = 1e308, + metrics::TradeFilter filter = metrics::TradeFilter::ALL) { + return metrics::compute_trade_stats(trades.data(), static_cast(trades.size()), + filter, capital); +} + +// Inject producer rows directly: this deliberately bypasses settlement, whose +// earlier guards have independent coverage. Exercise the actual report copy +// and aggregate, including the range-end section, without strategy semantics. +class ReportRowsHost : public NativeStrategyHost { +public: + void on_native_bar(const Bar&, const NativeDecisionContext&) override {} + void append(const TradeC& value, bool range_end = false) { + Trade t{}; + t.entry_time = value.entry_time; t.exit_time = value.exit_time; + t.entry_price = value.entry_price; t.exit_price = value.exit_price; + t.pnl = value.pnl; t.pnl_pct = value.pnl_pct; + t.qty = value.qty; t.commission = value.commission; + t.is_long = value.is_long != 0; + t.max_runup = value.max_runup; t.max_drawdown = value.max_drawdown; + t.entry_bar_index = value.entry_bar_index; t.exit_bar_index = value.exit_bar_index; + (range_end ? range_end_trades_ : trades_).push_back(t); + } + void read(ReportC& out) const { fill_trades_section(&out); } +}; + +void report_trade_assembly() { + for (bool tail : {false, true}) { + ReportRowsHost host; + host.append(trade(1e308)); + host.append(trade(1e308), tail); + nonfinite("report_net_profit_sum", "summary.net_pnl", [&] { + ReportC out{}; + try { host.read(out); } + catch (...) { + CHECK(out.total_trades == 2 && out.trades_len == 2); + CHECK(out.trades != nullptr); + if (out.trades && out.trades_len == 2) + CHECK(out.trades[0].pnl == 1e308 && out.trades[1].pnl == 1e308); + BacktestEngine::free_report(&out); + throw; + } + BacktestEngine::free_report(&out); + }); + } + ReportRowsHost corrupted; + auto bad = trade(1); + bad.max_runup = kNaN; + corrupted.append(bad, true); + nonfinite("report_trade_invariant", "trades.max_runup", [&] { + ReportC out{}; + try { corrupted.read(out); } + catch (...) { BacktestEngine::free_report(&out); throw; } + BacktestEngine::free_report(&out); + }); + ReportRowsHost prefix; + prefix.append(trade(3)); + prefix.append(bad, true); + nonfinite("report_trade_invariant_keeps_initialized_prefix", "trades.max_runup", [&] { + ReportC partial{}; + try { + prefix.read(partial); + } catch (...) { + CHECK(partial.total_trades == 1 && partial.trades_len == 1); + CHECK(partial.net_profit == 3); + CHECK(partial.trades != nullptr); + if (partial.trades && partial.trades_len == 1) + CHECK(partial.trades[0].pnl == 3 && partial.trades[0].qty == 0.5); + BacktestEngine::free_report(&partial); + throw; + } + BacktestEngine::free_report(&partial); + }); + row = "report_trade_assembly_finite"; + ReportRowsHost finite; + finite.append(trade(3)); + finite.append(trade(-1), true); + ReportC out{}; + finite.read(out); + CHECK(out.trades_len == 2 && out.total_trades == 2 && out.net_profit == 2); + CHECK(out.trades[0].pnl == 3 && out.trades[1].pnl == -1); + BacktestEngine::free_report(&out); +} + +void earlier_failure_keeps_its_code() { + row = "earlier_failure_keeps_its_code"; + ReportRowsHost host; + NativeRunSpec spec; + spec.identity = {"metrics-earlier-failure", 1}; + spec.input_tf = "1"; spec.script_tf = "1"; + spec.tickerid = "TEST:METRICS"; + spec.timezone = "UTC"; spec.session = "24x7"; + spec.initial_capital = 1; + spec.point_value = 1; spec.account_fx = 1; + spec.price_tick = 1; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + const Bar bar{1, 1, 1, 1, 1, kTime}; + host.run(&bar, 1); + CHECK(host.last_error().empty()); + auto bad = trade(1); + bad.max_runup = kInf; + host.append(bad); // Missed producer invariant in a partial report. + note_run_failure(host, "earlier strategy stop", RunFailureCode::strategy_runtime_error); + ReportC out{}; + try { + host.fill_report(&out); + CHECK(false); + } catch (const std::exception& error) { + CHECK(classify_run_failure(error).code == RunFailureCode::strategy_runtime_error); + CHECK(std::string(error.what()) == "earlier strategy stop"); + CHECK(run_failure_value_of(host).code == RunFailureCode::strategy_runtime_error); + } + BacktestEngine::free_report(&out); +} + +pf_equity_stats_t equity(std::vector values, double capital = 1.0, + std::int64_t step = kYear, double net_profit = 0.0, + double first_open = 1.0, double last_close = 1.0) { + std::vector curve; + for (std::size_t i = 0; i < values.size(); ++i) + curve.push_back({kTime + static_cast(i) * step, values[i], 0.0}); + return metrics::compute_equity_stats(curve.data(), static_cast(curve.size()), + capital, "UTC", first_open, last_close, 0, net_profit); +} + +void trade_arithmetic() { + struct Case { + const char* name; + const char* field; + std::vector trades; + double capital = 1e308; + }; + const std::vector cases = { + {"net_sum", "net_profit", {trade(1e308), trade(1e308)}}, + {"commission_sum", "commission_paid", {trade(1, 1, 1e308), trade(1, 1, 1e308)}}, + {"pct_sum", "avg_trade_pct", {trade(1, 1e308), trade(1, 1e308)}}, + {"gross_profit_sum", "gross_profit", {trade(1e308), trade(-1e308), trade(1e308)}}, + {"gross_loss_sum", "gross_loss", {trade(-1e308), trade(1e308), trade(-1e308)}}, + {"win_pct_sum", "avg_win_pct", + {trade(1, 1e308), trade(-1, -1e308), trade(1, 1e308)}}, + {"loss_pct_sum", "avg_loss_pct", + {trade(-1, -1e308), trade(1, 1e308), trade(-1, -1e308)}}, + {"net_pct_ratio", "net_profit_pct", {trade(1)}, 1e-308}, + {"net_pct_product", "net_profit_pct", {trade(1e308)}, 10}, + {"gross_profit_pct", "gross_profit_pct", {trade(1), trade(-1)}, 1e-308}, + {"gross_loss_pct", "gross_loss_pct", {trade(1), trade(-2)}, 1e-306}, + {"profit_factor", "profit_factor", {trade(1e308), trade(-1e-10)}}, + {"win_loss_ratio", "ratio_avg_win_avg_loss", + {trade(1e308), trade(-0.5), trade(-0.5)}}, + }; + for (auto filter : {metrics::TradeFilter::ALL, metrics::TradeFilter::LONG, + metrics::TradeFilter::SHORT}) { + const char* block = filter == metrics::TradeFilter::ALL ? "metrics.all." : + filter == metrics::TradeFilter::LONG ? "metrics.longs." : + "metrics.shorts."; + for (const auto& test : cases) { + auto trades = test.trades; + for (auto& t : trades) t.is_long = filter != metrics::TradeFilter::SHORT; + nonfinite(test.name, std::string(block) + test.field, + [&] { (void)stats(trades, test.capital, filter); }); + } + } +} + +void equity_arithmetic() { + nonfinite("c18_3_buy_hold_return", "metrics.equity.buy_hold_return", [] { + // The executed account can stay finite: qty .5 * PV 1e300 * + // (2.5e8 - 1) + capital 1e301 < DBL_MAX. Buy/hold uses all capital. + (void)equity({1e301, 1e301}, 1e301, 7 * 60'000, 0, 1, 2.5e8); + }); + nonfinite("buy_hold_ratio", "metrics.equity.buy_hold_return_pct", [] { + (void)equity({}, 1, kYear, 0, 1e-308, 1e308); + }); + nonfinite("buy_hold_pct_product", "metrics.equity.buy_hold_return_pct", [] { + (void)equity({}, 1, kYear, 0, 1, 1e307); + }); + nonfinite("cagr_pow_and_simple_overflow", "metrics.equity.cagr", [] { + const double span_years = static_cast(kDay) / (365.25 * 86400.0 * 1000.0); + const double ratio = 1e305 / 1.0; + CHECK(!std::isfinite((std::pow(ratio, 1.0 / span_years) - 1.0) * 100.0)); + CHECK(!std::isfinite((ratio - 1.0) * (1.0 / span_years) * 100.0)); + (void)equity({1, 1e305}, 1, kDay); + }); + nonfinite("cagr_ratio", "metrics.equity.cagr", [] { + (void)equity({1e-308, 1e308}, 1e-308, 100 * kYear); + }); + nonfinite("sub_day_cagr_ratio", "metrics.equity.cagr", [] { + (void)equity({1e-308, 1e308}, 1e-308, 7 * 60'000); + }); + nonfinite("sub_day_cagr_simple_overflow", "metrics.equity.cagr", [] { + (void)equity({1, 1e305}, 1, 7 * 60'000); + }); + nonfinite("cagr_pct_product", "metrics.equity.cagr", [] { + (void)equity({1, 1e307}); + }); + nonfinite("drawdown_subtraction", "metrics.equity.max_equity_drawdown", [] { + (void)equity({kMax, -kMax}); + }); + nonfinite("drawdown_percentage", "metrics.equity.max_equity_drawdown_pct", [] { + (void)equity({1e-308, -1}); + }); + nonfinite("runup_percentage", "metrics.equity.max_equity_runup_pct", [] { + (void)equity({1, 1e-308, 0.5}); + }); + nonfinite("recovery_ratio", "metrics.equity.recovery_factor", [] { + (void)equity({1, 0.75}, 1, kYear, 1e308); + }); + nonfinite("calmar_ratio", "metrics.equity.calmar", [] { + (void)equity({1, 1 - 1e-15, 1e306}, 1, kYear / 2); + }); + nonfinite("monthly_variance", "metrics.equity.sharpe_monthly", [] { + (void)equity({1, 1e200, 1}, 1, kYear); + }); + nonfinite("monthly_return_ratio", "metrics.equity.sharpe_monthly", [] { + (void)equity({1e-308, 1e308, 1e-308}, 1e-308, kYear); + }); + nonfinite("monthly_return_sum", "metrics.equity.max_equity_runup_pct", [] { + // The first rebound (1e308 - 1) / 1 * 100 overflows before resampling. + (void)equity({1, 1e308, 1, 1e308, 1}, 1, kYear); + }); + nonfinite("monthly_return_sum_isolated", "metrics.equity.sharpe_monthly", [] { + // Each runup percent is 1e308 (finite), but 200 returns of 1e306 + // overflow their shared sum before either deviation is evaluated. + std::vector values{1}; + for (int i = 0; i < 200; ++i) { + values.push_back(1e306); + values.push_back(1); + } + (void)equity(values, 1, kYear); + }); + nonfinite("monthly_downside_square", "metrics.equity.sortino_monthly", [] { + // The two admitted returns are identical: variance is zero, but the + // population downside square overflows. Negative-base steps are + // skipped by the existing return definition. + (void)equity({1, -1e200, 1, -1e200}, 1, kYear); + }); + nonfinite("bar_variance", "metrics.equity.sharpe_bar", [] { + (void)equity({1, 1e200, 1}, 1, kDay); + }); + nonfinite("sub_day_bar_variance", "metrics.equity.sharpe_bar", [] { + (void)equity({1, 1e200, 1}, 1, 60'000); + }); + nonfinite("bar_return_ratio", "metrics.equity.sharpe_bar", [] { + (void)equity({1e-308, 1e308, 1e-308}, 1e-308, kDay); + }); + nonfinite("bar_return_sum", "metrics.equity.max_equity_runup_pct", [] { + (void)equity({1, 1e308, 1, 1e308, 1}, 1, kDay); + }); + nonfinite("bar_return_sum_isolated", "metrics.equity.sharpe_bar", [] { + std::vector values{1}; + for (int i = 0; i < 200; ++i) { + values.push_back(1e306); + values.push_back(1); + } + // Exactly one day, all 401 points in January: no monthly returns. + (void)equity(values, 1, kDay / 400); + }); + nonfinite("sub_day_bar_return_sum_isolated", "metrics.equity.sharpe_bar", [] { + std::vector values{1}; + for (int i = 0; i < 200; ++i) { + values.push_back(1e306); + values.push_back(1); + } + (void)equity(values, 1, 60'000); + }); + nonfinite("bar_downside_square", "metrics.equity.sortino_bar", [] { + (void)equity({1, -1e200, 1, -1e200}, 1, kDay); + }); + nonfinite("sub_day_bar_downside_square", "metrics.equity.sortino_bar", [] { + (void)equity({1, -1e200, 1, -1e200}, 1, 60'000); + }); +} + +void annualization_span_rule() { + row = "seven_minutes_eight_percent"; + const pf_equity_point_t curve[] = { + {kTime, 10000, 0}, {kTime + 60'000, 9900, 0}, + {kTime + 7 * 60'000, 10800, 0}, + }; + // The frozen walk supplies all thirteen unchanged fields. The two + // proportional expectations come from the input curve independently. + auto want = equity_stats_reference::compute_equity_stats( + curve, 3, 10000, "UTC", 100, 108, 1, 800); + CHECK(std::isinf(want.cagr) && std::isinf(want.calmar)); + CHECK(std::isfinite(want.sharpe_bar) && std::isfinite(want.sortino_bar)); + const double span_years = static_cast(curve[2].time_ms - curve[0].time_ms) + / (365.25 * 86400.0 * 1000.0); + const double ratio = curve[2].equity / 10000.0; + want.cagr = (ratio - 1.0) * (1.0 / span_years) * 100.0; + want.calmar = want.cagr / want.max_equity_drawdown_pct; + CHECK(std::isfinite(want.cagr) && std::isfinite(want.calmar)); + try { + const auto got = metrics::compute_equity_stats( + curve, 3, 10000, "UTC", 100, 108, 1, 800); + CHECK(same_bits(got.cagr, want.cagr) && same_bits(got.calmar, want.calmar)); + CHECK(equity_stats_reference::first_difference(got, want) == nullptr); + CHECK(std::isfinite(got.sharpe_bar) && std::isfinite(got.sortino_bar)); + CHECK(same_bits(got.sharpe_bar, want.sharpe_bar)); + CHECK(same_bits(got.sortino_bar, want.sortino_bar)); + } catch (const std::exception&) { + CHECK(false); + } + // Monthly ratios remain their existing defined absence (one month). + CHECK(std::isnan(want.sharpe_monthly) && std::isnan(want.sortino_monthly)); + + row = "just_under_one_day_large_gain"; + try { + const double years = static_cast(kDay - 1) / (365.25 * 86400.0 * 1000.0); + const double large_ratio = 1e300 / 1.0; + const double expected = (large_ratio - 1.0) * (1.0 / years) * 100.0; + const auto got = equity({1, 1e300}, 1, kDay - 1); + CHECK(std::isfinite(got.cagr) && same_bits(got.cagr, expected)); + CHECK(std::isnan(got.calmar)); // No drawdown. + CHECK(std::isfinite(got.buy_hold_return)); + } catch (const std::exception&) { + CHECK(false); + } + row = "exactly_one_day_ordinary_gain"; + const pf_equity_point_t daily[] = {{kTime, 10000, 0}, {kTime + kDay, 10800, 0}}; + const auto daily_want = equity_stats_reference::compute_equity_stats( + daily, 2, 10000, "UTC", 1, 1, 0, 800); + const auto daily_got = metrics::compute_equity_stats( + daily, 2, 10000, "UTC", 1, 1, 0, 800); + CHECK(std::isfinite(daily_got.cagr)); + CHECK(equity_stats_reference::first_difference(daily_got, daily_want) == nullptr); + + // A zero return has a defined zero CAGR even on a sub-day span. + row = "sub_day_flat_curve"; + const auto flat = equity({10000, 10000}, 10000, 7 * 60'000); + CHECK(flat.cagr == 0.0 && std::isnan(flat.calmar)); +} + +void sub_day_monotonic_returns() { + row = "sub_day_monotonic_returns"; + const std::int64_t span = 7 * 60'000; + const double span_years = static_cast(span) / (365.25 * 86400.0 * 1000.0); + const double capital = 10000.0; + const double finals[] = {9200.0, 10400.0, 10800.0}; + double previous = -kInf; + for (const double final_equity : finals) { + const pf_equity_point_t curve[] = { + {kTime, capital, 0}, {kTime + span, final_equity, 0}, + }; + const double ratio = final_equity / capital; + const double expected = (ratio - 1.0) * (1.0 / span_years) * 100.0; + const auto got = metrics::compute_equity_stats( + curve, 2, capital, "UTC", 1, 1, 0, final_equity - capital); + CHECK(std::isfinite(got.cagr)); + CHECK(same_bits(got.cagr, expected)); + CHECK(got.cagr > previous); + CHECK(final_equity < capital ? got.cagr < 0.0 : got.cagr > 0.0); + previous = got.cagr; + } +} + +void compounded_overflow_fallback() { + struct Case { std::int64_t span; double final_equity; bool finite_power; }; + const Case cases[] = { + {kDay, 1e300, false}, {kDay + 1, 1e300, false}, {3 * kDay, 1e300, false}, + // pow itself fits, but converting its return to percent overflows. + {kYear - kYear / 100, 1e305, true}, + }; + for (const auto& test : cases) { + row = test.finite_power ? "compounded_percent_overflow_fallback" + : "one_or_several_days_pow_fallback"; + const pf_equity_point_t curve[] = { + {kTime, 1, 0}, {kTime + test.span, test.final_equity, 0}, + }; + auto want = equity_stats_reference::compute_equity_stats( + curve, 2, 1, "UTC", 1, 1, 0, 0); + const double years = static_cast(test.span) / (365.25 * 86400.0 * 1000.0); + const double ratio = test.final_equity / 1.0; + const double power = std::pow(ratio, 1.0 / years); + CHECK(std::isfinite(power) == test.finite_power); + CHECK(!std::isfinite(want.cagr)); + want.cagr = (ratio - 1.0) * (1.0 / years) * 100.0; + CHECK(std::isfinite(want.cagr)); + try { + const auto got = metrics::compute_equity_stats( + curve, 2, 1, "UTC", 1, 1, 0, 0); + CHECK(same_bits(got.cagr, want.cagr)); + CHECK(equity_stats_reference::first_difference(got, want) == nullptr); + CHECK(std::isfinite(got.buy_hold_return)); + } catch (const std::exception&) { + CHECK(false); + } + } + + row = "one_day_pow_fallback_with_drawdown"; + const pf_equity_point_t drawdown[] = { + {kTime, 1, 0}, {kTime + kDay / 2, 0.99, 0}, {kTime + kDay, 10, 0}, + }; + auto want = equity_stats_reference::compute_equity_stats( + drawdown, 3, 1, "UTC", 1, 1, 0, 9); + CHECK(std::isinf(want.cagr) && std::isinf(want.calmar)); + CHECK(want.max_equity_drawdown_pct > 0.0); + CHECK(std::isfinite(want.sharpe_bar) && std::isfinite(want.sortino_bar)); + const double years = static_cast(kDay) / (365.25 * 86400.0 * 1000.0); + const double ratio = drawdown[2].equity / 1.0; + want.cagr = (ratio - 1.0) * (1.0 / years) * 100.0; + want.calmar = want.cagr / want.max_equity_drawdown_pct; + try { + const auto got = metrics::compute_equity_stats( + drawdown, 3, 1, "UTC", 1, 1, 0, 9); + CHECK(std::isfinite(got.cagr) && std::isfinite(got.calmar)); + CHECK(same_bits(got.cagr, want.cagr) && same_bits(got.calmar, want.calmar)); + CHECK(equity_stats_reference::first_difference(got, want) == nullptr); + } catch (const std::exception&) { + CHECK(false); + } +} + +void bar_ratio_span_boundary() { + // Both ratios always equal the frozen walk. Only sub-day CAGR/calmar + // use the proportional formula; at and above one day compounding fits. + const std::int64_t spans[] = {7 * 60'000, kDay - 1, kDay, kDay + 1}; + for (const std::int64_t span : spans) { + row = span < kDay ? "sub_day_finite_bar_ratios" : "one_day_finite_bar_ratios"; + const pf_equity_point_t curve[] = { + {kTime, 10000, 0}, {kTime + span / 2, 9900, 0}, + {kTime + span, 10020, 0}, + }; + auto want = equity_stats_reference::compute_equity_stats( + curve, 3, 10000, "UTC", 1, 1, 0, 20); + CHECK(std::isfinite(want.cagr) && std::isfinite(want.calmar)); + CHECK(std::isfinite(want.sharpe_bar) && std::isfinite(want.sortino_bar)); + const double years = static_cast(span) / (365.25 * 86400.0 * 1000.0); + const double ratio = curve[2].equity / 10000.0; + const double simple = (ratio - 1.0) * (1.0 / years) * 100.0; + CHECK(!same_bits(want.cagr, simple)); + if (span < kDay) { + want.cagr = simple; + want.calmar = simple / want.max_equity_drawdown_pct; + } + try { + const auto got = metrics::compute_equity_stats( + curve, 3, 10000, "UTC", 1, 1, 0, 20); + CHECK(equity_stats_reference::first_difference(got, want) == nullptr); + CHECK(std::isfinite(got.cagr) && std::isfinite(got.calmar)); + CHECK(std::isfinite(got.sharpe_bar) && std::isfinite(got.sortino_bar)); + CHECK(same_bits(got.cagr, want.cagr)); + CHECK(same_bits(got.calmar, want.calmar)); + CHECK(same_bits(got.sharpe_bar, want.sharpe_bar)); + CHECK(same_bits(got.sortino_bar, want.sortino_bar)); + } catch (const std::exception&) { + CHECK(false); + } + } +} + +class ShortSpanReportHost final : public NativeStrategyHost { +public: + void on_native_bar(const Bar&, const NativeDecisionContext&) override { + if (!entered_) { + entered_ = true; + (void)submit({native_order::Transact{1.0}, "entry", ""}); + } + } +private: + bool entered_ = false; +}; + +void short_span_real_report() { + row = "seven_minutes_real_report"; + ShortSpanReportHost host; + NativeRunSpec spec; + spec.identity = {"metrics-short-span", 1}; + spec.input_tf = "1"; spec.script_tf = "1"; + spec.tickerid = "TEST:METRICS"; + spec.timezone = "UTC"; spec.session = "24x7"; + spec.initial_capital = 10000; + spec.point_value = 1; spec.account_fx = 1; spec.price_tick = 1; + spec.report_policy = NativeReportPolicy::KernelRecorded; + spec.report_open_position_at_end = true; + CHECK(host.configure_native(spec).status == NativeSetupStatus::Applied); + std::vector bars; + for (int i = 0; i < 8; ++i) + bars.push_back({100, i == 7 ? 900.0 : 100.0, 100, + i == 7 ? 900.0 : 100.0, 1, kTime + i * 60'000}); + pf_equity_point_t expected_curve[8]{}; + for (std::size_t i = 0; i < bars.size(); ++i) { + const double open_profit = i == 0 ? 0.0 : (bars[i].close - bars[1].open) * 1.0; + expected_curve[i] = {bars[i].timestamp, spec.initial_capital + open_profit, open_profit}; + } + host.run(bars.data(), static_cast(bars.size())); + CHECK(host.last_error().empty()); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + ReportC report{}; + try { + host.fill_report(&report); + CHECK(report.equity_curve_len == 8 && report.trades_len == 1); + if (report.equity_curve && report.equity_curve_len == 8) { + CHECK(report.equity_curve[0].equity == 10000); + CHECK(report.equity_curve[7].equity == 10800); + CHECK(report.equity_curve[7].time_ms - report.equity_curve[0].time_ms == 7 * 60'000); + for (std::size_t i = 0; i < bars.size(); ++i) { + CHECK(report.equity_curve[i].time_ms == expected_curve[i].time_ms); + CHECK(same_bits(report.equity_curve[i].equity, expected_curve[i].equity)); + } + } + CHECK(report.net_profit == 800); + auto reference = equity_stats_reference::compute_equity_stats( + expected_curve, 8, spec.initial_capital, "UTC", 100, 900, 0, 800); + CHECK(std::isfinite(reference.sharpe_bar) && std::isfinite(reference.sortino_bar)); + // Expected return and span come from the input tape, not ReportC. + const double final_equity = spec.initial_capital + + (bars.back().close - bars[1].open) * 1.0; + const double years = static_cast(bars.back().timestamp - bars.front().timestamp) + / (365.25 * 86400.0 * 1000.0); + const double ratio = final_equity / spec.initial_capital; + reference.cagr = (ratio - 1.0) * (1.0 / years) * 100.0; + if (reference.max_equity_drawdown > 0.0 && reference.max_equity_drawdown_pct > 0.0) + reference.calmar = reference.cagr / reference.max_equity_drawdown_pct; + CHECK(std::isfinite(report.metrics.equity.cagr)); + CHECK(same_bits(report.metrics.equity.cagr, reference.cagr)); + CHECK(same_bits(report.metrics.equity.calmar, reference.calmar)); + CHECK(same_bits(report.metrics.equity.sharpe_bar, reference.sharpe_bar)); + CHECK(same_bits(report.metrics.equity.sortino_bar, reference.sortino_bar)); + double pf_equity_stats_t::*const members[] = { + &pf_equity_stats_t::max_equity_drawdown, &pf_equity_stats_t::max_equity_drawdown_pct, + &pf_equity_stats_t::max_equity_runup, &pf_equity_stats_t::max_equity_runup_pct, + &pf_equity_stats_t::buy_hold_return, &pf_equity_stats_t::buy_hold_return_pct, + &pf_equity_stats_t::sharpe_monthly, &pf_equity_stats_t::sortino_monthly, + &pf_equity_stats_t::sharpe_bar, &pf_equity_stats_t::sortino_bar, + &pf_equity_stats_t::cagr, &pf_equity_stats_t::calmar, + &pf_equity_stats_t::recovery_factor, &pf_equity_stats_t::time_in_market_pct, + &pf_equity_stats_t::open_pl, + }; + for (const auto member : members) + CHECK(std::isnan(report.metrics.equity.*member) == std::isnan(reference.*member)); + CHECK(host.last_error().empty()); + CHECK(host.native_state().kind == NativeLifecycleKind::Completed); + // ReportC mirrors pf_report_t member for member at the C boundary; + // this size check keeps the cast honest if either side changes. + static_assert(sizeof(ReportC) == sizeof(pf_report_t), + "ReportC and pf_report_t must stay layout twins"); + const auto wire = live::native_report_json(reinterpret_cast(report)); + struct Metric { const char* name; double pf_equity_stats_t::*member; }; + const Metric annualized[] = { + {"cagr", &pf_equity_stats_t::cagr}, {"calmar", &pf_equity_stats_t::calmar}, + {"sharpe_bar", &pf_equity_stats_t::sharpe_bar}, + {"sortino_bar", &pf_equity_stats_t::sortino_bar}, + }; + for (const auto& field : annualized) { + const auto& value = wire.at("metrics").at("equity").at(field.name); + const double expected = reference.*field.member; + if (std::isnan(expected)) { + CHECK(value.kind == live::Json::Kind::String && value.value == "NaN"); + } else { + CHECK(value.kind == live::Json::Kind::Number); + CHECK(value.value == live::report_number(expected).value); + } + } + } catch (const std::exception&) { + CHECK(false); + } + BacktestEngine::free_report(&report); +} + +void defined_absences_and_finite_values() { + row = "defined_absences"; + const auto empty = stats({}, 0); + CHECK(empty.net_profit == 0 && empty.gross_profit == 0 && empty.gross_loss == 0); + CHECK(std::isnan(empty.net_profit_pct)); + CHECK(std::isnan(empty.profit_factor)); + CHECK(std::isnan(empty.avg_trade) && std::isnan(empty.avg_trade_pct)); + CHECK(std::isnan(empty.avg_win) && std::isnan(empty.avg_loss)); + CHECK(std::isnan(empty.largest_win) && std::isnan(empty.largest_loss)); + CHECK(std::isnan(empty.expectancy) && std::isnan(empty.avg_bars_in_trade)); + const auto no_losses = stats({trade(1)}, 0); + CHECK(std::isnan(no_losses.profit_factor) && std::isnan(no_losses.avg_loss)); + CHECK(std::isnan(no_losses.ratio_avg_win_avg_loss)); + const auto no_wins = stats({trade(-1)}, -1); + CHECK(std::isnan(no_wins.avg_win) && std::isnan(no_wins.gross_profit_pct)); + + const auto absent = equity({}, 0, kYear, 0, kNaN, kNaN); + CHECK(std::isnan(absent.buy_hold_return) && std::isnan(absent.cagr)); + CHECK(std::isnan(absent.calmar) && std::isnan(absent.recovery_factor)); + CHECK(std::isnan(absent.sharpe_monthly) && std::isnan(absent.sortino_monthly)); + CHECK(std::isnan(absent.time_in_market_pct) && absent.open_pl == 0); + const auto no_span = equity({1, 1}, 1, 0); + CHECK(std::isnan(no_span.cagr) && std::isnan(no_span.sharpe_bar)); + const auto zero_final = equity({1, 0}); + CHECK(std::isnan(zero_final.cagr) && std::isnan(zero_final.calmar)); + const auto flat = equity({1, 1, 1}, 1, kDay); + CHECK(std::isnan(flat.sharpe_bar) && std::isfinite(flat.sortino_bar)); + CHECK(std::isnan(flat.sharpe_monthly) && std::isnan(flat.sortino_monthly)); + const auto monthly_flat = equity({1, 1, 1}, 1, kYear); + CHECK(std::isnan(monthly_flat.sharpe_monthly) && std::isfinite(monthly_flat.sortino_monthly)); + const auto positive_returns = equity({1, 2, 4}); + CHECK(std::isnan(positive_returns.sharpe_monthly) && std::isnan(positive_returns.sortino_monthly)); + CHECK(std::isnan(positive_returns.sharpe_bar) && std::isnan(positive_returns.sortino_bar)); + const pf_equity_point_t anchor{kTime, 1, 0}; + const auto empty_window = metrics::compute_equity_stats( + &anchor, 1, 1, "UTC", kNaN, kNaN, 0, 0, 0); + CHECK(std::isnan(empty_window.time_in_market_pct) && std::isnan(empty_window.cagr)); + CHECK(empty_window.open_pl == 0 && empty_window.max_equity_drawdown == 0); + const pf_equity_point_t unobserved{kTime, kNaN, kNaN}; + const auto no_marks = metrics::compute_equity_stats( + &unobserved, 0, 1, "UTC", kNaN, kNaN, 0, 0); + CHECK(no_marks.open_pl == 0 && std::isnan(no_marks.cagr)); + + row = "finite_operation_order"; + const auto finite = equity({100, 90, 110}, 100, kYear / 2, 10, 1, 1.25); + CHECK(same_bits(finite.buy_hold_return_pct, (1.25 / 1.0 - 1.0) * 100.0)); + CHECK(same_bits(finite.buy_hold_return, 100.0 * (1.25 / 1.0 - 1.0))); + CHECK(same_bits(finite.cagr, (std::pow(110.0 / 100.0, 1.0) - 1.0) * 100.0)); + CHECK(finite.recovery_factor == 1); + CHECK(same_bits(finite.calmar, finite.cagr / 10.0)); + const auto large = equity({}, 1e300, kYear, 0, 1, 1e7); + CHECK(std::isfinite(large.buy_hold_return)); + CHECK(same_bits(large.buy_hold_return, 1e300 * (1e7 / 1.0 - 1.0))); + + row = "inclusive_bar_difference"; + auto wide = trade(0); + wide.entry_bar_index = std::numeric_limits::min(); + wide.exit_bar_index = std::numeric_limits::max(); + CHECK(stats({wide}).avg_bars_in_trade == 4294967296.0); + + row = "calendar_difference_before_double_conversion"; + // The pure helper's int64 interval exceeds INT64_MAX even though both + // endpoints fit, and these years still fit libc's month-key integer. + // This is arithmetic coverage; the public wire separately bounds times. + const pf_equity_point_t long_span[] = { + {-5'000'000'000'000'000'000LL, 1, 0}, + {0, 1, 0}, + { 5'000'000'000'000'000'000LL, 1, 0}, + }; + const auto span = metrics::compute_equity_stats(long_span, 3, 1, "UTC", 1, 1, 0, 0); + CHECK(span.cagr == 0); +} + +void producer_boundaries() { + struct Field { const char* name; double TradeC::*member; }; + const Field fields[] = { + {"entry_price", &TradeC::entry_price}, {"exit_price", &TradeC::exit_price}, + {"qty", &TradeC::qty}, {"pnl", &TradeC::pnl}, {"pnl_pct", &TradeC::pnl_pct}, + {"max_runup", &TradeC::max_runup}, {"max_drawdown", &TradeC::max_drawdown}, + {"commission", &TradeC::commission}, + }; + for (double bad : {kNaN, kInf, -kInf}) { + for (const auto& field : fields) { + auto t = trade(1); + t.*field.member = bad; + const auto path = std::string("trades.") + field.name; + nonfinite("native_trade_input", path, [&] { (void)stats({t}); }); + pf_trade_t wire{}; + static_assert(sizeof wire == sizeof t, "report trade ABI size differs"); + std::memcpy(&wire, &t, sizeof wire); + nonfinite("runner_trade", path, [&] { (void)live::report_trade(wire); }); + } + nonfinite("native_equity_input", "equity_curve.equity", [&] { + (void)equity({1, bad, 1}, 1, kDay); + }); + nonfinite("native_open_profit", "equity_curve.open_profit", [&] { + pf_equity_point_t point{kTime, 1, bad}; + (void)metrics::compute_equity_stats(&point, 1, 1, "UTC", 1, 1, 0, 0); + }); + nonfinite("runner_summary", "summary.net_pnl", [&] { + pf_report_t report{}; + report.net_profit = bad; + (void)live::native_report_json(report); + }); + nonfinite("runner_curve", "equity_curve.equity", [&] { + pf_equity_point_t point{kTime, bad, 0}; + pf_report_t report{}; + report.equity_curve = &point; + report.equity_curve_len = 1; + (void)live::native_report_json(report); + }); + } + struct Metric { const char* name; double pf_equity_stats_t::*member; }; + const Metric metrics[] = { + {"max_equity_drawdown", &pf_equity_stats_t::max_equity_drawdown}, + {"max_equity_drawdown_pct", &pf_equity_stats_t::max_equity_drawdown_pct}, + {"max_equity_runup", &pf_equity_stats_t::max_equity_runup}, + {"max_equity_runup_pct", &pf_equity_stats_t::max_equity_runup_pct}, + {"buy_hold_return", &pf_equity_stats_t::buy_hold_return}, + {"buy_hold_return_pct", &pf_equity_stats_t::buy_hold_return_pct}, + {"sharpe_tv", &pf_equity_stats_t::sharpe_monthly}, + {"sortino_tv", &pf_equity_stats_t::sortino_monthly}, + {"sharpe_bar", &pf_equity_stats_t::sharpe_bar}, + {"sortino_bar", &pf_equity_stats_t::sortino_bar}, + {"cagr", &pf_equity_stats_t::cagr}, {"calmar", &pf_equity_stats_t::calmar}, + {"recovery_factor", &pf_equity_stats_t::recovery_factor}, + {"time_in_market_pct", &pf_equity_stats_t::time_in_market_pct}, + {"open_pl", &pf_equity_stats_t::open_pl}, + }; + for (const auto& metric : metrics) { + for (double bad : {kInf, -kInf}) { + pf_equity_stats_t value{}; + value.*metric.member = bad; + nonfinite("runner_metric", std::string("metrics.equity.") + metric.name, + [&] { (void)live::report_equity_stats(value); }); + } + } + struct TradeMetric { const char* name; double pf_trade_stats_t::*member; }; + const TradeMetric trade_metrics[] = { + {"percent_profitable", &pf_trade_stats_t::percent_profitable}, + {"net_profit", &pf_trade_stats_t::net_profit}, + {"net_profit_pct", &pf_trade_stats_t::net_profit_pct}, + {"gross_profit", &pf_trade_stats_t::gross_profit}, + {"gross_profit_pct", &pf_trade_stats_t::gross_profit_pct}, + {"gross_loss", &pf_trade_stats_t::gross_loss}, + {"gross_loss_pct", &pf_trade_stats_t::gross_loss_pct}, + {"profit_factor", &pf_trade_stats_t::profit_factor}, + {"avg_trade", &pf_trade_stats_t::avg_trade}, + {"avg_trade_pct", &pf_trade_stats_t::avg_trade_pct}, + {"avg_win", &pf_trade_stats_t::avg_win}, {"avg_win_pct", &pf_trade_stats_t::avg_win_pct}, + {"avg_loss", &pf_trade_stats_t::avg_loss}, {"avg_loss_pct", &pf_trade_stats_t::avg_loss_pct}, + {"ratio_avg_win_avg_loss", &pf_trade_stats_t::ratio_avg_win_avg_loss}, + {"largest_win", &pf_trade_stats_t::largest_win}, + {"largest_win_pct", &pf_trade_stats_t::largest_win_pct}, + {"largest_loss", &pf_trade_stats_t::largest_loss}, + {"largest_loss_pct", &pf_trade_stats_t::largest_loss_pct}, + {"commission_paid", &pf_trade_stats_t::commission_paid}, + {"expectancy", &pf_trade_stats_t::expectancy}, + {"avg_bars_in_trade", &pf_trade_stats_t::avg_bars_in_trade}, + {"avg_bars_in_wins", &pf_trade_stats_t::avg_bars_in_wins}, + {"avg_bars_in_losses", &pf_trade_stats_t::avg_bars_in_losses}, + }; + struct Block { const char* name; pf_trade_stats_t pf_metrics_t::*member; }; + const Block blocks[] = { + {"metrics.all.", &pf_metrics_t::all}, + {"metrics.longs.", &pf_metrics_t::longs}, + {"metrics.shorts.", &pf_metrics_t::shorts}, + }; + for (const auto& block : blocks) { + for (const auto& metric : trade_metrics) { + for (double bad : {kInf, -kInf}) { + pf_report_t report{}; + (report.metrics.*block.member).*metric.member = bad; + nonfinite("runner_trade_metric", std::string(block.name) + metric.name, + [&] { (void)live::native_report_json(report); }); + } + } + } + row = "runner_defined_absence"; + pf_equity_stats_t absent{}; + absent.cagr = kNaN; // The producer's explicit out-of-domain sentinel. + const auto value = live::report_equity_stats(absent).at("cagr"); + CHECK(value.kind == live::Json::Kind::String && value.value == "NaN"); + // Recorded script trace values have their separate nonfinite wire convention. + CHECK(live::report_number(kNaN).value == "NaN"); +} +} // namespace + +int main() { + report_trade_assembly(); + earlier_failure_keeps_its_code(); + trade_arithmetic(); + equity_arithmetic(); + annualization_span_rule(); + sub_day_monotonic_returns(); + compounded_overflow_fallback(); + bar_ratio_span_boundary(); + short_span_real_report(); + defined_absences_and_finite_values(); + producer_boundaries(); + std::printf("metrics overflow: %d failure(s)\n", failures); + return failures ? 1 : 0; +} diff --git a/tests/test_native_anchored_legs.cpp b/tests/test_native_anchored_legs.cpp index 1ae7ec8f3..5ee90e5fb 100644 --- a/tests/test_native_anchored_legs.cpp +++ b/tests/test_native_anchored_legs.cpp @@ -44,7 +44,11 @@ namespace { // ── Portable pins (harvested on engine main 3f6fd57, see the header) ──── // expectation corrected: 16941677776193786888 -> 4011217037958131497, because v19-B: this host keeps NativeEventRetention::Full to read its whole event record back, and the spec digest folds a retention that is not the default Window. constexpr std::uint64_t kNeutralSpecDigest = 4011217037958131497ULL; -constexpr std::uint64_t kNeutralReportDigest = 7202933235328973332ULL; +// CAGR is the simple proportional value on this 14-minute span of 15 +// one-minute bars: 10000 -> about 10005, with no drawdown. Calmar remains +// NaN by that domain; the bar ratios retain main's formulas. +// The report digest pin is a lab harvest. +constexpr std::uint64_t kNeutralReportDigest = 8139101939074791901ULL; constexpr std::size_t kNeutralEventCount = 72; // A canonical one-minute slot label. The R4 harness's tape starts at diff --git a/tests/test_native_c_host_report_failure.cpp b/tests/test_native_c_host_report_failure.cpp new file mode 100644 index 000000000..b250034fb --- /dev/null +++ b/tests/test_native_c_host_report_failure.cpp @@ -0,0 +1,241 @@ +// A report failure must overwrite an uninitialized C output with owned rows +// or an empty report, and preserve an execution failure's earlier diagnostic. +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +int failures = 0; +const char* row = "setup"; +constexpr int kBars = 8; +constexpr std::int64_t kTime = 1704067200000LL; +constexpr double kCapital = 1e301; +constexpr double kPointValue = 1e300; +constexpr double kQty = 0.5; +constexpr double kLastClose = 2.5e8; + +#define CHECK(condition) do { \ + if (!(condition)) { \ + std::fprintf(stderr, "FAIL [%s] %s:%d %s\n", row, __FILE__, __LINE__, \ + #condition); \ + ++failures; \ + } \ +} while (0) + +struct Host { + pf_strategy_t handle = nullptr; + bool fail_callback = false; + int calculations = 0; + int opened = 0; + int closed = 0; + int command_error = PF_NATIVE_OK; + + void submit(std::uint32_t intent, const char* label) { + pf_native_request_v1 request{}; + request.struct_size = static_cast(sizeof(request)); + request.version = PF_NATIVE_API_VERSION; + request.intent = intent; + request.intent_value = intent == PF_NATIVE_INTENT_TRANSACT ? kQty : 0.0; + request.trigger = PF_NATIVE_TRIGGER_MARKET; + request.label = label; + const int rc = strategy_native_submit_v1(handle, &request, nullptr, nullptr); + if (rc != PF_NATIVE_OK && command_error == PF_NATIVE_OK) command_error = rc; + } +}; + +int on_bar(void* user, const pf_bar_t*, const pf_native_decision_v1*) { + auto& host = *static_cast(user); + ++host.calculations; + if (host.calculations == 1) host.submit(PF_NATIVE_INTENT_TRANSACT, "entry"); + if (host.calculations == 3) host.submit(PF_NATIVE_INTENT_FLATTEN, "exit"); + return host.fail_callback && host.calculations == kBars ? 1 : 0; +} + +int on_applied(void* user, const pf_native_applied_v1* applied, + const pf_native_decision_v1*) { + auto& host = *static_cast(user); + if (applied->opened_units != 0.0) ++host.opened; + if (applied->closed_units != 0.0) ++host.closed; + return 0; +} + +pf_native_run_spec_v1 spec() { + pf_native_run_spec_v1 value{}; + value.struct_size = static_cast(sizeof(value)); + value.session_key = "c-host-report-failure"; + value.run_number = 1; + value.input_tf = value.script_tf = "1"; + value.ticker = "METRICS"; + value.tickerid = "TEST:METRICS"; + value.type = "crypto"; + value.currency = "USDT"; + value.basecurrency = "ETH"; + value.description = value.volumetype = value.chart_timezone = ""; + value.timezone = "UTC"; + value.session = "24x7"; + value.initial_capital = kCapital; + value.point_value = kPointValue; + value.account_fx = 1; + value.price_tick = 0; + value.fee_kind = PF_NATIVE_FEE_CASH_PER_UNIT; + value.allowed_open_directions = PF_NATIVE_OPEN_DIRECTIONS_BOTH; + return value; +} + +std::array tape() { + std::array bars{}; + for (int i = 0; i < kBars; ++i) { + auto& bar = bars[static_cast(i)]; + // Close the real trade at 2 before the buy/hold-only price jump. + const double price = i < 3 ? 1.0 : i == 3 ? 2.0 : kLastClose; + bar.open = bar.high = bar.low = bar.close = price; + bar.volume = 1; + bar.timestamp = kTime + static_cast(i) * 60'000; + } + return bars; +} + +std::string text(const char* value) { + return value ? value : ""; +} + +void check_later_sections_empty(const pf_report_t& report) { + CHECK(report.security_diag == nullptr && report.security_diag_len == 0); + CHECK(report.security_feeds_total == 0); + CHECK(report.security_complete_total == 0 && report.security_partial_total == 0); + CHECK(report.trace == nullptr && report.trace_len == 0); + CHECK(report.trace_names == nullptr && report.trace_names_len == 0); + CHECK(report.broker_state_hash == nullptr && report.broker_state_hash_len == 0); +} + +void check_arrays_empty(const pf_report_t& report) { + check_later_sections_empty(report); + CHECK(report.trades == nullptr && report.trades_len == 0); + CHECK(report.equity_curve == nullptr && report.equity_curve_len == 0); +} + +void run_case(bool fail_callback) { + row = fail_callback ? "failed_execution_report_throw" : "completed_execution_report_throw"; + Host host; + host.fail_callback = fail_callback; + pf_native_callbacks_v1 callbacks{}; + callbacks.struct_size = static_cast(sizeof(callbacks)); + callbacks.version = PF_NATIVE_API_VERSION; + callbacks.user = &host; + callbacks.on_bar = &on_bar; + callbacks.on_applied = &on_applied; + host.handle = strategy_native_host_create_v1(&callbacks); + CHECK(host.handle != nullptr); + if (!host.handle) return; + + const auto base = spec(); + pf_native_run_spec_ext_v1 ext{}; + ext.struct_size = static_cast(sizeof(ext)); + ext.version = PF_NATIVE_API_VERSION; + ext.present_mask = PF_NATIVE_SPEC_EXT_REPORT; + ext.report_policy = PF_NATIVE_REPORT_KERNEL_RECORDED; + const int configured = strategy_configure_native_ext_v1(host.handle, &base, &ext); + CHECK(configured == PF_NATIVE_OK); + if (configured != PF_NATIVE_OK) { + strategy_native_host_free(host.handle); + return; + } + strategy_set_broker_state_hash_recording(host.handle, 1); + const auto bars = tape(); + // Same buy/hold overflow as the metrics fixture: pct and all real trade + // arithmetic are finite, but buying with all capital exceeds binary64. + const double buy_hold_ratio = bars.back().close / bars.front().open - 1.0; + CHECK(std::isfinite(buy_hold_ratio * 100.0)); + CHECK(std::isinf(kCapital * buy_hold_ratio)); + const double pnl = (bars[3].open - bars[1].open) * kQty * kPointValue; + CHECK(std::isfinite(pnl) && std::isfinite(kCapital + pnl)); + + pf_report_t report; + std::memset(&report, 0xA5, sizeof(report)); + const int rc = strategy_native_run_v1(host.handle, bars.data(), kBars, &report); + CHECK(host.command_error == PF_NATIVE_OK); + CHECK(host.calculations == kBars && host.opened == 1 && host.closed == 1); + pf_native_state_v1 state{}; + state.struct_size = static_cast(sizeof(state)); + CHECK(strategy_native_state_v1(host.handle, &state) == PF_NATIVE_OK); + + if (fail_callback) { + CHECK(rc == PF_NATIVE_E_RUN_FAILED); + CHECK(state.lifecycle == PF_NATIVE_LIFECYCLE_FAILED); + CHECK(state.failure_code == PF_NATIVE_FAILURE_CALLBACK_EXCEPTION); + CHECK(text(strategy_get_last_error_code(host.handle)) == "engine_unclassified_error"); + CHECK(text(strategy_get_last_error_args(host.handle)) == "{}"); + CHECK(text(strategy_get_last_error(host.handle)) == "native C callback refused in on_bar"); + // The header promises the already-closed row even if a later report + // metric throws. A zero report here would discard that promised row. + CHECK(report.total_trades == 1 && report.trades_len == 1); + CHECK(report.trades != nullptr); + if (report.trades && report.trades_len == 1) { + const auto& trade = report.trades[0]; + CHECK(trade.entry_time == bars[1].timestamp && trade.exit_time == bars[3].timestamp); + CHECK(trade.entry_price == 1 && trade.exit_price == 2 && trade.qty == kQty); + CHECK(trade.pnl == pnl && trade.open_at_end == 0); + } + CHECK(report.net_profit == pnl); + CHECK(report.equity_curve != nullptr && report.equity_curve_len > 0); + CHECK(report.equity_curve_len < kBars); + if (report.equity_curve && report.equity_curve_len > 0 + && report.equity_curve_len < kBars) { + CHECK(report.equity_curve[0].time_ms == kTime); + CHECK(report.equity_curve[0].equity == kCapital); + for (std::int64_t i = 0; i < report.equity_curve_len; ++i) { + CHECK(std::isfinite(report.equity_curve[i].equity)); + CHECK(std::isfinite(report.equity_curve[i].open_profit)); + } + } + CHECK(report.metrics.all.num_trades == 1); + CHECK(report.metrics.all.net_profit == pnl); + } else { + CHECK(rc == PF_NATIVE_E_STATE); + CHECK(state.lifecycle == PF_NATIVE_LIFECYCLE_COMPLETED); + CHECK(text(strategy_get_last_error_code(host.handle)) == "report_result_invalid"); + CHECK(text(strategy_get_last_error_args(host.handle)) + == "{\"field\":\"metrics.equity.buy_hold_return\",\"reason\":\"nonfinite\"}"); + CHECK(report.total_trades == 0 && report.net_profit == 0); + CHECK(report.input_bars_processed == 0 && report.script_bars_processed == 0); + CHECK(report.metrics.all.num_trades == 0 && report.metrics.all.net_profit == 0); + check_arrays_empty(report); + } + // This block was never assigned because buy/hold throws before the + // equity-statistics value can be returned; its local starts at zero. + CHECK(report.metrics.equity.buy_hold_return == 0); + CHECK(report.metrics.equity.cagr == 0 && report.metrics.equity.calmar == 0); + CHECK(report.metrics.equity.sharpe_bar == 0 && report.metrics.equity.sortino_bar == 0); + CHECK(report.metrics.equity.sharpe_monthly == 0 && report.metrics.equity.sortino_monthly == 0); + check_later_sections_empty(report); + + // Always use the public free, including after a failed CHECK. Garbage + // pointers left by report assembly must be a sanitizer failure. + strategy_native_report_free_v1(&report); + check_arrays_empty(report); + strategy_native_report_free_v1(&report); + check_arrays_empty(report); + strategy_native_host_free(host.handle); +} + +} // namespace + +int main(int argc, char** argv) { + if (argc > 2 || (argc == 2 && std::strcmp(argv[1], "completed") != 0 + && std::strcmp(argv[1], "failed") != 0)) { + std::fprintf(stderr, "usage: %s [completed|failed]\n", argv[0]); + return 2; + } + if (argc == 1 || std::strcmp(argv[1], "completed") == 0) run_case(false); + if (argc == 1 || std::strcmp(argv[1], "failed") == 0) run_case(true); + std::printf("native C-host report failure: %d failure(s)\n", failures); + return failures ? 1 : 0; +} diff --git a/tests/test_run_json_nonfinite_report.py b/tests/test_run_json_nonfinite_report.py index 09a413348..67c3dd0b7 100644 --- a/tests/test_run_json_nonfinite_report.py +++ b/tests/test_run_json_nonfinite_report.py @@ -22,11 +22,13 @@ import ctypes import io import json +import math import os import socket import subprocess import sys import types +from decimal import Decimal, localcontext from pathlib import Path from unittest import mock @@ -52,7 +54,7 @@ "trades.pnl_pct", "trades.max_runup", "trades.max_drawdown", "trades.commission", ) SUMMARY_FIELDS = ( - "summary.net_pnl", "summary.avg_trade", "summary.best_trade", + "summary.win_rate_pct", "summary.net_pnl", "summary.avg_trade", "summary.best_trade", "summary.worst_trade", "summary.max_drawdown", ) BAD_VALUES = ( @@ -61,6 +63,18 @@ pytest.param(float("-inf"), id="negative-infinity"), ) MODES = ("ordinary", "selected") +# Pinned from the revision 3.1 rule of proportional annualisation BEFORE any execution; never +# derived from output, the .def or the implementation. A curve's cagr is the simple proportional +# form (ratio - 1) * (1 / span_years) * 100 under one day, and from one day on the compounded +# form (pow(ratio, 1 / span_years) - 1) * 100 unless that is not finite, then the simple one. +# The wire names a simple form under diagnostics.annualization.cagr. calmar is cagr over the +# maximum drawdown percent, so it carries the SAME string under diagnostics.annualization.calmar +# exactly when the cagr string is present and calmar is not null (null at zero drawdown, and +# then no calmar key); sharpe_bar and sortino_bar never have a reason. +ONE_DAY_MS = 86_400_000 +YEAR_MS = 365.25 * 86400.0 * 1000.0 +SUBDAY = "simple_proportional_subday" +OVERFLOW = "simple_proportional_overflow" # The frozen producer: docker/run_json.py on engine main just before EN-133 (the file is # byte-identical at the commit the change was authored on, 9da4c984, and here). BASE_SOURCE = "7da487ab2907c428bdfc755e10452811f95beaf2:docker/run_json.py" @@ -77,18 +91,120 @@ def strict_line(stdout): return json.loads(lines[0], parse_constant=reject_constant) +def simple_cagr(span_ms, capital, final_equity): + """The simple proportional cagr (percent per year), in the kernel's operation order.""" + span_years = span_ms / YEAR_MS + return (final_equity / capital - 1.0) * (1.0 / span_years) * 100.0 + + +def compounded_cagr(span_ms, capital, final_equity): + """The compounded cagr, for the curves whose compounded form is a finite double.""" + span_years = span_ms / YEAR_MS + return ((final_equity / capital) ** (1.0 / span_years) - 1.0) * 100.0 + + +def max_drawdown_pct(equities): + """The kernel's max_equity_drawdown_pct over equity marks: the largest drop below the running + peak, as a percent of the peak in effect when it was hit; 0.0 for marks that never fall.""" + peak, worst, percent = equities[0], 0.0, 0.0 + for equity in equities: + peak = max(peak, equity) + if peak - equity > worst: + worst, percent = peak - equity, (peak - equity) / peak * 100.0 + return percent + + +def kernel_equity_metrics(form, span_ms, capital, final_equity, dip=None): + """What the kernel publishes for a curve of two marks, or of three when `dip` is the middle + mark, from the rule alone: `form` is the cagr form the rule gives the curve ("simple", + "compounded") or None where the existing domain leaves it absent (span <= 0, capital <= 0, + final equity <= 0). calmar is cagr over the drawdown percent, absent at zero drawdown (a + curve that never falls) and with an absent cagr. These short curves have fewer than three + returns, so both bar ratios stay in their existing absence: no span is involved.""" + nan = float("nan") + if form is None: + return {"cagr": nan, "calmar": nan, "sharpe_bar": nan, "sortino_bar": nan} + cagr = (simple_cagr if form == "simple" else compounded_cagr)(span_ms, capital, final_equity) + drawdown_pct = max_drawdown_pct( + [capital] + ([] if dip is None else [dip]) + [final_equity]) + return {"cagr": cagr, "max_equity_drawdown_pct": drawdown_pct, + "calmar": cagr / drawdown_pct if drawdown_pct > 0.0 else nan, + "sharpe_bar": nan, "sortino_bar": nan} + + +def metrics_of(cagr, calmar=None): + """The report's metric blocks as build_report_dict hands them to the annualization writer, + reduced to what it may read: the kernel's net_profit_pct (a defined number under a positive + capital), cagr and calmar (None: the defined absence of a curve that never falls).""" + return {"all": {"net_profit_pct": 0.0}, "equity": {"cagr": cagr, "calmar": calmar}} + + +def wire_document(marks, equity_metrics, entry_bars=(1,), positive_capital=True, **options): + """The strict JSON wire of the real report builder for a ReportC holding the given + (time_ms, equity, open_profit) marks, the kernel's equity metrics and one zeroed trade row + per entry bar index. compute_trade_stats' existing domain makes net_profit_pct null iff the + initial capital is not positive (positive_capital). options reach build_report_dict.""" + report = run_json.ReportC() + report.metrics.all.net_profit_pct = 0.0 if positive_capital else float("nan") + curve = (run_json.EquityPointC * len(marks))(*(run_json.EquityPointC(*m) for m in marks)) + report.equity_curve = curve + report.equity_curve_len = len(marks) + trades = (run_json.TradeC * len(entry_bars))() + for i, bar in enumerate(entry_bars): + trades[i].entry_bar_index = bar + report.trades = trades + report.trades_len = report.total_trades = len(entry_bars) + for name, value in equity_metrics.items(): + setattr(report.metrics.equity, name, value) + # Calendar-month ratios are already absent with fewer than two monthly returns; that + # domain has no span and never receives a reason. + report.metrics.equity.sharpe_monthly = report.metrics.equity.sortino_monthly = float("nan") + document = run_json.build_report_dict( + report, Path("annualization.csv"), len(marks), marks[0][0], marks[-1][0], + 0.0, {}, {}, {"chart_tz": "America/New_York"}, **options) + run_json._validate_report_result(document) + return json.loads(json.dumps(document, allow_nan=False)) + + +def exact_overflow_ratio(span_ms, factor): + """`factor` times the ratio at which the compounded form first overflows a double, in exact + arithmetic from the compounded formula. (pow - 1) * 100 rounds to infinity from + 2**1024 - 2**970 on (half an ulp above the largest double), and pow - 1 is pow at that + size, so the threshold ratio is that limit over 100, raised to span_years.""" + with localcontext() as context: + context.prec = 80 + exponent = 1.0 / (span_ms / YEAR_MS) + limit = (Decimal(2) ** 1024 - Decimal(2) ** 970) / 100 + return float(limit ** (Decimal(1) / Decimal(exponent)) * Decimal(factor)) + + class ReportLib(selected.FakeLib): - """Only the execution stand-in: owns the backing arrays until cleanup.""" + """Only the execution stand-in: owns the backing arrays until cleanup. equity_metrics are + the kernel's published equity fields; last_mark (time_ms, equity) replaces the curve's + last point after the stand-in run built it, and dip replaces the equity of point 1 (the + anchor is point 0 and every later point rises above it): a curve that falls once, from the + first mark to dip, then climbs, so its maximum drawdown is the one from the first mark.""" def __init__(self, plan, events, *, trade_field=None, bad_value=None, - net_profit=3.0, pnls=(10.0, -7.0), metric=None, curve_value=None): - super().__init__(plan, events) + net_profit=3.0, pnls=(10.0, -7.0), metric=None, curve_value=None, + failure_field=None, equity_metrics=None, last_mark=None, dip=None): + engine_failure = None + if failure_field is not None: + engine_failure = ( + RESULT_INVALID, "The report producer generated an invalid canonical result.") + super().__init__(plan, events, engine_failure=engine_failure) + if failure_field is not None: + self.strategy_get_last_error_args = lambda state: json.dumps( + {"field": failure_field, "reason": NONFINITE_REASON}).encode() self.trade_field = trade_field self.bad_value = bad_value self.net_profit = net_profit self.pnls = pnls self.metric = metric self.curve_value = curve_value + self.equity_metrics = equity_metrics + self.last_mark = last_mark + self.dip = dip self._trades = None def _run(self, *args): @@ -118,6 +234,12 @@ def _run(self, *args): report.net_profit = self.net_profit report.metrics.all.profit_factor = 2.0 report.metrics.equity.sharpe_monthly = 0.5 + if self.last_mark is not None: + self._curve[-1].time_ms, self._curve[-1].equity = self.last_mark + if self.dip is not None: + self._curve[1].equity = self.dip + for name, value in (self.equity_metrics or {}).items(): + setattr(report.metrics.equity, name, value) if self.metric is not None: block, name = self.metric setattr(getattr(report.metrics, block), name, self.bad_value) @@ -226,7 +348,9 @@ def assert_failure(result, code, args, *, serialization=False, phase_fd=True, mo @pytest.mark.parametrize("bad", BAD_VALUES) def test_trade_leaf_nonfinite(harness, mode, field, bad): result = harness.run(mode, trade_field=field.split(".")[1], bad_value=bad) - assert len(result.built) == 1 # the REAL report builder ran + # PnL is consumed during summary assembly and now fails before the real + # builder can return. Other leaves fail in canonical-result validation. + assert len(result.built) == (0 if field == "trades.pnl" else 1) assert_failure(result, RESULT_INVALID, {"field": field, "reason": NONFINITE_REASON}, mode=mode) @@ -268,7 +392,7 @@ def test_ordinary_typed_leaf_failure_without_phase_fd(harness, bad): @pytest.mark.parametrize("bad", BAD_VALUES) def test_writer_backstop(harness, mode, bad): def poison(document): - document["elapsed_seconds"] = bad # outside all 13 canonical leaves + document["elapsed_seconds"] = bad # outside the canonical result leaves result = harness.run(mode, inject=poison) assert_failure(result, POST_EXECUTION_FAILED, @@ -346,23 +470,380 @@ def poison(*args, **kwargs): (("shorts", "profit_factor"), "profit_factor"), (("equity", "sharpe_monthly"), "sharpe_tv"), ), ids=("all", "longs", "shorts", "equity")) -@pytest.mark.parametrize("bad", BAD_VALUES) -def test_metric_nonfinite_stays_null(harness, mode, metric, key, bad): - result = harness.run(mode, metric=metric, bad_value=bad) +def test_metric_defined_nan_stays_null(harness, mode, metric, key): + # Only a native producer's defined absence can reach this representation. + # Computed NaN and infinities are rejected by test_metrics_overflow.cpp. + result = harness.run(mode, metric=metric, bad_value=float("nan")) document = strict_line(result.stdout) assert (result.status, result.stderr) == (0, "") assert document["metrics"][metric[0]][key] is None assert [record["phase"] for record in result.records][-1] == "completed" +# Exercise every floating report metric, including both monthly public aliases. +METRIC_FIELDS = [ + (block, name, {"sharpe_monthly": "sharpe_tv", "sortino_monthly": "sortino_tv"}.get(name, name)) + for block, struct in (("all", run_json.TradeStatsC), ("longs", run_json.TradeStatsC), + ("shorts", run_json.TradeStatsC), ("equity", run_json.EquityStatsC)) + for name, kind in struct._fields_ if kind is ctypes.c_double +] + + @pytest.mark.parametrize("mode", MODES) -@pytest.mark.parametrize("bad", BAD_VALUES) -def test_equity_nonfinite_stays_null(harness, mode, bad): - result = harness.run(mode, curve_value=bad) +@pytest.mark.parametrize("block,name,key", METRIC_FIELDS) +@pytest.mark.parametrize("bad", (float("inf"), float("-inf"))) +def test_metric_overflow_is_typed(harness, mode, block, name, key, bad): + assert len(METRIC_FIELDS) == 87 + result = harness.run(mode, metric=(block, name), bad_value=bad) + assert_failure(result, RESULT_INVALID, + {"field": f"metrics.{block}.{key}", "reason": NONFINITE_REASON}, mode=mode) + + +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("phase_fd", (True, False), ids=("phase-fd", "no-phase-fd")) +@pytest.mark.parametrize("field,key", ( + ("sharpe_monthly", "sharpe_tv"), + ("sortino_monthly", "sortino_tv"), + ("sharpe_tv", "sharpe_tv"), + ("sortino_tv", "sortino_tv"), +), ids=("native-sharpe", "native-sortino", "public-sharpe", "public-sortino")) +def test_engine_monthly_overflow_field_uses_public_wire_key(harness, mode, phase_fd, field, key): + # Model the typed kernel failure through the real getters and main() + # forwarding path, rather than poisoning a returned report metric. + result = harness.run(mode, phase_fd=phase_fd, failure_field=f"metrics.equity.{field}") + assert result.built == [] + assert_failure(result, RESULT_INVALID, + {"field": f"metrics.equity.{key}", "reason": NONFINITE_REASON}, + mode=mode, phase_fd=phase_fd) + assert strict_line(result.stdout)["error"] == ( + "The report producer generated an invalid canonical result.") + + +@pytest.mark.parametrize("code,field", ( + (RESULT_INVALID, "metrics.equity.sharpe_bar"), + (RESULT_INVALID, "metrics.equity.buy_hold_return"), + (RESULT_INVALID, "metrics.all.sharpe_monthly"), + (RESULT_INVALID, "metrics.equity.sharpe_monthly.extra"), + (RESULT_INVALID, "sharpe_monthly"), + ("engine_command_value_invalid", "metrics.equity.sharpe_monthly"), +)) +def test_failure_line_preserves_fields_outside_monthly_report_aliases(code, field): + args = {"field": field, "reason": NONFINITE_REASON} + document = strict_line(run_json.failure_line("engine fault", code, args)) + assert document == {"engine": "pineforge", "error": "engine fault", + "code": code, "args": args} + + +def test_run_json_annualization_span_equals_the_native_span(): + # docker/report_annualization.def is what the C++ metric walk includes; run_json.py ships + # alone and copies its one span bound. The bound is the whole policy: no field is named, so + # no span makes any metric null (the .def holds nothing else, and run_json keeps no list). + import re + text = (Path(run_json.__file__).with_name("report_annualization.def") + .read_text(encoding="utf-8")) + spans = re.findall(r"^PF_ANNUALIZATION_SPAN_MS\((\d+)\)$", text, re.M) + assert [int(s) for s in spans] == [run_json.MIN_ANNUALIZATION_SPAN_MS] == [ONE_DAY_MS] + others = [line for line in text.splitlines() + if line.strip() and not line.startswith("//") + and not line.startswith("PF_ANNUALIZATION_SPAN_MS(")] + assert others == [] + assert not hasattr(run_json, "SHORT_SPAN_ANNUALIZED_FIELDS") + + +def route_equity_metrics(cagr, final_equity, dip, **others): + """The kernel's equity fields for the route harness curve, from the rule: it starts at the + initial capital, falls once to `dip` when one is given (ReportLib's dip), and ends at + final_equity. calmar is cagr over that drawdown percent, absent for a curve that never + falls (the harness curve rises). `others` are published as given.""" + drawdown_pct = max_drawdown_pct( + (selected.CAPITAL,) + (() if dip is None else (dip,)) + (final_equity,)) + return {"cagr": cagr, "max_equity_drawdown_pct": drawdown_pct, + "calmar": cagr / drawdown_pct if drawdown_pct > 0.0 else float("nan"), **others} + + +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("dip,flags", ( + (None, {"cagr": SUBDAY}), + (selected.CAPITAL * 0.98, {"cagr": SUBDAY, "calmar": SUBDAY}), +), ids=("zero-drawdown", "drawdown")) +def test_sub_day_annualization_completes_in_report_routes(harness, mode, dip, flags): + # Ten minutes, +8%: the rule gives a finite simple proportional cagr, named under cagr. + # calmar is cagr over the drawdown percent: null for a curve that never falls (no calmar + # key), a finite number for a curve 2% below its first mark at the dip, which carries the + # same string as cagr. The bar ratios keep their one formula (finite numbers, passed + # through) and never have a reason. + span_ms, final_equity = 10 * 60_000, selected.CAPITAL * 1.08 + cagr = simple_cagr(span_ms, selected.CAPITAL, final_equity) + metrics = route_equity_metrics(cagr, final_equity, dip, sharpe_bar=1.5, sortino_bar=2.5) + result = harness.run(mode, last_mark=(selected.T + span_ms, final_equity), dip=dip, + equity_metrics=metrics) document = strict_line(result.stdout) assert (result.status, result.stderr) == (0, "") - assert document["equity_curve"][-1]["equity"] is None - assert document["equity_curve"][-1]["open_profit"] is None + equity = document["metrics"]["equity"] + assert equity["cagr"] == cagr + assert equity["calmar"] == (None if dip is None else metrics["calmar"]) + assert (equity["sharpe_bar"], equity["sortino_bar"]) == (1.5, 2.5) + assert document["diagnostics"]["annualization"] == flags + assert result.records[-1]["phase"] == "completed" + + +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("dip,flags", ( + (None, {"cagr": OVERFLOW}), + (selected.CAPITAL * 0.5, {"cagr": OVERFLOW, "calmar": OVERFLOW}), +), ids=("zero-drawdown", "drawdown")) +def test_overflow_fallback_completes_in_report_routes(harness, mode, dip, flags): + # One day, a 1e30 ratio: pow(1e30, 365.25) is not a double, so the kernel takes the simple + # form, which is finite, and the wire names it under cagr. No trade, so the first mark (the + # capital; in selected mode the anchor) is the initial capital the writer recomputes the + # compounded form from. calmar (null at zero drawdown; a finite number after a fall to half + # the capital) carries the same string as cagr when it is not null. + final_equity = selected.CAPITAL * 1e30 + cagr = simple_cagr(ONE_DAY_MS, selected.CAPITAL, final_equity) + assert math.isfinite(cagr) + metrics = route_equity_metrics(cagr, final_equity, dip, sharpe_bar=1.5, sortino_bar=2.5) + result = harness.run( + mode, pnls=(), net_profit=0.0, last_mark=(selected.T + ONE_DAY_MS, final_equity), + dip=dip, equity_metrics=metrics) + document = strict_line(result.stdout) + assert (result.status, result.stderr) == (0, "") + equity = document["metrics"]["equity"] + assert equity["cagr"] == cagr + assert equity["calmar"] == (None if dip is None else metrics["calmar"]) + assert (equity["sharpe_bar"], equity["sortino_bar"]) == (1.5, 2.5) + assert document["diagnostics"]["annualization"] == flags + assert result.records[-1]["phase"] == "completed" + + +@pytest.mark.parametrize("dip,chosen_flags", ( + (None, {"cagr": OVERFLOW}), + (selected.CAPITAL * 0.5, {"cagr": OVERFLOW, "calmar": OVERFLOW}), +), ids=("zero-drawdown", "drawdown")) +def test_the_selected_route_gives_the_writer_the_anchor_and_the_ordinary_route_does_not( + harness, dip, chosen_flags): + # ReportLib's first trade is entered at script bar 0. A selected run still knows its initial + # capital (the anchor, passed by the selected route); an ordinary run, with a trade entered + # at its first bar, has no proof of it and names nothing: calmar carries cagr's string, so + # an ordinary run with a finite calmar still has no calmar key. + final_equity = selected.CAPITAL * 1e30 + cagr = simple_cagr(ONE_DAY_MS, selected.CAPITAL, final_equity) + options = {"last_mark": (selected.T + ONE_DAY_MS, final_equity), "dip": dip, + "equity_metrics": route_equity_metrics(cagr, final_equity, dip)} + chosen = strict_line(harness.run("selected", **options).stdout) + assert chosen["diagnostics"]["annualization"] == chosen_flags + plain = strict_line(harness.run("ordinary", **options).stdout) + assert (plain["metrics"]["equity"]["calmar"] is None) == (dip is None) + assert "annualization" not in plain["diagnostics"] + + +# (span_ms, capital, dip, final_equity, form, flags): the cagr form the rule gives a curve of two +# marks (three when `dip` is the middle one, a fall below the capital that gives the curve a +# drawdown) and the diagnostics.annualization object it carries on the wire, stated per row from +# the rule and not computed. Under one day cagr is always the simple form, named, even a flat +# curve's 0.0. From one day on it is the compounded form, not named; where that overflows a +# double (a 1e30 ratio over one day) it is the simple form, named. calmar carries cagr's string +# when it is not null and has no key when it is null (a curve that never falls: zero drawdown). +# A finite calmar beside a cagr that is not named (the one-day boundary rows, the losses) gets no +# key. The existing domain absences stay null with no object. +@pytest.mark.parametrize("span_ms,capital,dip,final_equity,form,flags", ( + pytest.param(7 * 60_000, 10_000.0, None, 10_800.0, "simple", + {"cagr": SUBDAY}, id="sub-day-rise-calmar-null"), + pytest.param(7 * 60_000, 10_000.0, None, 10_000.0, "simple", + {"cagr": SUBDAY}, id="sub-day-flat-calmar-null"), + pytest.param(7 * 60_000, 10_000.0, None, 9_000.0, "simple", + {"cagr": SUBDAY, "calmar": SUBDAY}, id="sub-day-loss"), + pytest.param(7 * 60_000, 10_000.0, 9_800.0, 10_800.0, "simple", + {"cagr": SUBDAY, "calmar": SUBDAY}, id="sub-day-dip-then-gain"), + pytest.param(86_399_999, 1.0, None, 1e300, "simple", + {"cagr": SUBDAY}, id="one-ms-under-a-day-1e300-calmar-null"), + pytest.param(86_399_999, 100.0, 50.0, 120.0, "simple", + {"cagr": SUBDAY, "calmar": SUBDAY}, id="one-ms-under-a-day-dip"), + pytest.param(86_400_000, 10_000.0, None, 10_800.0, "compounded", + None, id="exactly-a-day"), + pytest.param(86_400_000, 10_000.0, 9_800.0, 10_800.0, "compounded", + None, id="exactly-a-day-dip-calmar-finite"), + pytest.param(86_400_000, 10_000.0, None, 9_999.0, "compounded", + None, id="exactly-a-day-loss-calmar-finite"), + pytest.param(86_400_001, 10_000.0, None, 10_800.0, "compounded", + None, id="one-ms-over-a-day"), + pytest.param(30 * 86_400_000, 10_000.0, None, 9_000.0, "compounded", + None, id="30-days-loss-calmar-finite"), + pytest.param(86_400_000, 1.0, None, 1e30, "simple", + {"cagr": OVERFLOW}, id="a-day-overflow-calmar-null"), + pytest.param(3 * 86_400_000, 1.0, None, 1e30, "simple", + {"cagr": OVERFLOW}, id="3-days-overflow-calmar-null"), + pytest.param(86_400_000, 1.0, 0.5, 1e30, "simple", + {"cagr": OVERFLOW, "calmar": OVERFLOW}, id="a-day-overflow-dip"), + pytest.param(3 * 86_400_000, 1.0, 0.5, 1e30, "simple", + {"cagr": OVERFLOW, "calmar": OVERFLOW}, id="3-days-overflow-dip"), + pytest.param(7 * 60_000, 0.0, None, 10_800.0, None, None, id="sub-day-capital-0"), + pytest.param(7 * 60_000, -1.0, None, 10_800.0, None, None, id="sub-day-capital-negative"), + pytest.param(7 * 60_000, 10_000.0, None, 0.0, None, None, id="sub-day-final-0"), + pytest.param(86_400_000, 0.0, None, 10_800.0, None, None, id="a-day-capital-0"), + pytest.param(86_400_000, 10_000.0, None, 0.0, None, None, id="a-day-final-0"), + pytest.param(0, 10_000.0, None, 10_800.0, None, None, id="span-0"), + pytest.param(-1, 10_000.0, None, 10_800.0, None, None, id="span-negative"), +)) +def test_annualization_reason_on_wire(span_ms, capital, dip, final_equity, form, flags): + # The kernel's values are supplied by the producer stand-in, as in the other wire tests. + # The C++ rows separately prove the real metric computation. + metrics = kernel_equity_metrics(form, span_ms, capital, final_equity, dip) + marks = (((selected.T, capital, 0.0),) + + (() if dip is None else ((selected.T + span_ms // 2, dip, 0.0),)) + + ((selected.T + span_ms, final_equity, 0.0),)) + wire = wire_document(marks, metrics, positive_capital=capital > 0) + for name in ("cagr", "calmar", "sharpe_bar", "sortino_bar"): + # A finite value reaches the wire as that number, an absence as null; none is + # replaced or rounded by the writer, and only cagr and calmar have a reason. + assert wire["metrics"]["equity"][name] == ( + None if math.isnan(metrics[name]) else metrics[name]) + assert wire["metrics"]["equity"]["sharpe_tv"] is None + assert wire["metrics"]["equity"]["sortino_tv"] is None + if flags is None: + assert "annualization" not in wire["diagnostics"] + else: + # Row check on the published inputs: a calmar key is expected exactly for a finite calmar. + assert ("calmar" in flags) == (not math.isnan(metrics["calmar"])), flags + assert wire["diagnostics"]["annualization"] == flags + # Appendix A's scalar leaf expansion stays exactly 135. This tests the + # metric/summary names independently of the producer's descriptors. + trade_keys = { + "num_trades", "num_wins", "num_losses", "num_even", "percent_profitable", + "net_profit", "net_profit_pct", "gross_profit", "gross_profit_pct", + "gross_loss", "gross_loss_pct", "profit_factor", "avg_trade", "avg_trade_pct", + "avg_win", "avg_win_pct", "avg_loss", "avg_loss_pct", "ratio_avg_win_avg_loss", + "largest_win", "largest_win_pct", "largest_loss", "largest_loss_pct", + "commission_paid", "max_consecutive_wins", "max_consecutive_losses", + "expectancy", "avg_bars_in_trade", "avg_bars_in_wins", "avg_bars_in_losses", + } + equity_keys = { + "max_equity_drawdown", "max_equity_drawdown_pct", "max_equity_runup", + "max_equity_runup_pct", "buy_hold_return", "buy_hold_return_pct", + "sharpe_tv", "sortino_tv", "sharpe_bar", "sortino_bar", "cagr", "calmar", + "recovery_factor", "time_in_market_pct", "open_pl", + } + summary_keys = { + "total_trades", "wins", "losses", "bars_processed", "win_rate_pct", + "net_pnl", "avg_trade", "best_trade", "worst_trade", "max_drawdown", + } + assert set(wire["metrics"]) == {"all", "longs", "shorts", "equity"} + for side in ("all", "longs", "shorts"): + assert set(wire["metrics"][side]) == trade_keys + assert set(wire["metrics"]["equity"]) == equity_keys + assert set(wire["summary"]) == summary_keys + trade_row_keys = { + "n", "side", "entry_time", "exit_time", "entry_price", "exit_price", + "qty", "pnl", "pnl_pct", "max_runup", "max_drawdown", "commission", + "entry_bar_index", "exit_bar_index", "open_at_end", "entry_incarnation", + } + assert set(wire["trades"][0]) == trade_row_keys + assert all(set(point) == {"time_ms", "equity", "open_profit"} + for point in wire["equity_curve"]) + # version + summary + 3 trade blocks + equity + trade-row fields + curve: + assert (1 + len(summary_keys) + 3 * len(trade_keys) + len(equity_keys) + + len(trade_row_keys) + 3) == 135 + + +# The kernel's initial capital is 100 and the curve ends at 600 after exactly one day: a ratio of +# 6, whose compounded form is finite (6 ** 365.25 is about 1e284), so the kernel keeps the +# compounded form and nothing is flagged. A writer that took the first mark (75) for the capital +# would see a ratio of 8, whose compounded form overflows, and flag a form the kernel did not +# use. The first mark differs from the capital by open profit or a realized loss at it (the +# rows show a trade entered at the first bar); no row proves the capital, so nothing is named. +@pytest.mark.parametrize("first_mark,entry_bars", ( + ((selected.T, 75.0, -25.0), (0,)), + ((selected.T, 75.0, 0.0), (0,)), + ((selected.T, 75.0, -25.0), (3,)), + ((selected.T, 75.0, 0.0), (-1,)), +), ids=("open-position", "realized-at-the-first-bar", "open-profit-alone", "unusable-bar-index")) +def test_a_first_mark_that_is_not_provably_the_capital_names_no_form(first_mark, entry_bars): + capital, final_equity = 100.0, 600.0 + metrics = kernel_equity_metrics("compounded", ONE_DAY_MS, capital, final_equity) + wire = wire_document((first_mark, (selected.T + ONE_DAY_MS, final_equity, 0.0)), + metrics, entry_bars=entry_bars) + assert wire["metrics"]["equity"]["cagr"] == metrics["cagr"] + assert "annualization" not in wire["diagnostics"] + + +def test_the_window_anchor_is_the_initial_capital_whatever_the_trades(): + # The selected-window anchor is the engine's own initial capital by construction, so a trade + # entered at the first window bar (script bar 0 when no preroll precedes it) leaves it known. + # The curve falls to half the capital on the way, so calmar is finite and carries cagr's + # string; without the anchor and with that trade nothing is proved, and neither key is named. + marks = ((selected.T, 1.0, 0.0), (selected.T + ONE_DAY_MS // 2, 0.5, 0.0), + (selected.T + ONE_DAY_MS, 1e30, 0.0)) + metrics = kernel_equity_metrics("simple", ONE_DAY_MS, 1.0, 1e30, dip=0.5) + anchored = wire_document(marks, metrics, entry_bars=(0,), window_anchor=True) + assert anchored["metrics"]["equity"]["calmar"] == metrics["calmar"] + assert anchored["diagnostics"]["annualization"] == {"cagr": OVERFLOW, "calmar": OVERFLOW} + # The same ordinary curve and trade carry no proof of the capital. + plain = wire_document(marks, metrics, entry_bars=(0,)) + assert plain["metrics"]["equity"]["calmar"] == metrics["calmar"] + assert "annualization" not in plain["diagnostics"] + + +@pytest.mark.parametrize("days", (1, 2, 7, 30, 100, 300)) +def test_overflow_decision_agrees_with_exact_arithmetic_at_the_threshold(days): + # Ratios 1e-8 under and over the exact ratio at which (pow(ratio, 1 / span_years) - 1) * 100 + # first overflows a double, for several spans, from exact arithmetic and not from pow: a + # ratio under it has a finite compounded form (nothing named, under cagr or calmar), a ratio + # over it has none (the kernel takes the simple form: cagr named, and calmar with the same + # string when it is not null; with a null calmar, zero drawdown, only the cagr key). The + # margin moves the power by at least 1e-8, over 1e7 times the error of a double power, so + # every working pow, C's included, decides these the same way. + span_ms = days * ONE_DAY_MS + for factor, with_null_calmar, with_finite_calmar in ( + ("0.99999999", {}, {}), + ("1.00000001", {"cagr": OVERFLOW}, {"cagr": OVERFLOW, "calmar": OVERFLOW})): + ratio = exact_overflow_ratio(span_ms, factor) + curve = [{"time_ms": selected.T, "equity": 1.0, "open_profit": 0.0}, + {"time_ms": selected.T + span_ms, "equity": ratio, "open_profit": 0.0}] + assert run_json._annualization_diagnostics( + curve, metrics_of(1.0)) == with_null_calmar, (days, factor, ratio) + assert run_json._annualization_diagnostics( + curve, metrics_of(1.0, calmar=7.0)) == with_finite_calmar, (days, factor, ratio) + + +@pytest.mark.parametrize("final_equity", (0.5, 1e-300, 5e-324)) +@pytest.mark.parametrize("days", (1, 100)) +def test_a_losing_curve_never_names_an_overflow(days, final_equity): + # The power of a ratio under 1 underflows to 0.0 (cagr -100): an underflow is no overflow, + # and a finite calmar (the loss is a drawdown) is named only beside a named cagr. + curve = [{"time_ms": selected.T, "equity": 1.0, "open_profit": 0.0}, + {"time_ms": selected.T + days * ONE_DAY_MS, "equity": final_equity, + "open_profit": 0.0}] + assert run_json._annualization_diagnostics(curve, metrics_of(-100.0, calmar=-1.0)) == {} + + +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("bad", BAD_VALUES) +def test_executed_equity_nonfinite_is_typed(harness, mode, bad): + result = harness.run(mode, curve_value=bad) + assert_failure(result, RESULT_INVALID, + {"field": "equity_curve.equity", "reason": NONFINITE_REASON}, mode=mode) + + +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("pnls,net_profit", ( + ((1e308, 1e308, -1e308), 1e308), + ((1e308, -1e308, -1e308), -1e308), +), ids=("sum", "peak-difference")) +def test_cumulative_summary_overflow_cannot_hide_in_min_max(harness, mode, pnls, net_profit): + # Every trade and supplied net profit are finite. cum and cum-peak used + # to overflow, then min/max silently discarded the resulting NaN. + result = harness.run(mode, pnls=pnls, net_profit=net_profit) + assert_failure(result, RESULT_INVALID, + {"field": "summary.max_drawdown", "reason": NONFINITE_REASON}, mode=mode) + + +@pytest.mark.parametrize("mode", MODES) +def test_open_profit_nonfinite_is_typed(harness, mode): + def poison(document): + document["equity_curve"][-1]["open_profit"] = float("inf") + + result = harness.run(mode, inject=poison) + assert_failure(result, RESULT_INVALID, + {"field": "equity_curve.open_profit", "reason": NONFINITE_REASON}, mode=mode) @pytest.mark.parametrize("token", ("NaN", "Infinity", "-Infinity", "1e9999")) @@ -395,7 +876,11 @@ def base_runner(): @pytest.mark.parametrize("phase_fd", (True, False), ids=("phase-fd", "no-phase-fd")) @pytest.mark.parametrize("pnls", ((10.0, -7.0), ()), ids=("trades", "no-trades")) def test_finite_report_bytes_match_base(harness, base_runner, mode, phase_fd, pnls): - options = {"pnls": pnls, "net_profit": 3.0 if pnls else 0.0} + # These curves are minutes long, and a finite cagr on a sub-day span is flagged on the wire, + # which the frozen producer cannot write: the stand-in publishes cagr as an absence (a null + # on both wires), so no annualization flag applies and the bytes are the base's. + options = {"pnls": pnls, "net_profit": 3.0 if pnls else 0.0, + "equity_metrics": {"cagr": float("nan")}} before = harness.run(mode, runner=base_runner, phase_fd=phase_fd, **options) after = harness.run(mode, phase_fd=phase_fd, **options) assert (before.status, before.stderr, after.status, after.stderr) == (0, "", 0, "") diff --git a/tests/test_run_json_selected_window.py b/tests/test_run_json_selected_window.py index b4711b8b3..01004935b 100644 --- a/tests/test_run_json_selected_window.py +++ b/tests/test_run_json_selected_window.py @@ -784,8 +784,8 @@ def test_every_other_check_still_applies_without_an_established_capital(self): self.refused_curve(bad, bars, capital, fragment) def test_a_malformed_actual_anchor_is_refused_whatever_the_capital(self): - # The anchor the engine produced must be a finite number (the report writes a non-finite - # double as null): not null, not a bool, not text, not NaN or an infinity, and not an int + # The anchor the engine produced must be a finite number: not null, + # not a bool, not text, not NaN or an infinity, and not an int # beyond binary64. An unresolved capital never excuses it, and neither does an equal- # looking value: True == 1.0 must not pass for a capital of 1. malformed = (None, float("nan"), float("inf"), float("-inf"), True, False, "1000000", @@ -1621,8 +1621,8 @@ def test_an_established_capital_is_still_compared_with_the_native_anchor(self): def test_a_malformed_actual_anchor_is_refused_even_when_the_capital_is_unresolved(self): plan = full_plan() - # A non-finite native anchor reaches the report as null (build_report_dict writes every - # non-finite double that way); it must not be written out as a valid-looking number. + # The canonical-result guard types a nonfinite anchor as R + # before selected-window consistency checks or serialization. for anchor in (float("nan"), float("inf"), float("-inf")): for unresolved in (False, True): with self.subTest(anchor=anchor, unresolved=unresolved): @@ -1634,8 +1634,9 @@ def test_a_malformed_actual_anchor_is_refused_even_when_the_capital_is_unresolve status, out, _ = self.run_selected(lib, plan, patches=patches) self.assertEqual(status, 1) line = last_line(out) - self.assertEqual(line["code"], "engine_invariant") - self.assertIn("not a finite number", line["error"]) + self.assertEqual(line["code"], "report_result_invalid") + self.assertEqual(line["args"], + {"field": "equity_curve.equity", "reason": "nonfinite"}) self.assertEqual(len(out.strip().splitlines()), 1) # no report, no 2nd line self.assertNotIn("report_window", out) self.assertNotIn("advance:serialization", self.events) diff --git a/tests/test_selected_report_view.cpp b/tests/test_selected_report_view.cpp index 019218dff..d4afe5afa 100644 --- a/tests/test_selected_report_view.cpp +++ b/tests/test_selected_report_view.cpp @@ -29,6 +29,7 @@ #include "../src/selected_report_view.hpp" #include "equity_stats_reference.hpp" +#include "report_failure_checks.hpp" #include #include @@ -639,8 +640,30 @@ void compare_walk(const Walk& w, double capital, const std::string& tz) { ++walks_compared; const std::int64_t n = static_cast(w.points.size()); const pf_equity_point_t* data = w.points.empty() ? nullptr : w.points.data(); - const pf_equity_stats_t want = ref::compute_equity_stats( + pf_equity_stats_t want = ref::compute_equity_stats( data, n, capital, tz, w.first_open, w.last_close, w.in_market, w.net_profit); + report_failure_checks::apply_proportional_annualization(want, data, n, capital); + + if (const char* field = report_failure_checks::annualization_overflow(want)) { + // Remaining overflow is strict on all three entries; + // only the exposure denominator differs between them. + CHECK(report_failure_checks::rejects_nonfinite(field, [&] { + (void)metrics::compute_equity_stats( + data, n, capital, tz, w.first_open, w.last_close, w.in_market, w.net_profit); + })); + CHECK(report_failure_checks::rejects_nonfinite(field, [&] { + (void)metrics::compute_equity_stats( + data, n, capital, tz, w.first_open, w.last_close, w.in_market, w.net_profit, n); + })); + if (n >= 1) { + CHECK(report_failure_checks::rejects_nonfinite(field, [&] { + (void)metrics::compute_equity_stats( + data, n, capital, tz, w.first_open, w.last_close, + w.in_market, w.net_profit, n - 1); + })); + } + return; + } // The old entry, and the overload given the count the old entry gives it. const pf_equity_stats_t old_entry = metrics::compute_equity_stats( diff --git a/tests/test_utc_month_memo.cpp b/tests/test_utc_month_memo.cpp index b142d08d5..f07989010 100644 --- a/tests/test_utc_month_memo.cpp +++ b/tests/test_utc_month_memo.cpp @@ -22,6 +22,7 @@ #include "../src/metrics_month_memo.hpp" #include "equity_stats_reference.hpp" +#include "report_failure_checks.hpp" #include #include @@ -260,9 +261,19 @@ void compare(const Curve& c, double initial_capital, const std::string& tz) { ++stat_blocks; const auto n = static_cast(c.points.size()); const pf_equity_point_t* data = c.points.empty() ? nullptr : c.points.data(); - const pf_equity_stats_t got = pineforge::metrics::compute_equity_stats( + pf_equity_stats_t want = ref::compute_equity_stats( data, n, initial_capital, tz, c.first_open, c.last_close, c.in_market, c.net_profit); - const pf_equity_stats_t want = ref::compute_equity_stats( + report_failure_checks::apply_proportional_annualization(want, data, n, initial_capital); + if (const char* field = report_failure_checks::annualization_overflow(want)) { + // Any overflow remaining after the proportional rule is an exact-field fault. + CHECK(report_failure_checks::rejects_nonfinite(field, [&] { + (void)pineforge::metrics::compute_equity_stats( + data, n, initial_capital, tz, c.first_open, c.last_close, + c.in_market, c.net_profit); + })); + return; + } + const pf_equity_stats_t got = pineforge::metrics::compute_equity_stats( data, n, initial_capital, tz, c.first_open, c.last_close, c.in_market, c.net_profit); const char* differs = ref::first_difference(got, want); CHECK(differs == nullptr); From 06d046cf27b77571a37bf9699d7f4103a0cf26f0 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 20 Oct 2026 11:47:59 +0800 Subject: [PATCH 2/3] tests: keep the base assertion CHECK(!std::isnan(e.cagr)) in test_metrics_l4d (twin parity) scripts/check_twin_parity.py requires every assertion literal of the base test_metrics to survive in its switched-route twin test_metrics_l4d.cpp. The proportional-annualisation rewrite of test_equity_stats_drawdown_walk dropped `CHECK(!std::isnan(e.cagr));`, so the source-guard-twin-parity stage failed and the verifier-tests stage of preflight read `not-started` for every later stage. The literal is back, unchanged and still true (cagr is defined on every valid run); the reference comparison below it stays. Co-Authored-By: Claude Sonnet 5.5 --- tests/test_metrics_l4d.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_metrics_l4d.cpp b/tests/test_metrics_l4d.cpp index 509ab9e3b..3baf73e49 100644 --- a/tests/test_metrics_l4d.cpp +++ b/tests/test_metrics_l4d.cpp @@ -320,6 +320,7 @@ static void test_equity_stats_drawdown_walk() { CHECK(std::fabs(e.max_equity_runup - 200.0) < 1e-12); CHECK(std::fabs(e.max_equity_runup_pct - 200.0 / 9.0) < 1e-9); CHECK(std::fabs(e.recovery_factor - 100.0 / 300.0) < 1e-12); + CHECK(!std::isnan(e.cagr)); auto reference = equity_stats_reference::compute_equity_stats( c, 4, 1000.0, "", 100.0, 110.0, 0, 100.0); const double span_years = static_cast(c[3].time_ms - c[0].time_ms) From 259f0f4239ea9a4f443f84efde31139dd3a77b43 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 20 Oct 2026 12:29:41 +0800 Subject: [PATCH 3/3] tests: re-pin kNeutralReportDigest to the lab harvest of the proportional-annualisation report The neutral run (15 one-minute bars, a 14-minute span, KernelRecorded) now reports a finite simple proportional cagr (10000 to about 10005, no drawdown) and main's formulas for sharpe_bar and sortino_bar, so nothing is absent and its report digest moves again from the value pinned for the earlier null rule. Value harvested from lab job rj-20261020t035042-1a9c33 (label cr202-v6c-pre-rel, release step, test_native_anchored_legs:262), log line 2525: "neutral report digest 6216030702059937357, pinned 8139101939074791901". Fixture authority for exactly that printed value: CORE-TOP (2026-10-20 12:29). That test was the only failure of that release run (961 of 962 tests passed). Co-Authored-By: Claude Sonnet 5.5 --- tests/test_native_anchored_legs.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_native_anchored_legs.cpp b/tests/test_native_anchored_legs.cpp index 5ee90e5fb..011ecf769 100644 --- a/tests/test_native_anchored_legs.cpp +++ b/tests/test_native_anchored_legs.cpp @@ -48,7 +48,7 @@ constexpr std::uint64_t kNeutralSpecDigest = 4011217037958131497ULL; // one-minute bars: 10000 -> about 10005, with no drawdown. Calmar remains // NaN by that domain; the bar ratios retain main's formulas. // The report digest pin is a lab harvest. -constexpr std::uint64_t kNeutralReportDigest = 8139101939074791901ULL; +constexpr std::uint64_t kNeutralReportDigest = 6216030702059937357ULL; constexpr std::size_t kNeutralEventCount = 72; // A canonical one-minute slot label. The R4 harness's tape starts at