Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`.
Expand Down
16 changes: 15 additions & 1 deletion include/pineforge/math.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<double> 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);
Expand Down
137 changes: 2 additions & 135 deletions scripts/regen_validation_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions scripts/run_corpus.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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."
83 changes: 83 additions & 0 deletions scripts/validation_report_self_test.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading