diff --git a/corpus b/corpus index c49e1fd..481078b 160000 --- a/corpus +++ b/corpus @@ -1 +1 @@ -Subproject commit c49e1fd0fe5f5a2bd4699d08f2b19ac0427ae78f +Subproject commit 481078be600de2438f6253d4b743fe59c9816921 diff --git a/docs/coverage.md b/docs/coverage.md index b954a94..67ce080 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -426,7 +426,7 @@ The runtime exposes only two pieces under math: | Symbol | Signature | Notes | | ------------------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `pine_random(lo, call_site, hi, seed, bar_index)` | inline free function | Deterministic SplitMix64-style mixer. Stable across platforms / runs; **not** TradingView's PRNG. | -| `math::Sum(length)` | class with `compute(src)` / `recompute(src)` | Rolling-window sum used to back PineScript `math.sum(source, length)`. NaN inputs short-circuit to NaN output. | +| `math::Sum(length)` | class with `compute(src)` / `recompute(src)` | Rolling sum used to back PineScript `math.sum(source, length)`. `na` sources are ignored: output stays `na` until `length` non-`na` values exist, then retains the sum of the last `length` non-`na` values, including on `na`-input bars. | Order-fill rounding to mintick lives on `BacktestEngine::round_to_mintick(price)`. diff --git a/include/pineforge/math.hpp b/include/pineforge/math.hpp index a4eeb8f..175bcb1 100644 --- a/include/pineforge/math.hpp +++ b/include/pineforge/math.hpp @@ -21,12 +21,26 @@ inline double pine_random(double lo, uint32_t call_site, double hi, uint32_t see namespace math { -/// Rolling-window sum for PineScript `math.sum(source, length)` (not a `ta.*` builtin). +/// Rolling sum over the last `length` non-na sources for PineScript +/// `math.sum(source, length)` (not a `ta.*` builtin). Returns na until seeded, +/// then holds the seeded sum on na-input bars. class Sum { int length_; std::deque buffer_; double sum_; + // State needed to rewind the current bar before an intrabar recompute. + // A valid input can append one value and evict at most one old value, so + // restoring this delta stays O(1) without copying the rolling window. + double saved_sum_; + bool has_saved_state_; + bool current_value_added_; + bool current_value_evicted_; + double current_evicted_value_; + + double apply(double src); + void restore(); + public: explicit Sum(int length); double compute(double src); diff --git a/scripts/regen_validation_report.py b/scripts/regen_validation_report.py index cb35921..0d9c190 100755 --- a/scripts/regen_validation_report.py +++ b/scripts/regen_validation_report.py @@ -65,141 +65,8 @@ def _category_of(slug: str) -> str: def _verify_probe(strategy_dir: Path) -> dict: - """Run verify_corpus.verify_one but capture all the headline numbers. - - We replicate the inner logic (cheaper than parsing stdout) so we can - pull entry/exit/pnl p90, count delta, tv/eng/matched counts. - """ - rel = f"validation/{strategy_dir.name}" - meta = vc.load_strategy_metadata(strategy_dir) - tv_path = strategy_dir / str(meta.get("tv_trades_csv", "tv_trades.csv")) - eng_path = strategy_dir / "engine_trades.csv" - if not tv_path.exists() or not eng_path.exists(): - return { - "slug": strategy_dir.name, - "rel": rel, - "tier": "missing", - "tv": 0, "eng": 0, "matched": 0, - "count_delta": 0.0, "entry_p90": 0.0, "exit_p90": 0.0, "pnl_p90": 0.0, - "profile": "n/a", "notes": "tv_trades.csv or engine_trades.csv missing", - } - - tv = vc.parse_trades(tv_path, tz=vc.tv_tzinfo(meta)) - eng = vc.parse_trades(eng_path, tz=vc.timezone.utc) - # Keep the report in lock-step with verify_one: consolidate fragment rows - # (qty_step rounding / FIFO partial-close lots) symmetrically before pairing. - tv = vc.consolidate_fragments(tv) - eng = vc.consolidate_fragments(eng) - matched = vc.align_by_time(tv, eng) - tv_cmp, eng_cmp = vc.trim_to_common_match_window(tv, eng, matched) - matched = vc.align_by_time(tv_cmp, eng_cmp) - - profile = vc.resolve_profile(strategy_dir, meta) - thresh = vc.parity_for_profile(profile) - - trim_bars = int(meta.get("trim_bars", 0) or 0) - warmup_bars = int(meta.get("warmup_bars", 0) or 0) - first_ms, last_ms, bar_ms = vc._ohlcv_span_ms(strategy_dir) - bounds = vc.interior_time_bounds(trim_bars, warmup_bars, - first_ms, last_ms, bar_ms) - - if bounds is not None: - interior_matched = [(t, e) for (t, e) in matched - if vc.is_interior(t.entry_time * 1000, bounds)] - gating_matched = interior_matched if interior_matched else matched - tv_gate = [t for t in tv_cmp if vc.is_interior(t.entry_time * 1000, bounds)] - eng_gate = [e for e in eng_cmp if vc.is_interior(e.entry_time * 1000, bounds)] - else: - gating_matched = matched - tv_gate, eng_gate = tv_cmp, eng_cmp - - if not gating_matched: - # Mirror verify_one's no-aligned-trades branch. - tier = "excellent" if (len(tv_cmp) == 0 and len(eng_cmp) == 0) else "minimal" - return { - "slug": strategy_dir.name, - "rel": rel, - "tier": tier, - "tv": len(tv_cmp), "eng": len(eng_cmp), "matched": 0, - "count_delta": vc.relative_max(len(tv_gate), len(eng_gate)), - "count_abs_delta": abs(len(tv_gate) - len(eng_gate)), - "entry_p90": 0.0, "exit_p90": 0.0, "pnl_p90": 0.0, - "profile": profile, - "notes": meta.get("notes", "no aligned trades"), - } - - count_abs_delta = abs(len(tv_gate) - len(eng_gate)) - count_delta = vc.relative_max(len(tv_gate), len(eng_gate)) - entry_deltas = [vc.relative_max(t.entry_price, e.entry_price) for t, e in gating_matched] - exit_deltas = [vc.relative_max(t.exit_price, e.exit_price) for t, e in gating_matched] - pnl_deltas: list[float] = [] - for t, e in gating_matched: - if abs(t.pnl) < vc.PNL_NEAR_ZERO_USD: - continue - pnl_deltas.append(abs(t.pnl - e.pnl) / abs(t.pnl)) - - entry_p90 = vc.percentile(entry_deltas, 0.90) - exit_p90 = vc.percentile(exit_deltas, 0.90) - pnl_p90 = vc.percentile(pnl_deltas, 0.90) if pnl_deltas else 0.0 - - count_ok = count_abs_delta == 0 - entry_ok = entry_p90 < thresh["entry"] - exit_ok = exit_p90 < thresh["exit"] - pnl_ok = pnl_p90 < thresh["pnl"] - all_ok = count_ok and entry_ok and exit_ok and pnl_ok - if all_ok: - tier = "excellent" - elif ( - len(gating_matched) / max(len(tv_gate), 1) >= 0.99 - and count_abs_delta <= 1 - and count_delta < vc.STRONG_COUNT_DELTA - and entry_p90 < vc.STRONG_ENTRY_DELTA - and exit_p90 < vc.STRONG_EXIT_DELTA - and pnl_p90 < vc.STRONG_PNL_DELTA - ): - tier = "strong" - elif len(gating_matched) / max(len(tv_gate), 1) >= 0.90: - tier = "moderate" - elif gating_matched: - tier = "weak" - else: - tier = "minimal" - - expected_tier = str(meta.get("expected_tier", "")).strip().lower() - if expected_tier in {"anomaly", "engine_only"} and tier != "excellent": - tier = expected_tier - val_overrides = meta.get("validation_overrides") or {} - expect_tv_match = bool(val_overrides.get("expect_tv_match", True)) - if not expect_tv_match and tier != "excellent": - tier = "engine_only" - - notes = "" - if tier != "excellent": - notes = str(meta.get("notes", "")).strip() - if not notes: - # Synthesize a one-line reason from which gate failed. - failures = [] - if not count_ok: failures.append(f"count Δ {count_delta*100:.2f}%") - if not entry_ok: failures.append(f"entry p90 {entry_p90*100:.4f}%") - if not exit_ok: failures.append(f"exit p90 {exit_p90*100:.4f}%") - if not pnl_ok: failures.append(f"pnl p90 {pnl_p90*100:.4f}%") - notes = "; ".join(failures) if failures else "non-excellent" - - return { - "slug": strategy_dir.name, - "rel": rel, - "tier": tier, - "tv": len(tv_cmp), - "eng": len(eng_cmp), - "matched": len(matched), - "count_delta": count_delta, - "count_abs_delta": count_abs_delta, - "entry_p90": entry_p90, - "exit_p90": exit_p90, - "pnl_p90": pnl_p90, - "profile": profile, - "notes": notes, - } + """Adapt the canonical structured analysis to one report row.""" + return vc.analyze_strategy(strategy_dir).report_row() def _fmt_pct(x: float) -> str: diff --git a/scripts/run_corpus.sh b/scripts/run_corpus.sh index 8d8c430..75bfe27 100755 --- a/scripts/run_corpus.sh +++ b/scripts/run_corpus.sh @@ -5,6 +5,8 @@ # 2. Run each strategy through the Python harness against the reference # OHLCV feed; write engine_trades.csv next to each strategy.so. # 3. Verify TV parity with scripts/verify_corpus.py. +# 4. Regenerate the canonical validation report and assert report/verifier +# lock-step on the historical drift fixtures. # # Honours these env vars: # BUILD_DIR — CMake build directory (default: build) @@ -141,4 +143,10 @@ if [[ "${SKIP_VERIFY:-0}" != "1" ]]; then fi fi +# --- 4) report -------------------------------------------------------- + +log "regenerating canonical validation report" +"$PY" scripts/regen_validation_report.py +"$PY" scripts/validation_report_self_test.py + log "done." diff --git a/scripts/validation_report_self_test.py b/scripts/validation_report_self_test.py new file mode 100755 index 0000000..1970457 --- /dev/null +++ b/scripts/validation_report_self_test.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Assert the Markdown report and canonical corpus verifier stay lock-step. + +The default fixtures are the three probes that exposed distinct stale-report +paths in July 2026. Pass ``--all`` to audit every initialized validation probe. +The test compares both tiers and report metrics, so a copied/stale rubric +cannot silently change the headline or its supporting numbers. +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +SCRIPTS = REPO / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +import regen_validation_report as report # noqa: E402 +import verify_corpus as verifier # noqa: E402 + +DRIFT_FIXTURES = { + "composite-trendmaster-three-tier-ema-state-01", + "pyramid-deferred-flip-close-all-01", + "ta-stochastic-rsi-cross-01", +} + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--all", action="store_true", help="compare every validation probe") + args = ap.parse_args() + + root = REPO / "corpus" / "validation" + if not root.is_dir(): + print(f"SKIP: validation corpus not found ({root})") + return 0 + + probes = sorted( + p for p in root.iterdir() + if p.is_dir() + and p.name != "symbol-specified" + and (args.all or p.name in DRIFT_FIXTURES) + ) + if not args.all and {p.name for p in probes} != DRIFT_FIXTURES: + missing = sorted(DRIFT_FIXTURES - {p.name for p in probes}) + print(f"SKIP: drift fixtures not found: {', '.join(missing)}") + return 0 + mismatches: list[str] = [] + verifier_counts: dict[str, int] = {} + report_counts: dict[str, int] = {} + + for probe in probes: + analysis = verifier.analyze_strategy(probe) + canonical_row = analysis.report_row() + report_row = report._verify_probe(probe) + canonical_tier = analysis.label + report_tier = report_row["tier"] + verifier_counts[canonical_tier] = verifier_counts.get(canonical_tier, 0) + 1 + report_counts[report_tier] = report_counts.get(report_tier, 0) + 1 + if canonical_tier != report_tier: + mismatches.append( + f"{probe.name}: verifier={canonical_tier}, report={report_tier}" + ) + if report_row != canonical_row: + mismatches.append(f"{probe.name}: report metrics differ from canonical analysis") + + if mismatches: + print("FAIL: report tier drift detected") + for mismatch in mismatches: + print(f" {mismatch}") + print(f" verifier counts: {verifier_counts}") + print(f" report counts: {report_counts}") + return 1 + + scope = "all probes" if args.all else "drift fixtures" + print(f"OK: {len(probes)} {scope} match canonical tiers and metrics") + print(f" tier counts: {verifier_counts}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_corpus.py b/scripts/verify_corpus.py index 7a11976..8142c67 100755 --- a/scripts/verify_corpus.py +++ b/scripts/verify_corpus.py @@ -32,7 +32,7 @@ import csv import re import sys -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime, timezone, timedelta from pathlib import Path @@ -270,6 +270,88 @@ class TradePair: mae: float = 0.0 +@dataclass +class VerificationResult: + """Canonical structured parity analysis for one corpus strategy. + + ``verify_one`` is intentionally only a presentation wrapper around this + object. Report generators must consume :func:`analyze_strategy` instead + of copying the rubric; otherwise a new gate can silently change CLI tiers + without changing the generated validation report. + """ + + strategy_dir: Path + rel: str + label: str + profile: str = "n/a" + notes: str = "" + tv_path: Path | None = None + eng_path: Path | None = None + tv_exists: bool = True + eng_exists: bool = True + no_aligned_trades: bool = False + tv_count: int = 0 + eng_count: int = 0 + tv_raw_count: int = 0 + eng_raw_count: int = 0 + matched_count: int = 0 + gating_matched_count: int = 0 + tv_gate_count: int = 0 + eng_gate_count: int = 0 + count_delta: float = 0.0 + count_abs_delta: int = 0 + entry_p90: float = 0.0 + exit_p90: float = 0.0 + pnl_p90: float = 0.0 + coverage: float = 0.0 + unmatched_total: int = 0 + coverage_tv_count: int = 0 + trim_bars: int = 0 + warmup_bars: int = 0 + bounds: tuple[int, int] | None = None + count_ok: bool = False + entry_ok: bool = False + exit_ok: bool = False + pnl_ok: bool = False + coverage_ok: bool = False + unmatched_in_window: int = 0 + entry_p100: float = 0.0 + exit_p100: float = 0.0 + pnl_p100: float = 0.0 + qty_p100: float = 0.0 + pnlpct_p100: float = 0.0 + mfe_p90: float = 0.0 + mae_p90: float = 0.0 + matched: list[tuple[TradePair, TradePair]] = field(default_factory=list, repr=False) + entry_deltas: list[float] = field(default_factory=list, repr=False) + exit_deltas: list[float] = field(default_factory=list, repr=False) + pnl_deltas: list[float] = field(default_factory=list, repr=False) + qty_deltas: list[float] = field(default_factory=list, repr=False) + pnlpct_deltas: list[float] = field(default_factory=list, repr=False) + + @property + def tier(self) -> str: + return self.label + + def report_row(self) -> dict: + """Return the canonical report-facing fields without re-analysis.""" + return { + "slug": self.strategy_dir.name, + "rel": self.rel, + "tier": self.label, + "tv": self.tv_count, + "eng": self.eng_count, + "matched": self.matched_count, + "count_delta": self.count_delta, + "count_abs_delta": self.count_abs_delta, + "entry_p90": self.entry_p90, + "exit_p90": self.exit_p90, + "pnl_p90": self.pnl_p90, + "profile": self.profile, + "notes": self.notes, + } + + def tv_tzinfo(meta: dict): """Resolve the TV export timezone to a tzinfo. @@ -641,7 +723,12 @@ def percentile(xs: list[float], p: float) -> float: return s[f] * (c - k) + s[c] * (k - f) -def verify_one(strategy_dir: Path, *, verbose: bool = True, show_diffs: int = 0) -> str: +def analyze_strategy(strategy_dir: Path) -> VerificationResult: + """Analyze one strategy using the canonical parity rubric. + + This is the single structured API for both the CLI and generated reports. + Keep all tier-affecting and metric-affecting logic inside this function. + """ rel = strategy_dir.name if strategy_dir.parent.name in {"basic", "community", "validation", "parity-anomalies"}: rel = f"{strategy_dir.parent.name}/{strategy_dir.name}" @@ -649,9 +736,16 @@ def verify_one(strategy_dir: Path, *, verbose: bool = True, show_diffs: int = 0) tv_path = strategy_dir / str(meta.get("tv_trades_csv", "tv_trades.csv")) eng_path = strategy_dir / "engine_trades.csv" if not tv_path.exists() or not eng_path.exists(): - if verbose: - print(f"{rel}\n MISSING (tv: {tv_path.exists()}, engine: {eng_path.exists()})") - return "missing" + return VerificationResult( + strategy_dir=strategy_dir, + rel=rel, + label="missing", + notes="tv_trades.csv or engine_trades.csv missing", + tv_path=tv_path, + eng_path=eng_path, + tv_exists=tv_path.exists(), + eng_exists=eng_path.exists(), + ) tv_raw_all = parse_trades(tv_path, tz=tv_tzinfo(meta)) eng_raw_all = parse_trades(eng_path, tz=timezone.utc) @@ -668,9 +762,24 @@ def verify_one(strategy_dir: Path, *, verbose: bool = True, show_diffs: int = 0) matched = align_by_time(tv_cmp, eng_cmp) if not matched: - if verbose: - print(f"{rel}: TV={len(tv)} engine={len(eng)} matched=0 (no aligned trades)") - return "excellent" if len(tv_cmp) == 0 and len(eng_cmp) == 0 else "minimal" + label = "excellent" if len(tv_cmp) == 0 and len(eng_cmp) == 0 else "minimal" + return VerificationResult( + strategy_dir=strategy_dir, + rel=rel, + label=label, + profile=resolve_profile(strategy_dir, meta), + notes=str(meta.get("notes", "")).strip() or "no aligned trades", + tv_path=tv_path, + eng_path=eng_path, + no_aligned_trades=True, + tv_count=len(tv_cmp), + eng_count=len(eng_cmp), + tv_raw_count=len(tv), + eng_raw_count=len(eng), + count_delta=relative_max(len(tv_cmp), len(eng_cmp)), + count_abs_delta=abs(len(tv_cmp) - len(eng_cmp)), + count_ok=len(tv_cmp) == len(eng_cmp), + ) # Resolve parity profile (strict vs production) per probe. profile = resolve_profile(strategy_dir, meta) @@ -880,56 +989,146 @@ def _is_dropped_open(t): if not expect_tv_match and label != "excellent": label = "engine_only" - if verbose: - check = lambda b: "OK" if b else "X" - match_pct = 100.0 * len(matched) / max(len(tv), 1) - interior_line = "" - if bounds is not None: - interior_line = ( - f" Interior-only: tv={len(tv_gate)} eng={len(eng_gate)} " - f"matched={len(gating_matched)} (trim_bars={trim_bars}, warmup_bars={warmup_bars})\n" - ) + notes = "" + if label != "excellent": + notes = str(meta.get("notes", "")).strip() + if not notes: + failures = [] + if not count_ok: + failures.append(f"count Δ {count_delta*100:.2f}%") + if not entry_ok: + failures.append(f"entry p90 {entry_p90*100:.4f}%") + if not exit_ok: + failures.append(f"exit p90 {exit_p90*100:.4f}%") + if not pnl_ok: + failures.append(f"pnl p90 {pnl_p90*100:.4f}%") + if not cov_excellent: + failures.append(f"coverage {coverage*100:.1f}%") + notes = "; ".join(failures) if failures else "non-excellent" + + return VerificationResult( + strategy_dir=strategy_dir, + rel=rel, + label=label, + profile=profile, + notes=notes, + tv_path=tv_path, + eng_path=eng_path, + tv_count=len(tv_cmp), + eng_count=len(eng_cmp), + tv_raw_count=len(tv), + eng_raw_count=len(eng), + matched_count=len(matched), + gating_matched_count=len(gating_matched), + tv_gate_count=len(tv_gate), + eng_gate_count=len(eng_gate), + count_delta=count_delta, + count_abs_delta=count_abs_delta, + entry_p90=entry_p90, + exit_p90=exit_p90, + pnl_p90=pnl_p90, + coverage=coverage, + unmatched_total=unmatched_total, + coverage_tv_count=len(tv_cov_denom), + trim_bars=trim_bars, + warmup_bars=warmup_bars, + bounds=bounds, + count_ok=count_ok, + entry_ok=entry_ok, + exit_ok=exit_ok, + pnl_ok=pnl_ok, + coverage_ok=cov_excellent, + unmatched_in_window=unmatched_in_window, + entry_p100=entry_p100, + exit_p100=exit_p100, + pnl_p100=pnl_p100, + qty_p100=qty_p100, + pnlpct_p100=pnlpct_p100, + mfe_p90=mfe_p90, + mae_p90=mae_p90, + matched=matched, + entry_deltas=entry_deltas, + exit_deltas=exit_deltas, + pnl_deltas=pnl_deltas, + qty_deltas=qty_deltas, + pnlpct_deltas=pnlpct_deltas, + ) + + +def _print_verification(result: VerificationResult, *, show_diffs: int = 0) -> None: + if result.label == "missing": print( - f"{rel}\n" - f" Profile: {profile}\n" - f" TV trades: {len(tv_cmp)} (raw {len(tv)})\n" - f" Engine trades: {len(eng_cmp)} (raw {len(eng)})\n" - f" Matched: {len(matched)} ({match_pct:.1f}% of TV)\n" - f"{interior_line}" - f" Count delta: {count_delta * 100:8.4f}% ({check(count_ok)}; abs={count_abs_delta})\n" - f" Entry-price p90 delta: {entry_p90 * 100:8.4f}% ({check(entry_ok)})\n" - f" Exit-price p90 delta: {exit_p90 * 100:8.4f}% ({check(exit_ok)})\n" - f" PnL p90 delta: {pnl_p90 * 100:8.4f}% ({check(pnl_ok)})\n" - f" Coverage: {coverage * 100:8.1f}% ({check(cov_excellent)}; unmatched={unmatched_total} of {len(tv_cov_denom)} TV)\n" - f" -- report-only (not gated; MAE/MFE are intrabar-path-limited) --\n" - f" Entry/Exit p99 delta: {percentile(entry_deltas,0.99)*100:.4f}% / {percentile(exit_deltas,0.99)*100:.4f}%\n" - f" Entry/Exit/PnL p100: {entry_p100*100:.4f}% / {exit_p100*100:.4f}% / {pnl_p100*100:.4f}%\n" - f" Qty p90/p100 delta: {percentile(qty_deltas,0.90)*100:.4f}% / {qty_p100*100:.4f}%\n" - f" PnL% p90/p100 (pts): {percentile(pnlpct_deltas,0.90):.4f} / {pnlpct_p100:.4f}\n" - f" MFE/MAE p90 delta: {mfe_p90*100:.4f}% / {mae_p90*100:.4f}%\n" - f" Unmatched-in-window: {unmatched_in_window}\n" - f" -> {label}" + f"{result.rel}\n" + f" MISSING (tv: {result.tv_exists}, engine: {result.eng_exists})" ) - if show_diffs > 0: - # Show the trades with the worst deltas - ranked = sorted(zip(matched, entry_deltas, exit_deltas, pnl_deltas), - key=lambda x: -max(x[1], x[2], x[3])) - print(f"\n worst {show_diffs} matched trades by max-of-(entry, exit, pnl) delta:") - for (tv_t, e_t), ed, xd, pd in ranked[:show_diffs]: - print( - f" TV #{tv_t.trade_num:4d} {tv_t.direction:5s} " - f"@{datetime.fromtimestamp(tv_t.entry_time, tz=timezone.utc):%Y-%m-%d %H:%M} " - f"entry={tv_t.entry_price:10.4f} exit={tv_t.exit_price:10.4f} pnl={tv_t.pnl:+10.4f}" - ) - print( - f" eng #{e_t.trade_num:4d} {e_t.direction:5s} " - f"@{datetime.fromtimestamp(e_t.entry_time, tz=timezone.utc):%Y-%m-%d %H:%M} " - f"entry={e_t.entry_price:10.4f} exit={e_t.exit_price:10.4f} pnl={e_t.pnl:+10.4f}" - ) - print( - f" deltas: entry={ed*100:.4f}% exit={xd*100:.4f}% pnl={pd*100:.4f}%" - ) - return label + return + if result.no_aligned_trades: + print( + f"{result.rel}: TV={result.tv_raw_count} engine={result.eng_raw_count} " + "matched=0 (no aligned trades)" + ) + return + + check = lambda b: "OK" if b else "X" + match_pct = 100.0 * result.matched_count / max(result.tv_raw_count, 1) + interior_line = "" + if result.bounds is not None: + interior_line = ( + f" Interior-only: tv={result.tv_gate_count} eng={result.eng_gate_count} " + f"matched={result.gating_matched_count} " + f"(trim_bars={result.trim_bars}, warmup_bars={result.warmup_bars})\n" + ) + print( + f"{result.rel}\n" + f" Profile: {result.profile}\n" + f" TV trades: {result.tv_count} (raw {result.tv_raw_count})\n" + f" Engine trades: {result.eng_count} (raw {result.eng_raw_count})\n" + f" Matched: {result.matched_count} ({match_pct:.1f}% of TV)\n" + f"{interior_line}" + f" Count delta: {result.count_delta * 100:8.4f}% ({check(result.count_ok)}; abs={result.count_abs_delta})\n" + f" Entry-price p90 delta: {result.entry_p90 * 100:8.4f}% ({check(result.entry_ok)})\n" + f" Exit-price p90 delta: {result.exit_p90 * 100:8.4f}% ({check(result.exit_ok)})\n" + f" PnL p90 delta: {result.pnl_p90 * 100:8.4f}% ({check(result.pnl_ok)})\n" + f" Coverage: {result.coverage * 100:8.1f}% ({check(result.coverage_ok)}; unmatched={result.unmatched_total} of {result.coverage_tv_count} TV)\n" + f" -- report-only (not gated; MAE/MFE are intrabar-path-limited) --\n" + f" Entry/Exit p99 delta: {percentile(result.entry_deltas,0.99)*100:.4f}% / {percentile(result.exit_deltas,0.99)*100:.4f}%\n" + f" Entry/Exit/PnL p100: {result.entry_p100*100:.4f}% / {result.exit_p100*100:.4f}% / {result.pnl_p100*100:.4f}%\n" + f" Qty p90/p100 delta: {percentile(result.qty_deltas,0.90)*100:.4f}% / {result.qty_p100*100:.4f}%\n" + f" PnL% p90/p100 (pts): {percentile(result.pnlpct_deltas,0.90):.4f} / {result.pnlpct_p100:.4f}\n" + f" MFE/MAE p90 delta: {result.mfe_p90*100:.4f}% / {result.mae_p90*100:.4f}%\n" + f" Unmatched-in-window: {result.unmatched_in_window}\n" + f" -> {result.label}" + ) + if show_diffs > 0: + # Preserve the historical diagnostic display. Metrics and the tier are + # already fixed in ``result``; this formatting cannot affect grading. + ranked = sorted( + zip(result.matched, result.entry_deltas, result.exit_deltas, result.pnl_deltas), + key=lambda x: -max(x[1], x[2], x[3]), + ) + print(f"\n worst {show_diffs} matched trades by max-of-(entry, exit, pnl) delta:") + for (tv_t, e_t), ed, xd, pd in ranked[:show_diffs]: + print( + f" TV #{tv_t.trade_num:4d} {tv_t.direction:5s} " + f"@{datetime.fromtimestamp(tv_t.entry_time, tz=timezone.utc):%Y-%m-%d %H:%M} " + f"entry={tv_t.entry_price:10.4f} exit={tv_t.exit_price:10.4f} pnl={tv_t.pnl:+10.4f}" + ) + print( + f" eng #{e_t.trade_num:4d} {e_t.direction:5s} " + f"@{datetime.fromtimestamp(e_t.entry_time, tz=timezone.utc):%Y-%m-%d %H:%M} " + f"entry={e_t.entry_price:10.4f} exit={e_t.exit_price:10.4f} pnl={e_t.pnl:+10.4f}" + ) + print( + f" deltas: entry={ed*100:.4f}% exit={xd*100:.4f}% pnl={pd*100:.4f}%" + ) + + +def verify_one(strategy_dir: Path, *, verbose: bool = True, show_diffs: int = 0) -> str: + """Compatibility wrapper returning the tier and optionally printing it.""" + result = analyze_strategy(strategy_dir) + if verbose: + _print_verification(result, show_diffs=show_diffs) + return result.label def main() -> int: diff --git a/src/math.cpp b/src/math.cpp index b9d18d2..fc02ad9 100644 --- a/src/math.cpp +++ b/src/math.cpp @@ -4,30 +4,64 @@ namespace pineforge { namespace math { -Sum::Sum(int length) : length_(length), sum_(0.0) {} +Sum::Sum(int length) + : length_(length), sum_(0.0), saved_sum_(0.0), + has_saved_state_(false), current_value_added_(false), + current_value_evicted_(false), current_evicted_value_(0.0) {} + +double Sum::apply(double src) { + current_value_added_ = false; + current_value_evicted_ = false; -double Sum::compute(double src) { if (is_na(src)) { + // Pine ignores na sources rather than consuming a window slot. Once + // seeded, the last-N-valid sum is therefore held on na-input bars. + if (length_ > 0 && static_cast(buffer_.size()) >= length_) { + return sum_; + } return na(); } buffer_.push_back(src); sum_ += src; + current_value_added_ = true; while ((int)buffer_.size() > length_) { + current_value_evicted_ = true; + current_evicted_value_ = buffer_.front(); sum_ -= buffer_.front(); buffer_.pop_front(); } + + if (static_cast(buffer_.size()) < length_) { + return na(); + } return sum_; } +void Sum::restore() { + if (current_value_added_ && length_ > 0) { + buffer_.pop_back(); + if (current_value_evicted_) { + buffer_.push_front(current_evicted_value_); + } + } + sum_ = saved_sum_; + current_value_added_ = false; + current_value_evicted_ = false; +} + +double Sum::compute(double src) { + saved_sum_ = sum_; + has_saved_state_ = true; + return apply(src); +} + double Sum::recompute(double src) { - if (buffer_.empty()) { + if (!has_saved_state_) { return compute(src); } - double old_back = buffer_.back(); - buffer_.back() = src; - sum_ = sum_ - old_back + src; - return sum_; + restore(); + return apply(src); } } // namespace math diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5e0e566..fab1ec1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -6,6 +6,7 @@ set(TEST_SOURCES test_magnifier_distributions test_path_at test_na_runtime + test_math_sum test_recompute test_str_utils test_ta diff --git a/tests/test_math_sum.cpp b/tests/test_math_sum.cpp new file mode 100644 index 0000000..cc0ba6e --- /dev/null +++ b/tests/test_math_sum.cpp @@ -0,0 +1,118 @@ +#include + +#include +#include + +using namespace pineforge; + +static int failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + ++failures; \ + } \ + } while (0) + +static bool near(double actual, double expected) { + return !is_na(actual) && std::abs(actual - expected) < 1e-12; +} + +static void test_leading_na_and_warmup_boundary() { + std::printf("test_leading_na_and_warmup_boundary\n"); + math::Sum sum(3); + + CHECK(is_na(sum.compute(na()))); + CHECK(is_na(sum.compute(1.0))); + CHECK(is_na(sum.compute(na()))); + CHECK(is_na(sum.compute(2.0))); + CHECK(near(sum.compute(3.0), 6.0)); + + // Once seeded, na neither consumes a slot nor hides the rolling sum. + CHECK(near(sum.compute(na()), 6.0)); +} + +static void test_rolling_eviction_uses_last_non_na_values() { + std::printf("test_rolling_eviction_uses_last_non_na_values\n"); + math::Sum sum(3); + + CHECK(is_na(sum.compute(1.0))); + CHECK(is_na(sum.compute(2.0))); + CHECK(near(sum.compute(3.0), 6.0)); + CHECK(near(sum.compute(4.0), 9.0)); + CHECK(near(sum.compute(na()), 9.0)); + CHECK(near(sum.compute(5.0), 12.0)); +} + +static void test_recompute_during_and_beyond_warmup() { + std::printf("test_recompute_during_and_beyond_warmup\n"); + math::Sum sum(3); + + CHECK(is_na(sum.compute(1.0))); + CHECK(is_na(sum.recompute(10.0))); + + // Replacing an na tick with a finite tick adds a value to this bar; it + // must not replace the prior bar's last valid sample. + CHECK(is_na(sum.compute(na()))); + CHECK(is_na(sum.recompute(20.0))); + CHECK(near(sum.compute(30.0), 60.0)); + + // At the exact seed boundary, replacing the current value with na rewinds + // to an under-warmed window. Replacing it again must seed the same window. + CHECK(is_na(sum.recompute(na()))); + CHECK(near(sum.recompute(30.0), 60.0)); + + CHECK(near(sum.compute(40.0), 90.0)); + CHECK(near(sum.recompute(4.0), 54.0)); + + // Beyond warmup, an na recompute restores the pre-bar seeded window and + // holds its sum. The following bar then evicts from that restored window. + CHECK(near(sum.recompute(na()), 60.0)); + CHECK(near(sum.compute(5.0), 55.0)); + + // The inverse replacement beyond warmup must append the finite candidate + // to the committed window, not overwrite its most recent valid sample. + CHECK(near(sum.compute(na()), 55.0)); + CHECK(near(sum.recompute(6.0), 41.0)); + CHECK(near(sum.recompute(na()), 55.0)); + CHECK(near(sum.compute(7.0), 42.0)); +} + +static void test_recompute_before_first_compute() { + std::printf("test_recompute_before_first_compute\n"); + math::Sum sum(2); + + CHECK(is_na(sum.recompute(7.0))); + CHECK(near(sum.compute(8.0), 15.0)); +} + +static void test_length_one_na_hold_and_recompute() { + std::printf("test_length_one_na_hold_and_recompute\n"); + math::Sum sum(1); + + CHECK(is_na(sum.compute(na()))); + CHECK(near(sum.recompute(2.0), 2.0)); + + CHECK(near(sum.compute(na()), 2.0)); + CHECK(near(sum.recompute(3.0), 3.0)); + CHECK(near(sum.recompute(na()), 2.0)); + + CHECK(near(sum.compute(4.0), 4.0)); + CHECK(near(sum.recompute(na()), 2.0)); +} + +int main() { + test_leading_na_and_warmup_boundary(); + test_rolling_eviction_uses_last_non_na_values(); + test_recompute_during_and_beyond_warmup(); + test_recompute_before_first_compute(); + test_length_one_na_hold_and_recompute(); + + if (failures != 0) { + std::printf("math sum tests: %d FAILED\n", failures); + return 1; + } + std::printf("math sum tests: all passed\n"); + return 0; +}