diff --git a/CLAUDE.md b/CLAUDE.md index 683f691..56ec517 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,28 @@ If `scripts/run_corpus.sh` reports any parity drift or failures, investigate the - Run all unit tests: `ctest --test-dir build --output-on-failure` - Run single test executable: `./build/bin/test_integration` +## SOP: adding a runtime `PF_API` export (CI gate — recurring failure) + +CI runs `python3 scripts/check_c_abi_runtime.py` after build+test. It pins the +exact set of `PF_API` symbols implemented in `src/c_abi.cpp` against the +hardcoded `EXPECTED_RUNTIME` frozenset in that script. Adding (or removing) a +runtime export WITHOUT updating that list fails ALL CI matrix jobs at the +"C ABI runtime source check" step, even though build and ctest are green. + +Checklist when touching runtime exports — update ALL of these together: + +1. `src/c_abi.cpp` — the implementation (and its file-header symbol comment). +2. `include/pineforge/pineforge.h` — the `PF_API` declaration (+ doxygen). +3. `scripts/check_c_abi_runtime.py` — add the symbol to `EXPECTED_RUNTIME`. +4. Python ctypes harnesses if consumers must call it + (`scripts/run_strategy.py`, `tutorial/run*.py`, `docker/run_json.py`, + `benchmarks/throughput/grid_search_repro.py`). +5. README symbol table if it enumerates exports. + +Before pushing: `python3 scripts/check_c_abi_runtime.py` (must exit 0). +Per-strategy symbols (strategy_create, run_backtest, …) are NOT in this list — +they are codegen-emitted; the checker enforces exactly that split. + ## Code Style & Invariants - Modern C++17. Use of `` fixed-width types. diff --git a/CMakeLists.txt b/CMakeLists.txt index 3eb7ea6..36503e5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,6 +78,7 @@ add_library(pineforge STATIC src/c_abi.cpp src/engine_fills.cpp src/engine_lower_tf.cpp + src/engine_metrics.cpp src/engine_orders.cpp src/engine_path_resolve.cpp src/engine_report.cpp diff --git a/README.md b/README.md index bfa5d72..899d459 100644 --- a/README.md +++ b/README.md @@ -299,7 +299,7 @@ int main(void) { ## Public C ABI (the stability surface) -`` is the **single canonical consumer header**. Every compiled PineForge strategy `.so` exports exactly the 10 symbols declared there: +`` is the **single canonical consumer header**. Every compiled PineForge strategy `.so` exports the symbols declared there: | Symbol | Role | @@ -314,9 +314,10 @@ int main(void) { | `strategy_set_magnifier_volume_weighted` | Toggle volume-weighted magnifier | | `strategy_set_trace_enabled` | Toggle per-bar trace recording | | `pf_version_get` | Runtime version | +| `pf_abi_version` | Struct-layout version (`PF_ABI_VERSION`) | -POD types (`pf_bar_t`, `pf_trade_t`, `pf_report_t`, `pf_security_diag_t`, `pf_trace_entry_t`, `pf_version_t`) and the `pf_magnifier_distribution_t` enum complete the surface. +POD types (`pf_bar_t`, `pf_trade_t`, `pf_report_t`, `pf_security_diag_t`, `pf_trace_entry_t`, `pf_version_t`, and — since ABI v2 — `pf_trade_stats_t`, `pf_equity_stats_t`, `pf_metrics_t`, `pf_equity_point_t`) and the `pf_magnifier_distribution_t` enum complete the surface. ABI v2 (`PF_ABI_VERSION == 2`, exported as `pf_abi_version()`) appends computed trading metrics and a per-script-bar equity curve to `pf_report_t`; callers must verify the version before running since the report struct is caller-allocated. **Stability guarantee:** within the same `PINEFORGE_VERSION_MAJOR`, struct layouts and `extern "C"` signatures are append-only. New fields may be appended; existing fields are never reordered, removed, or retyped. New functions may be added; existing functions are never removed or signature-changed. Compile-time `static_assert`s in `src/c_abi.cpp` pin the layouts against drift. diff --git a/benchmarks/throughput/grid_search_repro.py b/benchmarks/throughput/grid_search_repro.py index b9e5ade..44f5ae3 100644 --- a/benchmarks/throughput/grid_search_repro.py +++ b/benchmarks/throughput/grid_search_repro.py @@ -26,8 +26,53 @@ class pf_trade_t(ctypes.Structure): ("max_runup", ctypes.c_double), ("max_drawdown", ctypes.c_double), ("qty", ctypes.c_double), + ("commission", ctypes.c_double), + ("entry_bar_index", ctypes.c_int32), + ("exit_bar_index", ctypes.c_int32), ] +class pf_trade_stats_t(ctypes.Structure): + _fields_ = [ + ("num_trades", ctypes.c_int32), ("num_wins", ctypes.c_int32), + ("num_losses", ctypes.c_int32), ("num_even", ctypes.c_int32), + ("percent_profitable", ctypes.c_double), + ("net_profit", ctypes.c_double), ("net_profit_pct", ctypes.c_double), + ("gross_profit", ctypes.c_double), ("gross_profit_pct", ctypes.c_double), + ("gross_loss", ctypes.c_double), ("gross_loss_pct", ctypes.c_double), + ("profit_factor", ctypes.c_double), + ("avg_trade", ctypes.c_double), ("avg_trade_pct", ctypes.c_double), + ("avg_win", ctypes.c_double), ("avg_win_pct", ctypes.c_double), + ("avg_loss", ctypes.c_double), ("avg_loss_pct", ctypes.c_double), + ("ratio_avg_win_avg_loss", ctypes.c_double), + ("largest_win", ctypes.c_double), ("largest_win_pct", ctypes.c_double), + ("largest_loss", ctypes.c_double), ("largest_loss_pct", ctypes.c_double), + ("commission_paid", ctypes.c_double), + ("expectancy", ctypes.c_double), + ("max_consecutive_wins", ctypes.c_int32), ("max_consecutive_losses", ctypes.c_int32), + ("avg_bars_in_trade", ctypes.c_double), ("avg_bars_in_wins", ctypes.c_double), + ("avg_bars_in_losses", ctypes.c_double), + ] + +class pf_equity_stats_t(ctypes.Structure): + _fields_ = [ + ("max_equity_drawdown", ctypes.c_double), ("max_equity_drawdown_pct", ctypes.c_double), + ("max_equity_runup", ctypes.c_double), ("max_equity_runup_pct", ctypes.c_double), + ("buy_hold_return", ctypes.c_double), ("buy_hold_return_pct", ctypes.c_double), + ("sharpe_tv", ctypes.c_double), ("sortino_tv", ctypes.c_double), + ("sharpe_bar", ctypes.c_double), ("sortino_bar", ctypes.c_double), + ("cagr", ctypes.c_double), ("calmar", ctypes.c_double), + ("recovery_factor", ctypes.c_double), ("time_in_market_pct", ctypes.c_double), + ("open_pl", ctypes.c_double), + ] + +class pf_metrics_t(ctypes.Structure): + _fields_ = [("all", pf_trade_stats_t), ("longs", pf_trade_stats_t), + ("shorts", pf_trade_stats_t), ("equity", pf_equity_stats_t)] + +class pf_equity_point_t(ctypes.Structure): + _fields_ = [("time_ms", ctypes.c_int64), ("equity", ctypes.c_double), + ("open_profit", ctypes.c_double)] + class pf_security_diag_t(ctypes.Structure): _fields_ = [ ("sec_id", ctypes.c_int), @@ -74,8 +119,29 @@ class pf_report_t(ctypes.Structure): ("trace_len", ctypes.c_int), ("trace_names", ctypes.POINTER(ctypes.c_char_p)), ("trace_names_len", ctypes.c_int), + + ("metrics", pf_metrics_t), + ("equity_curve", ctypes.POINTER(pf_equity_point_t)), + ("equity_curve_len", ctypes.c_int64), # int64, NOT c_int ] +# pf_report_t is caller-allocated; a layout mismatch means the runtime +# writes past this script's report buffer. Verify the ABI before running. +EXPECTED_PF_ABI = 2 + +def check_abi(lib): + try: + lib.pf_abi_version.restype = ctypes.c_int + abi = lib.pf_abi_version() + except AttributeError: + raise RuntimeError( + "strategy library predates pf_abi_version (ABI v1); rebuild it " + "against the current pineforge runtime (pf_report_t grew).") + if abi != EXPECTED_PF_ABI: + raise RuntimeError( + f"pineforge ABI mismatch: library reports {abi}, harness expects " + f"{EXPECTED_PF_ABI}; rebuild.") + def main(): repo_root = Path(__file__).parent.parent.parent.resolve() dylib_path = repo_root / "benchmarks/assets/strategies/19-scalping-wunder-bots/strategy.dylib" @@ -96,6 +162,7 @@ def main(): # Load shared library lib = ctypes.CDLL(str(dylib_path)) + check_abi(lib) # Declare functions lib.strategy_create.argtypes = [ctypes.c_char_p] diff --git a/docker/run_json.py b/docker/run_json.py index 3044cc3..b7c124a 100755 --- a/docker/run_json.py +++ b/docker/run_json.py @@ -81,9 +81,62 @@ class TradeC(ctypes.Structure): ("max_runup", ctypes.c_double), ("max_drawdown", ctypes.c_double), ("qty", ctypes.c_double), + ("commission", ctypes.c_double), + ("entry_bar_index", ctypes.c_int32), + ("exit_bar_index", ctypes.c_int32), ] +class TradeStatsC(ctypes.Structure): + """Mirror of pf_trade_stats_t (ABI v2).""" + _fields_ = [ + ("num_trades", ctypes.c_int32), ("num_wins", ctypes.c_int32), + ("num_losses", ctypes.c_int32), ("num_even", ctypes.c_int32), + ("percent_profitable", ctypes.c_double), + ("net_profit", ctypes.c_double), ("net_profit_pct", ctypes.c_double), + ("gross_profit", ctypes.c_double), ("gross_profit_pct", ctypes.c_double), + ("gross_loss", ctypes.c_double), ("gross_loss_pct", ctypes.c_double), + ("profit_factor", ctypes.c_double), + ("avg_trade", ctypes.c_double), ("avg_trade_pct", ctypes.c_double), + ("avg_win", ctypes.c_double), ("avg_win_pct", ctypes.c_double), + ("avg_loss", ctypes.c_double), ("avg_loss_pct", ctypes.c_double), + ("ratio_avg_win_avg_loss", ctypes.c_double), + ("largest_win", ctypes.c_double), ("largest_win_pct", ctypes.c_double), + ("largest_loss", ctypes.c_double), ("largest_loss_pct", ctypes.c_double), + ("commission_paid", ctypes.c_double), + ("expectancy", ctypes.c_double), + ("max_consecutive_wins", ctypes.c_int32), ("max_consecutive_losses", ctypes.c_int32), + ("avg_bars_in_trade", ctypes.c_double), ("avg_bars_in_wins", ctypes.c_double), + ("avg_bars_in_losses", ctypes.c_double), + ] + + +class EquityStatsC(ctypes.Structure): + """Mirror of pf_equity_stats_t (ABI v2).""" + _fields_ = [ + ("max_equity_drawdown", ctypes.c_double), ("max_equity_drawdown_pct", ctypes.c_double), + ("max_equity_runup", ctypes.c_double), ("max_equity_runup_pct", ctypes.c_double), + ("buy_hold_return", ctypes.c_double), ("buy_hold_return_pct", ctypes.c_double), + ("sharpe_tv", ctypes.c_double), ("sortino_tv", ctypes.c_double), + ("sharpe_bar", ctypes.c_double), ("sortino_bar", ctypes.c_double), + ("cagr", ctypes.c_double), ("calmar", ctypes.c_double), + ("recovery_factor", ctypes.c_double), ("time_in_market_pct", ctypes.c_double), + ("open_pl", ctypes.c_double), + ] + + +class MetricsC(ctypes.Structure): + """Mirror of pf_metrics_t (ABI v2).""" + _fields_ = [("all", TradeStatsC), ("longs", TradeStatsC), + ("shorts", TradeStatsC), ("equity", EquityStatsC)] + + +class EquityPointC(ctypes.Structure): + """Mirror of pf_equity_point_t (ABI v2).""" + _fields_ = [("time_ms", ctypes.c_int64), ("equity", ctypes.c_double), + ("open_profit", ctypes.c_double)] + + class SecurityDiagC(ctypes.Structure): _fields_ = [ ("sec_id", ctypes.c_int), @@ -126,9 +179,31 @@ class ReportC(ctypes.Structure): ("trace_len", ctypes.c_int), ("trace_names", ctypes.POINTER(ctypes.c_char_p)), ("trace_names_len", ctypes.c_int), + ("metrics", MetricsC), + ("equity_curve", ctypes.POINTER(EquityPointC)), + ("equity_curve_len", ctypes.c_int64), # int64, NOT c_int ] +# pf_report_t is CALLER-allocated: a .so built against a different ABI +# writes past (or short of) our ReportC buffer. Assert version up front. +EXPECTED_PF_ABI = 2 + + +def check_abi(lib: ctypes.CDLL) -> None: + try: + lib.pf_abi_version.restype = ctypes.c_int + abi = lib.pf_abi_version() + except AttributeError: + raise RuntimeError( + "strategy .so predates pf_abi_version (ABI v1); rebuild it against " + "the current pineforge runtime (pf_report_t grew).") + if abi != EXPECTED_PF_ABI: + raise RuntimeError( + f"pineforge ABI mismatch: .so reports {abi}, harness expects " + f"{EXPECTED_PF_ABI}; rebuild.") + + # --- helpers ---------------------------------------------------------- def load_bars(csv_path: Path) -> tuple[ctypes.Array, int]: @@ -158,6 +233,7 @@ def load_bars(csv_path: Path) -> tuple[ctypes.Array, int]: def load_strategy(so_path: Path) -> ctypes.CDLL: lib = ctypes.CDLL(str(so_path)) + check_abi(lib) lib.strategy_create.argtypes = [ctypes.c_char_p] lib.strategy_create.restype = ctypes.c_void_p diff --git a/docs/pages/examples-rust.md b/docs/pages/examples-rust.md index 0ca1a98..e969417 100644 --- a/docs/pages/examples-rust.md +++ b/docs/pages/examples-rust.md @@ -56,6 +56,59 @@ struct PfTrade { is_long: c_int, max_runup: f64, max_drawdown: f64, qty: f64, + // ABI v2 + commission: f64, + entry_bar_index: i32, + exit_bar_index: i32, +} + +// ── ABI v2 metrics PODs ────────────────────────────────────────────── + +#[repr(C)] +#[derive(Clone, Copy, Default)] +struct PfTradeStats { + num_trades: i32, num_wins: i32, num_losses: i32, num_even: i32, + percent_profitable: f64, + net_profit: f64, net_profit_pct: f64, + gross_profit: f64, gross_profit_pct: f64, + gross_loss: f64, gross_loss_pct: f64, + profit_factor: f64, + avg_trade: f64, avg_trade_pct: f64, + avg_win: f64, avg_win_pct: f64, + avg_loss: f64, avg_loss_pct: f64, + ratio_avg_win_avg_loss: f64, + largest_win: f64, largest_win_pct: f64, + largest_loss: f64, largest_loss_pct: f64, + commission_paid: f64, + expectancy: f64, + max_consecutive_wins: i32, max_consecutive_losses: i32, + avg_bars_in_trade: f64, avg_bars_in_wins: f64, avg_bars_in_losses: f64, +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +struct PfEquityStats { + max_equity_drawdown: f64, max_equity_drawdown_pct: f64, + max_equity_runup: f64, max_equity_runup_pct: f64, + buy_hold_return: f64, buy_hold_return_pct: f64, + sharpe_tv: f64, sortino_tv: f64, + sharpe_bar: f64, sortino_bar: f64, + cagr: f64, calmar: f64, + recovery_factor: f64, time_in_market_pct: f64, + open_pl: f64, +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +struct PfMetrics { + all: PfTradeStats, longs: PfTradeStats, shorts: PfTradeStats, + equity: PfEquityStats, +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +struct PfEquityPoint { + time_ms: i64, equity: f64, open_profit: f64, } #[repr(C)] @@ -102,10 +155,19 @@ struct PfReport { trace_len: c_int, trace_names: *mut *const c_char, trace_names_len: c_int, + + // ABI v2: computed metrics + per-script-bar equity curve + metrics: PfMetrics, + equity_curve: *mut PfEquityPoint, + equity_curve_len: i64, } const PF_MAGNIFIER_ENDPOINTS: c_int = 3; +// PfReport is CALLER-allocated: before any run, resolve `pf_abi_version` +// via libloading and assert it returns 2 (PF_ABI_VERSION) — an old .so +// writing into this larger struct (or vice versa) corrupts memory silently. + // ── Safe wrapper ────────────────────────────────────────────────────── struct StrategyLib { diff --git a/docs/pages/ffi-python.md b/docs/pages/ffi-python.md index e426035..d3462ce 100644 --- a/docs/pages/ffi-python.md +++ b/docs/pages/ffi-python.md @@ -2,9 +2,9 @@ @tableofcontents -The C ABI is FFI-friendly by design: 10 functions, 6 POD structs, one -enum, no callbacks, no opaque types except `pf_strategy_t` (which is -`void*`). This page shows the canonical `ctypes` wiring for Python; any +The C ABI is FFI-friendly by design: a handful of functions, 10 POD +structs, one enum, no callbacks, no opaque types except `pf_strategy_t` +(which is `void*`). This page shows the canonical `ctypes` wiring for Python; any language with a C-FFI (Rust `libc`, Go `cgo`, Node `ffi-napi`, Julia `ccall`) follows the same shape. @@ -37,8 +37,57 @@ class pf_trade_t(ctypes.Structure): ("max_runup", ctypes.c_double), ("max_drawdown", ctypes.c_double), ("qty", ctypes.c_double), + ("commission", ctypes.c_double), # ABI v2 + ("entry_bar_index", ctypes.c_int32), # ABI v2 + ("exit_bar_index", ctypes.c_int32), # ABI v2 ] +class pf_trade_stats_t(ctypes.Structure): + """ABI v2 — one block each for all / long-only / short-only trades.""" + _fields_ = [ + ("num_trades", ctypes.c_int32), ("num_wins", ctypes.c_int32), + ("num_losses", ctypes.c_int32), ("num_even", ctypes.c_int32), + ("percent_profitable", ctypes.c_double), + ("net_profit", ctypes.c_double), ("net_profit_pct", ctypes.c_double), + ("gross_profit", ctypes.c_double), ("gross_profit_pct", ctypes.c_double), + ("gross_loss", ctypes.c_double), ("gross_loss_pct", ctypes.c_double), + ("profit_factor", ctypes.c_double), + ("avg_trade", ctypes.c_double), ("avg_trade_pct", ctypes.c_double), + ("avg_win", ctypes.c_double), ("avg_win_pct", ctypes.c_double), + ("avg_loss", ctypes.c_double), ("avg_loss_pct", ctypes.c_double), + ("ratio_avg_win_avg_loss", ctypes.c_double), + ("largest_win", ctypes.c_double), ("largest_win_pct", ctypes.c_double), + ("largest_loss", ctypes.c_double), ("largest_loss_pct", ctypes.c_double), + ("commission_paid", ctypes.c_double), + ("expectancy", ctypes.c_double), + ("max_consecutive_wins", ctypes.c_int32), ("max_consecutive_losses", ctypes.c_int32), + ("avg_bars_in_trade", ctypes.c_double), ("avg_bars_in_wins", ctypes.c_double), + ("avg_bars_in_losses", ctypes.c_double), + ] + +class pf_equity_stats_t(ctypes.Structure): + """ABI v2 — equity-curve-derived stats (all-trades only).""" + _fields_ = [ + ("max_equity_drawdown", ctypes.c_double), ("max_equity_drawdown_pct", ctypes.c_double), + ("max_equity_runup", ctypes.c_double), ("max_equity_runup_pct", ctypes.c_double), + ("buy_hold_return", ctypes.c_double), ("buy_hold_return_pct", ctypes.c_double), + ("sharpe_tv", ctypes.c_double), ("sortino_tv", ctypes.c_double), + ("sharpe_bar", ctypes.c_double), ("sortino_bar", ctypes.c_double), + ("cagr", ctypes.c_double), ("calmar", ctypes.c_double), + ("recovery_factor", ctypes.c_double), ("time_in_market_pct", ctypes.c_double), + ("open_pl", ctypes.c_double), + ] + +class pf_metrics_t(ctypes.Structure): + """ABI v2 — composite metrics container.""" + _fields_ = [("all", pf_trade_stats_t), ("longs", pf_trade_stats_t), + ("shorts", pf_trade_stats_t), ("equity", pf_equity_stats_t)] + +class pf_equity_point_t(ctypes.Structure): + """ABI v2 — one per-script-bar equity point.""" + _fields_ = [("time_ms", ctypes.c_int64), ("equity", ctypes.c_double), + ("open_profit", ctypes.c_double)] + class pf_security_diag_t(ctypes.Structure): _fields_ = [ ("sec_id", ctypes.c_int), @@ -85,6 +134,11 @@ class pf_report_t(ctypes.Structure): ("trace_len", ctypes.c_int), ("trace_names", ctypes.POINTER(ctypes.c_char_p)), ("trace_names_len", ctypes.c_int), + + # ABI v2: computed metrics + per-script-bar equity curve + ("metrics", pf_metrics_t), + ("equity_curve", ctypes.POINTER(pf_equity_point_t)), + ("equity_curve_len", ctypes.c_int64), # int64 in the C header, NOT c_int ] class pf_version_t(ctypes.Structure): @@ -112,6 +166,18 @@ itself. Open it with `ctypes.CDLL`: ```python lib = ctypes.CDLL("./my_strategy.so") +# ABI guard — pf_report_t is CALLER-allocated, so running an old .so +# against the v2 mirror above (or vice versa) silently corrupts memory. +# Verify the .so's layout version before any run: +EXPECTED_PF_ABI = 2 # PF_ABI_VERSION in +try: + lib.pf_abi_version.restype = ctypes.c_int + abi = lib.pf_abi_version() +except AttributeError: + raise RuntimeError(".so predates pf_abi_version (ABI v1); rebuild it") +if abi != EXPECTED_PF_ABI: + raise RuntimeError(f"ABI mismatch: .so={abi}, mirror={EXPECTED_PF_ABI}") + lib.strategy_create.argtypes = [ctypes.c_char_p] lib.strategy_create.restype = ctypes.c_void_p diff --git a/docs/pages/report-schema.md b/docs/pages/report-schema.md index 61eb14a..62330ef 100644 --- a/docs/pages/report-schema.md +++ b/docs/pages/report-schema.md @@ -46,9 +46,21 @@ typedef struct pf_report_s { int trace_len; const char** trace_names; int trace_names_len; + + /* Computed trading metrics (ABI v2) */ + pf_metrics_t metrics; + + /* Per-script-bar equity curve (ABI v2) */ + pf_equity_point_t* equity_curve; + int64_t equity_curve_len; /* NOTE: int64, not int */ } pf_report_t; ``` +@note The `metrics` / `equity_curve` fields were appended in **ABI +version 2** (`PF_ABI_VERSION`). `pf_report_t` is caller-allocated, so +consumers must check `pf_abi_version() == 2` before running — a `.so` +with no `pf_abi_version` symbol is ABI v1 and predates these fields. + ## Trade fields | Field | Type | Meaning | @@ -67,11 +79,15 @@ typedef struct pf_trade_s { double entry_price; /* incl. slippage */ double exit_price; double pnl; /* net of commission, in account ccy */ - double pnl_pct; /* relative to entry capital */ + double pnl_pct; /* net return-on-cost: pnl / (entry_price*qty*pointvalue) * 100 + (TV "Net P&L %" convention) */ int is_long; /* 1 = long, 0 = short */ double max_runup; /* peak favorable price travel ($/unit qty) */ double max_drawdown; /* peak adverse price travel ($/unit qty) */ double qty; /* filled quantity */ + double commission; /* ABI v2: entry+exit commission deducted from pnl */ + int32_t entry_bar_index;/* ABI v2: script-bar index of entry fill (0-based) */ + int32_t exit_bar_index; /* ABI v2: script-bar index of exit fill (0-based) */ } pf_trade_t; ``` @@ -144,6 +160,72 @@ Populated only when: Trace records are zero-cost when disabled — no allocation, no formatting, no per-bar branch. +## Metrics (ABI v2) + +`pf_report_t::metrics` is a `pf_metrics_t` — four embedded blocks +computed at report time: + +| Block | Type | Scope | +| --- | --- | --- | +| `metrics.all` | `pf_trade_stats_t` | All closed trades. | +| `metrics.longs` | `pf_trade_stats_t` | Long trades only. | +| `metrics.shorts` | `pf_trade_stats_t` | Short trades only. | +| `metrics.equity` | `pf_equity_stats_t` | Equity-curve-derived stats (all-trades only, like TV). | + +```c +typedef struct pf_metrics_s { + pf_trade_stats_t all, longs, shorts; + pf_equity_stats_t equity; +} pf_metrics_t; +``` + +**Trade stats** (`pf_trade_stats_t`) cover counts (`num_trades`, +`num_wins`, `num_losses`, `num_even`), profit aggregates +(`net_profit`, `gross_profit`, `gross_loss`, `profit_factor`, +`expectancy`), per-trade averages and extremes (`avg_trade`, +`avg_win`, `avg_loss`, `largest_win`, `largest_loss` plus their `_pct` +twins), `commission_paid`, win/loss streaks, and bar-duration averages. +Loss-side fields (`gross_loss`, `avg_loss`, `largest_loss`) are +**positive magnitudes**, matching the TV display convention. `_pct` +fields are on a 0–100 percent scale. `largest_win_pct` / +`largest_loss_pct` are the **independent** maxima of per-trade +`pnl_pct` — not the percent of the largest-USD trade (TV convention, +validated 2026-06-12). Bar-duration averages (`avg_bars_in_*`) count +**inclusively** of the entry bar: `exit_bar_index - entry_bar_index + 1` +(TV convention, validated 2026-06-12). + +**Equity stats** (`pf_equity_stats_t`) cover the equity drawdown / +run-up extremes (currency + percent), `buy_hold_return`, Sharpe and +Sortino in two constructions — `sharpe_tv` / `sortino_tv` (TV-style +month-end resampling in the chart timezone, 2%/yr risk-free, +annualized by sqrt(12)) and `sharpe_bar` / `sortino_bar` (per-script-bar +returns annualized by observed bar density) — plus `cagr`, `calmar`, +`recovery_factor`, `time_in_market_pct`, and `open_pl`. + +**NaN convention:** any statistic whose denominator is empty or zero is +`NaN`, never 0 or an infinity — e.g. `profit_factor` with zero gross +loss, `avg_win` with no winning trades, `sharpe_tv` 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. + +## Equity curve (ABI v2) + +One `pf_equity_point_t` per script bar: + +```c +typedef struct pf_equity_point_s { + int64_t time_ms; /* script-bar OPEN timestamp (Unix ms) */ + double equity; /* initial_capital + net_profit + open_profit */ + double open_profit; /* mark-to-market open P&L at bar close */ +} pf_equity_point_t; +``` + +`equity_curve_len` equals `script_bars_processed` on a clean run (an +exception mid-run can truncate the curve — check +`strategy_get_last_error`). The array is heap-allocated and freed by +#report_free. Note the length field is `int64_t`, not `int`. + ## Lifetime and ownership Every heap pointer in `pf_report_t` is freed by a single call to @@ -153,7 +235,7 @@ Every heap pointer in `pf_report_t` is freed by a single call to pf_report_t r = {0}; run_backtest(s, bars, n, &r); /* ... use r ... */ -report_free(&r); /* frees trades, security_diag, trace */ +report_free(&r); /* frees trades, security_diag, trace, equity_curve */ ``` @warning `trace_names` points into a string table owned by the **strategy diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 6a65102..4b7281f 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -14,6 +14,15 @@ #include "timeframe.hpp" #include "magnifier.hpp" #include "session_time.hpp" +// Suppress per-strategy function declarations (strategy_create, run_backtest, +// etc.) whose pf_*_t parameter types conflict with the internal C++ types +// used in codegen-emitted extern "C" blocks that include this header. +// NOTE: this macro leaks into every TU that includes engine.hpp; include +// pineforge.h FIRST in any TU that needs the per-strategy declarations +// (see src/c_abi.cpp). +#define PINEFORGE_NO_STRATEGY_DECLS +// Angle-bracket form is the installed public path (deliberate). +#include namespace pineforge { @@ -63,6 +72,7 @@ struct Trade { std::string exit_id; double max_runup = 0.0; double max_drawdown = 0.0; + double commission = 0.0; }; struct TradeC { @@ -79,6 +89,9 @@ struct TradeC { double max_runup; double max_drawdown; double qty; + double commission; // mirrors pf_trade_t tail; semantics documented in pineforge.h + int32_t entry_bar_index; + int32_t exit_bar_index; }; struct SecurityDiagC { @@ -133,6 +146,9 @@ struct ReportC { int trace_len; const char** trace_names; int trace_names_len; + pf_metrics_t metrics; + pf_equity_point_t* equity_curve; + int64_t equity_curve_len; }; enum class OrderType { MARKET, ENTRY, EXIT, RAW_ORDER }; @@ -493,6 +509,11 @@ class BacktestEngine { double max_runup_ = 0.0; // maximum runup (positive number) double min_equity_ = 0.0; // trough equity for runup + // --- Per-script-bar equity curve (metrics + pf_report_t exposure) --- + std::vector equity_curve_; + int64_t bars_in_market_ = 0; // script bars with an open position at close + double first_bar_open_ = std::numeric_limits::quiet_NaN(); // buy&hold basis + // --- Position-size extremes (strategy.max_contracts_held_*) --- double max_contracts_held_all_ = 0.0; double max_contracts_held_long_ = 0.0; @@ -940,6 +961,8 @@ class BacktestEngine { virtual void finalize_bar() {} // --- Equity extremes update (called after each on_bar) --- + // NOTE: the dd/runup walk in src/engine_metrics.cpp (compute_equity_stats) + // MUST mirror this trough-reset logic; keep in lockstep. void update_equity_extremes() { double eq = initial_capital_ + net_profit_sum_ + open_profit(current_bar_.close); if (eq > max_equity_) { @@ -965,6 +988,20 @@ class BacktestEngine { } } + // Record one equity point per SCRIPT bar. ``script_bar_ts`` must be the + // script-bar open timestamp captured BEFORE dispatch — current_bar_.timestamp + // is overwritten by the magnifier sub-bar walk (engine_run.cpp), which would + // make the curve differ between magnifier on/off. + void record_equity_point(int64_t script_bar_ts) { + if (equity_curve_.empty()) first_bar_open_ = current_bar_.open; + pf_equity_point_t p; + p.time_ms = script_bar_ts; + p.open_profit = open_profit(current_bar_.close); + p.equity = initial_capital_ + net_profit_sum_ + p.open_profit; + equity_curve_.push_back(p); + if (position_side_ != PositionSide::FLAT) ++bars_in_market_; + } + // --- Trade history accessors (for strategy.closedtrades.*) --- double closed_trade_profit(int index) const { if (index >= 0 && index < (int)trades_.size()) @@ -977,8 +1014,7 @@ class BacktestEngine { } double closed_trade_commission(int idx) const { if (idx < 0 || idx >= (int)trades_.size()) return std::numeric_limits::quiet_NaN(); - const Trade& t = trades_[idx]; - return calc_commission(t.entry_price, t.qty) + calc_commission(t.exit_price, t.qty); + return trades_[idx].commission; } int closed_trade_entry_bar_index(int idx) const { if (idx < 0 || idx >= (int)trades_.size()) return na(); @@ -1271,6 +1307,7 @@ class BacktestEngine { // fill_report helpers (defined in engine_report.cpp). void fill_trades_section(ReportC* out) const; + void fill_metrics_section(ReportC* out) const; void fill_security_diag_section(ReportC* out) const; void fill_trace_section(ReportC* out) const; diff --git a/include/pineforge/metrics.hpp b/include/pineforge/metrics.hpp new file mode 100644 index 0000000..f468f48 --- /dev/null +++ b/include/pineforge/metrics.hpp @@ -0,0 +1,29 @@ +#pragma once +// Pure metric computations over closed-trade arrays and equity curves. +// No BacktestEngine dependency: unit-testable standalone, called by +// fill_report. Conventions (NaN rules, positive-magnitude losses, even- +// trade handling) are documented per-field in . +// pineforge.h MUST precede engine.hpp (PINEFORGE_NO_STRATEGY_DECLS; see engine.hpp). +#include +#include // TradeC +#include + +namespace pineforge { +namespace metrics { + +enum class TradeFilter { ALL, LONG, SHORT }; + +pf_trade_stats_t compute_trade_stats(const TradeC* trades, int n, + TradeFilter filter, double initial_capital); + +// Acquires the global timezone lock (pine_tz::ScopedTimezone) when chart_tz +// is non-UTC; MUST NOT be called while already holding a ScopedTimezone +// (non-recursive mutex -- deadlock). See src/timezone.hpp. +pf_equity_stats_t compute_equity_stats(const pf_equity_point_t* curve, int64_t n, + double initial_capital, + const std::string& chart_tz, + double first_open, double last_close, + int64_t bars_in_market, double net_profit); + +} // namespace metrics +} // namespace pineforge diff --git a/include/pineforge/pineforge.h b/include/pineforge/pineforge.h index d762579..3865c18 100644 --- a/include/pineforge/pineforge.h +++ b/include/pineforge/pineforge.h @@ -65,6 +65,18 @@ #define PF_API #endif +/** Monotonic ABI version of pf_report_t / pf_trade_t layout. Bumped + * whenever a caller-visible struct grows. Consumers MUST verify + * pf_abi_version() == PF_ABI_VERSION before calling run_backtest. + * pf_report_t is caller-allocated: growth causes silent stack corruption + * in old callers that under-size the struct. pf_trade_t is runtime- + * allocated: growth causes array-stride misindexing in old readers that + * iterate the trades array with the stale sizeof. Value 2 = first + * versioned layout (metrics + equity curve); .so files predating this + * macro have no pf_abi_version symbol — treat dlsym failure as + * version 1. */ +#define PF_ABI_VERSION 2 + #ifdef __cplusplus extern "C" { #endif @@ -110,19 +122,144 @@ typedef struct pf_trade_s { double entry_price; /**< Entry fill price (incl. slippage). */ double exit_price; /**< Exit fill price (incl. slippage). */ double pnl; /**< Net realized PnL in account currency (commission-inclusive). */ - double pnl_pct; /**< Per-unit price return in percent: (exit/entry-1)*100 for - * longs, (entry/exit-1)*100 for shorts. This is a GROSS - * price-return %, computed before commission and independent - * of qty (it equals pnl/entry_capital only at qty=1, zero - * commission). TradingView's "Net P&L %" uses a net-of- - * commission return-on-cost convention; aligning the engine - * to it is a tracked correction. */ + double pnl_pct; /**< Net return-on-cost in percent: pnl (NET of commission) / + * entry cost (entry_price * qty * pointvalue) * 100. This is + * TradingView's "Net P&L %" convention, arbitrated 2026-06-12 + * against a real TV export (trade #258 short: 102.44 USD on a + * 2276.66 entry => 4.50%). Degenerates to the old gross + * (exit/entry-1)*100 form for longs with zero commission; + * the previous short form (entry/exit-1)*100 was wrong on + * large moves. Sign always matches pnl. */ int is_long; /**< 1 if long, 0 if short. */ double max_runup; /**< Peak favorable price travel during the trade ($/unit qty). */ double max_drawdown; /**< Peak adverse price travel during the trade ($/unit qty). */ double qty; /**< Filled quantity. */ + double commission; /**< Entry+exit commission actually deducted from pnl + * (account currency). pnl is already net of this. */ + int32_t entry_bar_index;/**< Script-bar index of the entry fill (0-based). */ + int32_t exit_bar_index; /**< Script-bar index of the exit fill (0-based). */ } pf_trade_t; +/** Trade-level statistics block — computed once each for all / long / short. + * + * Loss-side fields (`gross_loss`, `avg_loss`, `largest_loss`) are + * **positive magnitudes** (absolute values of the underlying negative PnL). */ +typedef struct pf_trade_stats_s { + int32_t num_trades; /**< Closed trades in this block (all / long-only / short-only). */ + int32_t num_wins; /**< Trades with pnl > 0. */ + int32_t num_losses; /**< Trades with pnl < 0. */ + int32_t num_even; /**< Trades with pnl == 0.0 exactly; breaks both win and loss + * streaks; excluded from win/loss averages. + * Invariant: num_trades == num_wins + num_losses + num_even. */ + double percent_profitable; /**< 100 * num_wins / num_trades, in PERCENT (0-100). + * NaN when num_trades == 0. */ + double net_profit; /**< Sum of pnl (account currency, net of commission). */ + double net_profit_pct; /**< net_profit as a percent of initial capital (0-100 scale). + * NaN when initial capital <= 0. */ + double gross_profit; /**< Sum of winning pnl. */ + double gross_profit_pct; /**< gross_profit as a percent of initial capital (0-100 scale). + * NaN when initial capital <= 0. */ + double gross_loss; /**< Sum of |losing pnl| — POSITIVE magnitude (TV display convention). */ + double gross_loss_pct; /**< gross_loss as a percent of initial capital (0-100 scale). + * NaN when initial capital <= 0. */ + double profit_factor; /**< gross_profit / gross_loss. NaN when gross_loss == 0. */ + double avg_trade; /**< net_profit / num_trades. NaN when num_trades == 0. */ + double avg_trade_pct; /**< Mean of per-trade pnl_pct over all trades. + * NaN when num_trades == 0. */ + double avg_win; /**< gross_profit / num_wins. NaN when num_wins == 0. */ + double avg_win_pct; /**< Mean of per-trade pnl_pct over winning trades. + * NaN when num_wins == 0. */ + double avg_loss; /**< gross_loss / num_losses (positive magnitude). + * NaN when num_losses == 0. */ + double avg_loss_pct; /**< Mean of the NEGATED pnl_pct of the losing trades. Since + * pnl_pct is net return-on-cost (sign matches pnl), this is + * a genuinely POSITIVE magnitude. Basis = pf_trade_t::pnl_pct. + * NaN when num_losses == 0. */ + double ratio_avg_win_avg_loss; /**< avg_win / avg_loss. NaN unless both sides non-empty. */ + double largest_win; /**< Single largest pnl among winning trades. + * NaN when num_wins == 0. */ + double largest_win_pct; /**< Maximum pnl_pct over winning trades — an INDEPENDENT + * maximum, not the pct of the largest-USD win (TV convention, + * validated 2026-06-12 vs TV export). + * NaN when num_wins == 0. */ + double largest_loss; /**< Single largest |pnl| among losing trades (positive magnitude). + * NaN when num_losses == 0. */ + double largest_loss_pct; /**< Maximum of -pnl_pct over losing trades (positive magnitude) — + * an INDEPENDENT maximum, not the pct of the largest-USD loss + * (TV convention, validated 2026-06-12 vs TV export: All + * "Largest loss %" came from a different trade than the + * largest USD loss). NaN when num_losses == 0. */ + double commission_paid; /**< Sum of pf_trade_t::commission in the block. */ + double expectancy; /**< (num_wins/num_trades)*avg_win - (num_losses/num_trades)*avg_loss, + * account currency per trade. NaN when num_trades == 0. */ + int32_t max_consecutive_wins; /**< Longest winning run; even trades reset both streaks. */ + int32_t max_consecutive_losses; /**< Longest losing run; even trades reset both streaks. */ + double avg_bars_in_trade; /**< Mean of (exit_bar_index - entry_bar_index + 1) in SCRIPT + * bars, over all trades — inclusive of the entry bar (TV + * convention, validated 2026-06-12). + * NaN when num_trades == 0. */ + double avg_bars_in_wins; /**< Mean bar duration of winning trades, inclusive of the entry + * bar (TV convention, validated 2026-06-12). + * NaN when num_wins == 0. */ + double avg_bars_in_losses; /**< Mean bar duration of losing trades, inclusive of the entry + * bar (TV convention, validated 2026-06-12). + * NaN when num_losses == 0. */ +} pf_trade_stats_t; + +/** Equity-curve-derived statistics (all-trades only, like TV). */ +typedef struct pf_equity_stats_s { + double max_equity_drawdown; /**< Peak-to-trough equity drop, positive currency magnitude. */ + double max_equity_drawdown_pct; /**< max_equity_drawdown relative to the peak in effect + * (PERCENT 0-100). */ + double max_equity_runup; /**< Trough-to-peak rise where the trough resets on each new + * equity peak (mirrors the engine's intra-run extremes). */ + double max_equity_runup_pct; /**< max_equity_runup relative to that trough (PERCENT 0-100). */ + double buy_hold_return; /**< initial_capital * (last_close/first_open - 1), currency. + * NaN when first chart open is non-finite or <= 0. */ + double buy_hold_return_pct; /**< buy_hold_return as PERCENT. + * NaN when first chart open is non-finite or <= 0. */ + double sharpe_tv; /**< Month-end-resampled equity simple returns (chart timezone, + * open-time bucketing), risk-free 2%/yr (2/12 per month), + * annualized by sqrt(12). Uses sample (N-1) stddev. + * NaN with <2 monthly returns or zero deviation. */ + double sortino_tv; /**< Same resampling as sharpe_tv; uses population downside + * deviation vs the monthly risk-free. + * NaN with <2 monthly returns or zero deviation. */ + double sharpe_bar; /**< Per-script-bar returns, annualized by observed bar density + * (bars per year = (len-1)/calendar span), NOT a fixed + * calendar formula. Uses sample (N-1) stddev. + * NaN with <2 returns or zero deviation. */ + double sortino_bar; /**< Same construction as sharpe_bar over per-bar returns; + * uses population downside deviation. + * NaN with <2 returns or zero deviation. */ + double cagr; /**< PERCENT per year: 100*((final_equity/initial_capital)^(1/years)-1). + * NaN when span <= 0 or either side <= 0. */ + double calmar; /**< cagr / max_equity_drawdown_pct — BOTH IN PERCENT, so the + * ratio is dimensionless. NaN when drawdown is 0. */ + double recovery_factor; /**< net_profit / max_equity_drawdown (currency / currency). + * NaN when drawdown is 0. */ + double time_in_market_pct; /**< PERCENT (0-100) of script bars with an open position + * at bar close. */ + double open_pl; /**< Mark-to-market open profit at the final bar. */ +} pf_equity_stats_t; + +/** Composite metrics container: trade stats (all / long / short) + + * equity-curve stats. */ +typedef struct pf_metrics_s { + pf_trade_stats_t all, longs, shorts; + pf_equity_stats_t equity; +} pf_metrics_t; + +/** Single per-script-bar equity point. + * + * `time_ms` is the script-bar **open** timestamp (Unix ms). + * `equity` = `initial_capital` + `net_profit` + `open_profit` at bar close. */ +typedef struct pf_equity_point_s { + int64_t time_ms; /**< Script-bar OPEN timestamp (Unix ms). */ + double equity; /**< initial_capital + net_profit + open_profit. */ + double open_profit; /**< Mark-to-market open P&L at bar close. */ +} pf_equity_point_t; + /** Per-`request.security()` site diagnostic counters. * * Layout-compatible with internal `pineforge::SecurityDiagC`. */ @@ -151,10 +288,11 @@ typedef struct pf_trace_entry_s { * * ### Ownership and lifetime * The struct itself is caller-owned (typically stack). The embedded - * arrays (`trades`, `security_diag`, `trace`, `trace_names`) are - * heap-allocated by the runtime; the caller must invoke #report_free - * exactly once on each filled report. `trace_names` string pointers - * remain owned by the strategy handle until #strategy_free. */ + * arrays (`trades`, `security_diag`, `trace`, `trace_names`, + * `equity_curve`) are heap-allocated by the runtime; the caller must + * invoke #report_free exactly once on each filled report. + * `trace_names` string pointers remain owned by the strategy handle + * until #strategy_free. */ typedef struct pf_report_s { /* Trades */ @@ -192,6 +330,21 @@ typedef struct pf_report_s { int trace_len; /**< Length of #trace. */ const char** trace_names; /**< Names indexed by pf_trace_entry_t::name_id. */ int trace_names_len; /**< Length of #trace_names. */ + + /* Computed trading metrics. Trade-based blocks reported for all / + * long-only / short-only; equity-based stats are all-trades only. + * Loss-side fields are positive magnitudes. Undefined values are NaN + * (see per-field docs). */ + pf_metrics_t metrics; + /* Per-script-bar equity curve. time_ms is the script-bar OPEN + * timestamp; equity = initial_capital + net_profit + open_profit at + * bar close. Heap-allocated; freed by report_free. len == + * script_bars_processed, EXCEPT after a mid-run error (check + * strategy_get_last_error): an exception can truncate the curve, and + * metrics then describe the truncated prefix. NOTE int64_t length + * (ctypes: c_int64). */ + pf_equity_point_t* equity_curve; + int64_t equity_curve_len; } pf_report_t; /** @} */ /* end of pf_types */ @@ -216,8 +369,17 @@ typedef void* pf_strategy_t; /** @defgroup pf_lifecycle Strategy lifecycle * @brief Create, run, and destroy a compiled strategy instance. * @{ + * + * NOTE: Per-strategy symbols (strategy_create, run_backtest, etc.) are + * emitted by the codegen with internal C++ types (ReportC, Bar) that are + * layout-compatible but type-distinct from the public C PODs below. + * Guard with PINEFORGE_NO_STRATEGY_DECLS so engine.hpp can include this + * header for its POD types without conflicting with per-strategy TU + * definitions. */ +#ifndef PINEFORGE_NO_STRATEGY_DECLS + /** Allocate a new strategy instance. * * @param params_json Currently ignored; pass `NULL`. @@ -309,6 +471,8 @@ PF_API void strategy_set_override(pf_strategy_t s, PF_API void strategy_set_magnifier_volume_weighted(pf_strategy_t s, int on); +#endif /* PINEFORGE_NO_STRATEGY_DECLS */ + /* ─────────────────────────────────────────────────────────────────── * RUNTIME LIBRARY EXPORTS — implemented in libruntime * ─────────────────────────────────────────────────────────────────── */ @@ -415,6 +579,9 @@ typedef struct pf_version_s { /** @return Linked runtime version. */ PF_API pf_version_t pf_version_get(void); +/** @return Monotonic ABI version (see #PF_ABI_VERSION). */ +PF_API int pf_abi_version(void); + /** Full git-derived version descriptor. * * Returns `"MAJOR.MINOR.PATCH[-N-gSHA[-dirty]]"` for git checkouts, or diff --git a/scripts/check_c_abi_runtime.py b/scripts/check_c_abi_runtime.py index 4da3b28..3df2ace 100644 --- a/scripts/check_c_abi_runtime.py +++ b/scripts/check_c_abi_runtime.py @@ -27,6 +27,7 @@ "strategy_get_last_error", "pf_version_get", "pf_version_string", + "pf_abi_version", }) _PF_API_DECL = re.compile(r"^\s*PF_API\b.+\b(\w+)\s*\(") diff --git a/scripts/crossvalidate_metrics.py b/scripts/crossvalidate_metrics.py new file mode 100644 index 0000000..84f5e7a --- /dev/null +++ b/scripts/crossvalidate_metrics.py @@ -0,0 +1,483 @@ +#!/usr/bin/env python3 +"""Cross-validate pineforge equity/risk metrics against quantstats and +empyrical-reloaded. + +OPTIONAL OFFLINE VALIDATION TOOL — not part of the build or the ctest +suite. The engine repo takes no new Python dependencies; run this from a +throwaway venv OUTSIDE the repo: + + python3 -m venv /tmp/pf-xval-venv + /tmp/pf-xval-venv/bin/pip install quantstats empyrical-reloaded pandas numpy + /tmp/pf-xval-venv/bin/python scripts/crossvalidate_metrics.py \\ + corpus/validation/composite-4emarsi-integration-01 + +What it does +------------ +Loads a corpus strategy library through scripts/run_strategy.py's ctypes +ABI mirrors, runs the backtest while keeping the pf_report_t alive long +enough to read `metrics.equity` AND the raw per-bar `equity_curve`, then +recomputes the equity/risk statistics three independent ways: + + 1. plain numpy, replicating the engine's documented conventions + (include/pineforge/pineforge.h doxygen is authoritative); + 2. empyrical-reloaded, with explicit convention adapters; + 3. quantstats.stats, with explicit convention adapters. + +Engine conventions being validated (see pf_equity_stats_t doxygen): + - per-bar simple returns r_i = E_i/E_{i-1}-1 over the equity curve + (E = initial_capital + net_profit + open_profit at bar close, + indexed by script-bar OPEN time in ms); + - bars_per_year = (n-1)/span_years, + span_years = (t_last-t_first)/(365.25*86400e3); + - sharpe_bar = (mean(r)-rf_bar)/sample_sd(r, N-1)*sqrt(bpy), + rf_bar = 0.02/bpy; + - sortino_bar = (mean(r)-rf_bar)/pop_downside_dev(r vs rf_bar)*sqrt(bpy); + - sharpe_tv/sortino_tv: same construction over month-end-resampled + equities (UTC calendar-month bucketing of bar-open times when the + chart tz is empty), rf = 0.02/12, annualized by sqrt(12); + - max_equity_drawdown: peak-to-trough walk over the curve (USD), pct + taken vs the peak in effect WHEN THE MAX-USD DRAWDOWN was hit (which + can differ from the maximum fractional drawdown the libraries report); + - cagr = 100*((E_final/initial_capital)^(1/span_years)-1) — CALENDAR + span, base = declared initial_capital (not first curve equity); + - calmar = cagr_pct / max_dd_pct; recovery = net_profit / max_dd_usd. + +Library convention adapters applied (verified against installed sources): + - empyrical.sharpe_ratio(r, risk_free=p, annualization=N) + = mean(r-p)/std(r-p, ddof=1)*sqrt(N) -> engine-equivalent + when p = rf-per-period and N = bpy (constant shift leaves sd). + - empyrical.sortino_ratio(..., required_return=p, annualization=N) + = mean(r-p)*N / (sqrt(mean(clip(r-p,max=0)^2))*sqrt(N)) + = mean(r-p)/pop_dd*sqrt(N) -> engine-equivalent. + - empyrical.max_drawdown(returns) = most-negative fractional drawdown + (peak-to-trough on the compounded curve), NOT pct-at-max-USD. + - empyrical.annual_return(r, annualization=bpy): + (prod(1+r))^(bpy/len(r))-1. Because bpy = (n-1)/span_years and + len(r) = n-1, the exponent equals 1/span_years — i.e. the + periods-based formula coincides with the calendar formula here, + EXCEPT its base is the first curve equity E_0, not initial_capital. + - quantstats.stats.sharpe/sortino(r, rf=A, periods=N) de-annualize the + ANNUAL rf GEOMETRICALLY: rf_p = (1+A)^(1/N)-1, vs the engine's + arithmetic A/N. Two variants are reported: "adapted" feeds + pre-excess returns (r - rf_per_period) with rf=0 (must match the + engine exactly), and "native rf" passes rf=0.02 (tiny expected + geometric-deannualization delta). + - quantstats.stats.max_drawdown compounds returns to prices with a + phantom pre-start baseline -> max fractional drawdown incl. E_0. + +Output: one comparison table per metric family with relative deltas vs +the engine value. |rel| <= 1e-6 -> "match"; 1e-6 < |rel| <= 1e-3 -> +"CONVENTION-DELTA"; |rel| > 1e-3 -> "MISMATCH". Rows whose library +convention is KNOWN to differ are labelled with the differing convention +so a flagged delta there is expected, not an engine bug. Exit code is 1 +if any unexplained MISMATCH row (an engine-convention row, not a +known-different-convention row) fails. + +Usage +----- + crossvalidate_metrics.py STRATEGY_DIR [--trim-end-ms MS] [--ohlcv CSV] +""" +from __future__ import annotations + +import argparse +import ctypes +import json +import math +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import run_strategy as rs # noqa: E402 (ABI mirrors + Strategy loader) + +import numpy as np # noqa: E402 +import pandas as pd # noqa: E402 +import empyrical as ep # noqa: E402 +import quantstats as qs # noqa: E402 + +RF_ANNUAL = 0.02 +MS_PER_YEAR = 365.25 * 86400.0 * 1000.0 + + +# -------------------------------------------------------------------------- +# Engine run: replicate the minimal flow of run_strategy.Strategy.run but +# keep the report alive so metrics.equity + equity_curve can be read +# before report_free. +# -------------------------------------------------------------------------- + +def run_engine(strategy_dir: Path, ohlcv: Path, trim_end_ms: int | None) -> dict: + so_path = strategy_dir / "strategy.so" + if not so_path.exists(): + for alt in ("strategy.dylib", "strategy.so", "strategy.dll"): + cand = strategy_dir / alt + if cand.exists(): + so_path = cand + break + strat = rs.Strategy(so_path) # loads lib, asserts pf_abi_version + lib = strat.lib + + params: dict = {} + inputs_path = strategy_dir / "inputs.json" + if inputs_path.exists(): + with inputs_path.open(encoding="utf-8") as f: + params = json.load(f) + + ohlcv_path = ohlcv + if isinstance(params, dict) and "ohlcv_csv" in params: + v = str(params["ohlcv_csv"]) + ohlcv_path = (Path(v) if v.startswith("/") else strategy_dir / v).resolve() + start_ms = None + if isinstance(params, dict) and "ohlcv_start_ms" in params: + try: + start_ms = int(params["ohlcv_start_ms"]) + except (TypeError, ValueError): + start_ms = None + + bars, n = rs._load_bars(ohlcv_path, ohlcv_start_ms=start_ms) + if trim_end_ms is not None: + kept = [i for i in range(n) if bars[i].timestamp <= trim_end_ms] + trimmed = (rs.BarC * len(kept))() + for j, i in enumerate(kept): + trimmed[j] = bars[i] + bars, n = trimmed, len(kept) + if n < 3: + raise SystemExit(f"too few bars after trim: {n}") + + chart_tz = str(params.get("chart_timezone") or "") if isinstance(params, dict) else "" + + state = lib.strategy_create(json.dumps(params).encode()) + report = rs.ReportC() + try: + if chart_tz and hasattr(lib, "strategy_set_chart_timezone"): + lib.strategy_set_chart_timezone(state, chart_tz.encode()) + if hasattr(lib, "strategy_set_input") and isinstance(params, dict): + for key, value in params.items(): + if key.startswith("tv_") or key in rs._VALIDATION_META_KEYS: + continue + lib.strategy_set_input(state, str(key).encode(), str(value).encode()) + lib.run_backtest_full(state, bars, n, b"", b"", 0, 4, 3, + ctypes.byref(report)) + if hasattr(lib, "strategy_get_last_error"): + err = lib.strategy_get_last_error(state) + if err: + msg = err.decode("utf-8", "replace") + if msg: + raise RuntimeError("pineforge engine rejected run: " + msg) + + eq = report.metrics.equity + engine = {f: float(getattr(eq, f)) for f, _ in eq._fields_} + m = int(report.equity_curve_len) + t_ms = np.empty(m, dtype=np.int64) + equity = np.empty(m, dtype=np.float64) + open_pl = np.empty(m, dtype=np.float64) + for i in range(m): + p = report.equity_curve[i] + t_ms[i] = p.time_ms + equity[i] = p.equity + open_pl[i] = p.open_profit + return { + "engine": engine, + "net_profit": float(report.net_profit), + "total_trades": int(report.total_trades), + "t_ms": t_ms, + "equity": equity, + "open_pl": open_pl, + "chart_tz": chart_tz, + } + finally: + lib.report_free(ctypes.byref(report)) + lib.strategy_free(state) + + +# -------------------------------------------------------------------------- +# numpy third opinion — engine conventions reimplemented from the +# pineforge.h doxygen, independent of src/engine_metrics.cpp. +# -------------------------------------------------------------------------- + +def np_drawdown(equity: np.ndarray) -> dict: + peak = equity[0] + max_dd_usd = 0.0 + pct_at_max_usd = 0.0 + max_dd_frac = 0.0 # library convention: max fractional dd + for eq in equity: + if eq > peak: + peak = eq + dd = peak - eq + if dd > max_dd_usd: + max_dd_usd = dd + pct_at_max_usd = dd / peak * 100.0 if peak > 0 else float("nan") + if peak > 0 and dd / peak > max_dd_frac: + max_dd_frac = dd / peak + return {"usd": max_dd_usd, "pct_at_max_usd": pct_at_max_usd, + "max_frac": max_dd_frac} + + +def np_sharpe_sortino(r: np.ndarray, rf_period: float, ann: float) -> tuple[float, float]: + n = len(r) + if n < 2: + return float("nan"), float("nan") + mean = r.mean() + sd = r.std(ddof=1) + down = np.minimum(0.0, r - rf_period) + dd = math.sqrt(float((down * down).sum()) / n) + sharpe = (mean - rf_period) / sd * ann if sd > 0 else float("nan") + sortino = (mean - rf_period) / dd * ann if dd > 0 else float("nan") + return float(sharpe), float(sortino) + + +def month_end_equities_utc(t_ms: np.ndarray, equity: np.ndarray) -> pd.Series: + """Last equity of each UTC calendar month, bucketed by bar OPEN time. + Mirrors the engine's month_key_utc walk (empty chart tz => UTC).""" + idx = pd.to_datetime(t_ms, unit="ms", utc=True) + s = pd.Series(equity, index=idx) + return s.resample("ME").last().dropna() + + +# -------------------------------------------------------------------------- +# Comparison / reporting +# -------------------------------------------------------------------------- + +class Table: + def __init__(self, title: str): + self.title = title + self.rows: list[tuple[str, float, float | None, str, bool]] = [] + self.failures: list[str] = [] + + def add(self, label: str, engine: float, other: float | None, + known_convention_delta: bool = False): + """known_convention_delta: row compares against a library value whose + convention is documented to differ; a flag there is expected.""" + self.rows.append((label, engine, other, "", known_convention_delta)) + + def render(self) -> str: + out = [f"\n {self.title}", " " + "-" * 100] + out.append(f" {'field / source':56s} {'engine':>14s} {'other':>14s} " + f"{'rel.delta':>11s} verdict") + for label, eng, other, _, known in self.rows: + if other is None or (isinstance(other, float) and math.isnan(other)): + out.append(f" {label:56s} {eng:14.8f} {'n/a':>14s} {'':>11s} -") + continue + denom = max(1e-12, abs(eng)) + rel = (other - eng) / denom + if abs(rel) <= 1e-6: + verdict = "match" + elif abs(rel) <= 1e-3: + verdict = "CONVENTION-DELTA" + else: + verdict = "MISMATCH" + if known and verdict != "match": + verdict += " (expected: differing convention)" + elif verdict == "MISMATCH": + self.failures.append(label) + out.append(f" {label:56s} {eng:14.8f} {other:14.8f} " + f"{rel:11.2e} {verdict}") + return "\n".join(out) + + +def crossvalidate(strategy_dir: Path, ohlcv: Path, trim_end_ms: int | None, + initial_capital: float | None) -> int: + run = run_engine(strategy_dir, ohlcv, trim_end_ms) + eng = run["engine"] + t_ms, equity = run["t_ms"], run["equity"] + n = len(equity) + net_profit = run["net_profit"] + + # initial_capital is not in pf_report_t; default 1e6 matches the corpus + # strategies' strategy() declarations (override with --initial-capital). + cap = initial_capital if initial_capital is not None else 1_000_000.0 + + span_years = float(t_ms[-1] - t_ms[0]) / MS_PER_YEAR + bpy = (n - 1) / span_years + rf_bar = RF_ANNUAL / bpy + rf_month = RF_ANNUAL / 12.0 + + assert (equity > 0).all(), "curve has non-positive equity; adapters assume E>0" + r = equity[1:] / equity[:-1] - 1.0 + idx = pd.to_datetime(t_ms[1:], unit="ms", utc=True) + r_s = pd.Series(r, index=idx) + + me = month_end_equities_utc(t_ms, equity) + mr = me.to_numpy() + m_ret = mr[1:] / mr[:-1] - 1.0 + m_ret_s = pd.Series(m_ret, index=me.index[1:]) + + print(f"\n=== {strategy_dir.name} ===") + print(f" bars(curve)={n} trades={run['total_trades']} " + f"net_profit={net_profit:.2f} span_years={span_years:.4f} " + f"bars_per_year={bpy:.2f} months={len(mr)} " + f"chart_tz={'UTC' if not run['chart_tz'] else run['chart_tz']}") + print(f" curve: {pd.Timestamp(t_ms[0], unit='ms', tz='UTC')} .. " + f"{pd.Timestamp(t_ms[-1], unit='ms', tz='UTC')} " + f"E0={equity[0]:.2f} E_final={equity[-1]:.2f} cap={cap:.0f}") + + tables: list[Table] = [] + + # --- drawdown --------------------------------------------------------- + dd = np_drawdown(equity) + t = Table("max_equity_drawdown") + t.add("max_dd USD | numpy engine-walk", eng["max_equity_drawdown"], dd["usd"]) + t.add("max_dd_pct | numpy pct@max-USD (engine conv.)", + eng["max_equity_drawdown_pct"], dd["pct_at_max_usd"]) + t.add("max_dd_pct | numpy max fractional dd", + eng["max_equity_drawdown_pct"], dd["max_frac"] * 100.0, + known_convention_delta=True) + t.add("max_dd_pct | empyrical.max_drawdown (max frac)", + eng["max_equity_drawdown_pct"], -ep.max_drawdown(r_s) * 100.0, + known_convention_delta=True) + t.add("max_dd_pct | quantstats.max_drawdown (max frac)", + eng["max_equity_drawdown_pct"], -qs.stats.max_drawdown(r_s) * 100.0, + known_convention_delta=True) + tables.append(t) + # cross-check the two library values against the numpy max-frac walk + lib_dd_ok = (abs(-ep.max_drawdown(r_s) - dd["max_frac"]) <= 1e-9 * max(1.0, dd["max_frac"]), + abs(-qs.stats.max_drawdown(r_s) - dd["max_frac"]) <= 1e-9 * max(1.0, dd["max_frac"])) + + # --- per-bar sharpe / sortino ------------------------------------------ + np_sh, np_so = np_sharpe_sortino(r, rf_bar, math.sqrt(bpy)) + t = Table(f"sharpe_bar / sortino_bar (bpy={bpy:.4f}, rf/bar={rf_bar:.3e})") + t.add("sharpe_bar | numpy engine conv.", eng["sharpe_bar"], np_sh) + t.add("sharpe_bar | empyrical(risk_free=rf_bar, ann=bpy)", + eng["sharpe_bar"], ep.sharpe_ratio(r_s, risk_free=rf_bar, annualization=bpy)) + t.add("sharpe_bar | quantstats adapted (r-rf_bar, rf=0)", + eng["sharpe_bar"], qs.stats.sharpe(r_s - rf_bar, rf=0.0, periods=bpy)) + t.add("sharpe_bar | quantstats native rf (geometric deann.)", + eng["sharpe_bar"], qs.stats.sharpe(r_s, rf=RF_ANNUAL, periods=bpy), + known_convention_delta=True) + t.add("sortino_bar | numpy engine conv.", eng["sortino_bar"], np_so) + t.add("sortino_bar | empyrical(required_return=rf_bar, ann=bpy)", + eng["sortino_bar"], ep.sortino_ratio(r_s, required_return=rf_bar, annualization=bpy)) + t.add("sortino_bar | quantstats adapted (r-rf_bar, rf=0)", + eng["sortino_bar"], qs.stats.sortino(r_s - rf_bar, rf=0.0, periods=bpy)) + t.add("sortino_bar | quantstats native rf (geometric deann.)", + eng["sortino_bar"], qs.stats.sortino(r_s, rf=RF_ANNUAL, periods=bpy), + known_convention_delta=True) + tables.append(t) + + # --- TV monthly sharpe / sortino ---------------------------------------- + np_sh_tv, np_so_tv = np_sharpe_sortino(m_ret, rf_month, math.sqrt(12.0)) + t = Table(f"sharpe_tv / sortino_tv (monthly, {len(m_ret)} returns, rf/mo={rf_month:.6f})") + t.add("sharpe_tv | numpy engine conv.", eng["sharpe_tv"], np_sh_tv) + t.add("sharpe_tv | empyrical(risk_free=rf/12, ann=12)", + eng["sharpe_tv"], ep.sharpe_ratio(m_ret_s, risk_free=rf_month, annualization=12)) + t.add("sharpe_tv | quantstats adapted (m-rf/12, rf=0)", + eng["sharpe_tv"], qs.stats.sharpe(m_ret_s - rf_month, rf=0.0, periods=12)) + t.add("sharpe_tv | quantstats native rf (geometric deann.)", + eng["sharpe_tv"], qs.stats.sharpe(m_ret_s, rf=RF_ANNUAL, periods=12), + known_convention_delta=True) + t.add("sortino_tv | numpy engine conv.", eng["sortino_tv"], np_so_tv) + t.add("sortino_tv | empyrical(required_return=rf/12, ann=12)", + eng["sortino_tv"], ep.sortino_ratio(m_ret_s, required_return=rf_month, annualization=12)) + t.add("sortino_tv | quantstats adapted (m-rf/12, rf=0)", + eng["sortino_tv"], qs.stats.sortino(m_ret_s - rf_month, rf=0.0, periods=12)) + t.add("sortino_tv | quantstats native rf (geometric deann.)", + eng["sortino_tv"], qs.stats.sortino(m_ret_s, rf=RF_ANNUAL, periods=12), + known_convention_delta=True) + tables.append(t) + + # --- CAGR ---------------------------------------------------------------- + np_cagr_cal = 100.0 * ((equity[-1] / cap) ** (1.0 / span_years) - 1.0) + np_cagr_e0 = 100.0 * ((equity[-1] / equity[0]) ** (1.0 / span_years) - 1.0) + t = Table("cagr (engine: calendar span, base = initial_capital)") + t.add("cagr | numpy calendar, base=cap", eng["cagr"], np_cagr_cal) + t.add("cagr | numpy calendar, base=E0", eng["cagr"], np_cagr_e0, + known_convention_delta=True) + # empyrical annual_return(annualization=bpy): exponent bpy/len(r) == + # 1/span_years exactly (bpy = (n-1)/span_years, len(r) = n-1), so the + # ONLY remaining difference vs the engine is the base (E0 vs cap). + t.add("cagr | empyrical.annual_return(ann=bpy) [base=E0]", + eng["cagr"], 100.0 * ep.annual_return(r_s, annualization=bpy), + known_convention_delta=True) + t.add("cagr | quantstats.cagr(periods=bpy) [base=E0]", + eng["cagr"], 100.0 * qs.stats.cagr(r_s, periods=bpy), + known_convention_delta=True) + tables.append(t) + # prove the periods-based==calendar equivalence on this data: + ep_ann = float(ep.annual_return(r_s, annualization=bpy)) + eq0_delta = abs(100.0 * ep_ann - np_cagr_e0) + + # --- calmar / recovery ----------------------------------------------------- + np_calmar = (np_cagr_cal / dd["pct_at_max_usd"] + if dd["pct_at_max_usd"] > 0 else float("nan")) + np_recovery = net_profit / dd["usd"] if dd["usd"] > 0 else float("nan") + t = Table("calmar / recovery_factor") + t.add("calmar | numpy engine conv. (cagr%/dd%@maxUSD)", + eng["calmar"], np_calmar) + ep_calmar = float(ep.calmar_ratio(r_s, annualization=bpy)) + t.add("calmar | empyrical.calmar_ratio [base=E0, max-frac dd]", + eng["calmar"], ep_calmar, known_convention_delta=True) + qs_calmar = float(qs.stats.calmar(r_s, periods=bpy)) + t.add("calmar | quantstats.calmar [base=E0, max-frac dd]", + eng["calmar"], qs_calmar, known_convention_delta=True) + t.add("recovery_factor | numpy engine conv. (net_profit/ddUSD)", + eng["recovery_factor"], np_recovery) + # quantstats.recovery_factor = |sum(r)| / |max_dd_frac| — ARITHMETIC sum + # of returns over fractional dd; reproduce it by hand to prove the + # library value, then show it next to the engine's USD-based ratio. + qs_rec = float(qs.stats.recovery_factor(r_s)) + qs_rec_hand = abs(float(r.sum())) / dd["max_frac"] if dd["max_frac"] > 0 else float("nan") + t.add("recovery_factor | quantstats native (sum(r)/frac-dd)", + eng["recovery_factor"], qs_rec, known_convention_delta=True) + tables.append(t) + + for tb in tables: + print(tb.render()) + + print("\n cross-checks:") + print(f" empyrical max_drawdown == numpy max-frac walk: {lib_dd_ok[0]}") + print(f" quantstats max_drawdown == numpy max-frac walk: {lib_dd_ok[1]}") + # Prove the quantstats "native rf" rows are FULLY explained by its + # geometric rf de-annualization rf_p=(1+A)^(1/N)-1 (vs engine A/N): + # recompute quantstats' own formula by hand with the geometric rf. + for label, series, periods in (("bar", r, bpy), ("tv", m_ret, 12.0)): + rf_geo = (1.0 + RF_ANNUAL) ** (1.0 / periods) - 1.0 + ex = series - rf_geo + hand_sh = ex.mean() / ex.std(ddof=1) * math.sqrt(periods) + down = ex[ex < 0] + ddev = math.sqrt(float((down * down).sum()) / len(ex)) + hand_so = ex.mean() / ddev * math.sqrt(periods) if ddev > 0 else float("nan") + qs_sh = float(qs.stats.sharpe(pd.Series(series, index=(idx if label == "bar" else me.index[1:])), + rf=RF_ANNUAL, periods=periods)) + qs_so = float(qs.stats.sortino(pd.Series(series, index=(idx if label == "bar" else me.index[1:])), + rf=RF_ANNUAL, periods=periods)) + print(f" qs native sharpe_{label} hand-recompute (geometric rf): " + f"|delta|={abs(qs_sh - hand_sh):.3e}") + print(f" qs native sortino_{label} hand-recompute (geometric rf): " + f"|delta|={abs(qs_so - hand_so):.3e}") + print(f" empyrical.annual_return(ann=bpy) == calendar formula on base E0: " + f"|delta|={eq0_delta:.3e} pct-points") + if not math.isnan(qs_rec_hand): + print(f" quantstats.recovery_factor hand-recompute |delta|=" + f"{abs(qs_rec - qs_rec_hand):.3e}") + + failures = [f"{tb.title}: {lbl}" for tb in tables for lbl in tb.failures] + if failures: + print("\n UNEXPLAINED MISMATCHES (potential engine bug):") + for f in failures: + print(f" - {f}") + return 1 + print("\n RESULT: all engine-convention rows match; every flagged row is a " + "documented library-convention difference.") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("strategy_dir", type=Path, + help="corpus/// containing strategy.so|.dylib") + ap.add_argument("--trim-end-ms", type=int, default=None, + help="Drop input bars with timestamp > this (Unix ms) " + "before the run, to test alternate spans.") + ap.add_argument("--ohlcv", type=Path, default=rs.DEFAULT_OHLCV, + help="OHLCV CSV (default: the corpus default feed)") + ap.add_argument("--initial-capital", type=float, default=None, + help="strategy() initial_capital (default 1e6, the corpus " + "convention; pf_report_t does not expose it)") + args = ap.parse_args() + return crossvalidate(args.strategy_dir.resolve(), args.ohlcv.resolve(), + args.trim_end_ms, args.initial_capital) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/run_strategy.py b/scripts/run_strategy.py index 0afd87c..6cda478 100644 --- a/scripts/run_strategy.py +++ b/scripts/run_strategy.py @@ -99,9 +99,62 @@ class TradeC(ctypes.Structure): ("max_runup", ctypes.c_double), ("max_drawdown", ctypes.c_double), ("qty", ctypes.c_double), + ("commission", ctypes.c_double), + ("entry_bar_index", ctypes.c_int32), + ("exit_bar_index", ctypes.c_int32), ] +class TradeStatsC(ctypes.Structure): + """Mirror of pf_trade_stats_t (ABI v2).""" + _fields_ = [ + ("num_trades", ctypes.c_int32), ("num_wins", ctypes.c_int32), + ("num_losses", ctypes.c_int32), ("num_even", ctypes.c_int32), + ("percent_profitable", ctypes.c_double), + ("net_profit", ctypes.c_double), ("net_profit_pct", ctypes.c_double), + ("gross_profit", ctypes.c_double), ("gross_profit_pct", ctypes.c_double), + ("gross_loss", ctypes.c_double), ("gross_loss_pct", ctypes.c_double), + ("profit_factor", ctypes.c_double), + ("avg_trade", ctypes.c_double), ("avg_trade_pct", ctypes.c_double), + ("avg_win", ctypes.c_double), ("avg_win_pct", ctypes.c_double), + ("avg_loss", ctypes.c_double), ("avg_loss_pct", ctypes.c_double), + ("ratio_avg_win_avg_loss", ctypes.c_double), + ("largest_win", ctypes.c_double), ("largest_win_pct", ctypes.c_double), + ("largest_loss", ctypes.c_double), ("largest_loss_pct", ctypes.c_double), + ("commission_paid", ctypes.c_double), + ("expectancy", ctypes.c_double), + ("max_consecutive_wins", ctypes.c_int32), ("max_consecutive_losses", ctypes.c_int32), + ("avg_bars_in_trade", ctypes.c_double), ("avg_bars_in_wins", ctypes.c_double), + ("avg_bars_in_losses", ctypes.c_double), + ] + + +class EquityStatsC(ctypes.Structure): + """Mirror of pf_equity_stats_t (ABI v2).""" + _fields_ = [ + ("max_equity_drawdown", ctypes.c_double), ("max_equity_drawdown_pct", ctypes.c_double), + ("max_equity_runup", ctypes.c_double), ("max_equity_runup_pct", ctypes.c_double), + ("buy_hold_return", ctypes.c_double), ("buy_hold_return_pct", ctypes.c_double), + ("sharpe_tv", ctypes.c_double), ("sortino_tv", ctypes.c_double), + ("sharpe_bar", ctypes.c_double), ("sortino_bar", ctypes.c_double), + ("cagr", ctypes.c_double), ("calmar", ctypes.c_double), + ("recovery_factor", ctypes.c_double), ("time_in_market_pct", ctypes.c_double), + ("open_pl", ctypes.c_double), + ] + + +class MetricsC(ctypes.Structure): + """Mirror of pf_metrics_t (ABI v2).""" + _fields_ = [("all", TradeStatsC), ("longs", TradeStatsC), + ("shorts", TradeStatsC), ("equity", EquityStatsC)] + + +class EquityPointC(ctypes.Structure): + """Mirror of pf_equity_point_t (ABI v2).""" + _fields_ = [("time_ms", ctypes.c_int64), ("equity", ctypes.c_double), + ("open_profit", ctypes.c_double)] + + class SecurityDiagC(ctypes.Structure): _fields_ = [ ("sec_id", ctypes.c_int), @@ -144,9 +197,33 @@ class ReportC(ctypes.Structure): ("trace_len", ctypes.c_int), ("trace_names", ctypes.POINTER(ctypes.c_char_p)), ("trace_names_len", ctypes.c_int), + ("metrics", MetricsC), + ("equity_curve", ctypes.POINTER(EquityPointC)), + ("equity_curve_len", ctypes.c_int64), # int64 in the C header, NOT c_int ] +# ABI version this harness mirrors (PF_ABI_VERSION in pineforge.h). +# pf_report_t is CALLER-allocated: running an old .so against the v2 +# ReportC mirror (or vice versa) silently corrupts memory, so the .so's +# pf_abi_version() export is asserted before any run. +EXPECTED_PF_ABI = 2 + + +def _check_abi(lib: ctypes.CDLL) -> None: + try: + lib.pf_abi_version.restype = ctypes.c_int + abi = lib.pf_abi_version() + except AttributeError: + raise RuntimeError( + "strategy .so predates pf_abi_version (ABI v1); rebuild it against " + "the current pineforge runtime (pf_report_t grew).") + if abi != EXPECTED_PF_ABI: + raise RuntimeError( + f"pineforge ABI mismatch: .so reports {abi}, harness expects " + f"{EXPECTED_PF_ABI}; rebuild.") + + # --- Strategy harness -------------------------------------------------- class Strategy: @@ -159,6 +236,7 @@ def __init__(self, so_path: Path): f"hint: run `cmake --build build --target corpus_strategies` first" ) self.lib = ctypes.CDLL(str(so_path)) + _check_abi(self.lib) self._setup_signatures() def _setup_signatures(self) -> None: @@ -394,6 +472,9 @@ def _report_to_dict(r: ReportC) -> dict: "max_runup": float(t.max_runup), "max_drawdown": float(t.max_drawdown), "qty": float(t.qty), + "commission": float(t.commission), + "entry_bar_index": int(t.entry_bar_index), + "exit_bar_index": int(t.exit_bar_index), }) trace_names: list[str] = [] for i in range(r.trace_names_len): diff --git a/src/c_abi.cpp b/src/c_abi.cpp index 857e4ae..a494db2 100644 --- a/src/c_abi.cpp +++ b/src/c_abi.cpp @@ -7,13 +7,18 @@ * and the internal C++ types they mirror. If any of these trip, * the C ABI has drifted from the internal representation — fix * BEFORE shipping a .so that consumers depend on. - * - The runtime-library-side `extern "C"` symbols (currently just - * strategy_set_trace_enabled and pf_version_get). The other + * - The runtime-library-side `extern "C"` symbols (the setters, + * strategy_get_last_error, pf_version_get/pf_version_string, + * pf_abi_version — the authoritative list is EXPECTED_RUNTIME in + * scripts/check_c_abi_runtime.py, enforced by CI). The other * `extern "C"` symbols listed in pineforge.h (strategy_create, * run_backtest, etc.) are emitted per-compiled-strategy by the * codegen, not here. */ +// Include order is load-bearing: pineforge.h BEFORE engine.hpp keeps the +// per-strategy declarations visible so definitions here are prototype-checked. +// engine.hpp defines PINEFORGE_NO_STRATEGY_DECLS, which suppresses them. #include #include #include @@ -49,6 +54,12 @@ static_assert(offsetof(pf_trade_t, entry_price) == offsetof(pineforge::TradeC, e "pf_trade_t::entry_price offset mismatch"); static_assert(offsetof(pf_trade_t, qty) == offsetof(pineforge::TradeC, qty), "pf_trade_t::qty offset mismatch"); +static_assert(offsetof(pf_trade_t, commission) == offsetof(pineforge::TradeC, commission), + "pf_trade_t::commission offset mismatch"); +static_assert(offsetof(pf_trade_t, entry_bar_index) == offsetof(pineforge::TradeC, entry_bar_index), + "pf_trade_t::entry_bar_index offset mismatch"); +static_assert(offsetof(pf_trade_t, exit_bar_index) == offsetof(pineforge::TradeC, exit_bar_index), + "pf_trade_t::exit_bar_index offset mismatch"); /* ── SecurityDiag layout parity ─────────────────────────────────── */ /* The middle two fields differ in name (complete_count / partial_count @@ -85,6 +96,12 @@ static_assert(offsetof(pf_report_t, security_diag) == offsetof(pineforge::Report "pf_report_t::security_diag offset mismatch"); static_assert(offsetof(pf_report_t, trace_names_len) == offsetof(pineforge::ReportC, trace_names_len), "pf_report_t::trace_names_len tail offset mismatch"); +static_assert(offsetof(pf_report_t, metrics) == offsetof(pineforge::ReportC, metrics), + "pf_report_t::metrics offset mismatch"); +static_assert(offsetof(pf_report_t, equity_curve) == offsetof(pineforge::ReportC, equity_curve), + "pf_report_t::equity_curve offset mismatch"); +static_assert(offsetof(pf_report_t, equity_curve_len) == offsetof(pineforge::ReportC, equity_curve_len), + "pf_report_t::equity_curve_len offset mismatch"); /* ── Magnifier distribution enum parity ─────────────────────────── */ @@ -190,6 +207,9 @@ PF_API void strategy_set_syminfo_metadata(pf_strategy_t s, const char* key, std::string(key), value); } +/* See PF_ABI_VERSION doc in pineforge.h. */ +PF_API int pf_abi_version(void) { return PF_ABI_VERSION; } + /* Return the runtime library version. */ PF_API pf_version_t pf_version_get(void) { pf_version_t v; diff --git a/src/engine_metrics.cpp b/src/engine_metrics.cpp new file mode 100644 index 0000000..ddb7a9a --- /dev/null +++ b/src/engine_metrics.cpp @@ -0,0 +1,254 @@ +/* + * engine_metrics.cpp — pure metric computations (trade stats + equity + * stats) consumed by fill_report. Conventions (NaN rules, sign/percent + * bases, annualization) are pinned in the per-field doxygen of + * ; docs/pages/report-schema.md summarizes them. + */ +#include + +#include +#include +#include +#include +#include +#include + +#include "timezone.hpp" + +namespace pineforge { +namespace metrics { + +namespace { +constexpr double kNaN = std::numeric_limits::quiet_NaN(); +inline bool match(const TradeC& t, TradeFilter f) { + return f == TradeFilter::ALL || (f == TradeFilter::LONG) == (t.is_long != 0); +} + +// (year*12 + month) bucket key for ts in chart tz. Caller hoists ONE +// ScopedTimezone guard around the whole walk — per-point guards are a +// process-global setenv/tzset round trip each (see timezone.hpp). +inline int month_key_utc(int64_t ts_ms) { + // ts >= 0 assumed (pre-epoch truncation shifts month for negative ts) + time_t secs = (time_t)(ts_ms / 1000); + struct tm tb {}; + gmtime_r(&secs, &tb); + return (tb.tm_year + 1900) * 12 + tb.tm_mon; +} +inline int month_key_local(int64_t ts_ms) { // call ONLY under ScopedTimezone + // ts >= 0 assumed (pre-epoch truncation shifts month for negative ts) + time_t secs = (time_t)(ts_ms / 1000); + struct tm tb {}; + localtime_r(&secs, &tb); + return (tb.tm_year + 1900) * 12 + tb.tm_mon; +} + +// Sharpe/Sortino over a simple-returns series. rf_period = risk-free per +// period; ann = sqrt(periods/year). Sample stddev (N-1) for Sharpe; +// 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) { + *sharpe = kNaN; + *sortino = kNaN; + const size_t n = r.size(); + if (n < 2) return; + double mean = 0.0; + for (double x : r) mean += x; + mean /= (double)n; + 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 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; +} + +} // namespace + +pf_trade_stats_t compute_trade_stats(const TradeC* trades, int n, + TradeFilter filter, double initial_capital) { + 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; + int streak_w = 0, streak_l = 0; + double largest_win = 0.0, largest_win_pct = 0.0; + double largest_loss = 0.0, largest_loss_pct = 0.0; // magnitudes + + for (int i = 0; i < n; ++i) { + const TradeC& t = trades[i]; + if (!match(t, filter)) continue; + ++s.num_trades; + s.net_profit += t.pnl; + s.commission_paid += t.commission; + pct_sum += t.pnl_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; + if (t.pnl > 0.0) { + ++s.num_wins; + s.gross_profit += t.pnl; + win_pct_sum += t.pnl_pct; + win_bars_sum += bars; + 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 %, + // NOT the % of the largest-USD win (validated 2026-06-12 vs TV + // export). Track the two maxima separately. + if (t.pnl > largest_win) largest_win = t.pnl; + 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; + 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 + // return-on-cost (sign == pnl sign), so -pnl_pct is a true + // positive magnitude for losses. + if (-t.pnl > largest_loss) largest_loss = -t.pnl; + if (-t.pnl_pct > largest_loss_pct) largest_loss_pct = -t.pnl_pct; + } else { + ++s.num_even; // even trades break both streaks + streak_w = 0; + streak_l = 0; + } + } + + 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.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; + 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; + 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) + : 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; + return s; +} + +pf_equity_stats_t compute_equity_stats(const pf_equity_point_t* curve, int64_t n, + double initial_capital, + const std::string& chart_tz, + double first_open, double last_close, + int64_t bars_in_market, double net_profit) { + pf_equity_stats_t e{}; + e.sharpe_tv = kNaN; e.sortino_tv = 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; + + 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); + } + if (n <= 0 || curve == nullptr) return e; + + e.open_pl = curve[n - 1].open_profit; + e.time_in_market_pct = (double)bars_in_market / (double)n * 100.0; + + // --- Drawdown / runup walk. MUST mirror update_equity_extremes + // (engine.hpp): trough resets to eq on every new peak. The walk over the + // curve reproduces the engine's scalar extremes exactly (verified by the + // engine-vs-walk integration test in test_metrics.cpp). Percent vs the + // 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; + if (eq > peak) { peak = eq; trough = eq; } + if (eq < trough) trough = eq; + const double dd = peak - eq; + if (dd > e.max_equity_drawdown) { + e.max_equity_drawdown = dd; + e.max_equity_drawdown_pct = peak > 0.0 ? dd / peak * 100.0 : kNaN; + } + const double ru = eq - trough; + if (ru > e.max_equity_runup) { + e.max_equity_runup = ru; + e.max_equity_runup_pct = trough > 0.0 ? ru / trough * 100.0 : 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); + + 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; + } + if (e.max_equity_drawdown > 0.0) { + e.recovery_factor = net_profit / e.max_equity_drawdown; + if (!std::isnan(e.cagr) && e.max_equity_drawdown_pct > 0.0) + e.calmar = e.cagr / e.max_equity_drawdown_pct; + } + + // --- TV-method monthly Sharpe/Sortino: last point of each chart-tz + // (year,month) bucket; simple returns between consecutive month-ends. + // TODO(perf): decompose only at month boundaries (O(months)) instead + // of per point; shrinks the non-UTC critical section. + { + std::vector month_end; + const bool utc = chart_tz.empty() || chart_tz == "UTC" || chart_tz == "Etc/UTC"; + auto walk = [&](auto key_fn) { + int cur = key_fn(curve[0].time_ms); + double last_eq = curve[0].equity; + for (int64_t i = 1; i < n; ++i) { + const int k = key_fn(curve[i].time_ms); + if (k != cur) { month_end.push_back(last_eq); cur = k; } + last_eq = curve[i].equity; + } + month_end.push_back(last_eq); + }; + if (utc) { + walk(month_key_utc); + } else { + pine_tz::ScopedTimezone guard(chart_tz); // ONE guard for the whole walk + walk(month_key_local); + } + 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_tv, &e.sortino_tv); + } + + // --- 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; + 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); + } + return e; +} + +} // namespace metrics +} // namespace pineforge diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index b028e94..099a890 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -295,17 +295,23 @@ void BacktestEngine::emit_close_trade(const PyramidEntry& pe, double close_qty, double fill_price, bool was_long) { // Realized PnL scales by the instrument point value ($ per point per // contract). Crypto/equity (pointvalue=1) is unchanged; futures (e.g. ES=50) - // multiply the price-difference PnL. pnl_pct is a price-return % and is - // point-value-invariant. Commission is in account currency: cash-per-* is - // already absolute, and percent commission scales the notional by - // pointvalue inside calc_commission. + // multiply the price-difference PnL. Commission is in account currency: + // cash-per-* is already absolute, and percent commission scales the + // notional by pointvalue inside calc_commission. const double pv = syminfo_.pointvalue; double pnl = (was_long ? (fill_price - pe.price) : (pe.price - fill_price)) * close_qty * pv; - double pnl_pct = was_long - ? (fill_price / pe.price - 1.0) * 100.0 - : (pe.price / fill_price - 1.0) * 100.0; - pnl -= calc_commission(pe.price, close_qty) + calc_commission(fill_price, close_qty); + const double entry_commission = calc_commission(pe.price, close_qty); + const double exit_commission = calc_commission(fill_price, close_qty); + pnl -= entry_commission + exit_commission; + // TV "Net P&L %" convention (arbitrated 2026-06-12 vs TV export, + // trade #258 short: 102.44 USD on 2276.66 entry => 4.50%): NET pnl + // as a percent of entry cost (entry_price * qty * pointvalue). + // Long/no-commission degenerates to the old (exit/entry-1) form; + // shorts diverge on large moves ((entry/exit-1) was wrong). Computed + // AFTER the commission subtraction above — order matters. + const double entry_cost = pe.price * close_qty * pv; + double pnl_pct = (entry_cost > 0.0) ? (pnl / entry_cost) * 100.0 : 0.0; Trade trade; trade.entry_time = pe.time; @@ -320,6 +326,7 @@ void BacktestEngine::emit_close_trade(const PyramidEntry& pe, double close_qty, trade.exit_bar_index = bar_index_; trade.entry_id = pe.entry_id; trade.entry_comment = pe.entry_comment; + trade.commission = entry_commission + exit_commission; // Excursions: TV's per-trade excursion includes the exit fill itself — // a stop-out's adverse excursion is at least the loss at the SL fill and // a take-profit's favorable excursion includes the move to the TP fill. @@ -381,7 +388,6 @@ void BacktestEngine::emit_close_trade(const PyramidEntry& pe, double close_qty, // confirmed across all 757k corpus rows); adverse grows by the entry // commission (open profit at the entry tick is already -commission). // Both fields remain >= 0 here (Pine positive-drawdown convention). - const double entry_commission = calc_commission(pe.price, close_qty); trade.max_runup = std::max(0.0, runup * pv - entry_commission); trade.max_drawdown = drawdown * pv + entry_commission; const double trade_pnl = trade.pnl; diff --git a/src/engine_report.cpp b/src/engine_report.cpp index c6b607a..69f24f0 100644 --- a/src/engine_report.cpp +++ b/src/engine_report.cpp @@ -4,6 +4,8 @@ #include "engine_internal.hpp" +#include + #include #include #include @@ -48,6 +50,8 @@ void BacktestEngine::fill_report(ReportC* out) const { out->needs_aggregation = diag_needs_aggregation_ ? 1 : 0; out->bar_magnifier_enabled = bar_magnifier_enabled_ ? 1 : 0; + fill_metrics_section(out); // reads out->trades — keep after fill_trades_section + fill_security_diag_section(out); fill_trace_section(out); } @@ -77,6 +81,9 @@ void BacktestEngine::fill_trades_section(ReportC* out) const { out->trades[i].max_runup = t.max_runup; out->trades[i].max_drawdown = t.max_drawdown; out->trades[i].qty = t.qty; + out->trades[i].commission = t.commission; + out->trades[i].entry_bar_index = t.entry_bar_index; + out->trades[i].exit_bar_index = t.exit_bar_index; net_profit += t.pnl; } @@ -88,6 +95,36 @@ void BacktestEngine::fill_trades_section(ReportC* out) const { } +// Copy the equity curve out and compute all metric blocks. Must run AFTER +// fill_trades_section (reads out->trades). Owns the curve allocation; +// freed by free_report (which expects new pf_equity_point_t[n]). +// equity_curve_len derives from the vector size, NOT script_bars_processed: +// an exception mid-run can truncate the curve (metrics then describe the +// truncated prefix; consumers must check strategy_get_last_error). +// No ScopedTimezone may be held here: compute_equity_stats takes the +// non-recursive global tz lock when chart_timezone_ is non-UTC. +void BacktestEngine::fill_metrics_section(ReportC* out) const { + const int64_t n = (int64_t)equity_curve_.size(); + out->equity_curve_len = n; + if (n > 0) { + out->equity_curve = new pf_equity_point_t[n]; + std::copy(equity_curve_.begin(), equity_curve_.end(), out->equity_curve); + } else { + out->equity_curve = nullptr; + } + using metrics::TradeFilter; + out->metrics.all = metrics::compute_trade_stats( + out->trades, out->trades_len, TradeFilter::ALL, initial_capital_); + out->metrics.longs = metrics::compute_trade_stats( + out->trades, out->trades_len, TradeFilter::LONG, initial_capital_); + out->metrics.shorts = metrics::compute_trade_stats( + out->trades, out->trades_len, TradeFilter::SHORT, initial_capital_); + out->metrics.equity = metrics::compute_equity_stats( + out->equity_curve, n, initial_capital_, chart_timezone_, + first_bar_open_, current_bar_.close, bars_in_market_, net_profit_sum_); +} + + // Heap-allocate and populate the per-evaluator security diagnostics array // and the corresponding feed / complete-eval / partial-eval totals. // Owns the allocation; freed by ``free_report``. @@ -170,6 +207,13 @@ void BacktestEngine::free_report(ReportC* report) { report->trace_names = nullptr; report->trace_names_len = 0; } + if (report && report->equity_curve) { + // Allocation site (fill_metrics_section) must use + // `new pf_equity_point_t[n]` to match this delete[]. + delete[] report->equity_curve; + report->equity_curve = nullptr; + report->equity_curve_len = 0; + } } } // namespace pineforge diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 4afedfa..cfe9b00 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -79,6 +79,9 @@ void BacktestEngine::reset_run_state() { max_contracts_held_all_ = 0.0; max_contracts_held_long_ = 0.0; max_contracts_held_short_ = 0.0; + equity_curve_.clear(); // retain capacity (handle-reuse sweep pattern) + bars_in_market_ = 0; + first_bar_open_ = std::numeric_limits::quiet_NaN(); // Risk halt latch + day trackers (one-way halt must not survive a rerun). risk_halted_ = false; @@ -133,6 +136,7 @@ void BacktestEngine::run(const Bar* bars, int n) { } try { reset_run_state(); + equity_curve_.reserve((size_t)std::max(n, 0)); std::string detected_tf = ""; if (n >= 2 && bars != nullptr) { @@ -171,6 +175,7 @@ void BacktestEngine::run(const Bar* bars, int n) { pending_close_qty_in_bar_ = 0.0; dispatch_bar(); update_equity_extremes(); + record_equity_point(current_bar_.timestamp); // ts not mutated on this path prev_bar_timestamp_ = current_bar_.timestamp; } } catch (const std::exception& e) { @@ -353,6 +358,10 @@ void BacktestEngine::run(const Bar* input_bars, int n_input, int expected_script_bars = count_expected_script_bars(input_bars, n_input, needs_aggregation); last_bar_index_ = expected_script_bars - 1; + // reset_run_state() already ran above — reserve AFTER it so the capacity + // hint isn't wiped (clear() retains capacity but order still matters for + // any future reset that releases). + equity_curve_.reserve((size_t)std::max(expected_script_bars, 0)); validate_security_timeframes(effective_input_tf); @@ -465,6 +474,7 @@ void BacktestEngine::run_simple_bar_loop(const Bar* input_bars, int n_input) { dispatch_bar(); prev_in_session_ = session_ismarket_; update_equity_extremes(); + record_equity_point(current_bar_.timestamp); // ts not mutated on this path prev_bar_timestamp_ = current_bar_.timestamp; } } @@ -506,6 +516,20 @@ void BacktestEngine::run_aggregation_bar_loop(const Bar* input_bars, int n_input } if (ab.is_complete) { + // Script-bar label for the equity curve: ab.bar.timestamp — the + // aggregator's first-present sub-bar ts of the COMPLETED bucket. + // The aggregator is fed identically with magnifier on and off, so + // this label is magnifier-invariant by construction. Captured + // here because run_magnified_bar overwrites + // current_bar_.timestamp with each sub-bar's ts. + // + // Deliberately NOT group_sub_bars.front().timestamp: when a + // bucket completes via the boundary path (irregular/partial first + // bucket), the boundary-triggering input bar is walked with the + // PREVIOUS script bar's group but belongs to the new aggregator + // bucket, so the group front lags ab.bar.timestamp by one input + // bar and the on/off curves would disagree on that label. + const int64_t script_bar_ts = ab.bar.timestamp; bar_index_ = script_bar_index++; emitted_script_bars++; barstate_islast_ = (emitted_script_bars == expected_script_bars); @@ -556,6 +580,7 @@ void BacktestEngine::run_aggregation_bar_loop(const Bar* input_bars, int n_input prev_in_session_ = session_ismarket_; } update_equity_extremes(); + record_equity_point(script_bar_ts); prev_bar_timestamp_ = current_bar_.timestamp; } } diff --git a/src/engine_trade_accessors.cpp b/src/engine_trade_accessors.cpp index 2c71487..581f001 100644 --- a/src/engine_trade_accessors.cpp +++ b/src/engine_trade_accessors.cpp @@ -36,10 +36,15 @@ double BacktestEngine::open_trade_profit_percent(int idx) const { return na(); const PyramidEntry& pe = pyramid_entries_[(size_t)idx]; if (pe.price <= 0.0) return na(); - bool is_long = (position_side_ == PositionSide::LONG); - double px = current_bar_.close; - if (is_long) return (px / pe.price - 1.0) * 100.0; - return (pe.price / px - 1.0) * 100.0; + // TV net return-on-cost convention (same shape as the closed-trade + // pnl_pct fixed 2026-06-12 in emit_close_trade): the profit value this + // accessor pairs with — open_trade_profit, which is net of the + // entry-leg commission — as a percent of entry cost + // (entry_price * qty * pointvalue). No new commission handling is + // invented here; the percent just mirrors open_trade_profit. + const double entry_cost = pe.price * pe.qty * syminfo_.pointvalue; + if (!(entry_cost > 0.0)) return na(); + return open_trade_profit(idx) / entry_cost * 100.0; } double BacktestEngine::open_trade_commission(int idx) const { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index dea2602..ac350b3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -68,6 +68,7 @@ set(TEST_SOURCES test_ta_voltrend_edge test_timezone_concurrency test_pointvalue + test_metrics ) find_package(Threads REQUIRED) diff --git a/tests/test_c_abi.c b/tests/test_c_abi.c index 09f4f09..8813b8f 100644 --- a/tests/test_c_abi.c +++ b/tests/test_c_abi.c @@ -35,6 +35,23 @@ int main(void) { CHECK(v.patch == PINEFORGE_VERSION_PATCH, "version patch mismatch"); CHECK(v.commit_sha != NULL, "commit_sha is NULL"); + /* ── ABI version ────────────────────────────────────────────── */ + CHECK(pf_abi_version() == PF_ABI_VERSION, "pf_abi_version() != PF_ABI_VERSION"); + + /* ── Metrics / equity-point field-access smoke ──────────────── */ + { + pf_metrics_t m; + pf_equity_point_t p; + memset(&m, 0, sizeof(m)); + memset(&p, 0, sizeof(p)); + m.all.num_trades = 42; + m.equity.sharpe_tv = 1.5; + p.time_ms = 1700000000000LL; + CHECK(m.all.num_trades == 42, "m.all.num_trades roundtrip"); + CHECK(m.equity.sharpe_tv == 1.5, "m.equity.sharpe_tv roundtrip"); + CHECK(p.time_ms == 1700000000000LL, "p.time_ms roundtrip"); + } + /* ── Bar field access ────────────────────────────────────────── */ pf_bar_t bar; memset(&bar, 0, sizeof(bar)); @@ -79,8 +96,10 @@ int main(void) { "pf_bar_t too small for OHLCV + timestamp"); CHECK(sizeof(pf_trade_t) >= 80, "pf_trade_t unexpectedly small"); - CHECK(sizeof(pf_report_t) >= 80, - "pf_report_t unexpectedly small"); + CHECK(sizeof(pf_report_t) >= 80 + sizeof(pf_metrics_t) + + sizeof(pf_equity_point_t*) + + sizeof(int64_t), + "pf_report_t unexpectedly small (must include metrics + equity_curve ptr + len)"); CHECK(sizeof(pf_security_diag_t) == 4 + 8 + 8 + 8 + /* possible padding */ 0 || sizeof(pf_security_diag_t) == 32, "pf_security_diag_t unexpected size"); diff --git a/tests/test_c_abi_setters.cpp b/tests/test_c_abi_setters.cpp index d13c586..d294d11 100644 --- a/tests/test_c_abi_setters.cpp +++ b/tests/test_c_abi_setters.cpp @@ -33,6 +33,8 @@ * the canonical Release (-DNDEBUG) build where bare assert() is a no-op. */ +// Include order is load-bearing: pineforge.h BEFORE engine.hpp keeps the +// per-strategy declarations visible (engine.hpp defines PINEFORGE_NO_STRATEGY_DECLS). #include // the C ABI under test (extern "C") #include // BacktestEngine (to mint a valid handle) #include diff --git a/tests/test_engine_trade_accessors.cpp b/tests/test_engine_trade_accessors.cpp index 9f88e22..917c4c7 100644 --- a/tests/test_engine_trade_accessors.cpp +++ b/tests/test_engine_trade_accessors.cpp @@ -184,8 +184,10 @@ static void test_open_trade_accessors_flat_then_pyramid() { // open profit: (close - entry_price) * qty - commission. // close=108, entry=105, qty=1, commission = 105 * 1 * 0.1 / 100 = 0.105 CHECK(near(s.profit_0, (108.0 - 105.0) * 1.0 - 0.105)); - // profit_percent: long path (close / entry - 1) * 100 - CHECK(near(s.profit_pct_0, (108.0 / 105.0 - 1.0) * 100.0)); + // profit_percent: net return-on-cost (TV convention, 2026-06-12) = + // open_trade_profit / entry_cost * 100 = (3 - 0.105) / (105*1) * 100 + // = 2.895/105*100 ≈ 2.7571% (was gross (108/105-1)*100 ≈ 2.857%). + CHECK(near(s.profit_pct_0, (3.0 - 0.105) / 105.0 * 100.0)); CHECK(near(s.commission_0, 0.105)); } @@ -261,8 +263,10 @@ static void test_open_trade_short_path() { // Entry filled at open=99 short, qty=2, commission=0. // profit (short) = (entry - close) * qty - commission = (99 - 92) * 2 = 14 CHECK(near(strat.profit_at_close, 14.0)); - // profit_percent (short) = (entry / close - 1) * 100 = (99/92 - 1) * 100 ≈ 7.608 - CHECK(near(strat.pct_at_close, (99.0 / 92.0 - 1.0) * 100.0, 1e-6)); + // profit_percent: net return-on-cost (TV convention, 2026-06-12) = + // profit / entry_cost * 100 = 14 / (99*2) * 100 ≈ 7.0707% + // (was the gross short price-ratio (99/92-1)*100 ≈ 7.6087%). + CHECK(near(strat.pct_at_close, 14.0 / (99.0 * 2.0) * 100.0, 1e-6)); } // Zero-price guard branches in open_trade_profit_percent / diff --git a/tests/test_handle_reuse_reset.cpp b/tests/test_handle_reuse_reset.cpp index 6ee7ab1..e4d8bd1 100644 --- a/tests/test_handle_reuse_reset.cpp +++ b/tests/test_handle_reuse_reset.cpp @@ -65,6 +65,7 @@ class MomoFlip : public BacktestEngine { // reuse test isolates ENGINE state reset, not subclass member reset. void reset_strategy_state() { prev_close_ = std::numeric_limits::quiet_NaN(); } double net() const { return net_profit_sum_; } + const std::vector& curve() const { return equity_curve_; } }; std::vector make_feed(int n) { @@ -115,6 +116,13 @@ static void test_reused_handle_equals_fresh() { CHECK(reused.trade_count() == fresh.trade_count()); // RED before reset_run_state() CHECK(reused.net() == fresh.net()); CHECK(trades_equal(reused, fresh)); + + // Equity curve must reset between runs: reused-handle run 2 == fresh run. + CHECK(fresh.curve().size() == reused.curve().size()); + for (size_t i = 0; i < fresh.curve().size() && i < reused.curve().size(); ++i) { + CHECK(fresh.curve()[i].time_ms == reused.curve()[i].time_ms); + CHECK(fresh.curve()[i].equity == reused.curve()[i].equity); + } } static void test_reused_handle_script_tf_overload() { @@ -132,6 +140,13 @@ static void test_reused_handle_script_tf_overload() { CHECK(reused.trade_count() == fresh.trade_count()); CHECK(reused.net() == fresh.net()); CHECK(trades_equal(reused, fresh)); + + // Equity curve must reset between runs on the TF overload path too. + CHECK(fresh.curve().size() == reused.curve().size()); + for (size_t i = 0; i < fresh.curve().size() && i < reused.curve().size(); ++i) { + CHECK(fresh.curve()[i].time_ms == reused.curve()[i].time_ms); + CHECK(fresh.curve()[i].equity == reused.curve()[i].equity); + } } int main() { diff --git a/tests/test_metrics.cpp b/tests/test_metrics.cpp new file mode 100644 index 0000000..4062ce0 --- /dev/null +++ b/tests/test_metrics.cpp @@ -0,0 +1,596 @@ +/* + * test_metrics.cpp -- pins the C-ABI-exposed metrics surface. + * + * Coverage: + * - pf_trade_t commission ABI v2: verifies that emit_close_trade stores + * the entry+exit commission into Trade::commission and that fill_report + * copies it faithfully into TradeC. Commission tests recompute from the + * formula independently so stored-vs-charged drift fails. + * - pf_trade_stats_t blocks (ALL / LONG / SHORT): every field hand- + * computed inline (sign, NaN, positive-magnitude loss, streak, bar- + * duration conventions) against compute_trade_stats. + * - Equity-curve length / timestamp monotonicity / magnifier invariance. + */ + +#include +#include +#include +#include + +#include +#include +#include + +using namespace pineforge; + +static int tests_passed = 0; +static int tests_failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +namespace { + +class MomoFlip : public BacktestEngine { +public: + double prev_close_ = std::numeric_limits::quiet_NaN(); + MomoFlip() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.1; + pyramiding_ = 1; + } + void on_bar(const Bar& bar) override { + if (!std::isnan(prev_close_)) { + if (bar.close > prev_close_) + strategy_entry("L", true, std::numeric_limits::quiet_NaN(), + std::numeric_limits::quiet_NaN(), 1.0, "up"); + else if (bar.close < prev_close_) + strategy_entry("S", false, std::numeric_limits::quiet_NaN(), + std::numeric_limits::quiet_NaN(), 1.0, "dn"); + } + prev_close_ = bar.close; + } + const std::vector& curve() const { return equity_curve_; } + int64_t bim() const { return bars_in_market_; } + double max_dd() const { return max_drawdown_; } + double max_ru() const { return max_runup_; } +}; + +std::vector make_feed(int n) { + std::vector bars(n); + for (int i = 0; i < n; ++i) { + int phase = i % 20; + int tri = (phase < 10) ? phase : (20 - phase); + double close = 100.0 + tri * 1.5 + (i % 3); + bars[i].open = close; + bars[i].high = close + 1.0; + bars[i].low = close - 1.0; + bars[i].close = close; + bars[i].volume = 1000.0 + (i % 100); + bars[i].timestamp = (int64_t)(i + 1) * 900'000; + } + return bars; +} + +// Same OHLC shape as make_feed but with realistic 1-minute-spaced timestamps, +// suitable for "1" -> "15" aggregation runs (magnifier on/off invariance). +std::vector make_feed_1m(int n) { + std::vector bars = make_feed(n); + for (int i = 0; i < n; ++i) + bars[i].timestamp = 1700000000000LL + (int64_t)i * 60'000LL; + return bars; +} + +} // namespace + +static void test_trade_commission_and_bar_indexes() { + std::printf("trade commission + bar indexes\n"); + MomoFlip s; + std::vector bars = make_feed(120); + s.run(bars.data(), (int)bars.size()); + ReportC rep{}; + s.fill_report(&rep); + CHECK(rep.trades_len > 0); + for (int i = 0; i < rep.trades_len; ++i) { + const TradeC& t = rep.trades[i]; + // commission must equal what calc_commission charges for both legs + // 0.1% commission = price * qty * pointvalue * (0.1 / 100.0) + // = price * qty * 0.001 (pointvalue defaults to 1.0) + double expect = t.entry_price * t.qty * 0.001 + t.exit_price * t.qty * 0.001; + CHECK(std::fabs(t.commission - expect) < 1e-9); + CHECK(t.commission > 0.0); + CHECK(t.entry_bar_index >= 0); + CHECK(t.exit_bar_index >= t.entry_bar_index); + } + BacktestEngine::free_report(&rep); +} + +static void test_equity_curve_basic() { + std::printf("equity curve: length, last-point identity, monotonic ts\n"); + MomoFlip s; + std::vector bars = make_feed(120); + s.run(bars.data(), (int)bars.size()); + ReportC rep{}; + s.fill_report(&rep); + CHECK((int64_t)s.curve().size() == rep.script_bars_processed); + CHECK(!s.curve().empty()); + const pf_equity_point_t& last = s.curve().back(); + CHECK(std::fabs(last.equity - (1'000'000.0 + rep.net_profit + last.open_profit)) < 1e-9); + for (size_t i = 1; i < s.curve().size(); ++i) + CHECK(s.curve()[i].time_ms > s.curve()[i - 1].time_ms); + // report-side curve: fill_report copies the internal curve out + CHECK(rep.equity_curve_len == (int64_t)s.curve().size()); + CHECK(rep.equity_curve != nullptr); + BacktestEngine::free_report(&rep); +} + +static void test_equity_curve_magnifier_invariant() { + std::printf("equity curve: magnifier on/off bit-identical\n"); + std::vector bars = make_feed_1m(40 * 15); // 40 script bars of 15m + MomoFlip a, b; + a.run(bars.data(), (int)bars.size(), "1", "15", false, 4, MagnifierDistribution::ENDPOINTS); + b.run(bars.data(), (int)bars.size(), "1", "15", true, 4, MagnifierDistribution::ENDPOINTS); + CHECK(a.curve().size() == b.curve().size()); + CHECK(!a.curve().empty()); + for (size_t i = 0; i < a.curve().size() && i < b.curve().size(); ++i) { + CHECK(a.curve()[i].time_ms == b.curve()[i].time_ms); // blocker-1 pin + CHECK(a.curve()[i].equity == b.curve()[i].equity); // bit-equal: market-on-close fills identical + CHECK(a.curve()[i].open_profit == b.curve()[i].open_profit); + } + CHECK(a.bim() == b.bim()); +} + +// ---------- Trade-stats synthetic fixtures (Task 4) ------------------------- + +static TradeC mk(double pnl, double pnl_pct, bool is_long, double comm, + int ebar, int xbar) { + TradeC t{}; + t.pnl = pnl; t.pnl_pct = pnl_pct; t.is_long = is_long ? 1 : 0; + t.commission = comm; t.entry_bar_index = ebar; t.exit_bar_index = xbar; + t.qty = 1.0; t.entry_price = 100.0; t.exit_price = 100.0 + pnl; + return t; +} + +static void test_trade_stats_all() { + std::printf("trade stats: ALL block\n"); + // pnl: +100L, -50S, +20L, 0L | capital 1000 + // wins=2 losses=1 even=1; net=70; gp=120; gl=50(magnitude); pf=2.4 + // avg_trade=17.5; avg_trade_pct=(10-5+2+0)/4=1.75 + // avg_win=60 (pct 6); avg_loss=50 (pct 5); ratio=1.2 + // largest_win=100 (pct 10); largest_loss=50 (pct 5); commission=2.75 + // expectancy = 0.5*60 - 0.25*50 = 17.5 + // streaks: W,L,W,E -> max_wins=1, max_losses=1 (even breaks streaks) + // bars (inclusive of entry bar, TV convention 2026-06-12): + // (5-0+1),(8-6+1),(9-9+1),(12-10+1) = 6,3,1,3 -> avg 3.25; + // wins (6+1)/2=3.5; losses 3/1=3 + TradeC ts[4] = { mk(100, 10, true, 1.0, 0, 5), mk(-50, -5, false, 1.0, 6, 8), + mk(20, 2, true, 0.5, 9, 9), mk(0, 0, true, 0.25, 10, 12) }; + pf_trade_stats_t s = pineforge::metrics::compute_trade_stats( + ts, 4, pineforge::metrics::TradeFilter::ALL, 1000.0); + CHECK(s.num_trades == 4); CHECK(s.num_wins == 2); + CHECK(s.num_losses == 1); CHECK(s.num_even == 1); + CHECK(std::fabs(s.percent_profitable - 50.0) < 1e-12); + CHECK(std::fabs(s.net_profit - 70.0) < 1e-12); + CHECK(std::fabs(s.net_profit_pct - 7.0) < 1e-12); + CHECK(std::fabs(s.gross_profit - 120.0) < 1e-12); + CHECK(std::fabs(s.gross_profit_pct - 12.0) < 1e-12); + CHECK(std::fabs(s.gross_loss - 50.0) < 1e-12); // positive magnitude + CHECK(std::fabs(s.gross_loss_pct - 5.0) < 1e-12); + CHECK(std::fabs(s.profit_factor - 2.4) < 1e-12); + CHECK(std::fabs(s.avg_trade - 17.5) < 1e-12); + CHECK(std::fabs(s.avg_trade_pct - 1.75) < 1e-12); + CHECK(std::fabs(s.avg_win - 60.0) < 1e-12); + CHECK(std::fabs(s.avg_win_pct - 6.0) < 1e-12); + CHECK(std::fabs(s.avg_loss - 50.0) < 1e-12); // positive magnitude + CHECK(std::fabs(s.avg_loss_pct - 5.0) < 1e-12); + CHECK(std::fabs(s.ratio_avg_win_avg_loss - 1.2) < 1e-12); + CHECK(std::fabs(s.largest_win - 100.0) < 1e-12); + CHECK(std::fabs(s.largest_win_pct - 10.0) < 1e-12); + CHECK(std::fabs(s.largest_loss - 50.0) < 1e-12); + CHECK(std::fabs(s.largest_loss_pct - 5.0) < 1e-12); + CHECK(std::fabs(s.commission_paid - 2.75) < 1e-12); + CHECK(std::fabs(s.expectancy - 17.5) < 1e-12); + CHECK(s.max_consecutive_wins == 1); + CHECK(s.max_consecutive_losses == 1); + CHECK(std::fabs(s.avg_bars_in_trade - 3.25) < 1e-12); + CHECK(std::fabs(s.avg_bars_in_wins - 3.5) < 1e-12); + CHECK(std::fabs(s.avg_bars_in_losses - 3.0) < 1e-12); +} + +// TV "Largest profit/loss %" is the independent max of per-trade pnl_pct, +// NOT the % of the largest-USD trade (arbitrated 2026-06-12 vs TV export: +// All largest loss = 126.64 USD short @3.19% but "Largest loss %" = 4.06% +// from a different, long trade). Discriminating fixture: the larger-USD +// trade carries the smaller |pct| on both sides. +static void test_trade_stats_largest_pct_independent() { + std::printf("trade stats: largest win/loss pct independent of USD maxima\n"); + TradeC ts[4] = { mk(-100, -2, true, 0, 0, 1), // largest USD loss, small pct + mk(-50, -5, false, 0, 2, 3), // largest pct loss + mk(200, 3, true, 0, 4, 5), // largest USD win, small pct + mk(80, 7, false, 0, 6, 7) }; // largest pct win + pf_trade_stats_t s = pineforge::metrics::compute_trade_stats( + ts, 4, pineforge::metrics::TradeFilter::ALL, 1000.0); + CHECK(std::fabs(s.largest_loss - 100.0) < 1e-12); // USD max: trade 0 + CHECK(std::fabs(s.largest_loss_pct - 5.0) < 1e-12); // pct max: trade 1 + CHECK(std::fabs(s.largest_win - 200.0) < 1e-12); // USD max: trade 2 + CHECK(std::fabs(s.largest_win_pct - 7.0) < 1e-12); // pct max: trade 3 +} + +static void test_trade_stats_filters_and_nan() { + std::printf("trade stats: LONG/SHORT filters + NaN conventions\n"); + TradeC ts[4] = { mk(100, 10, true, 1.0, 0, 5), mk(-50, -5, false, 1.0, 6, 8), + mk(20, 2, true, 0.5, 9, 9), mk(0, 0, true, 0.25, 10, 12) }; + pf_trade_stats_t L = pineforge::metrics::compute_trade_stats( + ts, 4, pineforge::metrics::TradeFilter::LONG, 1000.0); + CHECK(L.num_trades == 3); CHECK(L.num_losses == 0); CHECK(L.num_even == 1); + CHECK(std::isnan(L.profit_factor)); // zero gross loss + CHECK(std::isnan(L.avg_loss)); + CHECK(std::isnan(L.ratio_avg_win_avg_loss)); + CHECK(std::isnan(L.avg_bars_in_losses)); + pf_trade_stats_t S = pineforge::metrics::compute_trade_stats( + ts, 4, pineforge::metrics::TradeFilter::SHORT, 1000.0); + CHECK(S.num_trades == 1); CHECK(S.num_wins == 0); + CHECK(std::isnan(S.avg_win)); + pf_trade_stats_t E = pineforge::metrics::compute_trade_stats( + ts, 0, pineforge::metrics::TradeFilter::ALL, 1000.0); + CHECK(E.num_trades == 0); + CHECK(E.net_profit == 0.0); + CHECK(std::isnan(E.avg_trade)); + CHECK(std::isnan(E.percent_profitable)); + // consecutive streaks: W W L L L W -> max_wins=2, max_losses=3 + TradeC seq[6] = { mk(1,1,true,0,0,1), mk(2,1,true,0,1,2), mk(-1,-1,true,0,2,3), + mk(-2,-1,true,0,3,4), mk(-3,-1,true,0,4,5), mk(4,1,true,0,5,6) }; + pf_trade_stats_t Q = pineforge::metrics::compute_trade_stats( + seq, 6, pineforge::metrics::TradeFilter::ALL, 1000.0); + CHECK(Q.max_consecutive_wins == 2); + CHECK(Q.max_consecutive_losses == 3); +} + +// ---------- Equity-stats synthetic fixtures (Task 5) ------------------------ + +static double kNaN_test() { return std::numeric_limits::quiet_NaN(); } + +static pf_equity_point_t pt(int64_t ms, double eq) { + pf_equity_point_t p{}; p.time_ms = ms; p.equity = eq; p.open_profit = 0.0; return p; +} +// Month-end UTC timestamps (ms): 2024-01-31, 02-29, 03-31, 04-30 — all 12:00Z. +static const int64_t kJan = 1706702400000LL, kFeb = 1709208000000LL, + kMar = 1711886400000LL, kApr = 1714478400000LL; + +static void test_equity_stats_sharpe_sortino_tv() { + std::printf("equity stats: TV monthly sharpe/sortino\n"); + // equities 1000 -> 1100 -> 990 -> 1089 : monthly returns +10%, -10%, +10% + pf_equity_point_t c[4] = { pt(kJan,1000), pt(kFeb,1100), pt(kMar,990), pt(kApr,1089) }; + pf_equity_stats_t e = pineforge::metrics::compute_equity_stats( + c, 4, 1000.0, "", /*first_open=*/100.0, /*last_close=*/110.0, + /*bars_in_market=*/2, /*net_profit=*/89.0); + // Python oracle (closed forms: sharpe = 19/20, sortino = 114/61): + // r = [0.1, -0.1, 0.1]; rf = 0.02/12 + // mean = 1/30; sd = sqrt(1/75) = 1/(5*sqrt(3)) + // sharpe = (mean - rf) / sd * sqrt(12) = 19/20 = 0.95 + // sortino numerator same; population downside dev vs rf: + // d = min(0, -0.1 - rf)^2 / 3 => dd = |(-61/600)| / sqrt(3) + // sortino = (mean - rf) / dd * sqrt(12) = 114/61 + CHECK(std::fabs(e.sharpe_tv - 0.95) < 1e-9); // 19/20 + CHECK(std::fabs(e.sortino_tv - 1.8688524590163935) < 1e-9); // 114/61 + CHECK(std::fabs(e.buy_hold_return - 100.0) < 1e-12); // 1000*(110/100-1) + CHECK(std::fabs(e.buy_hold_return_pct - 10.0) < 1e-12); + CHECK(std::fabs(e.time_in_market_pct - 50.0) < 1e-12); // 2/4 + CHECK(e.open_pl == 0.0); +} + +static void test_equity_stats_drawdown_walk() { + std::printf("equity stats: dd/runup walk mirrors update_equity_extremes\n"); + // 1000 -> 1200 -> 900 -> 1100 (same month is fine; dd walk is tz-free) + pf_equity_point_t c[4] = { pt(1,1000), pt(2,1200), pt(3,900), pt(4,1100) }; + pf_equity_stats_t e = pineforge::metrics::compute_equity_stats( + c, 4, 1000.0, "", 100.0, 110.0, 0, 100.0); + // peak 1200 -> trough 900: dd 300, pct vs peak 25%. + CHECK(std::fabs(e.max_equity_drawdown - 300.0) < 1e-12); + CHECK(std::fabs(e.max_equity_drawdown_pct - 25.0) < 1e-12); + // trough resets to eq on each new peak (update_equity_extremes semantics): + // runup = 1100 - 900 = 200; pct vs trough = 200/900*100 = 200/9. + 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)); + CHECK(std::isnan(e.sharpe_tv)); // single month bucket -> <2 returns +} + +static void test_equity_stats_edges() { + std::printf("equity stats: edges (flat, empty, zero-dd)\n"); + pf_equity_point_t flat[3] = { pt(kJan,1000), pt(kFeb,1000), pt(kMar,1000) }; + pf_equity_stats_t f = pineforge::metrics::compute_equity_stats( + flat, 3, 1000.0, "", 100.0, 100.0, 0, 0.0); + CHECK(std::isnan(f.sharpe_tv)); // zero deviation + CHECK(std::isnan(f.calmar)); // zero drawdown + CHECK(std::isnan(f.recovery_factor)); + CHECK(f.max_equity_drawdown == 0.0); + pf_equity_stats_t z = pineforge::metrics::compute_equity_stats( + nullptr, 0, 1000.0, "", kNaN_test(), kNaN_test(), 0, 0.0); + CHECK(std::isnan(z.sharpe_tv)); + CHECK(std::isnan(z.cagr)); + CHECK(std::isnan(z.buy_hold_return)); + CHECK(z.max_equity_drawdown == 0.0); + CHECK(std::isnan(z.time_in_market_pct)); + // first_open <= 0 => buy_hold NaN + pf_equity_stats_t bh = pineforge::metrics::compute_equity_stats( + flat, 3, 1000.0, "", /*first_open=*/0.0, /*last_close=*/100.0, 0, 0.0); + CHECK(std::isnan(bh.buy_hold_return)); + CHECK(std::isnan(bh.buy_hold_return_pct)); +} + +// ---------- Flat-strategy bars-in-market pin (carried review item) ----------- + +namespace { + +class NeverTrades : public BacktestEngine { +public: + NeverTrades() { initial_capital_ = 1'000'000; } + void on_bar(const Bar&) override {} // never trades + const std::vector& curve() const { return equity_curve_; } + int64_t bim() const { return bars_in_market_; } +}; + +} // namespace + +static void test_flat_strategy_bars_in_market() { + std::printf("flat strategy: bars_in_market == 0, curve pinned to capital\n"); + NeverTrades s; + std::vector bars = make_feed(50); + s.run(bars.data(), (int)bars.size()); + CHECK(s.bim() == 0); + CHECK(!s.curve().empty()); + CHECK(s.curve().front().equity == 1'000'000.0); +} + +// ---------- CASH_PER_CONTRACT commission test (deferred from Task 2) --------- +// Two-trade full-close test: simpler than partial-close choreography (which +// requires multi-bar qty management + close sequence that proved fragile with +// the synthetic feed). Two consecutive flip trades under CASH_PER_CONTRACT +// verify commission = commission_value_ * qty * 2 legs per trade. + +namespace { + +class CashPerContractFlip : public BacktestEngine { +public: + double prev_close_ = std::numeric_limits::quiet_NaN(); + CashPerContractFlip() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 3.0; // qty = 3 contracts + slippage_ = 0; + commission_type_ = CommissionType::CASH_PER_CONTRACT; + commission_value_ = 2.5; // $2.50 per contract per leg + pyramiding_ = 1; + } + void on_bar(const Bar& bar) override { + if (!std::isnan(prev_close_)) { + if (bar.close > prev_close_) + strategy_entry("L", true, std::numeric_limits::quiet_NaN(), + std::numeric_limits::quiet_NaN(), 3.0, "up"); + else if (bar.close < prev_close_) + strategy_entry("S", false, std::numeric_limits::quiet_NaN(), + std::numeric_limits::quiet_NaN(), 3.0, "dn"); + } + prev_close_ = bar.close; + } +}; + +} // namespace + +static void test_trade_commission_cash_per_contract() { + std::printf("trade commission: CASH_PER_CONTRACT full-close\n"); + CashPerContractFlip s; + std::vector bars = make_feed(120); + s.run(bars.data(), (int)bars.size()); + ReportC rep{}; + s.fill_report(&rep); + CHECK(rep.trades_len > 0); + for (int i = 0; i < rep.trades_len; ++i) { + const TradeC& t = rep.trades[i]; + // CASH_PER_CONTRACT: commission = value * qty per leg, two legs + double expect = 2.5 * t.qty * 2.0; + CHECK(std::fabs(t.commission - expect) < 1e-9); + CHECK(t.commission > 0.0); + } + BacktestEngine::free_report(&rep); +} + +// ---------- Engine-vs-walk integration: dd/runup invariant (Task 1) -------- +// The compute_equity_stats dd/runup walk over the equity curve MUST reproduce +// the engine's running max_drawdown_ / max_runup_ exactly. This holds when +// the walk seeds peak=trough=curve[0].equity and the engine seeds at +// initial_capital_ -- identical when the strategy is flat on bar 0 +// (MomoFlip enters from bar 1, so curve[0].equity == initial_capital). +// A strategy that trades on bar 0 may see a seed asymmetry; see the NOTE +// on update_equity_extremes in engine.hpp. + +static void test_engine_vs_walk_dd_invariant() { + std::printf("engine-vs-walk: dd/runup integration invariant\n"); + MomoFlip s; + std::vector bars = make_feed(300); + s.run(bars.data(), (int)bars.size()); + + ReportC rep{}; + s.fill_report(&rep); + + pf_equity_stats_t walk = pineforge::metrics::compute_equity_stats( + s.curve().data(), (int64_t)s.curve().size(), + 1'000'000.0, "", + /*first_open=*/bars.front().open, + /*last_close=*/bars.back().close, + s.bim(), rep.net_profit); + + CHECK(std::fabs(walk.max_equity_drawdown - s.max_dd()) < 1e-9); + CHECK(std::fabs(walk.max_equity_runup - s.max_ru()) < 1e-9); + BacktestEngine::free_report(&rep); +} + +// ---------- Per-bar sharpe/sortino oracle (Task 4a) ----------------------- +// Synthetic 5-point curve spaced exactly 1 day (86'400'000 ms): +// equities {1000, 1010, 999.9, 1009.899, 1019.99799} +// -> returns [0.01, -0.01, 0.01, 0.01] (FP-exact via chained multiply) +// +// Python3 oracle snippet: +// import math +// e = [1000.0, 1010.0, 999.9, 1009.899, 1019.99799] +// r = [e[i]/e[i-1]-1.0 for i in range(1,len(e))] +// span_years = 4*86400000 / (365.25*86400*1000) # 0.010951403... +// bpy = 4/span_years # 365.25 +// rf = 0.02/bpy +// mean = sum(r)/len(r) +// sd = math.sqrt(sum((x-mean)**2 for x in r)/(len(r)-1)) +// sharpe = (mean-rf)/sd*math.sqrt(bpy) # 9.451108474837675 +// dd = math.sqrt(sum(min(0,x-rf)**2 for x in r)/len(r)) +// sortino = (mean-rf)/dd*math.sqrt(bpy) # 18.79927771509577 + +static void test_equity_stats_per_bar_oracle() { + std::printf("equity stats: per-bar sharpe/sortino oracle\n"); + const int64_t day = 86'400'000LL; + const int64_t base = 1700000000000LL; + pf_equity_point_t c[5] = { + pt(base + 0*day, 1000.0), + pt(base + 1*day, 1010.0), + pt(base + 2*day, 999.9), + pt(base + 3*day, 1009.899), + pt(base + 4*day, 1019.99799), + }; + pf_equity_stats_t e = pineforge::metrics::compute_equity_stats( + c, 5, 1000.0, "", /*first_open=*/100.0, /*last_close=*/100.0, + /*bars_in_market=*/0, /*net_profit=*/19.99799); + // All 5 points in same UTC month -> single bucket -> sharpe_tv NaN. + CHECK(std::isnan(e.sharpe_tv)); + // Per-bar values from python oracle above. + CHECK(std::fabs(e.sharpe_bar - 9.451108474837675) < 1e-9); + CHECK(std::fabs(e.sortino_bar - 18.79927771509577) < 1e-9); +} + +// ---------- Non-UTC bucketing sharpe (Task 4b) ---------------------------- +// 3-point curve under chart_tz "America/New_York": +// 2024-02-01T00:30:00Z (= Jan 31 19:30 ET -> JANUARY bucket) +// 2024-02-15T12:00:00Z (-> February) +// 2024-03-15T12:00:00Z (-> March) +// equities: 1000, 1100, 990 +// +// Under NY: month-ends [1000, 1100, 990] -> 2 returns [0.1, -0.1] +// Under UTC: first point lands in February -> month-ends [1100, 990] +// -> 1 return -> NaN sharpe. +// +// Timestamps verified via python3: +// from datetime import datetime, timezone +// datetime.fromtimestamp(1706747400000/1000, tz=timezone.utc) +// # -> 2024-02-01 00:30:00+00:00 +// datetime.fromtimestamp(1707998400000/1000, tz=timezone.utc) +// # -> 2024-02-15 12:00:00+00:00 +// datetime.fromtimestamp(1710504000000/1000, tz=timezone.utc) +// # -> 2024-03-15 12:00:00+00:00 +// +// NY sharpe/sortino oracle (python3): +// r=[0.1,-0.1]; rf=0.02/12; mean=0; sd=0.14142135623730953 +// sharpe = (0 - rf)/sd * sqrt(12) = -0.04082482904638629 +// dd=sqrt(sum(min(0,x-rf)**2 for x in r)/2) = 0.07188918942063234 +// sortino = (0 - rf)/dd * sqrt(12) = -0.08031113910764517 + +static void test_equity_stats_non_utc_bucketing() { + std::printf("equity stats: non-UTC tz bucketing pins month_key_local\n"); + pf_equity_point_t c[3] = { + pt(1706747400000LL, 1000.0), + pt(1707998400000LL, 1100.0), + pt(1710504000000LL, 990.0), + }; + // UTC: first point in Feb -> 2 buckets (Feb, Mar) -> 1 return -> NaN. + pf_equity_stats_t utc = pineforge::metrics::compute_equity_stats( + c, 3, 1000.0, "", 100.0, 100.0, 0, -10.0); + CHECK(std::isnan(utc.sharpe_tv)); + + // NY: first point in Jan -> 3 buckets (Jan, Feb, Mar) -> 2 returns -> finite. + pf_equity_stats_t ny = pineforge::metrics::compute_equity_stats( + c, 3, 1000.0, "America/New_York", 100.0, 100.0, 0, -10.0); + CHECK(!std::isnan(ny.sharpe_tv)); + CHECK(std::fabs(ny.sharpe_tv - (-0.04082482904638629)) < 1e-9); + CHECK(std::fabs(ny.sortino_tv - (-0.08031113910764517)) < 1e-9); +} + +// ---------- fill_report metrics integration (Task 6) ----------------------- + +static void test_report_metrics_integration() { + std::printf("report metrics: cross-field invariants on a real run\n"); + MomoFlip s; + std::vector bars = make_feed(300); + s.run(bars.data(), (int)bars.size()); + ReportC rep{}; + s.fill_report(&rep); + const pf_metrics_t& m = rep.metrics; + CHECK(m.all.num_trades == rep.trades_len); + CHECK(std::fabs(m.all.net_profit - rep.net_profit) < 1e-9); + CHECK(m.all.num_trades == m.longs.num_trades + m.shorts.num_trades); + CHECK(m.all.num_trades == m.all.num_wins + m.all.num_losses + m.all.num_even); + CHECK(std::fabs(m.all.net_profit - (m.longs.net_profit + m.shorts.net_profit)) < 1e-9); + CHECK(rep.equity_curve_len == (int64_t)s.curve().size()); + CHECK(rep.equity_curve != nullptr); + // Guarded so a regression CHECK-fails (above) instead of segfaulting here. + if (rep.equity_curve != nullptr && rep.equity_curve_len > 0) { + const pf_equity_point_t& last = rep.equity_curve[rep.equity_curve_len - 1]; + CHECK(std::fabs(last.equity - (1'000'000.0 + rep.net_profit + m.equity.open_pl)) < 1e-9); + // curve dd walk must reproduce the engine's internal scalar extreme + CHECK(std::fabs(m.equity.max_equity_drawdown - s.max_dd()) < 1e-9); + // report curve must be a faithful copy of the internal one + for (int64_t i = 0; i < rep.equity_curve_len; ++i) { + CHECK(rep.equity_curve[i].time_ms == s.curve()[(size_t)i].time_ms); + CHECK(rep.equity_curve[i].equity == s.curve()[(size_t)i].equity); + } + } + BacktestEngine::free_report(&rep); +} + +// ---------- Empty-run fill_report (zero bars) ------------------------------- +// run(nullptr, 0) is safe: engine_run.cpp guards the bar loop on n > 0 and +// reset_run_state() still executes, so fill_report sees an empty curve and +// zero trades. Pins the nullptr/0/NaN conventions of the empty report. + +static void test_report_empty_run() { + std::printf("report metrics: empty run (n=0 bars)\n"); + MomoFlip s; + s.run(nullptr, 0); + ReportC rep{}; + s.fill_report(&rep); + CHECK(rep.equity_curve == nullptr); + CHECK(rep.equity_curve_len == 0); + CHECK(std::isnan(rep.metrics.equity.sharpe_tv)); + CHECK(rep.metrics.all.num_trades == 0); + BacktestEngine::free_report(&rep); +} + +int main() { + test_trade_commission_and_bar_indexes(); + test_trade_commission_cash_per_contract(); + test_equity_curve_basic(); + test_equity_curve_magnifier_invariant(); + test_trade_stats_all(); + test_trade_stats_filters_and_nan(); + test_trade_stats_largest_pct_independent(); + test_equity_stats_sharpe_sortino_tv(); + test_equity_stats_drawdown_walk(); + test_equity_stats_edges(); + test_flat_strategy_bars_in_market(); + test_engine_vs_walk_dd_invariant(); + test_equity_stats_per_bar_oracle(); + test_equity_stats_non_utc_bucketing(); + test_report_metrics_integration(); + test_report_empty_run(); + + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +} diff --git a/tests/test_pointvalue.cpp b/tests/test_pointvalue.cpp index 1ef6405..2569cb4 100644 --- a/tests/test_pointvalue.cpp +++ b/tests/test_pointvalue.cpp @@ -142,8 +142,11 @@ static void test_fixed_qty_pnl_scales_by_pointvalue() { CHECK(near(b.pnl, a.pnl * 50.0)); CHECK(near(b.max_runup, a.max_runup * 50.0)); CHECK(near(b.max_drawdown, a.max_drawdown * 50.0)); - // pnl_pct is a price-return % — point-value invariant. - CHECK(b.pnl_pct == a.pnl_pct); + // pnl_pct is net return-on-cost (pnl / (entry*qty*pointvalue)): + // pnl and entry cost both scale by pointvalue (percent commission + // scales the notional too), so the ratio stays pointvalue-invariant + // — but only mathematically, not bit-for-bit, hence near(). + CHECK(near(b.pnl_pct, a.pnl_pct)); } CHECK(near(fut.net_sum(), base.net_sum() * 50.0)); // Equity-curve extremes are in account currency too. diff --git a/tutorial/run.py b/tutorial/run.py index 5db75ed..bd76440 100755 --- a/tutorial/run.py +++ b/tutorial/run.py @@ -26,7 +26,54 @@ class TradeC(ctypes.Structure): ("entry_price", ctypes.c_double),("exit_price", ctypes.c_double), ("pnl", ctypes.c_double),("pnl_pct", ctypes.c_double), ("is_long", ctypes.c_int), ("max_runup", ctypes.c_double), - ("max_drawdown",ctypes.c_double),("qty", ctypes.c_double)] + ("max_drawdown",ctypes.c_double),("qty", ctypes.c_double), + ("commission", ctypes.c_double), + ("entry_bar_index", ctypes.c_int32), + ("exit_bar_index", ctypes.c_int32)] + +class TradeStatsC(ctypes.Structure): # pf_trade_stats_t + _fields_ = [("num_trades", ctypes.c_int32), ("num_wins", ctypes.c_int32), + ("num_losses", ctypes.c_int32), ("num_even", ctypes.c_int32), + ("percent_profitable", ctypes.c_double), + ("net_profit", ctypes.c_double), ("net_profit_pct", ctypes.c_double), + ("gross_profit", ctypes.c_double), ("gross_profit_pct", ctypes.c_double), + ("gross_loss", ctypes.c_double), ("gross_loss_pct", ctypes.c_double), + ("profit_factor", ctypes.c_double), + ("avg_trade", ctypes.c_double), ("avg_trade_pct", ctypes.c_double), + ("avg_win", ctypes.c_double), ("avg_win_pct", ctypes.c_double), + ("avg_loss", ctypes.c_double), ("avg_loss_pct", ctypes.c_double), + ("ratio_avg_win_avg_loss", ctypes.c_double), + ("largest_win", ctypes.c_double), ("largest_win_pct", ctypes.c_double), + ("largest_loss", ctypes.c_double), ("largest_loss_pct", ctypes.c_double), + ("commission_paid", ctypes.c_double), + ("expectancy", ctypes.c_double), + ("max_consecutive_wins", ctypes.c_int32), + ("max_consecutive_losses", ctypes.c_int32), + ("avg_bars_in_trade", ctypes.c_double), + ("avg_bars_in_wins", ctypes.c_double), + ("avg_bars_in_losses", ctypes.c_double)] + +class EquityStatsC(ctypes.Structure): # pf_equity_stats_t + _fields_ = [("max_equity_drawdown", ctypes.c_double), + ("max_equity_drawdown_pct", ctypes.c_double), + ("max_equity_runup", ctypes.c_double), + ("max_equity_runup_pct", ctypes.c_double), + ("buy_hold_return", ctypes.c_double), + ("buy_hold_return_pct", ctypes.c_double), + ("sharpe_tv", ctypes.c_double), ("sortino_tv", ctypes.c_double), + ("sharpe_bar", ctypes.c_double), ("sortino_bar", ctypes.c_double), + ("cagr", ctypes.c_double), ("calmar", ctypes.c_double), + ("recovery_factor", ctypes.c_double), + ("time_in_market_pct", ctypes.c_double), + ("open_pl", ctypes.c_double)] + +class MetricsC(ctypes.Structure): # pf_metrics_t + _fields_ = [("all", TradeStatsC), ("longs", TradeStatsC), + ("shorts", TradeStatsC), ("equity", EquityStatsC)] + +class EquityPointC(ctypes.Structure): # pf_equity_point_t + _fields_ = [("time_ms", ctypes.c_int64), ("equity", ctypes.c_double), + ("open_profit", ctypes.c_double)] class _Diag(ctypes.Structure): _fields_ = [("sec_id", ctypes.c_int), ("feed_count", ctypes.c_int64), @@ -57,7 +104,28 @@ class ReportC(ctypes.Structure): ("security_diag_len", ctypes.c_int), ("trace", ctypes.POINTER(_Trace)), ("trace_len", ctypes.c_int), ("trace_names", ctypes.POINTER(ctypes.c_char_p)), - ("trace_names_len", ctypes.c_int)] + ("trace_names_len", ctypes.c_int), + ("metrics", MetricsC), + ("equity_curve", ctypes.POINTER(EquityPointC)), + ("equity_curve_len", ctypes.c_int64)] # int64, NOT c_int + + +# pf_report_t is caller-allocated, so a stale mirror means the runtime +# writes past our buffer. Assert the .so's ABI version before any run. +EXPECTED_PF_ABI = 2 + +def check_abi(lib: ctypes.CDLL) -> None: + try: + lib.pf_abi_version.restype = ctypes.c_int + abi = lib.pf_abi_version() + except AttributeError: + raise RuntimeError( + "strategy .so predates pf_abi_version (ABI v1); rebuild it against " + "the current pineforge runtime (pf_report_t grew).") + if abi != EXPECTED_PF_ABI: + raise RuntimeError( + f"pineforge ABI mismatch: .so reports {abi}, harness expects " + f"{EXPECTED_PF_ABI}; rebuild.") def main() -> int: @@ -73,6 +141,7 @@ def main() -> int: float(r["close"]), float(r["volume"]), int(r["timestamp"])) lib = ctypes.CDLL(str(SO)) + check_abi(lib) lib.strategy_create.argtypes = [ctypes.c_char_p] lib.strategy_create.restype = ctypes.c_void_p lib.run_backtest_full.argtypes = [ diff --git a/tutorial/run_advanced.py b/tutorial/run_advanced.py index ffee3bf..93ca3b2 100755 --- a/tutorial/run_advanced.py +++ b/tutorial/run_advanced.py @@ -21,7 +21,7 @@ # Reuse the ctypes struct mirrors + paths from run.py — same engine, # same ABI, no need to retype 60 lines of struct fields. sys.path.insert(0, str(Path(__file__).resolve().parent)) -from run import BarC, ReportC, SO, OHLCV # noqa: E402 +from run import BarC, ReportC, SO, OHLCV, check_abi # noqa: E402 import csv # noqa: E402 @@ -40,6 +40,7 @@ def load_bars(): def load_lib(): lib = ctypes.CDLL(str(SO)) + check_abi(lib) lib.strategy_create.argtypes = [ctypes.c_char_p] lib.strategy_create.restype = ctypes.c_void_p lib.strategy_set_input.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p] diff --git a/tutorial/run_mtf.py b/tutorial/run_mtf.py index c77a255..f7ef2ef 100755 --- a/tutorial/run_mtf.py +++ b/tutorial/run_mtf.py @@ -18,7 +18,7 @@ # Reuse the ctypes mirrors + paths from run.py — same engine, same ABI. sys.path.insert(0, str(Path(__file__).resolve().parent)) -from run import BarC, ReportC, OHLCV # noqa: E402 +from run import BarC, ReportC, OHLCV, check_abi # noqa: E402 ROOT = Path(__file__).resolve().parent SO_HTF = ROOT / "mtf" / "strategy_htf.so" @@ -39,6 +39,7 @@ def load_bars(): def load_lib(so_path: Path): lib = ctypes.CDLL(str(so_path)) + check_abi(lib) lib.strategy_create.argtypes = [ctypes.c_char_p] lib.strategy_create.restype = ctypes.c_void_p lib.strategy_set_input.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p]