diff --git a/scripts/cov_union.py b/scripts/cov_union.py new file mode 100644 index 0000000..9b16f29 --- /dev/null +++ b/scripts/cov_union.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Accurate line-coverage via per-binary union (clang/llvm-cov only). + +WHY THIS EXISTS +--------------- +`llvm-cov report` over MANY test binaries that share inline/template code +(our header-only Pine wrappers: series.hpp, generic_matrix.hpp, ta.hpp, the +inline accessors in engine.hpp) emits "N functions have mismatched data" and +UNDERCOUNTS those headers. Each binary instantiates the same inline/template +function with its own coverage-mapping hash; the merged profile splinters per +hash, so a single cross-binary report only matches one variant and drops the +rest. + +This helper sidesteps that: it asks `llvm-cov show` for each binary +individually (where every function matches its own binary's hash, so the +per-line counts are authoritative), then UNIONS line hits across binaries — a +line is executable if any binary maps it, covered if any binary executed it. +For headers it recovers the undercount. + +METRIC NOTE: this counts PHYSICAL source lines (what `llvm-cov show` marks +executable), which differs from `llvm-cov report`/`export` — those count +region/expansion lines and yield larger absolute line totals. So this tool's +absolute %/counts are NOT directly comparable to totals.txt; use it to check the +RELATIVE truth for template-heavy headers (e.g. generic_matrix.hpp), where the +multi-binary `report` undercounts. The canonical headline metric remains +`llvm-cov report` (scripts/coverage.sh → totals.txt). + +USAGE +----- + cov_union.py --profdata P --bindir D --bin-glob 'test_*' SOURCE [SOURCE...] + +Outputs a per-file line-coverage table + TOTAL to stdout. Exit 0 always +(measurement tool). With --uncovered FILE, also writes a file sorted ascending +by line coverage. +""" +from __future__ import annotations +import argparse, glob, os, re, subprocess, sys + +LINE_RE = re.compile(r"^\s*(\d+)\|([^|]*)\|") +BANNER_RE = re.compile(r"^(/.*):$") + + +def find_llvm_cov() -> list[str]: + if sys.platform == "darwin": + from shutil import which + if which("xcrun"): + return ["xcrun", "llvm-cov"] + return ["llvm-cov"] + + +def show_one(llvm_cov, binary, profdata, src): + """Run `llvm-cov show` for ONE source file and return {lineno: covered_bool} + for executable lines, deduped across instantiation groups (covered if ANY + instantiation executed the line). + + Must be ONE file per invocation: passing many files to a single `show` + suppresses template instantiation groups, which silently drops most of a + template header's covered lines (e.g. generic_matrix.hpp). + """ + r = subprocess.run( + llvm_cov + ["show", binary, "-instr-profile", profdata, "--format=text", src], + capture_output=True, text=True) + out: dict[int, bool] = {} + for ln in r.stdout.splitlines(): + m = LINE_RE.match(ln) + if not m: + continue + cnt = m.group(2).strip() + if cnt == "": + continue # non-executable line + lineno = int(m.group(1)) + covered = cnt != "0" # any non-zero (incl. human "1.2k") => covered + prev = out.get(lineno) + out[lineno] = covered if prev is None else (prev or covered) + return out + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--profdata", required=True) + ap.add_argument("--bindir", required=True, help="dir containing test binaries") + ap.add_argument("--bin-glob", default="test_*") + ap.add_argument("--uncovered", help="write file sorted ascending by line %%") + ap.add_argument("sources", nargs="+") + args = ap.parse_args() + + llvm_cov = find_llvm_cov() + bins = sorted(b for b in glob.glob(os.path.join(args.bindir, args.bin_glob)) + if os.path.isfile(b) and os.access(b, os.X_OK)) + if not bins: + print(f"cov_union: no binaries matched {args.bindir}/{args.bin_glob}", file=sys.stderr) + return 0 + + # canonical source list (realpath -> display path) + disp = {} + for s in args.sources: + if os.path.isfile(s): + disp[os.path.realpath(s)] = s + if not disp: + print("cov_union: no source files found", file=sys.stderr) + return 0 + + union_exec: dict[str, set] = {ap_: set() for ap_ in disp} + union_cov: dict[str, set] = {ap_: set() for ap_ in disp} + # One `show` per (binary, file): multi-file show suppresses template + # instantiation groups and undercounts template headers. + for binary in bins: + for ap_, d in disp.items(): + lines = show_one(llvm_cov, binary, args.profdata, d) + for lineno, cov in lines.items(): + union_exec[ap_].add(lineno) + if cov: + union_cov[ap_].add(lineno) + + rows = [] + tot_c = tot_n = 0 + for ap_, d in disp.items(): + n = len(union_exec[ap_]); c = len(union_cov[ap_]) + tot_c += c; tot_n += n + rows.append((d, c, n, (100.0 * c / n) if n else 0.0)) + rows.sort(key=lambda r: r[0]) + + w = max((len(r[0]) for r in rows), default=20) + print(f"{'Filename':<{w}} {'Lines':>7} {'Missed':>7} {'Cover':>7}") + print("-" * (w + 26)) + for d, c, n, pct in rows: + print(f"{d:<{w}} {n:>7} {n-c:>7} {pct:>6.2f}%") + print("-" * (w + 26)) + tot_pct = (100.0 * tot_c / tot_n) if tot_n else 0.0 + print(f"{'TOTAL':<{w}} {tot_n:>7} {tot_n-tot_c:>7} {tot_pct:>6.2f}%") + print(f"\n(union of {len(bins)} binaries — accurate header line coverage; " + f"see scripts/cov_union.py header for why)") + + if args.uncovered: + with open(args.uncovered, "w") as f: + for d, c, n, pct in sorted(rows, key=lambda r: r[3]): + f.write(f"{d}\t{n}\t{n-c}\t{pct:.2f}%\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 85a75c4..098e18a 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -144,6 +144,18 @@ if [[ "$COMPILER" == "clang" ]]; then [[ -f "$f" ]] && SOURCES+=( "$f" ) done + # llvm-cov prints "N functions have mismatched data" to stderr when the same + # inline/template function is instrumented in many test binaries with + # differing coverage-mapping hashes (our header-only Pine wrappers: + # series.hpp / generic_matrix.hpp / ta.hpp + engine.hpp inline accessors). + # The merged profile splinters per hash, so the single cross-binary report + # drops the unmatched variants. This is BENIGN for .cpp (compiled once into + # libpineforge.a → one hash) and only undercounts a few template-heavy + # headers. We capture that stderr, suppress the raw scary line, and emit a + # plain-language note instead. See scripts/cov_union.py to verify exact + # header line coverage on demand (per-binary union recovers the undercount; + # e.g. generic_matrix.hpp reads ~77% here but is ~86% true). + COV_STDERR="$COV_DIR/llvm-cov.stderr" { echo "PineForge coverage — $(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "Compiler: $($CXX_BIN --version | head -n1)" @@ -151,28 +163,43 @@ if [[ "$COMPILER" == "clang" ]]; then "${LLVM_COV[@]}" report "${BINS[@]:1}" \ -instr-profile="$PROFDATA" \ "${SHARED_FILTER[@]}" \ - "${SOURCES[@]}" + "${SOURCES[@]}" 2> "$COV_STDERR" + if grep -q 'mismatched data' "$COV_STDERR" 2>/dev/null; then + _mm="$(grep -oE '[0-9]+ functions? have mismatched data' "$COV_STDERR" | head -n1)" + echo + echo "NOTE: llvm-cov reported \"${_mm}\" — this is BENIGN, not a coverage gap." + echo " Cause: header inline/template Pine code is instantiated in every test" + echo " binary with differing mapping hashes; the multi-binary merge drops the" + echo " unmatched variants. .cpp line coverage is UNAFFECTED (single hash via" + echo " libpineforge.a). Only template-heavy headers are undercounted here —" + echo " e.g. generic_matrix.hpp shows ~77% but is ~86% true. engine.hpp's lower" + echo " figure is real (unexercised inline overloads), not an artifact." + echo " Verify exact header coverage: scripts/cov_union.py --profdata \"$PROFDATA\" \\" + echo " --bindir \"$BUILD_DIR/bin\" include/pineforge/*.hpp" + fi } | tee "$COV_DIR/totals.txt" - # Per-file annotated listings (text, fast to grep). + # Per-file annotated listings (text, fast to grep). stderr re-emits the same + # benign mismatch warning already explained above → discard it. "${LLVM_COV[@]}" show "${BINS[@]:1}" \ -instr-profile="$PROFDATA" \ "${SHARED_FILTER[@]}" \ -format=text \ -output-dir="$COV_DIR/per-file" \ "${SOURCES[@]}" \ - > /dev/null + > /dev/null 2>&1 - # Sortable per-file totals (lowest-covered first → easy hole-spotter). - # The text report rows look like: - # src/engine_orders.cpp 87 12 86.21% ... - # so column 4 is line-coverage percent. + # Sortable per-file totals (lowest line-covered first → easy hole-spotter). + # `llvm-cov report` rows carry stats in fixed columns: + # $1 file $2 Regions $3 MissedReg $4 Cover(region) + # $5 Funcs $6 MissedFn $7 Exec% $8 Lines $9 MissedLines $10 Cover(line) + # so column 10 is the line-coverage percent (column 4 is region coverage). "${LLVM_COV[@]}" report "${BINS[@]:1}" \ -instr-profile="$PROFDATA" \ "${SHARED_FILTER[@]}" \ - "${SOURCES[@]}" \ + "${SOURCES[@]}" 2>/dev/null \ | awk '/^[A-Za-z0-9_\/\.\-]+\.(cpp|hpp|h|cc|c)[[:space:]]/ {print $0}' \ - | sort -k4n \ + | sort -k10n \ > "$COV_DIR/uncovered.txt" if [[ "$FORMAT" == "html" ]]; then diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 337031d..dcb5f8e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -51,6 +51,21 @@ set(TEST_SOURCES test_handle_reuse_reset test_calendar_aggregation_wm test_adversarial_ohlcv + test_report_trace + test_path_resolve_extra + test_security_validation_throws + test_session_calendar_extra + test_run_inputs_overrides + test_matrix_ops_extra + test_timeframe_aggregator_extra + test_c_abi_setters + test_fills_edge + test_strategy_commands_extra + test_lower_tf_parse_extra + test_ta_ma_warmup_extra + test_ta_osc_edge + test_ta_extremes_edge + test_ta_voltrend_edge ) # When PINEFORGE_ENABLE_COVERAGE is ON we also instrument the test diff --git a/tests/test_c_abi_setters.cpp b/tests/test_c_abi_setters.cpp new file mode 100644 index 0000000..d13c586 --- /dev/null +++ b/tests/test_c_abi_setters.cpp @@ -0,0 +1,182 @@ +/* + * test_c_abi_setters.cpp — exercises the runtime-library C-ABI setter + * entry points in src/c_abi.cpp (lines ~119-205) that the existing + * tests/test_c_abi.c does NOT touch: + * + * strategy_set_trace_enabled, strategy_get_last_error, + * strategy_set_trade_start_time, strategy_set_chart_timezone, + * strategy_set_syminfo_timezone / _session / _mintick / _pointvalue / + * _metadata, and pf_version_string. + * + * Each entry point begins with a null-guard (`if (!s) return;` — or + * `if (!s || !arg) return;`) and then forwards to a BacktestEngine + * method. We hit BOTH branches of every one: + * + * 1. NULL handle → the guard returns early, must not crash / mutate. + * 2. Valid handle → the forwarding line runs against a real engine. + * + * WHY C++ (not the assigned .c): the only legitimate `pf_strategy_t` + * value is a `pineforge::BacktestEngine*`. The public `strategy_create` + * is emitted *per compiled strategy* by the codegen — it is NOT defined + * in libpineforge — so a unit test cannot obtain a handle through the + * public C ABI. `BacktestEngine::on_bar` is pure-virtual, so a valid + * handle can only come from a concrete subclass, which requires C++. We + * still drive every assertion through the `extern "C"` C-ABI symbols + * (declared by the public C header), which is exactly the surface under + * test; the concrete subclass merely lets us read engine state back + * through its protected members to prove each forwarding line ran. This + * mirrors the engine-subclass pattern used throughout the suite (e.g. + * test_engine_trade_accessors.cpp). + * + * NDEBUG-proof: uses a self-rolled CHECK that increments a failure + * counter and returns nonzero from main(), so it fails for real even in + * the canonical Release (-DNDEBUG) build where bare assert() is a no-op. + */ + +#include // the C ABI under test (extern "C") +#include // BacktestEngine (to mint a valid handle) +#include +#include // is_na (absent-metadata sentinel) + +#include +#include +#include +#include +#include + +static int g_fail = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, \ + #cond); \ + ++g_fail; \ + } \ + } while (0) + +// Minimal concrete engine: a valid handle whose internal state the C-ABI +// setters mutate. The protected members are visible to this subclass, so +// we read them back to confirm each forwarding line actually ran. +namespace { +class ProbeEngine : public pineforge::BacktestEngine { +public: + void on_bar(const pineforge::Bar&) override {} // never invoked here + + // Thin protected-member accessors (this subclass owns the access). + const std::string& tz_chart() const { return chart_timezone_; } + bool trace_on() const { return trace_enabled_; } + int64_t trade_start() const { return trade_start_time_; } + const std::string& sym_tz() const { return syminfo_.timezone; } + const std::string& sym_session() const { return syminfo_.session; } + double sym_mintick() const { return syminfo_.mintick; } + double sym_pv() const { return syminfo_.pointvalue; } + // get_syminfo_metadata() is protected on BacktestEngine; expose it via + // the subclass. It returns na() (NaN) for keys never injected. + double meta(const std::string& key) const { return get_syminfo_metadata(key); } +}; +} + +int main() { + // ── pf_version_string: no handle involved ─────────────────────── + const char* vs = pf_version_string(); + CHECK(vs != nullptr); + CHECK(vs[0] != '\0'); // non-empty + CHECK(std::strcmp(vs, PINEFORGE_VERSION_FULL) == 0); + + // ── NULL-handle guard branch for every setter ─────────────────── + // Each must take the `if (!s ...) return;` early-out and NOT crash. + // strategy_get_last_error(NULL) returns NULL by contract. + strategy_set_trace_enabled(nullptr, 1); + strategy_set_trade_start_time(nullptr, 123456789LL); + strategy_set_chart_timezone(nullptr, "Asia/Taipei"); + strategy_set_syminfo_timezone(nullptr, "America/New_York"); + strategy_set_syminfo_session(nullptr, "0930-1600:23456"); + strategy_set_syminfo_mintick(nullptr, 0.25); + strategy_set_syminfo_pointvalue(nullptr, 50.0); + strategy_set_syminfo_metadata(nullptr, "shares_outstanding_total", 1.0); + CHECK(strategy_get_last_error(nullptr) == nullptr); + + // Secondary `|| !arg` guard arms (valid handle, NULL arg) — these + // must early-out too, leaving engine state untouched. + ProbeEngine guard_eng; + pf_strategy_t gh = static_cast(&guard_eng); + const std::string guard_tz_before = guard_eng.sym_tz(); // "UTC" + const std::string guard_session_before = guard_eng.sym_session(); // "24x7" + strategy_set_syminfo_timezone(gh, nullptr); // !tz → return + strategy_set_syminfo_session(gh, nullptr); // !session → return + strategy_set_syminfo_metadata(gh, nullptr, 9.0); // !key → return + CHECK(guard_eng.sym_tz() == guard_tz_before); + CHECK(guard_eng.sym_session() == guard_session_before); + + // ── Valid-handle forwarding branch ────────────────────────────── + ProbeEngine eng; + pf_strategy_t h = static_cast(&eng); + + // last_error() defaults to empty string on a fresh engine; the C ABI + // returns the c_str() of that → non-NULL and empty. + const char* err = strategy_get_last_error(h); + CHECK(err != nullptr); + CHECK(err[0] == '\0'); + + // trace_enabled: default false → 1/non-zero maps to true, 0 to false. + CHECK(eng.trace_on() == false); + strategy_set_trace_enabled(h, 1); + CHECK(eng.trace_on() == true); + strategy_set_trace_enabled(h, 0); + CHECK(eng.trace_on() == false); + strategy_set_trace_enabled(h, 7); // any non-zero → true + CHECK(eng.trace_on() == true); + + // trade_start_time: default is INT64_MIN; the setter forwards verbatim. + CHECK(eng.trade_start() == std::numeric_limits::min()); + strategy_set_trade_start_time(h, 1700000000000LL); + CHECK(eng.trade_start() == 1700000000000LL); + + // chart_timezone: default empty; a non-empty value is stored as-is, + // and NULL normalises back to the empty-string (legacy UTC fast path). + CHECK(eng.tz_chart().empty()); + strategy_set_chart_timezone(h, "Asia/Taipei"); + CHECK(eng.tz_chart() == std::string("Asia/Taipei")); + strategy_set_chart_timezone(h, nullptr); + CHECK(eng.tz_chart().empty()); + + // syminfo timezone / session forward the C string into syminfo_. + CHECK(eng.sym_tz() == std::string("UTC")); // constructor default + strategy_set_syminfo_timezone(h, "America/New_York"); + CHECK(eng.sym_tz() == std::string("America/New_York")); + CHECK(eng.sym_session() == std::string("24x7")); // constructor default + strategy_set_syminfo_session(h, "0930-1600:23456"); + CHECK(eng.sym_session() == std::string("0930-1600:23456")); + + // mintick: positive accepted; non-positive ignored (engine-side guard). + CHECK(eng.sym_mintick() == 0.01); // constructor default + strategy_set_syminfo_mintick(h, 0.25); + CHECK(eng.sym_mintick() == 0.25); + strategy_set_syminfo_mintick(h, 0.0); // non-positive → ignored + CHECK(eng.sym_mintick() == 0.25); + strategy_set_syminfo_mintick(h, -1.0); // negative → ignored + CHECK(eng.sym_mintick() == 0.25); + + // pointvalue: positive accepted; non-positive ignored. + CHECK(eng.sym_pv() == 1.0); // constructor default + strategy_set_syminfo_pointvalue(h, 50.0); + CHECK(eng.sym_pv() == 50.0); + strategy_set_syminfo_pointvalue(h, 0.0); // ignored + CHECK(eng.sym_pv() == 50.0); + + // metadata: forwards (key, value) into the engine's metadata map, + // surfaced via the public get_syminfo_metadata(). Absent → na (NaN). + CHECK(pineforge::is_na(eng.meta("shares_outstanding_total"))); + strategy_set_syminfo_metadata(h, "shares_outstanding_total", 12345.0); + CHECK(eng.meta("shares_outstanding_total") == 12345.0); + // A key that was never injected still reports na. + CHECK(pineforge::is_na(eng.meta("never_injected"))); + + if (g_fail == 0) { + std::printf("test_c_abi_setters: OK (pineforge %s)\n", vs); + return 0; + } + std::fprintf(stderr, "test_c_abi_setters: %d FAILURES\n", g_fail); + return 1; +} diff --git a/tests/test_fills_edge.cpp b/tests/test_fills_edge.cpp new file mode 100644 index 0000000..09626b2 --- /dev/null +++ b/tests/test_fills_edge.cpp @@ -0,0 +1,425 @@ +/* + * test_fills_edge.cpp — edge-arm coverage for src/engine_fills.cpp's + * bar-pump fill loop (process_pending_orders + helpers). Engine-behaviour + * tests: each subclasses BacktestEngine, drives strategy.* commands inside + * on_bar, and asserts the resulting CLOSED-TRADE exit prices / counts. + * + * Targets (uncovered arms in engine_fills.cpp): + * - gap-at-open priced entry/exit -> fill at bar.open (the std::max/std::min + * gap shortcut in evaluate_fill_price + the fill-phase 0 classification + * in sort_orders_by_fill_phase, including the SHORT exit-style arms + * lines 192-194 and the SHORT entry-limit arm lines 929-934). + * - same-bar competing sibling exits resolved by the path-fill comparator + * in sort_exit_siblings_by_path_fill (full-before-partial / earliest- + * touch arms, lines 132-167). + * - intraday max-filled-orders cap that latches then resets on the next + * chart-day, with the cap auto-close price taken at the bar extreme + * (bar.high for a long stop-entry that fired intra-bar; lines 350-360, + * 481-485). + * - percent-based partial exit by entry (execute_partial_exit_by_entry_percent, + * reached via close_entries_rule_any_ + from_entry, lines 583-591). + * - same-bar market exit on the entry bar is SKIPPED (classify_order_ + * eligibility lines 826-828): a no-price strategy.exit placed on the + * entry bar does NOT fire that bar. + * + * NDEBUG-PROOF: every assertion uses the returning CHECK macro (failure + * increments g_fail; main returns nonzero). bare assert() is never used, so + * the canonical Release/-DNDEBUG gate cannot make these pass vacuously. + * Non-vacuity was confirmed by temporarily corrupting one expected value + * and observing a FAIL + nonzero exit, then restoring it. + */ + +#include +#include +#include +#include + +#include +#include + +using namespace pineforge; + +static int g_fail = 0; +static int g_pass = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #cond);\ + ++g_fail; \ + } else { \ + ++g_pass; \ + } \ + } while (0) + +static bool near(double a, double b, double tol = 1e-6) { + return std::fabs(a - b) <= tol; +} + +namespace { +constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +// Day-rollover anchors (UTC, chart_tz unset => gate keys off UTC day): +// 2025-03-31 00:00 UTC -> 1743379200000 ms; 15m cadence. +constexpr int64_t kT0_UTC = 1743379200000LL; +constexpr int64_t k15m_ms = 900'000LL; +constexpr int64_t kNextDay_UTC = kT0_UTC + 86'400'000LL; +} // namespace + +// ───────────────────────────────────────────────────────────────────── +// 1. Gap-at-open LONG stop entry fills AT bar.open (not snapped up). +// +// A long stop entry with stop_price <= the fill bar's open: the broker +// gap-fills at the open (std::max(open, stop) == open) and the +// directional ceil snap is skipped because fill_price is not > open. +// ───────────────────────────────────────────────────────────────────── +class GapLongStop : public BacktestEngine { +public: + GapLongStop() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; commission_value_ = 0; pyramiding_ = 1; + syminfo_mintick_ = 0.01; + } + void on_bar(const Bar&) override { + // Stop @ 100.005 (sub-tick). Bar 1 opens at 101 (already above the + // stop) -> gap-fill at open=101, NOT at a snapped 100.01. + if (bar_index_ == 0) + strategy_entry("L", true, kNaN, /*stop=*/100.005, 1.0, "gap long stop"); + if (bar_index_ == 3 && position_side_ == PositionSide::LONG) + strategy_close("L", "close"); + } +}; + +static void test_gap_open_long_stop_fills_at_open() { + std::printf("test_gap_open_long_stop_fills_at_open\n"); + GapLongStop p; + Bar bars[5] = { + {100, 100.5, 99.5, 100, 1000, kT0_UTC + 0 * k15m_ms}, + {101, 102.0, 100.5, 101.5, 1000, kT0_UTC + 1 * k15m_ms}, // gap up; stop 100.005 < open 101 -> fill @ 101 + {101.5, 102, 101, 101.5, 1000, kT0_UTC + 2 * k15m_ms}, + {101.5, 102, 101, 101.5, 1000, kT0_UTC + 3 * k15m_ms}, // close @ next open + {101.5, 102, 101, 101.5, 1000, kT0_UTC + 4 * k15m_ms}, + }; + p.run(bars, 5); + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) { + // Entry filled at the gap open, exactly bar.open, not the ceil-snapped + // 100.01. + CHECK(near(p.get_trade(0).entry_price, 101.0)); + CHECK(p.get_trade(0).is_long); + } +} + +// ───────────────────────────────────────────────────────────────────── +// 2. Gap-at-open SHORT limit entry fills AT bar.open. +// +// A short (sell) limit entry fills when price rises to/through the limit. +// When the fill bar gaps OPEN above the limit, the broker fills at the +// open (std::max(open, limit) == open) — exercises the SHORT entry-limit +// arm (engine_fills.cpp lines ~929-934) plus the fill-phase-0 short +// exit/entry gap classification. +// ───────────────────────────────────────────────────────────────────── +class GapShortLimit : public BacktestEngine { +public: + GapShortLimit() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; commission_value_ = 0; pyramiding_ = 1; + syminfo_mintick_ = 0.01; + } + void on_bar(const Bar&) override { + // Short sell-limit @ 100. Bar 1 gaps open to 105 (above the limit) + // -> fills at open=105. + if (bar_index_ == 0) + strategy_entry("S", false, /*limit=*/100.0, kNaN, 1.0, "gap short limit"); + if (bar_index_ == 3 && position_side_ == PositionSide::SHORT) + strategy_close("S", "close"); + } +}; + +static void test_gap_open_short_limit_fills_at_open() { + std::printf("test_gap_open_short_limit_fills_at_open\n"); + GapShortLimit p; + Bar bars[5] = { + {99, 99.5, 98.5, 99, 1000, kT0_UTC + 0 * k15m_ms}, + {105, 106, 104, 105, 1000, kT0_UTC + 1 * k15m_ms}, // gap up over limit 100 -> short fills @ 105 + {105, 106, 104, 105, 1000, kT0_UTC + 2 * k15m_ms}, + {105, 106, 104, 105, 1000, kT0_UTC + 3 * k15m_ms}, // close @ next open + {105, 106, 104, 105, 1000, kT0_UTC + 4 * k15m_ms}, + }; + p.run(bars, 5); + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) { + CHECK(near(p.get_trade(0).entry_price, 105.0)); + CHECK(!p.get_trade(0).is_long); + } +} + +// ───────────────────────────────────────────────────────────────────── +// 3. Same-bar competing sibling exits: full-before-partial path-fill +// ordering (sort_exit_siblings_by_path_fill, lines 132-167). +// +// One qty=2 long position with two strategy.exit brackets sharing the +// same from_entry "L": +// - X_FULL: full (100%) stop @ 95 +// - X_PART: partial (50%) limit @ 110 +// Bar 2 sweeps BOTH (high 112 >= 110, low 94 <= 95). The earliest-touch +// path comparator orders the two siblings; the bar opens nearer the high +// (|112-100| ... vs |100-94|) so path is O->H->L->C: the limit @110 is +// touched first on the up-leg, then the stop @95 on the down-leg. The +// partial limit fires first (qty 1 @ 110), then the full stop closes the +// remaining qty 1 @ 95. Two closed trades, exit prices 110 and 95. +// ───────────────────────────────────────────────────────────────────── +class TwoSiblingExits : public BacktestEngine { +public: + TwoSiblingExits() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 2.0; + slippage_ = 0; commission_value_ = 0; pyramiding_ = 1; + syminfo_mintick_ = 0.01; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) + strategy_entry("L", true, kNaN, kNaN, 2.0, "long"); + if (position_side_ == PositionSide::LONG) { + // partial TP (50% -> qty 1) @ 110 + strategy_exit("X_PART", "L", /*limit=*/110.0, /*stop=*/kNaN, + kNaN, kNaN, kNaN, /*qty_percent=*/50.0, "tp"); + // full SL (100%) @ 95 + strategy_exit("X_FULL", "L", /*limit=*/kNaN, /*stop=*/95.0, + kNaN, kNaN, kNaN, /*qty_percent=*/100.0, "sl"); + } + } +}; + +static void test_two_sibling_exits_path_order() { + std::printf("test_two_sibling_exits_path_order\n"); + TwoSiblingExits p; + Bar bars[5] = { + {100, 100.5, 99.5, 100, 1000, kT0_UTC + 0 * k15m_ms}, + {100, 101, 99, 100, 1000, kT0_UTC + 1 * k15m_ms}, // L fills @ 100 (bar1 open) + {100, 112, 94, 100, 1000, kT0_UTC + 2 * k15m_ms}, // both swept; O nearer high -> O->H->L->C + {100, 101, 99, 100, 1000, kT0_UTC + 3 * k15m_ms}, + {100, 101, 99, 100, 1000, kT0_UTC + 4 * k15m_ms}, + }; + p.run(bars, 5); + + // Two closed trades: the partial @110 then the full-close @95. + CHECK(p.trade_count() == 2); + bool seen_tp = false, seen_sl = false; + for (int i = 0; i < p.trade_count(); ++i) { + const Trade& t = p.get_trade(i); + CHECK(near(t.qty, 1.0)); + if (near(t.exit_price, 110.0)) { seen_tp = true; CHECK(t.exit_comment == "tp"); } + if (near(t.exit_price, 95.0)) { seen_sl = true; CHECK(t.exit_comment == "sl"); } + } + CHECK(seen_tp); + CHECK(seen_sl); +} + +// ───────────────────────────────────────────────────────────────────── +// 4. Intraday fill cap latches then resets on chart-day rollover, with +// the cap auto-close priced at the bar extreme for an intra-bar stop +// entry (lines 350-360, 481-485). +// +// max_intraday_filled_orders_ = 1: the FIRST fill of each chart-day is the +// cap-triggering one. We make that fill a LONG STOP entry that fires +// INTRA-bar (stop > bar.open), so TV's synthetic cap-close exits at +// bar.high (NOT the entry's stop price). The latch then blocks the second +// same-day stop entry; the next chart-day's stop entry is accepted afresh. +// ───────────────────────────────────────────────────────────────────── +class CapBarExtremeClose : public BacktestEngine { +public: + CapBarExtremeClose() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; commission_value_ = 0; pyramiding_ = 5; + syminfo_mintick_ = 0.01; + max_intraday_filled_orders_ = 1; + } + void on_bar(const Bar&) override { + // Place a fresh long STOP entry every bar (stop above the open so it + // fires intra-bar when high reaches it). Placement-time latch gate + // drops these once the day is latched. + std::string id = "L" + std::to_string(bar_index_); + strategy_entry(id, true, kNaN, /*stop=*/105.0, 1.0, "stop entry"); + } +}; + +static void test_cap_autoclose_at_bar_extreme_and_rollover() { + std::printf("test_cap_autoclose_at_bar_extreme_and_rollover\n"); + CapBarExtremeClose p; + Bar bars[6] = { + // Day A + {100, 101, 99, 100, 1000, kT0_UTC + 0 * k15m_ms}, // L0 placed + {100, 110, 99, 105, 1000, kT0_UTC + 1 * k15m_ms}, // L0 stop@105 fires intra-bar; cap=1 -> close @ high=110, LATCH + {100, 110, 99, 105, 1000, kT0_UTC + 2 * k15m_ms}, // placement blocked (latched) + // Day B (rollover resets latch) + {100, 112, 99, 105, 1000, kNextDay_UTC + 0 * k15m_ms}, // L3 placed (fresh day) + {100, 112, 99, 105, 1000, kNextDay_UTC + 1 * k15m_ms}, // L3 stop@105 fires; cap=1 -> close @ high=112, LATCH + {100, 112, 99, 105, 1000, kNextDay_UTC + 2 * k15m_ms}, // placement blocked + }; + p.run(bars, 6); + + // Two cap-cycles -> two closed trades, each a self-close at the bar's + // high (NOT at the stop price 105, and NOT at the open 100). + CHECK(p.trade_count() == 2); + const std::string kCapMsg = + "Close Position (Max number of filled orders in one day)"; + if (p.trade_count() == 2) { + // Day A cycle: entry @ ceil-snapped stop 105, close @ high 110. + CHECK(near(p.get_trade(0).entry_price, 105.0)); + CHECK(near(p.get_trade(0).exit_price, 110.0)); + CHECK(p.get_trade(0).exit_comment == kCapMsg); + // Day B cycle: entry @ 105, close @ high 112. + CHECK(near(p.get_trade(1).entry_price, 105.0)); + CHECK(near(p.get_trade(1).exit_price, 112.0)); + CHECK(p.get_trade(1).exit_comment == kCapMsg); + } +} + +// ───────────────────────────────────────────────────────────────────── +// 5. Percent-based partial exit BY ENTRY (close_entries_rule="ANY"): +// execute_partial_exit_by_entry_percent (lines 583-591 dispatch arm). +// +// With close_entries_rule_any_ = true and a strategy.exit bound to a +// from_entry, a partial (qty_percent<100) priced exit routes to +// execute_partial_exit_by_entry_percent rather than the FIFO +// execute_partial_exit. We open qty=4 long, attach a 25% TP @ 110 bound +// to entry "L"; bar 2 high 111 fires it -> closes 25% of the 4-lot +// matched entry = qty 1 @ 110, leaving qty 3 open. +// ───────────────────────────────────────────────────────────────────── +class PartialByEntryPercent : public BacktestEngine { +public: + PartialByEntryPercent() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 4.0; + slippage_ = 0; commission_value_ = 0; pyramiding_ = 1; + syminfo_mintick_ = 0.01; + close_entries_rule_any_ = true; // route to *_by_entry_percent + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) + strategy_entry("L", true, kNaN, kNaN, 4.0, "long"); + if (position_side_ == PositionSide::LONG) { + strategy_exit("TP25", "L", /*limit=*/110.0, /*stop=*/kNaN, + kNaN, kNaN, kNaN, /*qty_percent=*/25.0, "tp25"); + } + } + double signed_pos() const { return signed_position_size(); } +}; + +static void test_partial_exit_by_entry_percent() { + std::printf("test_partial_exit_by_entry_percent\n"); + PartialByEntryPercent p; + Bar bars[5] = { + {100, 100.5, 99.5, 100, 1000, kT0_UTC + 0 * k15m_ms}, + {100, 101, 99, 100, 1000, kT0_UTC + 1 * k15m_ms}, // L fills qty 4 @ 100 + {100, 111, 99, 100, 1000, kT0_UTC + 2 * k15m_ms}, // TP25 @110 fires -> close qty 1 @ 110 + {100, 101, 99, 100, 1000, kT0_UTC + 3 * k15m_ms}, + {100, 101, 99, 100, 1000, kT0_UTC + 4 * k15m_ms}, + }; + p.run(bars, 5); + + // Exactly one partial-close trade for qty 1 @ 110; remaining position 3 long. + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) { + CHECK(near(p.get_trade(0).qty, 1.0)); + CHECK(near(p.get_trade(0).exit_price, 110.0)); + CHECK(p.get_trade(0).is_long); + } + CHECK(near(p.signed_pos(), 3.0)); +} + +// ───────────────────────────────────────────────────────────────────── +// 6. Same-bar MARKET exit on the ENTRY bar is SKIPPED +// (classify_order_eligibility lines 826-828: "skip market exits on +// entry bar"). A no-price strategy.exit placed on the same bar the +// position opens must NOT fire that bar; it only acts later when the +// position is explicitly closed. +// +// We open long on bar 0 (fills bar 1 open @ 100). On bar 1, while long & +// on the entry bar, we place a no-price (market-style) strategy.exit "MX". +// It must be SKIPPED for bar 1 (the entry bar) — the position stays open +// through bar 1. On bar 2 (no longer the entry bar) "MX" is a live market +// exit and fills at bar 2's OPEN (102). So the single closed trade exits at +// 102 (bar 2 open), NOT at the entry price 100 on bar 1. The skip is the +// load-bearing arm: without it the exit would fire same-bar at entry price +// 100 (flat $0 trade) and exit_bar_index would be 1, not 2. +// ───────────────────────────────────────────────────────────────────── +class EntryBarMarketExitSkip : public BacktestEngine { +public: + int exit_placed_bar = -1; + EntryBarMarketExitSkip() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; commission_value_ = 0; pyramiding_ = 1; + syminfo_mintick_ = 0.01; + } + void on_bar(const Bar&) override { + if (bar_index_ == 0) + strategy_entry("L", true, kNaN, kNaN, 1.0, "long"); + // On the entry bar (position just opened, position_open_bar_ == + // bar_index_), attach a no-price (market) strategy.exit. The + // engine must skip it for this bar. + if (position_side_ == PositionSide::LONG && position_was_just_opened()) { + strategy_exit("MX", "L", /*limit=*/kNaN, /*stop=*/kNaN, + kNaN, kNaN, kNaN, /*qty_percent=*/100.0, "market exit"); + exit_placed_bar = bar_index_; + } + } + bool position_was_just_opened() const { + return position_side_ != PositionSide::FLAT + && position_open_bar_ == bar_index_; + } + double signed_pos() const { return signed_position_size(); } +}; + +static void test_entry_bar_market_exit_skipped() { + std::printf("test_entry_bar_market_exit_skipped\n"); + EntryBarMarketExitSkip p; + Bar bars[5] = { + {100, 100.5, 99.5, 100, 1000, kT0_UTC + 0 * k15m_ms}, + {100, 101, 99, 100, 1000, kT0_UTC + 1 * k15m_ms}, // L fills @ 100; entry-bar market exit must be SKIPPED here + {102, 103, 101, 102, 1000, kT0_UTC + 2 * k15m_ms}, // not entry bar: MX fires @ open 102 + {103, 104, 102, 103, 1000, kT0_UTC + 3 * k15m_ms}, + {120, 121, 119, 120, 1000, kT0_UTC + 4 * k15m_ms}, + }; + p.run(bars, 5); + + // The market exit was placed on the entry bar (bar 1). + CHECK(p.exit_placed_bar == 1); + // If the entry-bar market exit had NOT been skipped, the position would + // have closed on bar 1 at the entry price (100) for a flat $0 trade with + // exit_bar_index == 1. The skip defers it one bar: exits on bar 2 at + // bar 2's open (102), exit_bar_index == 2. + CHECK(p.trade_count() == 1); + if (p.trade_count() == 1) { + const Trade& t = p.get_trade(0); + CHECK(near(t.entry_price, 100.0)); + CHECK(near(t.exit_price, 102.0)); // deferred to bar 2 open, NOT 100 + CHECK(t.entry_bar_index == 1); + CHECK(t.exit_bar_index == 2); // NOT 1 (the entry bar) + CHECK(t.exit_comment == "market exit"); + } + CHECK(near(p.signed_pos(), 0.0)); +} + +int main() { + test_gap_open_long_stop_fills_at_open(); + test_gap_open_short_limit_fills_at_open(); + test_two_sibling_exits_path_order(); + test_cap_autoclose_at_bar_extreme_and_rollover(); + test_partial_exit_by_entry_percent(); + test_entry_bar_market_exit_skipped(); + + std::printf("\n%d passed, %d failed\n", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tests/test_lower_tf_parse_extra.cpp b/tests/test_lower_tf_parse_extra.cpp new file mode 100644 index 0000000..ceacad2 --- /dev/null +++ b/tests/test_lower_tf_parse_extra.cpp @@ -0,0 +1,162 @@ +// test_lower_tf_parse_extra — exercise the rejection / early-return paths of +// the lower-timeframe helpers in src/engine_lower_tf.cpp. +// +// The seconds-suffix happy path is covered by test_lower_tf_seconds_suffix. +// This file targets the FALSE / early-return branches that the validator +// relies on to reject malformed timeframe literals, plus the empty-vector +// guards of synthesize_lower_tf_bars(): +// +// is_fixed_intraday_minute_tf(): +// - empty string -> false (src lines 18-20) +// - bare "S" / "s" (suffix, no digits) -> false (src lines 29-31) +// - non-digit chars before the suffix -> false (src loop, 33-37) +// supports_lower_tf_emulation(): +// - either operand not a fixed TF -> false (src 46-48) +// - requested >= input -> false (src 52-54) +// - non-divisor -> false (src 55-57) +// synthesize_lower_tf_bars(): +// - ratio <= 1 -> {} (src 87-89) +// - requested_seconds <= 0 -> {} (src 87-89) +// - valid ratio -> ratio sub-bars with exact +// open/close endpoints, volume +// conservation, OHLC ordering and +// evenly-spaced timestamps. +// +// Release builds define NDEBUG, so plain assert() is a no-op. We use a +// hand-rolled CHECK macro that always evaluates and makes main() return +// non-zero on any failure, so the gate cannot pass vacuously. + +#include "../src/engine_internal.hpp" +#include "pineforge/bar.hpp" + +#include +#include +#include +#include + +using namespace pineforge; +using namespace pineforge::internal; + +static int g_failures = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++g_failures; \ + } \ + } while (0) + +static bool approx(double a, double b) { + return std::fabs(a - b) <= 1e-9 * (1.0 + std::fabs(a) + std::fabs(b)); +} + +int main() { + // ── is_fixed_intraday_minute_tf rejection paths ────────────────────────── + + // Empty string is not a fixed intraday TF. + CHECK(!is_fixed_intraday_minute_tf("")); + + // Bare "S"/"s": a seconds suffix with no leading digits is rejected. + CHECK(!is_fixed_intraday_minute_tf("S")); + CHECK(!is_fixed_intraday_minute_tf("s")); + + // Non-digit characters before a (possible) suffix are rejected. + CHECK(!is_fixed_intraday_minute_tf("S5")); // suffix-looking char up front + CHECK(!is_fixed_intraday_minute_tf("5X")); // unknown trailing letter + CHECK(!is_fixed_intraday_minute_tf("1.5")); // decimal point + CHECK(!is_fixed_intraday_minute_tf("1 5")); // embedded space + CHECK(!is_fixed_intraday_minute_tf("D")); // daily, not intraday-minute + CHECK(!is_fixed_intraday_minute_tf("12D")); // 12 days, trailing 'D' + + // Sanity: the accepted forms still pass (guards against an over-eager + // rejection that would make every CHECK above pass for the wrong reason). + CHECK(is_fixed_intraday_minute_tf("1")); + CHECK(is_fixed_intraday_minute_tf("15")); + CHECK(is_fixed_intraday_minute_tf("30S")); + CHECK(is_fixed_intraday_minute_tf("45s")); + + // ── supports_lower_tf_emulation early-return paths ─────────────────────── + + int ratio = 7; + int secs = 7; + + // Malformed input TF -> false, out-params untouched. + CHECK(!supports_lower_tf_emulation("S", "1", &ratio, &secs)); + CHECK(ratio == 7 && secs == 7); + + // Malformed requested TF -> false. + CHECK(!supports_lower_tf_emulation("5", "", &ratio, &secs)); + CHECK(ratio == 7 && secs == 7); + + // Requested TF equal to input (1m -> 1m): requested >= input -> false. + CHECK(!supports_lower_tf_emulation("1", "1", &ratio, &secs)); + + // Requested TF coarser than input (1m requested on 5m would be finer, but + // here requested=15m on input=5m is coarser) -> requested >= input -> false. + CHECK(!supports_lower_tf_emulation("5", "15", &ratio, &secs)); + + // Non-divisor: 60s input, 45s requested -> 60 % 45 != 0 -> false. + CHECK(!supports_lower_tf_emulation("1", "45S", &ratio, &secs)); + + // Control: a valid finer divisor still succeeds and writes out-params. + ratio = 0; + secs = 0; + CHECK(supports_lower_tf_emulation("1", "20S", &ratio, &secs)); + CHECK(ratio == 3); // 60 / 20 + CHECK(secs == 20); + + // ── synthesize_lower_tf_bars guard / valid paths ───────────────────────── + + Bar in{100.0, 110.0, 90.0, 105.0, 600.0, 1'000'000}; + + // ratio <= 1 -> empty. + CHECK(synthesize_lower_tf_bars(in, 1, 30).empty()); + CHECK(synthesize_lower_tf_bars(in, 0, 30).empty()); + CHECK(synthesize_lower_tf_bars(in, -4, 30).empty()); + + // requested_seconds <= 0 -> empty (even with a valid ratio). + CHECK(synthesize_lower_tf_bars(in, 3, 0).empty()); + CHECK(synthesize_lower_tf_bars(in, 3, -10).empty()); + + // Valid case: 1m bar split into 2 x 30s sub-bars. + const int r = 2; + const int rsecs = 30; + std::vector sub = synthesize_lower_tf_bars(in, r, rsecs); + CHECK(static_cast(sub.size()) == r); + + if (static_cast(sub.size()) == r) { + // Endpoints are pinned by sample_price_path: first sub-bar opens at the + // parent open, last sub-bar closes at the parent close. + CHECK(approx(sub.front().open, in.open)); + CHECK(approx(sub.back().close, in.close)); + + // Volume is conserved across the split (last slice carries the + // rounding remainder). + double vol_sum = 0.0; + for (const Bar& b : sub) vol_sum += b.volume; + CHECK(approx(vol_sum, in.volume)); + + // Each sub-bar has consistent OHLC ordering and a timestamp offset by + // i * requested_seconds * 1000 ms from the parent bar. + for (int i = 0; i < r; ++i) { + const Bar& b = sub[static_cast(i)]; + CHECK(b.high >= std::max(b.open, b.close) - 1e-9); + CHECK(b.low <= std::min(b.open, b.close) + 1e-9); + CHECK(b.high >= b.low); + int64_t expected_ts = + in.timestamp + static_cast(i) * rsecs * 1000; + CHECK(b.timestamp == expected_ts); + } + + // Sub-bars are chained: each close is the next open. + CHECK(approx(sub[0].close, sub[1].open)); + } + + if (g_failures == 0) { + std::printf("test_lower_tf_parse_extra PASSED\n"); + return 0; + } + std::printf("test_lower_tf_parse_extra FAILED (%d checks)\n", g_failures); + return 1; +} diff --git a/tests/test_matrix_ops_extra.cpp b/tests/test_matrix_ops_extra.cpp new file mode 100644 index 0000000..669add6 --- /dev/null +++ b/tests/test_matrix_ops_extra.cpp @@ -0,0 +1,329 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +// Verification is via assert(). The CLAUDE.md-prescribed gate builds Release +// (-DNDEBUG), which would no-op assert() and make every check vacuous. Re-enable +// assert() unconditionally for this TU, after all other includes. +#undef NDEBUG +#include + +using namespace pineforge; + +static constexpr double TOL = 1e-9; + +static void assert_near(double a, double b, const char* msg) { + if (std::abs(a - b) > TOL) { + std::fprintf(stderr, "FAIL: %s (%.12f != %.12f)\n", msg, a, b); + std::abort(); + } +} + +// ── add_row / add_col size-mismatch → std::invalid_argument (lines 52, 64) ──── + +static void test_add_row_size_mismatch_throws() { + // 2x3 matrix; a new row must have exactly 3 values. + auto m = PineMatrix::new_(2, 3, 0.0); + + bool threw = false; + try { + m.add_row(1, {1, 2}); // too few — 2 != 3 columns + } catch (const std::invalid_argument&) { + threw = true; + } + assert(threw && "add_row with too few values must throw invalid_argument"); + + // Too many values also mismatches. + threw = false; + try { + m.add_row(0, {1, 2, 3, 4}); // 4 != 3 columns + } catch (const std::invalid_argument&) { + threw = true; + } + assert(threw && "add_row with too many values must throw invalid_argument"); + + // Failed inserts must not have mutated dimensions. + assert(m.rows() == 2 && "add_row failure left row count unchanged"); + assert(m.columns() == 3 && "add_row failure left column count unchanged"); + std::printf(" PASS test_add_row_size_mismatch_throws\n"); +} + +static void test_add_col_size_mismatch_throws() { + // 2x3 matrix; a new column must have exactly 2 values (one per row). + auto m = PineMatrix::new_(2, 3, 0.0); + + bool threw = false; + try { + m.add_col(1, {1}); // 1 != 2 rows + } catch (const std::invalid_argument&) { + threw = true; + } + assert(threw && "add_col with too few values must throw invalid_argument"); + + threw = false; + try { + m.add_col(0, {1, 2, 3}); // 3 != 2 rows + } catch (const std::invalid_argument&) { + threw = true; + } + assert(threw && "add_col with too many values must throw invalid_argument"); + + assert(m.rows() == 2 && "add_col failure left row count unchanged"); + assert(m.columns() == 3 && "add_col failure left column count unchanged"); + + // A correctly-sized add_col still works after the failures (no corruption). + m.add_col(1, {9, 9}); + assert(m.columns() == 4 && "valid add_col after failed attempts"); + assert_near(m.get(0, 1), 9.0, "valid add_col value [0,1]"); + assert_near(m.get(1, 1), 9.0, "valid add_col value [1,1]"); + std::printf(" PASS test_add_col_size_mismatch_throws\n"); +} + +// ── reshape total-count mismatch → std::invalid_argument (line 112) ─────────── + +static void test_reshape_count_mismatch_throws() { + auto m = PineMatrix::new_(2, 3, 0.0); // 6 elements + bool threw = false; + try { + m.reshape(2, 2); // 4 != 6 + } catch (const std::invalid_argument&) { + threw = true; + } + assert(threw && "reshape to incompatible element count must throw"); + + // Dimensions unchanged after the failed reshape. + assert(m.rows() == 2 && m.columns() == 3 && "reshape failure preserved shape"); + + // A count-preserving reshape (6 -> 6) succeeds. + m.reshape(6, 1); + assert(m.rows() == 6 && m.columns() == 1 && "valid reshape applied"); + std::printf(" PASS test_reshape_count_mismatch_throws\n"); +} + +// ── det / inv guarded returns on non-square & non-finite (lines 224-240) ────── + +static void test_det_non_square_is_nan() { + auto rect = PineMatrix::new_(2, 3, 1.0); + double d = rect.det(); + assert(std::isnan(d) && "det of non-square matrix must be NaN"); + std::printf(" PASS test_det_non_square_is_nan\n"); +} + +static void test_det_non_finite_is_nan() { + auto m = PineMatrix::new_(2, 2, 1.0); + m.set(0, 1, std::numeric_limits::quiet_NaN()); + double d = m.det(); + assert(std::isnan(d) && "det of matrix containing NaN must be NaN"); + + auto mi = PineMatrix::new_(2, 2, 1.0); + mi.set(1, 0, std::numeric_limits::infinity()); + assert(std::isnan(mi.det()) && "det of matrix containing inf must be NaN"); + std::printf(" PASS test_det_non_finite_is_nan\n"); +} + +static void test_inv_non_square_empty() { + auto rect = PineMatrix::new_(2, 3, 1.0); + auto r = rect.inv(); + // Default-constructed PineMatrix has an empty (0x0) Eigen matrix. + assert(r.rows() == 0 && r.columns() == 0 && + "inv of non-square matrix returns empty matrix"); + std::printf(" PASS test_inv_non_square_empty\n"); +} + +static void test_inv_non_finite_empty() { + auto m = PineMatrix::new_(2, 2, 1.0); + m.set(0, 0, std::numeric_limits::quiet_NaN()); + auto r = m.inv(); + assert(r.rows() == 0 && r.columns() == 0 && + "inv of non-finite matrix returns empty matrix"); + std::printf(" PASS test_inv_non_finite_empty\n"); +} + +// ── pinv / rank guarded returns on non-finite (lines 242-254) ───────────────── + +static void test_pinv_non_finite_empty() { + auto m = PineMatrix::new_(2, 3, 1.0); + m.set(1, 2, std::numeric_limits::infinity()); + auto r = m.pinv(); + assert(r.rows() == 0 && r.columns() == 0 && + "pinv of non-finite matrix returns empty matrix"); + std::printf(" PASS test_pinv_non_finite_empty\n"); +} + +static void test_rank_non_finite_zero() { + auto m = PineMatrix::new_(3, 3, 1.0); + m.set(2, 2, std::numeric_limits::quiet_NaN()); + assert(m.rank() == 0 && "rank of non-finite matrix is 0"); + std::printf(" PASS test_rank_non_finite_zero\n"); +} + +// ── eigenvalues / eigenvectors guarded returns (lines 258-310) ──────────────── + +static void test_eigenvalues_non_square_empty() { + auto rect = PineMatrix::new_(2, 3, 1.0); + auto ev = rect.eigenvalues(); + assert(ev.empty() && "eigenvalues of non-square matrix is empty"); + std::printf(" PASS test_eigenvalues_non_square_empty\n"); +} + +static void test_eigenvectors_non_square_empty() { + auto rect = PineMatrix::new_(3, 2, 1.0); + auto v = rect.eigenvectors(); + assert(v.rows() == 0 && v.columns() == 0 && + "eigenvectors of non-square matrix is empty"); + std::printf(" PASS test_eigenvectors_non_square_empty\n"); +} + +static void test_eigenvectors_non_finite_empty() { + auto m = PineMatrix::new_(2, 2, 1.0); + m.set(0, 0, std::numeric_limits::quiet_NaN()); + auto v = m.eigenvectors(); + assert(v.rows() == 0 && v.columns() == 0 && + "eigenvectors of non-finite matrix is empty"); + std::printf(" PASS test_eigenvectors_non_finite_empty\n"); +} + +// ── eigenvalues: symmetric path, KNOWN eigenvalues, sorted descending ───────── + +static void test_eigenvalues_symmetric_diag_sorted_desc() { + // [[2,0],[0,3]] is symmetric & diagonal → eigenvalues are exactly {2, 3}. + // The implementation sorts descending, so result must be {3, 2}. + auto m = PineMatrix::new_(2, 2, 0.0); + m.set(0, 0, 2); + m.set(1, 1, 3); + auto ev = m.eigenvalues(); + assert(ev.size() == 2 && "two eigenvalues"); + assert(ev[0] >= ev[1] && "eigenvalues sorted descending"); + assert_near(ev[0], 3.0, "largest eigenvalue == 3"); + assert_near(ev[1], 2.0, "smallest eigenvalue == 2"); + std::printf(" PASS test_eigenvalues_symmetric_diag_sorted_desc\n"); +} + +static void test_eigenvalues_symmetric_3x3_sorted_desc() { + // Symmetric tridiagonal [[2,-1,0],[-1,2,-1],[0,-1,2]]. + // Known eigenvalues: 2 - sqrt(2), 2, 2 + sqrt(2). + auto m = PineMatrix::new_(3, 3, 0.0); + m.set(0, 0, 2); m.set(0, 1, -1); + m.set(1, 0, -1); m.set(1, 1, 2); m.set(1, 2, -1); + m.set(2, 1, -1); m.set(2, 2, 2); + auto ev = m.eigenvalues(); + assert(ev.size() == 3 && "three eigenvalues"); + // Sorted descending. + assert(ev[0] >= ev[1] && ev[1] >= ev[2] && "eigenvalues sorted descending"); + const double s2 = std::sqrt(2.0); + assert_near(ev[0], 2.0 + s2, "largest eigenvalue == 2+sqrt2"); + assert_near(ev[1], 2.0, "middle eigenvalue == 2"); + assert_near(ev[2], 2.0 - s2, "smallest eigenvalue == 2-sqrt2"); + std::printf(" PASS test_eigenvalues_symmetric_3x3_sorted_desc\n"); +} + +// ── eigenvalues: NON-symmetric real path (lines 275-283) ────────────────────── + +static void test_eigenvalues_nonsymmetric_real() { + // Upper-triangular, non-symmetric: [[2,1],[0,3]]. + // Eigenvalues of a triangular matrix are its diagonal: {2, 3}. + // Goes through the EigenSolver branch (not SelfAdjoint), taking .real() + // and sorting descending → {3, 2}. + auto m = PineMatrix::new_(2, 2, 0.0); + m.set(0, 0, 2); m.set(0, 1, 1); + m.set(1, 0, 0); m.set(1, 1, 3); + assert(!m.is_symmetric() && "matrix is intentionally non-symmetric"); + auto ev = m.eigenvalues(); + assert(ev.size() == 2 && "two eigenvalues"); + assert(ev[0] >= ev[1] && "eigenvalues sorted descending"); + assert_near(ev[0], 3.0, "largest real eigenvalue == 3"); + assert_near(ev[1], 2.0, "smallest real eigenvalue == 2"); + std::printf(" PASS test_eigenvalues_nonsymmetric_real\n"); +} + +// ── eigenvectors: NON-symmetric real path (lines 300-309) ───────────────────── + +static void test_eigenvectors_nonsymmetric_real() { + // Non-symmetric [[2,1],[0,3]] has eigenvectors: + // λ=2 → (1, 0) (any scalar multiple) + // λ=3 → (1, 1)/sqrt2 (any scalar multiple) + // We assert the result is the right shape and that each returned column, + // when treated as v, satisfies A v == λ v for one of the eigenvalues. + auto m = PineMatrix::new_(2, 2, 0.0); + m.set(0, 0, 2); m.set(0, 1, 1); + m.set(1, 0, 0); m.set(1, 1, 3); + assert(!m.is_symmetric() && "matrix is intentionally non-symmetric"); + + auto V = m.eigenvectors(); + assert(V.rows() == 2 && V.columns() == 2 && "eigenvectors shape 2x2"); + + // For each eigenvector column, A*v should be parallel to v with ratio + // equal to an eigenvalue (2 or 3). + for (int c = 0; c < 2; ++c) { + auto v = V.col(c); + // A*v + double av0 = 2.0 * v[0] + 1.0 * v[1]; + double av1 = 0.0 * v[0] + 3.0 * v[1]; + // Determine candidate eigenvalue from the dominant component. + // Use the component with the larger magnitude to avoid /0. + double lambda; + if (std::abs(v[0]) >= std::abs(v[1])) { + assert(std::abs(v[0]) > 1e-12 && "eigenvector not degenerate"); + lambda = av0 / v[0]; + } else { + assert(std::abs(v[1]) > 1e-12 && "eigenvector not degenerate"); + lambda = av1 / v[1]; + } + // lambda must be one of {2, 3}. + bool is_eig = std::abs(lambda - 2.0) < 1e-9 || std::abs(lambda - 3.0) < 1e-9; + assert(is_eig && "ratio A*v/v equals a real eigenvalue"); + // And the full A*v == lambda*v relation must hold componentwise. + assert_near(av0, lambda * v[0], "A*v[0] == lambda*v[0]"); + assert_near(av1, lambda * v[1], "A*v[1] == lambda*v[1]"); + } + std::printf(" PASS test_eigenvectors_nonsymmetric_real\n"); +} + +// ── eigenvectors: symmetric path returns orthonormal basis ──────────────────── + +static void test_eigenvectors_symmetric_diag_shape() { + // [[2,0],[0,3]] symmetric → eigenvectors form an orthonormal 2x2 basis + // (columns of unit length, mutually orthogonal). KNOWN eigenvalues 2 and 3. + auto m = PineMatrix::new_(2, 2, 0.0); + m.set(0, 0, 2); + m.set(1, 1, 3); + auto V = m.eigenvectors(); + assert(V.rows() == 2 && V.columns() == 2 && "symmetric eigenvectors shape"); + auto c0 = V.col(0); + auto c1 = V.col(1); + double n0 = c0[0] * c0[0] + c0[1] * c0[1]; + double n1 = c1[0] * c1[0] + c1[1] * c1[1]; + double dot = c0[0] * c1[0] + c0[1] * c1[1]; + assert_near(n0, 1.0, "symmetric eigvec col0 unit length"); + assert_near(n1, 1.0, "symmetric eigvec col1 unit length"); + assert_near(dot, 0.0, "symmetric eigvecs orthogonal"); + std::printf(" PASS test_eigenvectors_symmetric_diag_shape\n"); +} + +int main() { + std::printf("test_matrix_ops_extra:\n"); + test_add_row_size_mismatch_throws(); + test_add_col_size_mismatch_throws(); + test_reshape_count_mismatch_throws(); + test_det_non_square_is_nan(); + test_det_non_finite_is_nan(); + test_inv_non_square_empty(); + test_inv_non_finite_empty(); + test_pinv_non_finite_empty(); + test_rank_non_finite_zero(); + test_eigenvalues_non_square_empty(); + test_eigenvectors_non_square_empty(); + test_eigenvectors_non_finite_empty(); + test_eigenvalues_symmetric_diag_sorted_desc(); + test_eigenvalues_symmetric_3x3_sorted_desc(); + test_eigenvalues_nonsymmetric_real(); + test_eigenvectors_nonsymmetric_real(); + test_eigenvectors_symmetric_diag_shape(); + std::printf("All matrix ops extra tests passed.\n"); + return 0; +} diff --git a/tests/test_path_resolve_extra.cpp b/tests/test_path_resolve_extra.cpp new file mode 100644 index 0000000..bdb49d6 --- /dev/null +++ b/tests/test_path_resolve_extra.cpp @@ -0,0 +1,379 @@ +/* + * test_path_resolve_extra.cpp — pin the path-resolution helpers in + * src/engine_path_resolve.cpp that the engine-driven bracket tests + * (test_exit_path_segment_tiebreak.cpp) leave uncovered. + * + * These helpers live in pineforge::internal and are declared in the + * RUNTIME-PRIVATE header src/engine_internal.hpp. libpineforge is a STATIC + * archive, so the symbols resolve at link time even though they are hidden + * from any .so export table. We call them directly to drive the exact + * branch logic, AND through the public free functions + * resolve_exit_path_fill / exit_order_earliest_path_metric_no_trail to + * reach the anonymous-namespace trail/gap/entry-bar helpers that are not + * individually addressable. + * + * Every expected value below is a closed-form function of the OHLC + * waypoints with mintick = 0.01 and lands exactly on the tick grid: + * + * - bar_path_uses_high_first: high-first iff |H-O| < |O-L| (ties low-first) + * - high-first path: O -> H -> L -> C ; low-first: O -> L -> H -> C + * - within a segment, the FIRST level crossed (smaller parametric t) wins + * - an exit stop/limit that GAPS past the bar open fills at the open + * - trail arms once the running best crosses the activation level; with no + * offset it exits AT the activation level, with an offset it trails best±off + * - on the entry bar a no-trail exit on the wrong side of entry is blocked + * + * All asserts are real Pine-correct return values derived by instrumenting + * the engine, not tautologies. + */ + +#include +#include +#include +#include + +#include "../src/engine_internal.hpp" + +using namespace pineforge; +using namespace pineforge::internal; + +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) + +static bool near(double a, double b, double tol = 1e-9) { + return std::fabs(a - b) <= tol; +} + +namespace { + +constexpr double kNaN = std::numeric_limits::quiet_NaN(); +constexpr double kMintick = 0.01; + +Bar mk(double o, double h, double l, double c) { + Bar b{}; + b.open = o; b.high = h; b.low = l; b.close = c; + b.volume = 1000.0; b.timestamp = 0; + return b; +} + +PendingOrder mk_raw(double stop, double limit, bool is_long) { + PendingOrder o{}; + o.type = OrderType::RAW_ORDER; + o.is_long = is_long; + o.stop_price = stop; + o.limit_price = limit; + o.trail_points = kNaN; + o.trail_offset = kNaN; + o.qty = kNaN; + o.qty_type = -1; + o.qty_percent = 100.0; + o.oca_type = 0; + o.created_bar = 0; + return o; +} + +PendingOrder mk_exit(double stop, double limit, double trail_points, + double trail_offset) { + PendingOrder o{}; + o.type = OrderType::EXIT; + o.is_long = false; + o.stop_price = stop; + o.limit_price = limit; + o.trail_points = trail_points; + o.trail_offset = trail_offset; + o.qty = kNaN; + o.qty_type = -1; + o.qty_percent = 100.0; + o.oca_type = 0; + o.created_bar = 0; + return o; +} + +} // namespace + +// ── price_path_priority: -1 stop first, +1 limit first, 0 neither ── +// +// Bar (100, 101, 98, 100): |H-O|=1 < |O-L|=2 -> high-first path +// O(100) -> H(101) -> L(98) -> C(100). +// The H->L segment descends 101 -> 98 and contains both levels in the +// scenarios below; parametric t along that segment decides who fires first. +static void test_price_path_priority_branches() { + std::printf("test_price_path_priority_branches\n"); + Bar hi_first = mk(100, 101, 98, 100); + CHECK(bar_path_uses_high_first(hi_first) == true); + + // (a) stop nearer the open ALONG the H->L leg: + // t_stop=(100-101)/(98-101)=1/3, t_limit=(99-101)/(98-101)=2/3 -> stop first. + CHECK(price_path_priority(hi_first, /*stop=*/100, /*limit=*/99) == -1); + + // (b) limit nearer the open along the same leg: + // t_stop=2/3, t_limit=1/3 -> limit first. + CHECK(price_path_priority(hi_first, /*stop=*/99, /*limit=*/100) == 1); + + // (g) equal levels in a non-degenerate segment -> exact t tie -> -1. + CHECK(price_path_priority(hi_first, /*stop=*/99.5, /*limit=*/99.5) == -1); + + // (d) only the stop falls in any segment -> -1. + CHECK(price_path_priority(hi_first, /*stop=*/99.5, /*limit=*/kNaN) == -1); + + // (e) only the limit falls in any segment -> +1. + CHECK(price_path_priority(hi_first, /*stop=*/kNaN, /*limit=*/99.5) == 1); + + // (f) neither level inside the bar's range -> 0. + CHECK(price_path_priority(hi_first, /*stop=*/50, /*limit=*/200) == 0); + + // (c) degenerate segment: bar (100,100,99,99.5) has |H-O|=0 < |O-L|=1 + // -> high-first path O(100) -> H(100) -> L(99) -> C(99.5). The first leg + // O->H is FLAT at 100; both levels==100 land in it with denom~0 -> -1. + Bar degenerate = mk(100, 100, 99, 99.5); + CHECK(bar_path_uses_high_first(degenerate) == true); + CHECK(price_path_priority(degenerate, /*stop=*/100, /*limit=*/100) == -1); +} + +// ── exit_order_touch_position: gap-through-open arms fill at path pos 0 ── +// +// When bar.open already sits at/past the exit level in the firing direction, +// the order fills at the open (path position 0) — one arm per (side, kind). +static void test_exit_order_touch_gap_arms() { + std::printf("test_exit_order_touch_gap_arms\n"); + double pos = -1.0; + + // LONG position, pure-stop exit: open=96 gaps below stop=98 -> pos 0. + Bar long_stop_gap = mk(96, 99, 95, 97); + PendingOrder ls = mk_raw(/*stop=*/98, /*limit=*/kNaN, /*is_long=*/false); + CHECK(exit_order_touch_position(long_stop_gap, ls, PositionSide::LONG, &pos)); + CHECK(near(pos, 0.0)); + + // LONG position, pure-limit exit: open=103 gaps above limit=102 -> pos 0. + Bar long_limit_gap = mk(103, 104, 102, 103); + PendingOrder ll = mk_raw(/*stop=*/kNaN, /*limit=*/102, /*is_long=*/false); + CHECK(exit_order_touch_position(long_limit_gap, ll, PositionSide::LONG, &pos)); + CHECK(near(pos, 0.0)); + + // SHORT position, pure-stop exit: open=104 gaps above stop=102 -> pos 0. + Bar short_stop_gap = mk(104, 105, 103, 104); + PendingOrder ss = mk_raw(/*stop=*/102, /*limit=*/kNaN, /*is_long=*/true); + CHECK(exit_order_touch_position(short_stop_gap, ss, PositionSide::SHORT, &pos)); + CHECK(near(pos, 0.0)); + + // SHORT position, pure-limit exit: open=97 gaps below limit=98 -> pos 0. + Bar short_limit_gap = mk(97, 98, 96, 97); + PendingOrder sl = mk_raw(/*stop=*/kNaN, /*limit=*/98, /*is_long=*/true); + CHECK(exit_order_touch_position(short_limit_gap, sl, PositionSide::SHORT, &pos)); + CHECK(near(pos, 0.0)); + + // Non-gap LONG stop: open=100 above stop=98, low=97 reaches it later. + // high-first (|1|<|3|) path 100->101->97->99; stop 98 on the 101->97 leg + // at pos 1 + (98-101)/(97-101) = 1 + 0.75 = 1.75. + Bar non_gap = mk(100, 101, 97, 99); + PendingOrder ng = mk_raw(/*stop=*/98, /*limit=*/kNaN, /*is_long=*/false); + CHECK(exit_order_touch_position(non_gap, ng, PositionSide::LONG, &pos)); + CHECK(near(pos, 1.75)); + + // Pure-na / dual-priced orders are rejected (has_stop == has_limit). + PendingOrder both = mk_raw(/*stop=*/98, /*limit=*/102, /*is_long=*/false); + CHECK(exit_order_touch_position(non_gap, both, PositionSide::LONG, &pos) == false); + // FLAT position is never an exit context. + CHECK(exit_order_touch_position(non_gap, ng, PositionSide::FLAT, &pos) == false); +} + +// ── path_cross_kind_priority: STOP(0) < TRAIL(1) < LIMIT(2) ── +// +// When several levels cross at the same path position, collect_cross_events +// orders them by this priority so a stop beats a co-located trail beats a +// co-located limit. Exercised both directly and through the sort. +static void test_path_cross_kind_priority_order() { + std::printf("test_path_cross_kind_priority_order\n"); + CHECK(path_cross_kind_priority(PathCrossKind::STOP) == 0); + CHECK(path_cross_kind_priority(PathCrossKind::TRAIL) == 1); + CHECK(path_cross_kind_priority(PathCrossKind::LIMIT) == 2); + + // All three cross at the midpoint of a 100->110 leg (pos 0.5). The sort + // comparator must emit them STOP, TRAIL, LIMIT. + std::vector ev = + collect_cross_events(100, 110, /*stop=*/105, /*limit=*/105, /*trail=*/105); + CHECK(ev.size() == 3); + CHECK(ev[0].kind == PathCrossKind::STOP); + CHECK(ev[1].kind == PathCrossKind::TRAIL); + CHECK(ev[2].kind == PathCrossKind::LIMIT); + CHECK(near(ev[0].path_pos, 0.5)); + CHECK(near(ev[2].path_pos, 0.5)); + + // A lone trail level still appends (kind TRAIL) at its interpolated pos. + std::vector trail_only = + collect_cross_events(100, 110, kNaN, kNaN, /*trail=*/107); + CHECK(trail_only.size() == 1); + CHECK(trail_only[0].kind == PathCrossKind::TRAIL); + CHECK(near(trail_only[0].path_pos, 0.7)); + + // Levels outside the leg are not appended. + std::vector none = + collect_cross_events(100, 110, /*stop=*/120, /*limit=*/90, kNaN); + CHECK(none.empty()); +} + +// ── resolve_exit_path_fill: trailing-stop activation + fill levels ── +static void test_resolve_exit_trail_fills() { + std::printf("test_resolve_exit_trail_fills\n"); + + // FLAT short-circuits to no fill regardless of levels. + Bar flat_bar = mk(100, 102, 98, 100); + ExitPathFill flat = resolve_exit_path_fill( + flat_bar, PositionSide::FLAT, /*stop=*/98, /*limit=*/102, + /*trail_points=*/kNaN, /*trail_offset=*/kNaN, /*entry=*/100, + /*best_start=*/kNaN, /*is_entry_bar=*/false, /*magnifier=*/false, + kMintick); + CHECK(flat.should_fill == false); + + // LONG trail WITH offset, arming intrabar (update_exit_trail_state rising, + // active_exit_trail_level = best - offset). + // entry=100, trail_points=100 ticks -> activation = 100 + 100*0.01 = 101. + // trail_offset = 50 ticks -> 0.50 price. + // Bar (100.5, 102, 100, 100.2): |H-O|=1.5 NOT < |O-L|=0.5 -> LOW-first + // path O(100.5) -> L(100) -> H(102) -> C(100.2). + // leg L->H rises to 102 -> best=102 >= 101 -> trail arms. + // leg H->C falls 102->100.2; trail level = 102 - 0.5 = 101.5, crossed -> fill@101.5. + Bar trail_long = mk(100.5, 102, 100, 100.2); + ExitPathFill fl = resolve_exit_path_fill( + trail_long, PositionSide::LONG, /*stop=*/kNaN, /*limit=*/kNaN, + /*trail_points=*/100, /*trail_offset=*/50, /*entry=*/100, + /*best_start=*/kNaN, /*is_entry_bar=*/false, /*magnifier=*/false, + kMintick); + CHECK(fl.should_fill == true); + CHECK(near(fl.fill_price, 101.5)); + + // LONG trail with NO offset -> exits AT the activation level itself + // (active_exit_trail_level returns activation_level; the limit-leg of + // select_exit_segment_levels arms trail_level=activation when not yet active). + // activation = 101 is crossed on the rising L->H leg -> fill@101. + ExitPathFill fl_nooff = resolve_exit_path_fill( + trail_long, PositionSide::LONG, kNaN, kNaN, + /*trail_points=*/100, /*trail_offset=*/kNaN, /*entry=*/100, + /*best_start=*/kNaN, false, false, kMintick); + CHECK(fl_nooff.should_fill == true); + CHECK(near(fl_nooff.fill_price, 101.0)); + + // SHORT trail WITH offset, arming on a FALLING leg (update_exit_trail_state + // short branch, best tracks the low). + // entry=100, trail_points=100 -> activation = 100 - 1 = 99. offset 50t = 0.5. + // Bar (99.5, 101.5, 98, 99.8): |H-O|=2 >= |O-L|=1.5 -> low-first path + // 99.5 -> 98 -> 101.5 -> 99.8. + // leg O->L falls to 98 <= 99 -> trail arms, best=98. + // leg L->H rises 98->101.5; trail level = best + offset = 98 + 0.5 = 98.5 -> fill@98.5. + Bar trail_short = mk(99.5, 101.5, 98, 99.8); + ExitPathFill fs = resolve_exit_path_fill( + trail_short, PositionSide::SHORT, kNaN, kNaN, + /*trail_points=*/100, /*trail_offset=*/50, /*entry=*/100, + /*best_start=*/kNaN, false, false, kMintick); + CHECK(fs.should_fill == true); + CHECK(near(fs.fill_price, 98.5)); + + // LONG trail no-offset, ALREADY armed via best_start, bar opens past the + // activation level -> gap-fill at the open (exits-at-activation gap arm). + // activation=101; best_start=101.5 (>=activation so armed); open=102>=101. + Bar gap_nooff = mk(102, 103, 101, 102); + ExitPathFill g_nooff = resolve_exit_path_fill( + gap_nooff, PositionSide::LONG, kNaN, kNaN, + /*trail_points=*/100, /*trail_offset=*/kNaN, /*entry=*/100, + /*best_start=*/101.5, false, false, kMintick); + CHECK(g_nooff.should_fill == true); + CHECK(near(g_nooff.fill_price, 102.0)); + + // LONG trail WITH offset, already armed via best_start, bar opens at/under + // the trail level -> gap-fill at the open (active-trail gap arm, best-off). + // best_start=102, offset 50t=0.5 -> trail level=101.5; open=101<=101.5. + Bar gap_off = mk(101, 101.5, 100, 100.5); + ExitPathFill g_off = resolve_exit_path_fill( + gap_off, PositionSide::LONG, kNaN, kNaN, + /*trail_points=*/100, /*trail_offset=*/50, /*entry=*/100, + /*best_start=*/102, false, false, kMintick); + CHECK(g_off.should_fill == true); + CHECK(near(g_off.fill_price, 101.0)); +} + +// ── exit_order_earliest_path_metric_no_trail: entry-bar wrong-side block ── +// +// On the entry bar a no-trail EXIT whose stop/limit lies on the wrong side of +// entry would have fired before the position existed -> blocked (+inf metric). +// Off the entry bar, or on the correct side, it returns a finite coordinate. +static void test_entry_bar_blocks_no_trail_exit() { + std::printf("test_entry_bar_blocks_no_trail_exit\n"); + Bar wide = mk(100, 105, 95, 100); // spans both 102 and 98 + const double inf = std::numeric_limits::infinity(); + + // LONG entry@100, stop ABOVE entry -> wrong side -> blocked. + PendingOrder l_stop_hi = mk_exit(/*stop=*/102, /*limit=*/kNaN, kNaN, kNaN); + CHECK(exit_order_earliest_path_metric_no_trail( + wide, l_stop_hi, PositionSide::LONG, /*is_entry_bar=*/true, + /*entry=*/100) == inf); + + // LONG entry@100, limit BELOW entry -> wrong side -> blocked. + PendingOrder l_lim_lo = mk_exit(/*stop=*/kNaN, /*limit=*/98, kNaN, kNaN); + CHECK(exit_order_earliest_path_metric_no_trail( + wide, l_lim_lo, PositionSide::LONG, true, 100) == inf); + + // SHORT entry@100, stop BELOW entry -> wrong side -> blocked. + PendingOrder s_stop_lo = mk_exit(/*stop=*/98, /*limit=*/kNaN, kNaN, kNaN); + CHECK(exit_order_earliest_path_metric_no_trail( + wide, s_stop_lo, PositionSide::SHORT, true, 100) == inf); + + // SHORT entry@100, limit ABOVE entry -> wrong side -> blocked. + PendingOrder s_lim_hi = mk_exit(/*stop=*/kNaN, /*limit=*/102, kNaN, kNaN); + CHECK(exit_order_earliest_path_metric_no_trail( + wide, s_lim_hi, PositionSide::SHORT, true, 100) == inf); + + // Bar (100,105,95,100) has |H-O| == |O-L| == 5 -> TIE -> low-first path + // O(100) -> L(95) -> H(105) -> C(100). + + // LONG entry@100, stop BELOW entry (correct side) -> NOT blocked, finite. + // A long stop fires on a falling leg: O->L (100->95). stop=98 lands at + // pos 0 + (98-100)/(95-100) = 0.4, minus a 1e-15 nudge. + PendingOrder l_ok = mk_exit(/*stop=*/98, /*limit=*/kNaN, kNaN, kNaN); + double m_ok = exit_order_earliest_path_metric_no_trail( + wide, l_ok, PositionSide::LONG, /*is_entry_bar=*/true, 100); + CHECK(std::isfinite(m_ok)); + CHECK(near(m_ok, 0.4, 1e-6)); + + // SHORT stop above entry on a NON-entry bar walks the path (no open gap: + // short gaps only when open >= stop, and 100 < 102). A short stop fires + // on a rising leg: L->H (95->105). stop=102 lands at + // pos 1 + (102-95)/(105-95) = 1.7, minus a 1e-15 nudge. + PendingOrder s_walk = mk_exit(/*stop=*/102, /*limit=*/kNaN, kNaN, kNaN); + double m_walk = exit_order_earliest_path_metric_no_trail( + wide, s_walk, PositionSide::SHORT, /*is_entry_bar=*/false, 100); + CHECK(near(m_walk, 1.7, 1e-6)); + + // NON-entry-bar open gap: a LONG stop the bar opens straight through fills + // at the open -> metric 0. Bar open=96 <= stop=98. + Bar open_gap = mk(96, 99, 95, 97); + PendingOrder l_gap = mk_exit(/*stop=*/98, /*limit=*/kNaN, kNaN, kNaN); + double m_open_gap = exit_order_earliest_path_metric_no_trail( + open_gap, l_gap, PositionSide::LONG, /*is_entry_bar=*/false, 100); + CHECK(near(m_open_gap, 0.0)); + + // A trail order opts out of this metric entirely -> +inf. + PendingOrder trail = mk_exit(/*stop=*/98, /*limit=*/kNaN, + /*trail_points=*/10, /*trail_offset=*/kNaN); + CHECK(exit_order_earliest_path_metric_no_trail( + wide, trail, PositionSide::LONG, false, 100) == inf); +} + +int main() { + test_price_path_priority_branches(); + test_exit_order_touch_gap_arms(); + test_path_cross_kind_priority_order(); + test_resolve_exit_trail_fills(); + test_entry_bar_blocks_no_trail_exit(); + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +} diff --git a/tests/test_report_trace.cpp b/tests/test_report_trace.cpp new file mode 100644 index 0000000..dfbe988 --- /dev/null +++ b/tests/test_report_trace.cpp @@ -0,0 +1,173 @@ +// test_report_trace.cpp — exercises the runtime trace path in engine_report.cpp: +// - BacktestEngine::intern_trace_name (name interning + dedup) +// - BacktestEngine::trace (trace_enabled_ gate + POD push_back) +// - BacktestEngine::fill_trace_section (heap copy of trace[] + trace_names[]) +// - BacktestEngine::free_report (delete[] trace / trace_names) +// +// Pins: trace_len == 2*nbars, trace_names_len == 2 (interning dedups the two +// reused names), per-entry timestamp/bar_index/name_id/value, and the name_id +// -> trace_names[] indexing. Also verifies the gate (no traces when disabled) +// and that free_report nulls + zero-lengths the trace arrays. + +#include +#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) + +// Strategy that emits two traces per bar using the SAME two names every bar, +// so interning collapses them to exactly 2 unique names. The values are +// deterministic functions of the bar so we can pin exact expected numbers. +// "ema_fast" -> bar.close +// "signal" -> bar.high - bar.low (the bar's range) +class TraceStrategy : public BacktestEngine { +public: + TraceStrategy() { + initial_capital_ = 100000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + slippage_ = 0; + } + + void on_bar(const Bar& bar) override { + // Emit via the double overload and via the int overload to exercise the + // forwarding overload path too. "signal" is computed as an int range. + trace("ema_fast", bar.close); + int range = static_cast(bar.high - bar.low); + trace("signal", range); + } +}; + +static void test_trace_disabled_emits_nothing() { + std::printf("test_trace_disabled_emits_nothing\n"); + TraceStrategy strat; + // trace_enabled_ defaults to false -> trace() is a no-op. + CHECK(!strat.trace_enabled()); + + Bar bars[] = { + {100.0, 110.0, 95.0, 102.0, 50, 1000}, + {102.0, 112.0, 99.0, 108.0, 50, 2000}, + {108.0, 118.0, 105.0, 112.0, 50, 3000}, + }; + strat.run(bars, 3); + + ReportC rep; + std::memset(&rep, 0, sizeof(rep)); + strat.fill_report(&rep); + + // Gate held: no trace records, no name table. + CHECK(rep.trace_len == 0); + CHECK(rep.trace == nullptr); + CHECK(rep.trace_names_len == 0); + CHECK(rep.trace_names == nullptr); + + BacktestEngine::free_report(&rep); +} + +static void test_trace_enabled_records_and_interns() { + std::printf("test_trace_enabled_records_and_interns\n"); + + const int nbars = 5; + Bar bars[nbars] = { + {100.0, 110.0, 95.0, 102.0, 50, 1000}, + {102.0, 112.0, 99.0, 108.0, 50, 2000}, + {108.0, 118.0, 105.0, 112.0, 50, 3000}, + {112.0, 120.0, 110.0, 115.0, 50, 4000}, + {115.0, 125.0, 111.0, 119.0, 50, 5000}, + }; + + TraceStrategy strat; + strat.set_trace_enabled(true); + CHECK(strat.trace_enabled()); + strat.run(bars, nbars); + + ReportC rep; + std::memset(&rep, 0, sizeof(rep)); + strat.fill_report(&rep); + + // Two traces per bar. + CHECK(rep.trace_len == 2 * nbars); + CHECK(rep.trace != nullptr); + // Two distinct reused names -> interned to exactly 2. + CHECK(rep.trace_names_len == 2); + CHECK(rep.trace_names != nullptr); + + // First-occurrence interning order: "ema_fast" (id 0), then "signal" (id 1). + if (rep.trace_names_len == 2) { + CHECK(std::strcmp(rep.trace_names[0], "ema_fast") == 0); + CHECK(std::strcmp(rep.trace_names[1], "signal") == 0); + } + + // Verify every entry: layout is [ema_fast, signal] per bar in emission + // order, timestamps/bar_index follow the bar, values match what we emitted. + if (rep.trace_len == 2 * nbars && rep.trace_names_len == 2) { + for (int b = 0; b < nbars; ++b) { + const TraceEntryC& ef = rep.trace[2 * b + 0]; + const TraceEntryC& sg = rep.trace[2 * b + 1]; + + // ema_fast entry + CHECK(ef.name_id == 0); + CHECK(std::strcmp(rep.trace_names[ef.name_id], "ema_fast") == 0); + CHECK(ef.bar_index == b); + CHECK(ef.timestamp == bars[b].timestamp); + CHECK(std::fabs(ef.value - bars[b].close) < 1e-9); + + // signal entry (int range overload -> double) + int expected_range = static_cast(bars[b].high - bars[b].low); + CHECK(sg.name_id == 1); + CHECK(std::strcmp(rep.trace_names[sg.name_id], "signal") == 0); + CHECK(sg.bar_index == b); + CHECK(sg.timestamp == bars[b].timestamp); + CHECK(std::fabs(sg.value - static_cast(expected_range)) < 1e-9); + } + } + + // Pin the very first record's concrete values explicitly (bar 0). + if (rep.trace_len >= 2) { + CHECK(rep.trace[0].timestamp == 1000); + CHECK(rep.trace[0].bar_index == 0); + CHECK(rep.trace[0].name_id == 0); + CHECK(std::fabs(rep.trace[0].value - 102.0) < 1e-9); // bar 0 close + + CHECK(rep.trace[1].timestamp == 1000); + CHECK(rep.trace[1].bar_index == 0); + CHECK(rep.trace[1].name_id == 1); + // bar 0 range = 110 - 95 = 15 + CHECK(std::fabs(rep.trace[1].value - 15.0) < 1e-9); + } + + // free_report must release and reset the trace arrays. + BacktestEngine::free_report(&rep); + CHECK(rep.trace == nullptr); + CHECK(rep.trace_len == 0); + CHECK(rep.trace_names == nullptr); + CHECK(rep.trace_names_len == 0); +} + +int main() { + test_trace_disabled_emits_nothing(); + test_trace_enabled_records_and_interns(); + + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +} diff --git a/tests/test_run_inputs_overrides.cpp b/tests/test_run_inputs_overrides.cpp new file mode 100644 index 0000000..2b4a1c7 --- /dev/null +++ b/tests/test_run_inputs_overrides.cpp @@ -0,0 +1,487 @@ +// test_run_inputs_overrides.cpp — coverage for the input getters and the +// full run() overload (inputs + SymInfo + StrategyOverrides) in +// src/engine_run.cpp. +// +// Three concern groups, each pinning Pine-correct behaviour: +// +// 1. get_input_double / int / int64 / bool / string (lines 557-599): +// valid parse, fallback-on-garbage (the catch(...) arms on bad numeric +// strings), and the "true"/"1"/"false"/"0" bool grammar. These back the +// generated code's input.* lookups; an operator override string that +// cannot be parsed must silently fall back to the Pine default rather +// than throw across the engine. +// +// 2. The run-with-overrides overload (lines 624-669): apply a +// StrategyOverrides struct (initial_capital, pyramiding, slippage, +// commission_value/type, default_qty_value/type, process_orders_on_close, +// close_entries_rule), run a strategy, and assert the report/equity +// reflect each field — initial_capital flows to equity, pyramiding caps +// the number of same-direction market legs, commission reduces realized +// PnL, process_orders_on_close changes the market fill price. +// +// 3. The timeframe auto-detection branch (line 301): call the TF-aware +// overload with an EMPTY script_tf (and empty input_tf) so +// detect_timeframe runs over the bar timestamps; assert the report's +// input_tf_seconds / script_tf_seconds match the detected median delta. +// +// All expected values were derived by reading src/engine_run.cpp + +// src/engine_orders.cpp and confirmed by running this test. + +#include +#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) + +static bool near(double a, double b, double tol = 1e-6) { + return std::fabs(a - b) <= tol; +} + +namespace { + +// ── Group 1: input-getter probe ────────────────────────────────────────── +// Thin passthrough to the protected get_input_* surface. +struct GetterProbe : public BacktestEngine { + void on_bar(const Bar&) override {} + double dbl(const std::string& k, double d) const { return get_input_double(k, d); } + int integer(const std::string& k, int d) const { return get_input_int(k, d); } + int64_t i64(const std::string& k, int64_t d) const { return get_input_int64(k, d); } + bool boolean(const std::string& k, bool d) const { return get_input_bool(k, d); } + std::string str(const std::string& k, const std::string& d) const { + return get_input_string(k, d); + } +}; + +void test_get_input_double() { + std::printf("test_get_input_double\n"); + GetterProbe p; + // Valid float parses; partial trailing junk is tolerated by std::stod. + p.set_input("len", "14.5"); + CHECK(near(p.dbl("len", 0.0), 14.5)); + p.set_input("neg", "-2.25"); + CHECK(near(p.dbl("neg", 0.0), -2.25)); + // Missing key → default. + CHECK(near(p.dbl("absent", 7.0), 7.0)); + // Malformed numeric → catch(...) → default (NOT a throw). + p.set_input("garbage", "not-a-number"); + CHECK(near(p.dbl("garbage", 3.5), 3.5)); + p.set_input("empty", ""); + CHECK(near(p.dbl("empty", 99.0), 99.0)); +} + +void test_get_input_int() { + std::printf("test_get_input_int\n"); + GetterProbe p; + p.set_input("n", "21"); + CHECK(p.integer("n", 0) == 21); + p.set_input("neg", "-5"); + CHECK(p.integer("neg", 0) == -5); + CHECK(p.integer("absent", 42) == 42); + // std::stoi throws on a non-numeric leading char → catch(...) → default. + p.set_input("bad", "xyz"); + CHECK(p.integer("bad", 13) == 13); + p.set_input("empty", ""); + CHECK(p.integer("empty", -1) == -1); +} + +void test_get_input_int64() { + std::printf("test_get_input_int64\n"); + GetterProbe p; + // ms-epoch value well past int32 range. + p.set_input("ts", "1700000000000"); + CHECK(p.i64("ts", 0) == 1700000000000LL); + CHECK(p.i64("absent", -9) == -9); + p.set_input("bad", "abc"); + CHECK(p.i64("bad", 8) == 8); +} + +void test_get_input_bool() { + std::printf("test_get_input_bool\n"); + GetterProbe p; + // Pine bool grammar: "true"/"1" → true, "false"/"0" → false. + p.set_input("a", "true"); + CHECK(p.boolean("a", false) == true); + p.set_input("b", "1"); + CHECK(p.boolean("b", false) == true); + p.set_input("c", "false"); + CHECK(p.boolean("c", true) == false); + p.set_input("d", "0"); + CHECK(p.boolean("d", true) == false); + // Missing key → default (both polarities). + CHECK(p.boolean("absent", true) == true); + CHECK(p.boolean("absent", false) == false); + // Any other string is NOT recognized → default is returned unchanged. + p.set_input("weird", "yes"); + CHECK(p.boolean("weird", true) == true); + CHECK(p.boolean("weird", false) == false); +} + +void test_get_input_string() { + std::printf("test_get_input_string\n"); + GetterProbe p; + p.set_input("mode", "SMA"); + CHECK(p.str("mode", "EMA") == "SMA"); + CHECK(p.str("absent", "EMA") == "EMA"); + // Empty string is a PRESENT value — returned verbatim, not the default. + p.set_input("blank", ""); + CHECK(p.str("blank", "fallback") == ""); +} + +// ── Group 2: run-with-overrides overload ───────────────────────────────── +// +// Strategy: place one market entry per bar with a distinct id and never +// close. Market entries fill at the NEXT bar's open. pyramiding=N caps the +// number of same-direction legs at N, so only the first N placements ever +// open a leg. With default_qty_value=Q (FIXED), each leg adds qty Q; final +// position holds N*Q contracts (no closed trades → net_profit==0, equity +// stays at initial_capital). +class PyramidEntryStrat : public BacktestEngine { +public: + void on_bar(const Bar&) override { + // Distinct ids so each call is a fresh pyramid-add attempt rather + // than a same-id replacement. + strategy_entry("E" + std::to_string(bar_index_), /*is_long=*/true); + } + // Observers for the protected runtime state. + double equity() const { return current_equity(); } + double init_cap() const { return initial_capital_; } + double signed_size() const { return signed_position_size(); } + int pyramiding() const { return pyramiding_; } + int slippage() const { return slippage_; } + double commission_value() const { return commission_value_; } + int commission_type() const { return static_cast(commission_type_); } + double default_qty_value() const { return default_qty_value_; } + int default_qty_type() const { return static_cast(default_qty_type_); } + bool process_orders_on_close() const { return process_orders_on_close_; } + bool close_entries_rule_any() const { return close_entries_rule_any_; } +}; + +// Build a flat-priced rising-open bar series so every leg fills at a known +// open. 6 bars, opens 100, 101, 102, ... (range ±1). +static void make_bars(Bar* bars, int n, int64_t step_ms = 60'000) { + double open_price = 100.0; + for (int i = 0; i < n; ++i) { + bars[i].open = open_price; + bars[i].high = open_price + 1.0; + bars[i].low = open_price - 1.0; + bars[i].close = open_price; + bars[i].volume = 1000.0; + bars[i].timestamp = (int64_t)(i + 1) * step_ms; + open_price += 1.0; + } +} + +void test_overrides_applied_to_config_and_equity() { + std::printf("test_overrides_applied_to_config_and_equity\n"); + PyramidEntryStrat s; + + StrategyOverrides ov; + ov.initial_capital = 250000.0; + ov.pyramiding = 2; + ov.slippage = 3; + ov.commission_value = 0.5; + ov.commission_type = static_cast(CommissionType::PERCENT); // 0 + ov.default_qty_value = 4.0; + ov.default_qty_type = static_cast(QtyType::FIXED); // 0 + ov.process_orders_on_close = 0; // false + ov.close_entries_rule = 1; // ANY + + constexpr int N = 6; + Bar bars[N]; + make_bars(bars, N); + + std::unordered_map inputs; + SymInfo sym; // defaults: mintick 0.01, pointvalue 1.0 + + s.run(bars, N, "1", "1", inputs, sym, &ov); + + CHECK(s.last_error().empty()); + + // Every scalar override landed on the matching config field. + CHECK(near(s.init_cap(), 250000.0)); + CHECK(s.pyramiding() == 2); + CHECK(s.slippage() == 3); + CHECK(near(s.commission_value(), 0.5)); + CHECK(s.commission_type() == static_cast(CommissionType::PERCENT)); + CHECK(near(s.default_qty_value(), 4.0)); + CHECK(s.default_qty_type() == static_cast(QtyType::FIXED)); + CHECK(s.process_orders_on_close() == false); + CHECK(s.close_entries_rule_any() == true); + + // pyramiding=2 caps same-direction legs at 2; default_qty_value=4 each. + // No leg ever closes → final long position holds 2*4 = 8 contracts. + CHECK(near(s.signed_size(), 8.0)); + + // No closed trades → net_profit==0 → equity stays at the overridden + // initial_capital. (open_profit is not part of current_equity().) + ReportC rep{}; + s.fill_report(&rep); + CHECK(rep.total_trades == 0); + CHECK(near(rep.net_profit, 0.0)); + CHECK(near(s.equity(), 250000.0)); + BacktestEngine::free_report(&rep); +} + +// Larger pyramiding cap lets every placement through, proving the override +// is what bounds the leg count (not some other gate). With pyramiding=10 on +// a 6-bar series, the first 5 placements (bars 0..4) all fill (bar i's +// market order fills at bar i+1's open; bar 5's order would fill at bar 6 +// which doesn't exist), so 5 legs open at qty 1 each → 5 contracts. +void test_overrides_large_pyramiding_opens_all_legs() { + std::printf("test_overrides_large_pyramiding_opens_all_legs\n"); + PyramidEntryStrat s; + + StrategyOverrides ov; + ov.initial_capital = 1'000'000.0; + ov.pyramiding = 10; + ov.default_qty_value = 1.0; + ov.default_qty_type = static_cast(QtyType::FIXED); + + constexpr int N = 6; + Bar bars[N]; + make_bars(bars, N); + + std::unordered_map inputs; + SymInfo sym; + s.run(bars, N, "1", "1", inputs, sym, &ov); + + CHECK(s.last_error().empty()); + CHECK(s.pyramiding() == 10); + // 5 legs fill (bars 0..4 fill at bars 1..5 open); bar 5's order can't + // fill (no bar 6). Each leg qty 1 → 5 contracts long. + CHECK(near(s.signed_size(), 5.0)); +} + +// nullptr overrides leaves the engine's compiled-in defaults intact. The +// PyramidEntryStrat ctor is the implicit default: initial_capital_ 1e6, +// pyramiding_ 1, default_qty_value_ 1. With pyramiding=1 only the first leg +// opens → 1 contract. +void test_overrides_null_keeps_defaults() { + std::printf("test_overrides_null_keeps_defaults\n"); + PyramidEntryStrat s; + + constexpr int N = 6; + Bar bars[N]; + make_bars(bars, N); + + std::unordered_map inputs; + SymInfo sym; + s.run(bars, N, "1", "1", inputs, sym, /*overrides=*/nullptr); + + CHECK(s.last_error().empty()); + CHECK(near(s.init_cap(), 1'000'000.0)); // BacktestEngine default + CHECK(s.pyramiding() == 1); // BacktestEngine default + // Only the first placement opens a leg; the rest are gated by the + // default pyramiding=1. 1 contract long. + CHECK(near(s.signed_size(), 1.0)); +} + +// process_orders_on_close override changes the market fill price: when ON, +// a market order placed in on_bar fills at THIS bar's close instead of the +// next bar's open. We verify by realizing a closed trade and comparing PnL. +class CloseThenExitStrat : public BacktestEngine { +public: + void on_bar(const Bar&) override { + if (bar_index_ == 0) strategy_entry("L", /*is_long=*/true); + if (bar_index_ == 1) strategy_close("L", "exit"); + } + int trades() const { return (int)trades_.size(); } + double trade_pnl(int i) const { return trades_[i].pnl; } + double trade_entry(int i) const { return trades_[i].entry_price; } + double trade_exit(int i) const { return trades_[i].exit_price; } +}; + +void test_override_process_orders_on_close_fills_at_close() { + std::printf("test_override_process_orders_on_close_fills_at_close\n"); + // Bars: open != close so the close-fill vs next-open-fill prices differ. + constexpr int N = 4; + Bar bars[N]; + for (int i = 0; i < N; ++i) { + bars[i].open = 100.0 + i * 10.0; // 100, 110, 120, 130 + bars[i].close = bars[i].open + 5.0; // 105, 115, 125, 135 + bars[i].high = bars[i].close + 1.0; + bars[i].low = bars[i].open - 1.0; + bars[i].volume = 1000.0; + bars[i].timestamp = (int64_t)(i + 1) * 60'000; + } + + std::unordered_map inputs; + SymInfo sym; // mintick 0.01 → directional snap is a no-op on these prices + + // process_orders_on_close = ON: entry placed bar 0 fills at bar 0 close + // (105), close placed bar 1 fills at bar 1 close (115). PnL = (115-105)*1. + { + CloseThenExitStrat s; + StrategyOverrides ov; + ov.process_orders_on_close = 1; // ON + ov.slippage = 0; + ov.commission_value = 0.0; + s.run(bars, N, "1", "1", inputs, sym, &ov); + CHECK(s.last_error().empty()); + CHECK(s.trades() == 1); + if (s.trades() == 1) { + CHECK(near(s.trade_entry(0), 105.0)); + CHECK(near(s.trade_exit(0), 115.0)); + CHECK(near(s.trade_pnl(0), 10.0)); + } + } + + // process_orders_on_close = OFF: entry placed bar 0 fills at bar 1 open + // (110), close placed bar 1 fills at bar 2 open (120). PnL = (120-110)*1. + { + CloseThenExitStrat s; + StrategyOverrides ov; + ov.process_orders_on_close = 0; // OFF + ov.slippage = 0; + ov.commission_value = 0.0; + s.run(bars, N, "1", "1", inputs, sym, &ov); + CHECK(s.last_error().empty()); + CHECK(s.trades() == 1); + if (s.trades() == 1) { + CHECK(near(s.trade_entry(0), 110.0)); + CHECK(near(s.trade_exit(0), 120.0)); + CHECK(near(s.trade_pnl(0), 10.0)); + } + } +} + +// Commission override (CASH_PER_ORDER) flows into realized PnL. With a flat +// market (entry open == exit open) the gross PnL is 0, so the net PnL equals +// -(entry_commission + exit_commission) = -2 * commission_value. +void test_override_commission_reduces_pnl() { + std::printf("test_override_commission_reduces_pnl\n"); + constexpr int N = 4; + Bar bars[N]; + for (int i = 0; i < N; ++i) { + bars[i].open = 100.0; // flat market → zero gross PnL + bars[i].close = 100.0; + bars[i].high = 101.0; + bars[i].low = 99.0; + bars[i].volume = 1000.0; + bars[i].timestamp = (int64_t)(i + 1) * 60'000; + } + + std::unordered_map inputs; + SymInfo sym; + + CloseThenExitStrat s; + StrategyOverrides ov; + ov.process_orders_on_close = 0; + ov.slippage = 0; + ov.commission_value = 2.5; + ov.commission_type = static_cast(CommissionType::CASH_PER_ORDER); // 1 + ov.default_qty_value = 1.0; + ov.default_qty_type = static_cast(QtyType::FIXED); + s.run(bars, N, "1", "1", inputs, sym, &ov); + + CHECK(s.last_error().empty()); + CHECK(s.trades() == 1); + if (s.trades() == 1) { + // Gross PnL 0; two CASH_PER_ORDER commissions of 2.5 each → -5.0. + CHECK(near(s.trade_pnl(0), -5.0)); + } +} + +// ── Group 3: timeframe auto-detection (empty tf strings) ───────────────── +// +// With an EMPTY input_tf AND empty script_tf, the TF-aware overload runs +// detect_timeframe over the bar timestamps. detect_timeframe takes the +// MEDIAN inter-bar delta and snaps to the nearest standard TF label, then +// fill_report converts that label back to seconds via tf_to_seconds. +void test_empty_tf_triggers_detect_timeframe() { + std::printf("test_empty_tf_triggers_detect_timeframe\n"); + + // 5-minute spacing → median delta 300s → detect_timeframe → "5". + { + PyramidEntryStrat s; + constexpr int N = 6; + Bar bars[N]; + make_bars(bars, N, /*step_ms=*/300'000); // 5 minutes + std::unordered_map inputs; + SymInfo sym; + StrategyOverrides ov; + ov.pyramiding = 10; // irrelevant here, just keep config explicit + // Empty input_tf + empty script_tf → both go through detect_timeframe. + s.run(bars, N, "", "", inputs, sym, &ov); + CHECK(s.last_error().empty()); + ReportC rep{}; + s.fill_report(&rep); + CHECK(rep.input_tf_seconds == 300); + CHECK(rep.script_tf_seconds == 300); + // Same TF on input + script → no aggregation. + CHECK(rep.needs_aggregation == 0); + BacktestEngine::free_report(&rep); + } + + // 60-minute spacing → median delta 3600s → detect_timeframe → "60". + { + PyramidEntryStrat s; + constexpr int N = 6; + Bar bars[N]; + make_bars(bars, N, /*step_ms=*/3'600'000); // 1 hour + std::unordered_map inputs; + SymInfo sym; + s.run(bars, N, "", "", inputs, sym, /*overrides=*/nullptr); + CHECK(s.last_error().empty()); + ReportC rep{}; + s.fill_report(&rep); + CHECK(rep.input_tf_seconds == 3600); + CHECK(rep.script_tf_seconds == 3600); + BacktestEngine::free_report(&rep); + } + + // Daily spacing → median delta 86400s → detect_timeframe → "D" → 86400s. + { + PyramidEntryStrat s; + constexpr int N = 6; + Bar bars[N]; + make_bars(bars, N, /*step_ms=*/86'400'000); // 1 day + std::unordered_map inputs; + SymInfo sym; + s.run(bars, N, "", "", inputs, sym, /*overrides=*/nullptr); + CHECK(s.last_error().empty()); + ReportC rep{}; + s.fill_report(&rep); + CHECK(rep.input_tf_seconds == 86400); + CHECK(rep.script_tf_seconds == 86400); + BacktestEngine::free_report(&rep); + } +} + +} // namespace + +int main() { + std::printf("--- run inputs + overrides + tf-detect ---\n"); + test_get_input_double(); + test_get_input_int(); + test_get_input_int64(); + test_get_input_bool(); + test_get_input_string(); + test_overrides_applied_to_config_and_equity(); + test_overrides_large_pyramiding_opens_all_legs(); + test_overrides_null_keeps_defaults(); + test_override_process_orders_on_close_fills_at_close(); + test_override_commission_reduces_pnl(); + test_empty_tf_triggers_detect_timeframe(); + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +} diff --git a/tests/test_security_validation_throws.cpp b/tests/test_security_validation_throws.cpp new file mode 100644 index 0000000..37340cb --- /dev/null +++ b/tests/test_security_validation_throws.cpp @@ -0,0 +1,263 @@ +// Tests for the remaining uncovered validation / dispatch arms of +// src/engine_security.cpp that the existing security suites +// (test_security_tf_validation, test_security_lower_tf_script_bound, +// test_security_lower_tf_input_passthrough, test_ltf_buffer_no_leak) do +// NOT exercise: +// +// 1. validate_security_timeframes line ~191: a request.security_lower_tf +// whose requested TF is NOT finer than input (so it reaches the +// input-passthrough block) while the script TF parses to <= 0 +// seconds (calendar month "M") -> "script timeframe is unknown". +// +// 2. validate_security_timeframes line ~211: a request.security_lower_tf +// whose requested TF is >= input, strictly finer than script, an +// exact divisor of script, but NOT an integer multiple of input +// -> "is not an integer multiple of input". +// +// 3. security_series_slot_is_new (lines 228-236): all three return +// arms -- unknown sec_id (default true), lookahead_off (always +// true), and lookahead_on with current_sub_bar_count > 1 (false) +// vs <= 1 (true). +// +// 4. feed_security_eval_state aggregator partial branch (lines 380-381): +// an HTF request.security with lookahead_on=true must emit a partial +// (is_complete=false) evaluation for every incomplete aggregated +// sub-bar and increment eval_partial_count. +// +// Style: cassert + plain int main(), mirroring tests/test_security_tf_validation.cpp. +#include +#include +#include +#include +#include + +#include + +// This suite verifies via assert(). The CLAUDE.md-prescribed gate builds +// Release (-DNDEBUG), which would no-op assert() and make every check vacuous. +// Re-enable assert() unconditionally for this TU, placed after all other +// includes so no earlier/later header can disable it again. +#undef NDEBUG +#include + +using namespace pineforge; + +namespace { + +void expect_contains(const std::string& haystack, const std::string& needle, + const char* label) { + if (haystack.find(needle) == std::string::npos) { + std::cerr << label << ": expected error to contain '" << needle + << "' but got: '" << haystack << "'\n"; + assert(false); + } +} + +// ---- Validation-throw harness (mirrors the template's ValidationHarness) ---- +// Exposes register_security{,_lower_tf}_eval and never dispatches. +struct ThrowHarness : public BacktestEngine { + void on_bar(const Bar&) override {} + void evaluate_security(int, const Bar&, bool) override {} + + void add_security_lower_tf(const std::string& requested_tf) { + // Empty input_tf at register time so validate_security_timeframes + // (driven from run() once input_tf_/script_tf_ are known) is the + // layer under test. + register_security_lower_tf_eval(0, requested_tf, ""); + } +}; + +std::vector two_bars(int64_t step_ms) { + return { + {10.0, 10.0, 10.0, 10.0, 100.0, 0}, + {11.0, 11.0, 11.0, 11.0, 100.0, step_ms}, + }; +} + +std::string run_throw(ThrowHarness& strat, + const std::string& input_tf, + const std::string& script_tf, + int64_t step_ms) { + auto bars = two_bars(step_ms); + strat.run(bars.data(), static_cast(bars.size()), + input_tf, script_tf, false, 4, + MagnifierDistribution::ENDPOINTS); + return strat.last_error(); +} + +// 1. line ~191: LTF, requested TF == input (not finer, so reaches the +// input-passthrough block) while script_tf="M" -> tf_to_seconds("M") +// is -1 (calendar marker), so script_seconds <= 0 fires the +// "script timeframe is unknown" arm. run()'s ratio check sees +// tf_ratio("15","M") == -1 (calendar HTF) and does NOT throw early, +// so validation is reached. +void test_ltf_script_tf_unknown_rejected() { + ThrowHarness strat; + strat.add_security_lower_tf("15"); // req == input, not finer + auto err = run_throw(strat, "15", "M", 900000); + assert(!err.empty()); + expect_contains(err, "script timeframe is unknown", + "test_ltf_script_tf_unknown_rejected"); + expect_contains(err, "request.security_lower_tf", + "test_ltf_script_tf_unknown_rejected"); + std::cout << "test_ltf_script_tf_unknown_rejected passed.\n"; +} + +// 2. line ~211: LTF, input=2m, req=3m, script=6m. +// req(180s) >= input(120s) -> input-passthrough block. +// req < script (180 < 360) ok; script % req == 0 (360 % 180 == 0) ok; +// BUT req % input == 180 % 120 == 60 != 0 -> "is not an integer +// multiple of input". supports_lower_tf_emulation("2","3") is false +// (req not finer than input) so the LTF-emulation early-accept is +// skipped, and run()'s tf_ratio("2","6")==3 so no early throw. +void test_ltf_req_not_integer_multiple_of_input_rejected() { + ThrowHarness strat; + strat.add_security_lower_tf("3"); + auto err = run_throw(strat, "2", "6", 120000); + assert(!err.empty()); + expect_contains(err, "is not an integer multiple of input", + "test_ltf_req_not_integer_multiple_of_input_rejected"); + expect_contains(err, "request.security_lower_tf", + "test_ltf_req_not_integer_multiple_of_input_rejected"); + std::cout << "test_ltf_req_not_integer_multiple_of_input_rejected passed.\n"; +} + +// ---- security_series_slot_is_new harness (lines 228-236) ---- +// Exposes the protected predicate and lets us hand-craft eval states so +// every return arm is hit deterministically (no run() needed). +struct SlotHarness : public BacktestEngine { + void on_bar(const Bar&) override {} + void evaluate_security(int, const Bar&, bool) override {} + + bool slot_is_new(int sec_id) const { return security_series_slot_is_new(sec_id); } + + // Register one HTF security and then tweak its eval-state fields so + // each branch of security_series_slot_is_new is reachable. + void add(bool lookahead_on, int sub_bar_count) { + register_security_eval(7, "60", "15", lookahead_on, false); + auto& st = security_eval_states_.back(); + st.lookahead_on = lookahead_on; + st.current_sub_bar_count = sub_bar_count; + } +}; + +// 3a. Unknown sec_id -> default true (loop falls through, line 235). +void test_slot_unknown_sec_id_is_new() { + SlotHarness strat; + // No securities registered at all. + assert(strat.slot_is_new(99) == true); + std::cout << "test_slot_unknown_sec_id_is_new passed.\n"; +} + +// 3b. lookahead_off -> always new regardless of sub_bar_count (line 233, +// left operand of ||). +void test_slot_lookahead_off_is_new() { + SlotHarness strat; + strat.add(/*lookahead_on=*/false, /*sub_bar_count=*/5); + assert(strat.slot_is_new(7) == true); + // A different (unregistered) sec_id still defaults to true. + assert(strat.slot_is_new(8) == true); + std::cout << "test_slot_lookahead_off_is_new passed.\n"; +} + +// 3c. lookahead_on with sub_bar_count > 1 -> NOT new (false); +// sub_bar_count <= 1 -> new (true). Covers both halves of the +// "current_sub_bar_count <= 1" comparison (line 233). +void test_slot_lookahead_on_depends_on_sub_bar_count() { + { + SlotHarness strat; + strat.add(/*lookahead_on=*/true, /*sub_bar_count=*/3); + assert(strat.slot_is_new(7) == false); // mid-bar: not a new slot + } + { + SlotHarness strat; + strat.add(/*lookahead_on=*/true, /*sub_bar_count=*/1); + assert(strat.slot_is_new(7) == true); // first sub-bar: new slot + } + { + SlotHarness strat; + strat.add(/*lookahead_on=*/true, /*sub_bar_count=*/0); + assert(strat.slot_is_new(7) == true); // count==0 also <= 1: new + } + std::cout << "test_slot_lookahead_on_depends_on_sub_bar_count passed.\n"; +} + +// ---- Partial-eval harness (lines 380-381) ---- +// HTF request.security with lookahead_on=true. Each input bar inside an +// aggregation group that does NOT complete the HTF bar must trigger a +// partial (is_complete=false) evaluate_security call. +struct PartialEvalHarness : public BacktestEngine { + std::vector> dispatches; // (close, is_complete) + int partial_calls = 0; + int complete_calls = 0; + + PartialEvalHarness() { + // input=15m, requested=60m -> ratio 4. lookahead_on=true so the + // aggregator emits partials for the 3 incomplete sub-bars per group. + register_security_eval(0, "60", "15", /*lookahead_on=*/true, false); + security_eval_states_.back().lookahead_on = true; + } + + void evaluate_security(int sec_id, const Bar& bar, bool is_complete) override { + if (sec_id != 0) return; + dispatches.emplace_back(bar.close, is_complete); + if (is_complete) complete_calls++; + else partial_calls++; + } + + void on_bar(const Bar&) override {} +}; + +// 4. Eight 15m input bars => two complete 60m HTF bars. With lookahead_on +// each group emits 3 partials + 1 complete, so 6 partials + 2 completes. +// The partial close tracks the running aggregate close (== the latest +// input bar's close at the time, since each input bar's close becomes +// the partial bar's close). We pin both the counts and the per-step +// is_complete pattern. +void test_htf_lookahead_emits_partial_evals() { + PartialEvalHarness strat; + std::vector bars; + for (int i = 0; i < 8; ++i) { + double c = 100.0 + i; // closes 100..107 + bars.push_back({c, c, c, c, 10.0, static_cast(i) * 900000}); + } + strat.run(bars.data(), static_cast(bars.size()), + "15", "60", false, 4, MagnifierDistribution::ENDPOINTS); + assert(strat.last_error().empty()); + + // 8 input bars -> 8 dispatches (one per fed input bar). + assert(strat.dispatches.size() == 8); + // 2 HTF bars * (3 partials + 1 complete). + assert(strat.partial_calls == 6); + assert(strat.complete_calls == 2); + + // is_complete pattern: F F F T F F F T (complete only on every 4th bar). + const bool expect_complete[8] = {false, false, false, true, + false, false, false, true}; + for (int i = 0; i < 8; ++i) { + assert(strat.dispatches[static_cast(i)].second + == expect_complete[i]); + } + + // The bar handed to evaluate_security carries the running aggregate + // close, which for these monotone bars equals the i-th input close. + for (int i = 0; i < 8; ++i) { + double got = strat.dispatches[static_cast(i)].first; + double want = 100.0 + i; + assert(got == want); + } + std::cout << "test_htf_lookahead_emits_partial_evals passed.\n"; +} + +} // namespace + +int main() { + test_ltf_script_tf_unknown_rejected(); + test_ltf_req_not_integer_multiple_of_input_rejected(); + test_slot_unknown_sec_id_is_new(); + test_slot_lookahead_off_is_new(); + test_slot_lookahead_on_depends_on_sub_bar_count(); + test_htf_lookahead_emits_partial_evals(); + std::cout << "All test_security_validation_throws tests passed.\n"; + return 0; +} diff --git a/tests/test_session_calendar_extra.cpp b/tests/test_session_calendar_extra.cpp new file mode 100644 index 0000000..9f47c97 --- /dev/null +++ b/tests/test_session_calendar_extra.cpp @@ -0,0 +1,266 @@ +/* + * test_session_calendar_extra.cpp — densification tests for src/session_time.cpp + * + * Targets previously-uncovered code paths: + * - overnight session window (start>end wrap) inside/outside/edges + * (local_time_in_session_windows wrap arm) + * - utc_bucket_open_ms negative-bar floor-toward-(-inf) quantization + * (lines 112-119, including the `--q` underflow correction) + * - calendar_week_open_local_ms / calendar_month_open_local_ms and the + * calendar-period dispatch in compute_tf_open_ms (154-167) + * - compute_tf_close_ms DAY/WEEK/MONTH period-END boundaries (180-211) + * - parse_day_filter all-digits guard (non-1-7 suffix is NOT a day filter) + * and the day-of-week insert/count path (79-100) + * - local_time_in_session_windows malformed-window `continue` arms + * (no-dash skip; invalid-HHMM skip) (288-302) + * - pine_time_tradingday unparseable-session fprintf fallback (474-480) + * + * All expected ms values were derived empirically by running the helpers and + * cross-checked against the calendar (UTC, and America/New_York EDT = UTC-4 in + * April 2026). They pin the current Pine-correct behavior. + */ + +#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) + +// Build a Unix-ms for YYYY-MM-DD HH:MM:SS in UTC. +static int64_t utc_ms(int y, int m, int d, int hh, int mm, int ss) { + struct tm t {}; + t.tm_year = y - 1900; + t.tm_mon = m - 1; + t.tm_mday = d; + t.tm_hour = hh; + t.tm_min = mm; + t.tm_sec = ss; + t.tm_isdst = 0; + return static_cast(timegm(&t)) * 1000LL; +} + +// --------------------------------------------------------------------------- +// Overnight session window "2200-0500" (start minute 1320 > end minute 300). +// Exercises the wrap arm `mod >= sm || mod < em` in +// local_time_in_session_windows. +// --------------------------------------------------------------------------- +static void test_overnight_window_wrap() { + std::printf("test_overnight_window_wrap\n"); + const std::string sess = "2200-0500"; + + // 2026-04-07 (Tue) 23:30 UTC — inside the late-evening half. + CHECK(pine_session_ismarket(sess, "UTC", utc_ms(2026, 4, 7, 23, 30, 0)) == true); + // 2026-04-08 03:00 UTC — inside the early-morning half (after midnight). + CHECK(pine_session_ismarket(sess, "UTC", utc_ms(2026, 4, 8, 3, 0, 0)) == true); + // 2026-04-07 12:00 UTC — daytime, outside the overnight window. + CHECK(pine_session_ismarket(sess, "UTC", utc_ms(2026, 4, 7, 12, 0, 0)) == false); + + // Boundaries: start is inclusive, end is exclusive. + CHECK(pine_session_ismarket(sess, "UTC", utc_ms(2026, 4, 7, 22, 0, 0)) == true); // 22:00 in + CHECK(pine_session_ismarket(sess, "UTC", utc_ms(2026, 4, 7, 21, 59, 0)) == false); // 21:59 out + CHECK(pine_session_ismarket(sess, "UTC", utc_ms(2026, 4, 8, 4, 59, 0)) == true); // 04:59 in + CHECK(pine_session_ismarket(sess, "UTC", utc_ms(2026, 4, 8, 5, 0, 0)) == false); // 05:00 out +} + +// --------------------------------------------------------------------------- +// utc_bucket_open_ms quantization, including negative bar_ms. +// For period 3600s (TF "60"): floor toward -infinity. +// bar = -90,061,000 ms; -90061000 / 3600000 = -25 (trunc), remainder != 0, +// so q is decremented to -26 -> -93,600,000 ms. +// bar = -3,600,000 ms is exactly aligned -> returned unchanged. +// For period 300s (TF "5"): +// bar = -1,000 ms -> floor to -300,000 ms. +// Positive path: a normal intraday bucket. +// --------------------------------------------------------------------------- +static void test_utc_bucket_negative_quantization() { + std::printf("test_utc_bucket_negative_quantization\n"); + + // Unaligned negative -> --q correction branch. + CHECK(pine_time(-90061000LL, "60", "", "UTC", "60") == -93600000LL); + // Aligned negative -> remainder zero, no decrement. + CHECK(pine_time(-3600000LL, "60", "", "UTC", "60") == -3600000LL); + // Unaligned negative with 5-minute bucket. + CHECK(pine_time(-1000LL, "5", "", "UTC", "5") == -300000LL); + + // Positive intraday bucket sanity: 2026-04-07 14:30 UTC -> 14:00 UTC bucket. + int64_t pos = utc_ms(2026, 4, 7, 14, 30, 0); + CHECK(pine_time(pos, "60", "", "UTC", "60") == utc_ms(2026, 4, 7, 14, 0, 0)); +} + +// --------------------------------------------------------------------------- +// Calendar WEEK open/close (UTC). Week anchors on Monday 00:00. +// bar = 2026-04-08 (Wed) 14:30 UTC. +// week open = Mon 2026-04-06 00:00:00 UTC. +// week close = next Mon 2026-04-13 00:00 UTC minus 1 ms +// = Sun 2026-04-12 23:59:59.999 UTC. +// --------------------------------------------------------------------------- +static void test_calendar_week_utc() { + std::printf("test_calendar_week_utc\n"); + int64_t bar = utc_ms(2026, 4, 8, 14, 30, 0); + + int64_t open = pine_time(bar, "W", "", "UTC", "W"); + int64_t close = pine_time_close(bar, "W", "", "UTC", "W"); + + CHECK(!is_na(open)); + CHECK(open == utc_ms(2026, 4, 6, 0, 0, 0)); // Monday open + CHECK(close == utc_ms(2026, 4, 13, 0, 0, 0) - 1); // Sunday 23:59:59.999 +} + +// --------------------------------------------------------------------------- +// Calendar MONTH open/close (UTC). +// bar = 2026-04-08 14:30 UTC. +// month open = 2026-04-01 00:00:00 UTC. +// month close = 2026-05-01 00:00 UTC minus 1 ms = 2026-04-30 23:59:59.999. +// --------------------------------------------------------------------------- +static void test_calendar_month_utc() { + std::printf("test_calendar_month_utc\n"); + int64_t bar = utc_ms(2026, 4, 8, 14, 30, 0); + + int64_t open = pine_time(bar, "M", "", "UTC", "M"); + int64_t close = pine_time_close(bar, "M", "", "UTC", "M"); + + CHECK(!is_na(open)); + CHECK(open == utc_ms(2026, 4, 1, 0, 0, 0)); + CHECK(close == utc_ms(2026, 5, 1, 0, 0, 0) - 1); +} + +// --------------------------------------------------------------------------- +// Calendar DAY open/close (UTC) — compute_tf_close_ms DAY branch. +// bar = 2026-04-08 14:30 UTC. +// day open = 2026-04-08 00:00:00 UTC. +// day close = 2026-04-09 00:00 UTC minus 1 ms = 2026-04-08 23:59:59.999. +// --------------------------------------------------------------------------- +static void test_calendar_day_utc() { + std::printf("test_calendar_day_utc\n"); + int64_t bar = utc_ms(2026, 4, 8, 14, 30, 0); + + int64_t open = pine_time(bar, "D", "", "UTC", "D"); + int64_t close = pine_time_close(bar, "D", "", "UTC", "D"); + + CHECK(open == utc_ms(2026, 4, 8, 0, 0, 0)); + CHECK(close == utc_ms(2026, 4, 9, 0, 0, 0) - 1); +} + +// --------------------------------------------------------------------------- +// Calendar opens/closes honour the requested IANA timezone (not UTC). +// America/New_York is EDT (UTC-4) on 2026-04-08. +// bar = 2026-04-08 18:00 UTC = 14:00 ET (Wed). +// week open = Mon 2026-04-06 00:00 ET = 2026-04-06 04:00 UTC. +// month open = 2026-04-01 00:00 ET = 2026-04-01 04:00 UTC. +// day open = 2026-04-08 00:00 ET = 2026-04-08 04:00 UTC. +// week close = next Mon 00:00 ET - 1ms = 2026-04-13 03:59:59.999 UTC. +// month close= 2026-05-01 00:00 ET -1 = 2026-05-01 03:59:59.999 UTC. +// day close = 2026-04-09 00:00 ET -1 = 2026-04-09 03:59:59.999 UTC. +// --------------------------------------------------------------------------- +static void test_calendar_opens_new_york_tz() { + std::printf("test_calendar_opens_new_york_tz\n"); + const std::string tz = "America/New_York"; + int64_t bar = utc_ms(2026, 4, 8, 18, 0, 0); // 14:00 ET Wed + + CHECK(pine_time(bar, "W", "", tz, "W") == utc_ms(2026, 4, 6, 4, 0, 0)); + CHECK(pine_time(bar, "M", "", tz, "M") == utc_ms(2026, 4, 1, 4, 0, 0)); + CHECK(pine_time(bar, "D", "", tz, "D") == utc_ms(2026, 4, 8, 4, 0, 0)); + + CHECK(pine_time_close(bar, "W", "", tz, "W") == utc_ms(2026, 4, 13, 4, 0, 0) - 1); + CHECK(pine_time_close(bar, "M", "", tz, "M") == utc_ms(2026, 5, 1, 4, 0, 0) - 1); + CHECK(pine_time_close(bar, "D", "", tz, "D") == utc_ms(2026, 4, 9, 4, 0, 0) - 1); +} + +// --------------------------------------------------------------------------- +// parse_day_filter all-digits guard: a colon suffix that is NOT all 1-7 is +// treated as part of the window body, not a weekday filter. Here the trailing +// ":foo" is ignored when the window is re-parsed (only "0930-1600" matters), +// so a 10:30 ET (14:30 UTC) bar is still inside the NYSE session. +// --------------------------------------------------------------------------- +static void test_day_filter_non_digit_suffix_ignored() { + std::printf("test_day_filter_non_digit_suffix_ignored\n"); + int64_t bar = utc_ms(2026, 4, 7, 14, 30, 0); // 10:30 ET (Tue) + CHECK(pine_session_ismarket("0930-1600:foo", "America/New_York", bar) == true); +} + +// --------------------------------------------------------------------------- +// parse_day_filter weekday set (digit insert + count path). +// "1400-1500:23456" = Mon..Fri only (TV days 2-6). +// 2026-04-07 is Tuesday (TV day 3) -> day passes; 14:30 inside window -> in. +// 2026-04-11 is Saturday (TV day 7) -> excluded by filter -> out. +// --------------------------------------------------------------------------- +static void test_weekday_filter_digits() { + std::printf("test_weekday_filter_digits\n"); + CHECK(pine_session_ismarket("1400-1500:23456", "UTC", + utc_ms(2026, 4, 7, 14, 30, 0)) == true); // Tue + CHECK(pine_session_ismarket("1400-1500:23456", "UTC", + utc_ms(2026, 4, 11, 14, 30, 0)) == false); // Sat +} + +// --------------------------------------------------------------------------- +// local_time_in_session_windows malformed-window `continue` arms: +// - window without a '-' is skipped. +// - window with valid dash geometry but invalid HHMM digits is skipped. +// - a valid window after a skipped one still matches. +// --------------------------------------------------------------------------- +static void test_malformed_window_skips() { + std::printf("test_malformed_window_skips\n"); + int64_t bar = utc_ms(2026, 4, 7, 14, 30, 0); // 14:30 UTC + + // No dash anywhere -> no usable window -> outside. + CHECK(pine_session_ismarket("xxxx", "UTC", bar) == false); + CHECK(pine_session_ismarket("0930", "UTC", bar) == false); + + // First window has bad HHMM digits (h=99 invalid); skipped, no other -> outside. + CHECK(pine_session_ismarket("99xx-1500", "UTC", bar) == false); + + // First window malformed/invalid, second window valid and matches. + CHECK(pine_session_ismarket("zz,1400-1500", "UTC", bar) == true); + CHECK(pine_session_ismarket("9999-9999,1400-1500", "UTC", bar) == true); +} + +// --------------------------------------------------------------------------- +// pine_time_tradingday unparseable-session fallback (fprintf to stderr + +// UTC calendar-day midnight). The session is non-empty and not 24/7, but its +// first window has no valid HHMM start, so parse_session_start_minutes() fails. +// bar = 2024-09-05 16:00 UTC -> falls back to 2024-09-05 00:00 UTC. +// --------------------------------------------------------------------------- +static void test_tradingday_unparseable_fallback() { + std::printf("test_tradingday_unparseable_fallback\n"); + int64_t bar = utc_ms(2024, 9, 5, 16, 0, 0); + int64_t expected = utc_ms(2024, 9, 5, 0, 0, 0); + + // Non-numeric "window": is_allday_session is false (start4 != "0000"), + // parse_session_start_minutes returns -1 -> UTC-midnight fallback. + CHECK(pine_time_tradingday(bar, "abcd-efgh", "America/New_York") == expected); + // A bare garbage token with no dash also hits the same fallback. + CHECK(pine_time_tradingday(bar, "garbage", "UTC") == expected); +} + +int main() { + test_overnight_window_wrap(); + test_utc_bucket_negative_quantization(); + test_calendar_week_utc(); + test_calendar_month_utc(); + test_calendar_day_utc(); + test_calendar_opens_new_york_tz(); + test_day_filter_non_digit_suffix_ignored(); + test_weekday_filter_digits(); + test_malformed_window_skips(); + test_tradingday_unparseable_fallback(); + + std::printf("session_calendar_extra: %d passed, %d failed\n", + tests_passed, tests_failed); + return tests_failed > 0 ? 1 : 0; +} diff --git a/tests/test_strategy_commands_extra.cpp b/tests/test_strategy_commands_extra.cpp new file mode 100644 index 0000000..9aa96b5 --- /dev/null +++ b/tests/test_strategy_commands_extra.cpp @@ -0,0 +1,448 @@ +/* + * test_strategy_commands_extra.cpp — densify coverage of + * src/engine_strategy_commands.cpp. + * + * Mirrors tests/test_strategy_oca.cpp / test_strategy_pyramiding.cpp / + * test_integration.cpp: subclass BacktestEngine, override on_bar to drive + * the strategy.* command surface, and snapshot pending_orders_ / position + * state each bar so the test can pin Pine-correct expected values. + * + * Targets (engine_strategy_commands.cpp uncovered lines): + * - trade-start-time buffer gate (60-69): current_ms >= start_ms - (one + * script TF) * 1000. With 1-minute bars the buffer is 60_000 ms. + * - strategy_cancel_all() clears pending orders (374-376). + * - strategy_order raw-order reset of limit/stop to NaN (415-418). + * - purge_exit_orders() paths via execute_immediate_close (546-559). + * - explicit-qty exit reservation with a NaN-qty percent sibling + * (310-321) and the same NaN-qty percent accounting inside + * compute_exit_reserved_qty (666-668). + * + * NDEBUG-proof: uses a returning CHECK + failure counter; main() returns + * nonzero on any failure regardless of -DNDEBUG (bare assert is a no-op + * under Release). + */ + +#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) + +static bool near(double a, double b, double tol = 1e-6) { + return std::fabs(a - b) <= tol; +} + +static const double kNaN = std::numeric_limits::quiet_NaN(); + +// Build a contiguous run of bars spaced one MINUTE apart (timestamps +// (i+1)*60000). detect_timeframe() therefore maps the median delta to +// the "1" (1-minute) TF, so the trade-start buffer = 60_000 ms. +static std::vector make_minute_bars(int n, double open, double high, + double low, double close) { + std::vector bars(n); + for (int i = 0; i < n; ++i) { + bars[i].open = open; + bars[i].high = high; + bars[i].low = low; + bars[i].close = close; + bars[i].volume = 1000.0; + bars[i].timestamp = (int64_t)(i + 1) * 60'000; + } + return bars; +} + +// ───────────────────────────────────────────────────────────────────── +// (1) strategy_cancel_all() wipes the whole pending queue (374-376). +// +// Place several pending RAW_ORDER entries (priced so they would fill on a +// later bar), then call strategy_cancel_all() on the next bar. Afterwards +// no fill may occur: the queue is empty, the position stays flat, and no +// trades are produced. +// ───────────────────────────────────────────────────────────────────── +static void test_cancel_all_clears_pending() { + std::printf("test_cancel_all_clears_pending\n"); + class CancelAllProbe : public BacktestEngine { + public: + int pending_after_place = -1; // count snapshot at bar 2 (post-place) + int pending_after_cancel = -1; // count snapshot at bar 3 (post-cancel) + double final_pos = 1234.0; // signed position at last bar + CancelAllProbe() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; + commission_value_ = 0; + pyramiding_ = 100; + } + void on_bar(const Bar& bar) override { + (void)bar; + // Bar 1: arm three buy-stop RAW_ORDER entries above the bar + // (so they do NOT fire until a higher bar prints). + if (bar_index_ == 1) { + strategy_order("E1", true, 1.0, /*limit=*/kNaN, /*stop=*/200.0); + strategy_order("E2", true, 2.0, /*limit=*/kNaN, /*stop=*/210.0); + strategy_order("E3", true, 3.0, /*limit=*/kNaN, /*stop=*/220.0); + } + if (bar_index_ == 2) { + pending_after_place = (int)pending_orders_.size(); + strategy_cancel_all(); // <-- target + } + if (bar_index_ == 3) { + pending_after_cancel = (int)pending_orders_.size(); + } + final_pos = signed_position_size(); + } + }; + CancelAllProbe p; + // Keep highs BELOW every stop (200/210/220) through bar 2 so the + // orders are still pending when on_bar runs cancel_all on bar 2 + // (process_pending_orders runs BEFORE on_bar each bar). From bar 3 + // on, highs jump to 230 — every stop WOULD trigger if it had survived + // the cancel. + std::vector bars(6); + double highs[6] = {105, 105, 105, 230, 230, 230}; + for (int i = 0; i < 6; ++i) { + bars[i] = {100.0, highs[i], 90.0, 100.0, 1000.0, + (int64_t)(i + 1) * 60'000}; + } + p.run(bars.data(), (int)bars.size()); + + CHECK(p.pending_after_place == 3); // all three armed + CHECK(p.pending_after_cancel == 0); // cancel_all emptied the queue + CHECK(p.trade_count() == 0); // nothing ever filled + CHECK(p.final_pos == 0.0); +} + +// ───────────────────────────────────────────────────────────────────── +// (2) trade-start-time buffer gate (60-69). +// +// set_trade_start_time(T) gates strategy.* commands until current bar +// timestamp >= T - buffer, where buffer = one script-TF interval (60_000 +// ms on a 1-minute feed). A market RAW_ORDER placed on a bar BEFORE the +// buffered start is dropped (no order enters the queue → no fill); one +// placed at/after the buffered start enters and fills next bar's open. +// +// Bars timestamps: bar i → (i+1)*60000. With T = 240_000 (bar 3) and +// buffer 60_000, the active boundary is 180_000 (bar 2). So a placement +// on bar 1 (ts=120_000) is gated; on bar 2 (ts=180_000) it is active. +// ───────────────────────────────────────────────────────────────────── +static void test_trade_start_buffer_gate() { + std::printf("test_trade_start_buffer_gate\n"); + class GateProbe : public BacktestEngine { + public: + int place_bar = -1; + double final_pos = 1234.0; + double final_avg = -1.0; + GateProbe(int pb) : place_bar(pb) { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; + commission_value_ = 0; + } + void on_bar(const Bar& bar) override { + (void)bar; + // Market RAW_ORDER (no limit/stop): fills next bar's open if + // the gate admits it. + if (bar_index_ == place_bar) { + strategy_order("E", true, 2.0, /*limit=*/kNaN, /*stop=*/kNaN); + } + final_pos = signed_position_size(); + final_avg = position_entry_price_; + } + }; + auto bars = make_minute_bars(6, 100, 105, 95, 100); + + // Gated: place on bar 1 (ts=120_000) — strictly BEFORE the buffered + // start (180_000). The order must never enter the queue, so the + // position stays flat and no trade is produced. + { + GateProbe gated(/*place_bar=*/1); + gated.set_trade_start_time(240'000); // bar 3 + gated.run(bars.data(), (int)bars.size()); + CHECK(gated.trade_count() == 0); + CHECK(gated.final_pos == 0.0); + } + + // Active at the buffer boundary: place on bar 2 (ts=180_000 == + // start-buffer). trading_is_active returns true → the order is armed + // and fills bar 3's open (=100), opening a long of qty 2. + { + GateProbe active(/*place_bar=*/2); + active.set_trade_start_time(240'000); // bar 3 + active.run(bars.data(), (int)bars.size()); + CHECK(active.final_pos == 2.0); + CHECK(near(active.final_avg, 100.0)); + } + + // Sanity: with no trade-start set (start == INT64_MIN), the gate is a + // no-op (line 62-63) — a bar-1 placement fills normally. + { + GateProbe ungated(/*place_bar=*/1); + ungated.run(bars.data(), (int)bars.size()); + CHECK(ungated.final_pos == 2.0); + CHECK(near(ungated.final_avg, 100.0)); + } +} + +// ───────────────────────────────────────────────────────────────────── +// (3) strategy_order raw-order reset of limit/stop to NaN (415-418). +// +// A strategy_order() with NaN limit AND NaN stop becomes a RAW_ORDER +// MARKET: its limit_price/stop_price are reset to NaN and it fills at the +// next bar's OPEN (engine_fills.cpp evaluate_fill_price: no price +// condition → fill at bar.open). From flat, qty = order.qty exactly. +// ───────────────────────────────────────────────────────────────────── +static void test_raw_market_order_fills_at_open() { + std::printf("test_raw_market_order_fills_at_open\n"); + class RawProbe : public BacktestEngine { + public: + bool saw_nan_prices = false; + double final_pos = 1234.0; + double final_avg = -1.0; + RawProbe() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; + commission_value_ = 0; + } + void on_bar(const Bar& bar) override { + (void)bar; + if (bar_index_ == 0) { + strategy_order("R", /*is_long=*/true, /*qty=*/3.0, + /*limit=*/kNaN, /*stop=*/kNaN); + } + // Right after placement (still bar 0, before fill), confirm the + // armed order is a no-price RAW_ORDER (limit/stop reset to NaN). + if (bar_index_ == 0) { + for (const auto& o : pending_orders_) { + if (o.id == "R") { + saw_nan_prices = + std::isnan(o.limit_price) && std::isnan(o.stop_price); + } + } + } + final_pos = signed_position_size(); + final_avg = position_entry_price_; + } + }; + RawProbe p; + // Bar 1 open = 101 → fill price for the market RAW_ORDER. + std::vector bars(4); + double opens[4] = {100, 101, 102, 103}; + double highs[4] = {105, 106, 107, 108}; + double lows[4] = { 95, 96, 97, 98}; + double closes[4] = {100, 101, 102, 103}; + for (int i = 0; i < 4; ++i) { + bars[i] = {opens[i], highs[i], lows[i], closes[i], 1000.0, + (int64_t)(i + 1) * 60'000}; + } + p.run(bars.data(), (int)bars.size()); + + CHECK(p.saw_nan_prices); // limit/stop reset to NaN + CHECK(p.final_pos == 3.0); // long qty 3 + CHECK(near(p.final_avg, 101.0)); // filled at bar 1's open +} + +// ───────────────────────────────────────────────────────────────────── +// (4) purge_exit_orders() via the immediate-close path (546-559). +// +// With process_orders_on_close enabled, a full strategy.close fills at +// the bar's close and then purge_exit_orders() wipes every pending EXIT +// bracket (so a stale TP/SL cannot re-fire). We verify that a pending +// strategy.exit bracket is GONE after a full close on the same bar. +// +// Also exercises the partial-then-flat purge branch (549-553): a partial +// strategy.close that happens to drain the whole position also triggers +// purge_exit_orders() once flat. +// ───────────────────────────────────────────────────────────────────── +static void test_immediate_close_purges_exit_orders() { + std::printf("test_immediate_close_purges_exit_orders\n"); + class PurgeProbe : public BacktestEngine { + public: + int exit_pending_before_close = -1; + int exit_pending_after_close = -1; + double final_pos = 1234.0; + PurgeProbe() { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; + commission_value_ = 0; + process_orders_on_close_ = true; // immediate fills at bar close + } + static int count_exits(const std::vector& v) { + int c = 0; + for (const auto& o : v) if (o.type == OrderType::EXIT) ++c; + return c; + } + void on_bar(const Bar& bar) override { + (void)bar; + // Bar 0: open a long qty 4 immediately (process_orders_on_close + // fills market entries at bar close). + if (bar_index_ == 0) { + strategy_entry("L", true, kNaN, kNaN, 4.0, "enter"); + } + // Bar 1: arm a far-away TP bracket (won't fire on its own), + // then fully close. The full close runs immediately and must + // purge the pending EXIT bracket. + if (bar_index_ == 1 && position_side_ == PositionSide::LONG) { + strategy_exit("TP", "L", /*limit=*/9999.0, /*stop=*/kNaN); + exit_pending_before_close = count_exits(pending_orders_); + strategy_close("L", "close-full"); // full close, immediate + exit_pending_after_close = count_exits(pending_orders_); + } + final_pos = signed_position_size(); + } + }; + PurgeProbe p; + auto bars = make_minute_bars(5, 100, 110, 90, 100); + p.run(bars.data(), (int)bars.size()); + + CHECK(p.exit_pending_before_close == 1); // TP bracket armed + CHECK(p.exit_pending_after_close == 0); // purge_exit_orders() wiped it + CHECK(p.final_pos == 0.0); // fully closed + // One full-close trade per pyramid entry (single entry here → 1 row). + CHECK(p.trade_count() == 1); +} + +// ───────────────────────────────────────────────────────────────────── +// (5) qty reservation with a NaN-qty percent sibling. +// +// (a) explicit-qty exit path (310-321): a pending NaN-qty EXIT sibling +// (a deferred strategy.close with qty_percent) is counted toward +// already_reserved via position_qty_ * qty_percent/100. The new +// explicit-qty exit clamps to the remaining available qty. +// (b) default-qty exit path → compute_exit_reserved_qty (666-668): +// the SAME NaN-qty percent accounting clamps a 100%-requested exit +// down to the leftover qty. +// +// Setup (both sub-cases): close_entries_rule="ANY" so a partial +// strategy.close(id, qty) queues a deferred EXIT with from_entry=id, +// qty=NaN, qty_percent = (qty/matching)*100. Position is long qty 4. +// strategy.close("L", qty=2) → __close__L: qty=NaN, qty_percent=50. +// ───────────────────────────────────────────────────────────────────── +static void run_reservation_case(bool explicit_qty, + double& exit_qty_out, + double& exit_qp_out, + double& close_qp_out, + bool& close_qty_is_nan_out) { + class ResProbe : public BacktestEngine { + public: + bool use_explicit_qty; + double exit_qty = -1, exit_qp = -1, close_qp = -1; + bool close_qty_is_nan = false; + bool snapped = false; + ResProbe(bool eq) : use_explicit_qty(eq) { + initial_capital_ = 1'000'000; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + slippage_ = 0; + commission_value_ = 0; + close_entries_rule_any_ = true; // "ANY" → deferred close keyed by id + } + void on_bar(const Bar& bar) override { + (void)bar; + // Bar 0: open long qty 4 (market, fills bar 1 open). + if (bar_index_ == 0) { + strategy_entry("L", true, kNaN, kNaN, 4.0, "enter"); + } + // Bar 2: while long qty 4, queue a partial deferred close + // (qty=2 → NaN-qty EXIT sibling with qty_percent=50), then a + // strategy.exit on the same from_entry "L". + if (bar_index_ == 2 && position_side_ == PositionSide::LONG) { + strategy_close("L", "partial", /*qty=*/2.0); // deferred EXIT + if (use_explicit_qty) { + // explicit qty path (310-321): qty=1 clamps to leftover 2. + strategy_exit("X", "L", /*limit=*/9999.0, /*stop=*/kNaN, + /*trail_points=*/kNaN, /*trail_offset=*/kNaN, + /*trail_price=*/kNaN, /*qty_percent=*/100.0, + /*comment=*/"x", /*qty=*/1.0); + } else { + // default qty path → compute_exit_reserved_qty (666-668): + // 100% requested clamps to leftover 2. + strategy_exit("X", "L", /*limit=*/9999.0, /*stop=*/kNaN); + } + // Snapshot the resulting pending EXIT orders. + for (const auto& o : pending_orders_) { + if (o.type != OrderType::EXIT) continue; + if (o.id == "X") { + exit_qty = o.qty; + exit_qp = o.qty_percent; + } else if (o.from_entry == "L") { + // The deferred __close__L sibling. + close_qty_is_nan = std::isnan(o.qty); + close_qp = o.qty_percent; + } + } + snapped = true; + } + } + }; + ResProbe p(explicit_qty); + auto bars = make_minute_bars(6, 100, 110, 90, 100); + p.run(bars.data(), (int)bars.size()); + CHECK(p.snapped); + exit_qty_out = p.exit_qty; + exit_qp_out = p.exit_qp; + close_qp_out = p.close_qp; + close_qty_is_nan_out = p.close_qty_is_nan; +} + +static void test_exit_qty_reservation_with_percent_sibling() { + std::printf("test_exit_qty_reservation_with_percent_sibling\n"); + + // Sub-case (a): explicit qty=1. + // __close__L reserves position_qty_(4) * 50% = 2 → available = 2. + // reserved = min(qty=1, available=2) = 1. qp = (1/4)*100 = 25. + { + double xq = -1, xqp = -1, cqp = -1; bool cnan = false; + run_reservation_case(/*explicit_qty=*/true, xq, xqp, cqp, cnan); + CHECK(cnan); // deferred close sibling has NaN qty + CHECK(near(cqp, 50.0)); // qty_percent = (2/4)*100 + CHECK(near(xq, 1.0)); // explicit qty honoured literally + CHECK(near(xqp, 25.0)); // effective fraction 1/4 + } + + // Sub-case (b): default qty (qty_percent=100, no explicit qty). + // already_reserved = 4*50% = 2 → available = 2. + // requested = 4*100% = 4 → reserved = min(4, 2) = 2. qp = (2/4)*100 = 50. + { + double xq = -1, xqp = -1, cqp = -1; bool cnan = false; + run_reservation_case(/*explicit_qty=*/false, xq, xqp, cqp, cnan); + CHECK(cnan); // deferred close sibling has NaN qty + CHECK(near(cqp, 50.0)); + CHECK(near(xq, 2.0)); // clamped to leftover 2 + CHECK(near(xqp, 50.0)); // 2/4 + } +} + +int main() { + test_cancel_all_clears_pending(); + test_trade_start_buffer_gate(); + test_raw_market_order_fills_at_open(); + test_immediate_close_purges_exit_orders(); + test_exit_qty_reservation_with_percent_sibling(); + + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +} diff --git a/tests/test_ta_extremes_edge.cpp b/tests/test_ta_extremes_edge.cpp new file mode 100644 index 0000000..cc05c33 --- /dev/null +++ b/tests/test_ta_extremes_edge.cpp @@ -0,0 +1,256 @@ +/* + * test_ta_extremes_edge.cpp — warmup na-guards + sliding-window eviction edges + * for the range-extreme indicators in src/ta_extremes_volume.cpp. + * + * Targets the uncovered guard/eviction lines of Highest, Lowest, PivotHigh, + * PivotLow, Median, HighestBars, LowestBars: + * - "fewer than period bars seen" -> na() (the size length)` loops) — proven by asserting that a value + * that should have been evicted no longer influences the output. + * - na input propagation (is_na(src) -> na out). + * - Median's EVEN-count two-middle-average branch, both in compute() and in + * recompute() (line 627), with an exact pinned median. + * + * NDEBUG-PROOF: uses a self-rolled CHECK that increments a global failure + * counter and a main() that returns nonzero on any failure — independent of + * assert()/NDEBUG. Verified non-vacuous by temporarily corrupting an expected + * value and confirming the test fails, then restoring it. + */ + +#include +#include +#include + +#include +#include + +using namespace pineforge; + +static int g_pass = 0; +static int g_fail = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \ + ++g_fail; \ + } else { \ + ++g_pass; \ + } \ + } while (0) + +static bool near(double a, double b, double tol = 1e-9) { + return std::fabs(a - b) <= tol; +} + +// --- Highest: warmup na, full-window value, eviction, na-input --- +static void test_highest_warmup_evict_na() { + std::printf("test_highest_warmup_evict_na\n"); + ta::Highest hi(3); + + // Fewer than `length` bars -> na (covers size finite max over {5,9,2} = 9. + double v = hi.compute(2.0); + CHECK(!is_na(v)); + CHECK(near(v, 9.0)); + + // Now slide: feeding three more small values must EVICT the 9 (pop_front). + // If the window did not evict, max would stay 9. + CHECK(near(hi.compute(1.0), 9.0)); // window {9,2,1} -> still 9 + CHECK(near(hi.compute(1.0), 2.0)); // window {2,1,1} -> 9 evicted, max 2 + CHECK(near(hi.compute(1.0), 1.0)); // window {1,1,1} -> all evicted, max 1 + + // na input -> na out (covers is_na(src) guard) and must not perturb window. + CHECK(is_na(hi.compute(na()))); + CHECK(near(hi.compute(0.5), 1.0)); // window still {1,1,0.5} -> max 1 +} + +// --- Lowest: warmup na, full-window value, eviction, na-input --- +static void test_lowest_warmup_evict_na() { + std::printf("test_lowest_warmup_evict_na\n"); + ta::Lowest lo(3); + + CHECK(is_na(lo.compute(5.0))); // size 1 < 3 + CHECK(is_na(lo.compute(1.0))); // size 2 < 3 + + double v = lo.compute(9.0); // window {5,1,9} -> min 1 + CHECK(!is_na(v)); + CHECK(near(v, 1.0)); + + // Slide and evict the 1 (the running minimum). + CHECK(near(lo.compute(8.0), 1.0)); // {1,9,8} -> still 1 + CHECK(near(lo.compute(7.0), 7.0)); // {9,8,7} -> 1 evicted, min 7 + CHECK(near(lo.compute(6.0), 6.0)); // {8,7,6} -> min 6 + + CHECK(is_na(lo.compute(na()))); // na in -> na out + CHECK(near(lo.compute(10.0), 6.0)); // {7,6,10} -> min 6 +} + +// --- HighestBars: warmup na, offset semantics, eviction --- +static void test_highest_bars_offset_and_evict() { + std::printf("test_highest_bars_offset_and_evict\n"); + ta::HighestBars hb(4); + + // Warmup: 3 bars < length 4 -> na. + CHECK(is_na(hb.compute(1.0))); + CHECK(is_na(hb.compute(2.0))); + CHECK(is_na(hb.compute(3.0))); + + // 4th bar fills window {1,2,3,7}. Max is the most-recent bar (index 3), + // offset = 3 - (4-1) = 0. + CHECK(near(hb.compute(7.0), 0.0)); + + // Window {2,3,7,4}: max 7 at index 2, offset = 2 - 3 = -1. + CHECK(near(hb.compute(4.0), -1.0)); + // Window {3,7,4,5}: max 7 at index 1, offset = 1 - 3 = -2. + CHECK(near(hb.compute(5.0), -2.0)); + // Window {7,4,5,6}: max 7 at index 0, offset = 0 - 3 = -3. + CHECK(near(hb.compute(6.0), -3.0)); + // Window {4,5,6,3}: the 7 is EVICTED here (pop_front). New max 6 at idx 2, + // offset = 2 - 3 = -1. If eviction did not occur, max would still be 7. + CHECK(near(hb.compute(3.0), -1.0)); + + // na in -> na out. + CHECK(is_na(hb.compute(na()))); +} + +// --- LowestBars: warmup na, offset semantics, eviction --- +static void test_lowest_bars_offset_and_evict() { + std::printf("test_lowest_bars_offset_and_evict\n"); + ta::LowestBars lb(4); + + CHECK(is_na(lb.compute(9.0))); + CHECK(is_na(lb.compute(8.0))); + CHECK(is_na(lb.compute(7.0))); + + // Window {9,8,7,1}: min at index 3, offset = 3 - 3 = 0. + CHECK(near(lb.compute(1.0), 0.0)); + // Window {8,7,1,5}: min 1 at index 2, offset = -1. + CHECK(near(lb.compute(5.0), -1.0)); + // Window {7,1,5,6}: min 1 at index 1, offset = -2. + CHECK(near(lb.compute(6.0), -2.0)); + // Window {1,5,6,4}: min 1 at index 0, offset = -3. + CHECK(near(lb.compute(4.0), -3.0)); + // Window {5,6,4,3}: the 1 is EVICTED. New min 3 at index 3, offset = 0. + CHECK(near(lb.compute(3.0), 0.0)); + + CHECK(is_na(lb.compute(na()))); +} + +// --- Median: warmup na, EVEN-count two-middle average (compute + recompute), +// eviction, na input. --- +static void test_median_even_and_evict() { + std::printf("test_median_even_and_evict\n"); + ta::Median med(4); // EVEN length -> exercises (sorted[n/2-1]+sorted[n/2])/2 + + // Warmup: 3 bars < 4 -> na. + CHECK(is_na(med.compute(10.0))); + CHECK(is_na(med.compute(30.0))); + CHECK(is_na(med.compute(20.0))); + + // 4th bar: window {10,30,20,40} -> sorted {10,20,30,40} -> + // median = (20 + 30) / 2 = 25 (the even-count branch). + double m = med.compute(40.0); + CHECK(!is_na(m)); + CHECK(near(m, 25.0)); + + // recompute() with empty-check false (buffer non-empty): replaces the last + // sample (40 -> 50). Window {10,30,20,50} -> sorted {10,20,30,50} -> + // median = (20 + 30) / 2 = 25. Exercises the recompute even branch (line + // 627). The two-middle pair is unchanged so the value is still 25, but a + // single-middle (odd) branch would instead return sorted[2]=30. + double mr = med.recompute(50.0); + CHECK(!is_na(mr)); + CHECK(near(mr, 25.0)); + + // Slide forward; the original 10 must be EVICTED (pop_front). Window after + // feeding 60: {30,20,50,60} -> sorted {20,30,50,60} -> median (30+50)/2=40. + // (recompute above left the window at {10,30,20,50}; compute now pushes 60 + // and pops the front 10.) + double m2 = med.compute(60.0); + CHECK(near(m2, 40.0)); + + // na in -> na out (and window unchanged). + CHECK(is_na(med.compute(na()))); +} + +// --- Median: recompute on an EMPTY buffer routes to compute (and stays na +// until warmup), then odd-length sanity to anchor the even-vs-odd contrast. +static void test_median_recompute_empty_and_odd() { + std::printf("test_median_recompute_empty_and_odd\n"); + { + ta::Median med(2); + // recompute() on empty buffer -> compute(): first bar size 1 < 2 -> na. + CHECK(is_na(med.recompute(5.0))); + // Second bar: window {5,7} -> sorted {5,7} -> even median (5+7)/2 = 6. + CHECK(near(med.compute(7.0), 6.0)); + } + { + // Odd length -> single middle element (contrast with even branch). + ta::Median med(3); + CHECK(is_na(med.compute(10.0))); + CHECK(is_na(med.compute(30.0))); + // window {10,30,20} -> sorted {10,20,30} -> middle = 20. + CHECK(near(med.compute(20.0), 20.0)); + } +} + +// --- PivotHigh: warmup na (size total window size 3. Candidate is at index left=1. + ta::PivotHigh ph(1, 1); + + // Warmup: fewer than total(3) bars -> na (size left 1 and > right 2 -> confirmed pivot 5. + CHECK(near(ph.compute(2.0), 5.0)); + + // Window {5,2,4}: candidate 2 has left 5 > 2 -> LEFT guard rejects -> na. + CHECK(is_na(ph.compute(4.0))); + // Window {2,4,4}: candidate 4, right bar 4 >= 4 -> RIGHT strict guard -> na. + CHECK(is_na(ph.compute(4.0))); + // Window {4,4,3}: candidate 4, left 4 (4 > 4 false, allowed), right 3 < 4 + // -> confirmed pivot 4 (LEFT non-strict equal allowed). + CHECK(near(ph.compute(3.0), 4.0)); +} + +// --- PivotLow: mirror of the above (LEFT non-strict, RIGHT strict). --- +static void test_pivot_low_warmup_and_confirm() { + std::printf("test_pivot_low_warmup_and_confirm\n"); + ta::PivotLow pl(1, 1); // total 3, candidate at index 1 + + CHECK(is_na(pl.compute(9.0))); // size 1 + CHECK(is_na(pl.compute(2.0))); // size 2 + + // Window {9,2,8}: candidate 2 < left 9 and < right 8 -> confirmed pivot 2. + CHECK(near(pl.compute(8.0), 2.0)); + + // Window {2,8,5}: candidate 8, left 2 < 8 -> LEFT guard rejects -> na. + CHECK(is_na(pl.compute(5.0))); + // Window {8,5,5}: candidate 5, right 5 <= 5 -> RIGHT strict guard -> na. + CHECK(is_na(pl.compute(5.0))); + // Window {5,5,6}: candidate 5, left 5 (5 < 5 false, allowed), right 6 > 5 + // -> confirmed pivot 5 (LEFT non-strict equal allowed). + CHECK(near(pl.compute(6.0), 5.0)); +} + +int main() { + test_highest_warmup_evict_na(); + test_lowest_warmup_evict_na(); + test_highest_bars_offset_and_evict(); + test_lowest_bars_offset_and_evict(); + test_median_even_and_evict(); + test_median_recompute_empty_and_odd(); + test_pivot_high_warmup_and_confirm(); + test_pivot_low_warmup_and_confirm(); + std::printf("\n%d passed, %d failed\n", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tests/test_ta_ma_warmup_extra.cpp b/tests/test_ta_ma_warmup_extra.cpp new file mode 100644 index 0000000..e9f06b0 --- /dev/null +++ b/tests/test_ta_ma_warmup_extra.cpp @@ -0,0 +1,237 @@ +/* + * test_ta_ma_warmup_extra.cpp — warmup, na-propagation and sliding-window + * eviction coverage for the moving-average primitives in + * src/ta_moving_averages.cpp that were left un-exercised by test_ta.cpp / + * test_ta_indicators_extras.cpp / test_ta_rma_warmup.cpp. + * + * Targeted uncovered lines in src/ta_moving_averages.cpp: + * 28-29 RMA::compute na(src) -> na guard + * 104-105 SMA::compute periodic exact-sum recompute (bar_count & 255 == 0) + * 145-146 WMA::compute na(src) -> na guard + * 183-184 HMA::compute na(src) -> na guard + * 213-217 VWMA::compute while(size > length) pop_front sv/v eviction + * 306-310 SMA::recompute na(src) guard + empty-buffer dispatch to compute + * 354-355 HMA::recompute na inner-WMA result -> na guard + * + * NDEBUG-PROOF: this test never uses bare assert(). It uses a returning + * CHECK macro that increments a failure counter; main() returns nonzero if + * any check fails, so the canonical Release (-DNDEBUG) gate cannot pass it + * vacuously. Reference values were hand-derived (see comments) and confirmed + * against an independent Python computation. + */ + +#include +#include + +#include +#include + +using namespace pineforge; + +static int tests_passed = 0; +static int tests_failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +static bool near(double a, double b, double tol = 1e-9) { + if (is_na(a) && is_na(b)) return true; + if (is_na(a) || is_na(b)) return false; + return std::fabs(a - b) <= tol; +} + +// -------------------------------------------------------------------- +// RMA::compute na-input guard (lines 28-29). +// -------------------------------------------------------------------- +// Pine ta.rma propagates na on na input *without* advancing bar_count. +// Verify: an na input mid-warmup returns na and does NOT count toward the +// seed length — the seed therefore lands one real bar later than it would +// have if the na had been counted. +static void test_rma_na_input_guard() { + std::printf("test_rma_na_input_guard\n"); + + // Period 4. Feed three real bars, then an na, then more reals. + ta::RMA rma(4); + CHECK(is_na(rma.compute(10.0))); // bar_count 1 + CHECK(is_na(rma.compute(11.0))); // bar_count 2 + CHECK(is_na(rma.compute(12.0))); // bar_count 3 + // na input: returns na, bar_count stays at 3 (the 28-29 early return). + CHECK(is_na(rma.compute(na()))); + // Because the na did NOT advance bar_count, this real bar is only the + // 4th counted sample -> seed fires here = mean(10,11,12,13) = 11.5. + double seed = rma.compute(13.0); + CHECK(near(seed, (10.0 + 11.0 + 12.0 + 13.0) / 4.0)); // 11.5 + + // Control: without the na, the seed would have fired one bar earlier. + ta::RMA ctrl(4); + ctrl.compute(10.0); + ctrl.compute(11.0); + ctrl.compute(12.0); + double ctrl_seed = ctrl.compute(13.0); // 4th counted -> seed + CHECK(near(ctrl_seed, 11.5)); +} + +// -------------------------------------------------------------------- +// SMA::compute periodic exact-sum self-correction (lines 104-105). +// -------------------------------------------------------------------- +// The branch `if ((bar_count & 255) == 0) running_sum = recalculate_exact_sum();` +// only fires once bar_count is a non-zero multiple of 256 AND past the warmup +// window. Feed >256 bars to a period-3 SMA so bar_count reaches 256. +// +// The recompute does not change the mathematical result (it re-derives the +// exact window sum), so the value at bar 256 must equal the mean of the last +// three inputs. We pin that value to confirm the self-correction path leaves +// the result correct rather than corrupting running_sum. +static void test_sma_periodic_exact_sum_recompute() { + std::printf("test_sma_periodic_exact_sum_recompute\n"); + + ta::SMA sma(3); + auto src = [](int i) -> double { return 1.0 + (double)(i % 50); }; + + double v256 = na(); + double v257 = na(); + // bar_count == i+1. We need bar_count to reach 256 -> i == 255, and 257. + for (int i = 0; i < 260; ++i) { + double out = sma.compute(src(i)); + if (i == 255) v256 = out; // bar_count 256 -> recalc branch fires + if (i == 256) v257 = out; // bar_count 257 -> ordinary path + if (i >= 2) { + CHECK(!is_na(out)); // warmup over after 3 bars + } + } + + // bar 256 (i=255): window = src(253), src(254), src(255) + // = (253%50)+1, (254%50)+1, (255%50)+1 = 4, 5, 6 -> mean 5.0 + CHECK(near(v256, (src(253) + src(254) + src(255)) / 3.0)); + CHECK(near(v256, 5.0)); + // bar 257 (i=256): window = src(254..256) = 5, 6, 7 -> mean 6.0 + CHECK(near(v257, (src(254) + src(255) + src(256)) / 3.0)); + CHECK(near(v257, 6.0)); +} + +// -------------------------------------------------------------------- +// WMA::compute na-input guard (lines 145-146). +// -------------------------------------------------------------------- +static void test_wma_na_input_guard() { + std::printf("test_wma_na_input_guard\n"); + + ta::WMA wma(3); + CHECK(is_na(wma.compute(10.0))); + CHECK(is_na(wma.compute(20.0))); + // na input -> early na return (lines 145-146); buffer is NOT advanced. + CHECK(is_na(wma.compute(na()))); + // Buffer still holds {10, 20} (na was not pushed), so this is only the + // 3rd real sample -> window {10,20,30}: weights oldest..newest = 1,2,3. + // (10*1 + 20*2 + 30*3) / (1+2+3) = (10 + 40 + 90) / 6 = 140/6. + double v = wma.compute(30.0); + CHECK(near(v, 140.0 / 6.0)); +} + +// -------------------------------------------------------------------- +// HMA::compute na-input guard (lines 183-184) and HMA::recompute na-result +// guard (lines 354-355). +// -------------------------------------------------------------------- +static void test_hma_na_guards() { + std::printf("test_hma_na_guards\n"); + + // compute na-guard: na input returns na immediately (lines 183-184) and + // does not advance the inner WMA chain. + ta::HMA hma(4); + CHECK(is_na(hma.compute(na()))); + // Subsequent real bars still warm up normally (the na was a no-op): with + // length 4, wma_full_ needs 4 samples before any non-na, so the first + // three reals are na. + CHECK(is_na(hma.compute(1.0))); + CHECK(is_na(hma.compute(2.0))); + CHECK(is_na(hma.compute(3.0))); + + // recompute na-result guard (lines 354-355): during warmup the inner + // wma_half_/wma_full_.recompute return na -> HMA::recompute must return + // na. Drive a fresh HMA with one compute (so the inner buffers are + // non-empty and recompute takes the in-place update path, not the + // empty-buffer dispatch), then recompute while still in warmup. + ta::HMA hma2(9); + hma2.compute(1.0); // inner WMA buffers now non-empty + double r = hma2.recompute(2.0); // still far from warmup -> inner na -> na + CHECK(is_na(r)); +} + +// -------------------------------------------------------------------- +// VWMA::compute sliding-window eviction (lines 213-217). +// -------------------------------------------------------------------- +// Feed period+extra bars so the while-loop pops the oldest sv/v samples once +// the buffer exceeds length_. Verify the result reflects ONLY the most recent +// `length_` samples: if pop_front (and the matching sv_sum_/v_sum_ subtraction) +// did not run, stale samples would remain in the sums and the value would be +// wrong. +static void test_vwma_window_eviction() { + std::printf("test_vwma_window_eviction\n"); + + ta::VWMA vwma(3); + // (src, vol) pairs. + CHECK(is_na(vwma.compute(10.0, 100.0))); // size 1 < 3 -> na + CHECK(is_na(vwma.compute(20.0, 200.0))); // size 2 < 3 -> na + + // size hits 3 -> first finite value, window {(10,100),(20,200),(30,300)}. + // sv = 1000 + 4000 + 9000 = 14000 ; v = 600 -> 14000/600. + double v3 = vwma.compute(30.0, 300.0); + CHECK(near(v3, 14000.0 / 600.0)); + + // size would be 4 -> while-loop evicts the oldest (10,100). New window + // {(20,200),(30,300),(40,400)} : sv = 4000+9000+16000 = 29000 ; v = 900. + double v4 = vwma.compute(40.0, 400.0); + CHECK(near(v4, 29000.0 / 900.0)); // 32.2222... + // Cross-check: if the oldest were NOT evicted the value would instead be + // the 4-sample mean 30000/1000 = 30.0; assert we are NOT seeing that. + CHECK(!near(v4, 30000.0 / 1000.0)); + + // Another bar -> evicts (20,200). Window {(30,300),(40,400),(50,500)} : + // sv = 9000+16000+25000 = 50000 ; v = 1200 -> 41.6666... + double v5 = vwma.compute(50.0, 500.0); + CHECK(near(v5, 50000.0 / 1200.0)); + CHECK(!near(v5, (16000.0 + 25000.0 + 9000.0 + 4000.0) / (400.0 + 500.0 + 300.0 + 200.0))); +} + +// -------------------------------------------------------------------- +// SMA::recompute na-input guard + empty-buffer dispatch (lines 306-310). +// -------------------------------------------------------------------- +static void test_sma_recompute_guards() { + std::printf("test_sma_recompute_guards\n"); + + // (lines 306-307) na input to recompute returns na directly. + ta::SMA sma(3); + sma.compute(10.0); + sma.compute(20.0); + sma.compute(30.0); // warmed up; value would be 20.0 + CHECK(is_na(sma.recompute(na()))); + + // (lines 308-310) empty-buffer recompute must dispatch to compute(). A + // brand-new SMA with no compute history: recompute behaves exactly like + // the first compute -> still in warmup -> na. + ta::SMA fresh(3); + CHECK(is_na(fresh.recompute(7.0))); + // The dispatched compute() did push 7.0, so continuing to warm up works. + CHECK(is_na(fresh.compute(8.0))); + double v = fresh.compute(9.0); // window {7,8,9} -> mean 8.0 + CHECK(near(v, 8.0)); +} + +int main() { + test_rma_na_input_guard(); + test_sma_periodic_exact_sum_recompute(); + test_wma_na_input_guard(); + test_hma_na_guards(); + test_vwma_window_eviction(); + test_sma_recompute_guards(); + + std::printf("\ntest_ta_ma_warmup_extra: %d passed, %d failed\n", + tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +} diff --git a/tests/test_ta_osc_edge.cpp b/tests/test_ta_osc_edge.cpp new file mode 100644 index 0000000..45028b0 --- /dev/null +++ b/tests/test_ta_osc_edge.cpp @@ -0,0 +1,254 @@ +/* + * test_ta_osc_edge.cpp — edge/guard coverage for src/ta_oscillators.cpp. + * + * Targets the na-guards, flat / divide-by-zero arms, window-eviction + * (pop_front) lines, and cross prev-state false branches that the broader + * indicator suites (test_ta.cpp / test_ta_indicators_extras.cpp / + * test_ta_rma_warmup.cpp) leave under-exercised: + * + * RSI : na-input early return (lines 34-36). + * Stoch : flat-range -> 50.0 midpoint (126-127) + recompute na/flat + * arms (561-567). + * Change : history window eviction via pop_front (141-143) + recompute + * na-prev arm (579-585). + * Cross : na-input -> prev-state-only update, returns false (168-172). + * Mom : na-input early return (207-208). + * ROC : na-input early return (227-229). + * CCI : na-input early return (308-309). + * RCI : na-input early return (346-348). + * + * NDEBUG-PROOF: every assertion goes through CHECK(), which increments a + * file-scope failure counter and is reported by main()'s nonzero return. + * It does NOT use bare assert(), so it fires identically under -DNDEBUG. + * Expected numeric values are Pine-correct, derived by hand from the + * documented behaviour of ta_oscillators.cpp and pinned here. + */ + +#include +#include +#include + +#include +#include + +using namespace pineforge; + +static int g_fail = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr);\ + ++g_fail; \ + } \ + } while (0) + +static bool near(double a, double b, double tol = 1e-9) { + if (is_na(a) && is_na(b)) return true; + if (is_na(a) || is_na(b)) return false; + return std::fabs(a - b) <= tol; +} + +// ---------------------------------------------------------------------------- +// RSI na-input early return (lines 34-36): once past the first bar, an na +// source must short-circuit to na before touching the RMA chain. +// ---------------------------------------------------------------------------- +static void test_rsi_na_input() { + std::printf("test_rsi_na_input\n"); + ta::RSI rsi(3); + // Bar 0: seed -> na (bar_count==0 branch). + CHECK(is_na(rsi.compute(10.0))); + // Subsequent finite bars stay na during warmup (RMA seed not reached). + CHECK(is_na(rsi.compute(11.0))); + // na source on a non-first bar hits the `if (is_na(src)) return na` arm + // *before* bar_count is consulted -> na, no state advance crash. + CHECK(is_na(rsi.compute(na()))); + // Still produces na (RMA hasn't seeded after the na bar was skipped). + CHECK(is_na(rsi.compute(12.0))); + // After enough finite bars RSI eventually becomes finite; a strictly + // rising tail collapses rma_down to 0 -> RSI == 100 (short-circuit arm). + double v = std::numeric_limits::quiet_NaN(); + for (int i = 0; i < 10; ++i) v = rsi.compute(20.0 + i); + CHECK(!is_na(v)); + CHECK(near(v, 100.0)); +} + +// ---------------------------------------------------------------------------- +// Stoch flat-range midpoint (lines 124-129) + na guard (120-122) + recompute +// flat/na arms (561-567). +// ---------------------------------------------------------------------------- +static void test_stoch_flat_and_na() { + std::printf("test_stoch_flat_and_na\n"); + + // Warmup: fewer than `length` bars -> na (Highest/Lowest not full). + ta::Stoch stoch(3); + CHECK(is_na(stoch.compute(10.0, 11.0, 9.0))); + CHECK(is_na(stoch.compute(10.0, 12.0, 8.0))); + + // Flat high/low across the whole window -> hi == lo -> range 0 -> 50.0 + // midpoint. Feed a constant high/low (range zero) for `length` bars. + ta::Stoch flat(3); + flat.compute(5.0, 5.0, 5.0); + flat.compute(5.0, 5.0, 5.0); + CHECK(near(flat.compute(5.0, 5.0, 5.0), 50.0)); + // recompute on the same flat bar also takes the range==0 -> 50.0 arm. + CHECK(near(flat.recompute(5.0, 5.0, 5.0), 50.0)); + + // na source with a valid window -> na (the is_na(src) guard). + ta::Stoch s2(2); + s2.compute(10.0, 12.0, 8.0); + s2.compute(11.0, 13.0, 7.0); + CHECK(is_na(s2.compute(na(), 14.0, 6.0))); + // recompute na-src arm. + CHECK(is_na(s2.recompute(na(), 14.0, 6.0))); + + // Sanity: a real (non-flat) value is computed correctly so the test is + // not vacuous. window high = max(12,13)=13, low = min(8,7)=7, src=10: + // (10 - 7) / (13 - 7) * 100 = 50.0 ... pick src=8.5 -> 25.0 to differ. + ta::Stoch s3(2); + s3.compute(10.0, 12.0, 8.0); + double v = s3.compute(8.5, 13.0, 7.0); + // hi = max(12,13)=13, lo = min(8,7)=7 -> (8.5-7)/(13-7)*100 = 25.0 + CHECK(near(v, 25.0)); + // recompute same bar matches. + CHECK(near(s3.recompute(8.5, 13.0, 7.0), 25.0)); +} + +// ---------------------------------------------------------------------------- +// Change: window eviction via history.pop_front (lines 140-143) + the na-prev +// guard in compute (151-153) and recompute (579-585). +// ---------------------------------------------------------------------------- +static void test_change_window_eviction() { + std::printf("test_change_window_eviction\n"); + + // max_length default 1, length default 1 -> keep = max(1,1)+1 = 2. + // Feeding a 3rd bar pushes size to 3 > 2 -> pop_front() evicts the oldest. + ta::Change change(1); + CHECK(is_na(change.compute(10.0))); // size 1 <= length 1 -> na + CHECK(near(change.compute(13.0), 3.0)); // 13 - 10 + // Third bar: history was [10,13]; push 17 -> [10,13,17] size 3 > keep 2 + // -> pop_front removes 10 -> [13,17]; result = 17 - 13 = 4. + CHECK(near(change.compute(17.0), 4.0)); // exercises pop_front + // Fourth bar confirms the window keeps sliding: [13,17] push 20 -> + // [13,17,20] -> pop_front -> [17,20]; 20 - 17 = 3. + CHECK(near(change.compute(20.0), 3.0)); + + // na source -> na (is_na(src) arm in compute). + CHECK(is_na(change.compute(na()))); + + // A larger lookback exercises keep = max(max_length, length)+1 with a + // deeper history and still evicts. max_length 3, length 2 -> keep = 4. + ta::Change deep(3); + CHECK(is_na(deep.compute(1.0, 2))); // size 1 <= 2 -> na + CHECK(is_na(deep.compute(2.0, 2))); // size 2 <= 2 -> na + CHECK(near(deep.compute(3.0, 2), 2.0)); // 3 - history[0]=1 + CHECK(near(deep.compute(4.0, 2), 2.0)); // 4 - history[1]=2 + // 5th push: [1,2,3,4] push 5 -> size 5 > keep 4 -> pop_front -> [2,3,4,5] + // idx = 4-1-2 = 1 -> history[1] = 3 -> 5 - 3 = 2. + CHECK(near(deep.compute(5.0, 2), 2.0)); + + // recompute na-prev arm: replace the last bar with na -> na. + ta::Change rc(3); + rc.compute(10.0, 1); + rc.compute(12.0, 1); // history [10,12], result 2 (not checked) + CHECK(is_na(rc.recompute(na(), 1))); // src na -> na (line 581-585) + // recompute with a finite replacement returns a finite change: + // history.back() = 20 -> [10,20], length 1 -> idx 0 -> 20 - 10 = 10. + CHECK(near(rc.recompute(20.0, 1), 10.0)); +} + +// ---------------------------------------------------------------------------- +// Cross: na-input -> only update prev state, return false (lines 168-172), +// plus the false branch where current sign matches the remembered sign. +// ---------------------------------------------------------------------------- +static void test_cross_na_and_false_branch() { + std::printf("test_cross_na_and_false_branch\n"); + + ta::Cross cross; + // na on either operand -> false, prev updated (na-guard arm). + CHECK(!cross.compute(na(), 1.0)); + CHECK(!cross.compute(1.0, na())); + + // First finite comparison: last_nonzero_sign_ was 0 -> no fire. + CHECK(!cross.compute(3.0, 2.0)); // a>b -> sign +1, remembered, no fire + // Same side again -> curr_sign == last_nonzero_sign_ -> false branch. + CHECK(!cross.compute(4.0, 2.0)); // still +1 -> no cross + // Opposite side -> fires. + CHECK(cross.compute(1.0, 2.0)); // sign -1 vs remembered +1 -> cross + // na again mid-stream -> false, and remembered sign unchanged. + CHECK(!cross.compute(na(), 2.0)); + + // Tie bar (a == b) -> curr_sign 0 -> no fire, remembered sign untouched. + CHECK(!cross.compute(2.0, 2.0)); + // Returning to the SAME side as the last non-tied sign (-1) -> false. + CHECK(!cross.compute(1.5, 2.0)); + // Crossing to +1 now fires (opposite of remembered -1). + CHECK(cross.compute(5.0, 2.0)); + + // recompute na arm: replace last bar with na -> false, no crash. + CHECK(!cross.recompute(na(), 2.0)); +} + +// ---------------------------------------------------------------------------- +// Mom / ROC na-input early returns (lines 207-208 / 227-229) plus warmup na +// and a finite sanity value so the checks are non-vacuous. +// ---------------------------------------------------------------------------- +static void test_mom_roc_na() { + std::printf("test_mom_roc_na\n"); + + ta::Mom mom(2); + CHECK(is_na(mom.compute(10.0))); // warmup + CHECK(is_na(mom.compute(na()))); // na-input arm, no buffer push + CHECK(is_na(mom.compute(11.0))); // still building window + // Now have [10,11,12] -> Mom(2) = 12 - buffer.front()=10 = 2. + CHECK(near(mom.compute(12.0), 2.0)); + + ta::ROC roc(2); + CHECK(is_na(roc.compute(10.0))); // warmup + CHECK(is_na(roc.compute(na()))); // na-input arm + CHECK(is_na(roc.compute(20.0))); // still building + // [10,20,40] -> ROC(2) = (40 - 10)/10 * 100 = 300. + CHECK(near(roc.compute(40.0), 300.0)); +} + +// ---------------------------------------------------------------------------- +// CCI / RCI na-input early returns (lines 308-309 / 346-348) plus warmup na +// and a finite sanity value. +// ---------------------------------------------------------------------------- +static void test_cci_rci_na() { + std::printf("test_cci_rci_na\n"); + + ta::CCI cci(3); + CHECK(is_na(cci.compute(na()))); // na-input arm, no buffer push + CHECK(is_na(cci.compute(10.0))); // warmup + CHECK(is_na(cci.compute(12.0))); // warmup + // Window [10,12,14]: mean 12, mean_dev = (2+0+2)/3 = 4/3, + // cci = (14-12)/(0.015*4/3). + double v = cci.compute(14.0); + CHECK(near(v, (14.0 - 12.0) / (0.015 * (4.0 / 3.0)), 1e-9)); + + ta::RCI rci(5); + CHECK(is_na(rci.compute(na()))); // na-input arm, no buffer push + // Strictly rising series of `length` bars -> Spearman rho 1 -> 100. + double r = std::numeric_limits::quiet_NaN(); + for (int i = 1; i <= 5; ++i) r = rci.compute(double(i)); + CHECK(near(r, 100.0)); + // na after a full window -> na (guard fires before recomputing rho). + CHECK(is_na(rci.compute(na()))); +} + +int main() { + test_rsi_na_input(); + test_stoch_flat_and_na(); + test_change_window_eviction(); + test_cross_na_and_false_branch(); + test_mom_roc_na(); + test_cci_rci_na(); + + if (g_fail) { + std::fprintf(stderr, "\nta_osc_edge: %d CHECK(s) FAILED\n", g_fail); + } else { + std::printf("\nta_osc_edge: all checks passed\n"); + } + return g_fail ? 1 : 0; +} diff --git a/tests/test_ta_voltrend_edge.cpp b/tests/test_ta_voltrend_edge.cpp new file mode 100644 index 0000000..f1b19c5 --- /dev/null +++ b/tests/test_ta_voltrend_edge.cpp @@ -0,0 +1,284 @@ +/* + * test_ta_voltrend_edge.cpp — edge-path coverage for ta_volatility_trend.cpp. + * + * Targets the na-guards and state transitions that the existing TA tests + * (test_ta, test_ta_indicators_extras, test_dmi_parity) skip: + * + * - Supertrend: direction flip from the bearish init (+1) up to an + * uptrend (-1) and then back down to bearish (+1) when the close + * pierces the trailing band; the final_upper/final_lower min/max vs + * basic-band branches; the ATR-warmup na return. + * - SAR: na-input guard, the prev_close-na priming bar, the + * first-trend-bar init for BOTH long and short, the ep/af + * acceleration steps (long: high>ep, short: low +#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) + +static bool near(double a, double b, double tol = 1e-9) { + if (is_na(a) && is_na(b)) return true; + if (is_na(a) || is_na(b)) return false; + return std::fabs(a - b) <= tol; +} + +// ============================================================================ +// Supertrend: ATR-warmup na, bearish init, flip up to -1, flip back to +1. +// ATR period 2 → ATR(bar1)=na, finite from bar2. factor 0.5 is small enough +// that the trailing band sits close to price, so the direction can flip both +// ways across a short rising-then-crashing series. +// ============================================================================ + +static void test_supertrend_flip() { + std::printf("test_supertrend_flip\n"); + ta::Supertrend st(0.5, 2); + + // bar1: ATR not yet warmed up → value+direction na (lines 141-143). + auto r1 = st.compute(100.0, 99.0, 99.5); + CHECK(is_na(r1.value)); + CHECK(is_na(r1.direction)); + + // bar2: ATR warms up (= (1.0 + 1.5)/2 = 1.25). hl2 = 100.5, + // final_upper = 100.5 + 0.5*1.25 = 101.125, final_lower = 99.875. + // Init branch: close (101) is NOT > final_upper (101.125) → dir = +1 + // (bearish). st_val = dir==1 ? final_upper : final_lower = 101.125. + auto r2 = st.compute(101.0, 100.0, 101.0); + CHECK(near(r2.direction, 1.0)); + CHECK(near(r2.value, 101.125)); + + // bar3: prev_dir == +1 and close (102) > final_upper → flip to UPTREND + // (-1). In an uptrend the line trails the LOWER band (st_val = lower). + auto r3 = st.compute(102.0, 101.0, 102.0); + CHECK(near(r3.direction, -1.0)); + CHECK(near(r3.value, 100.9375)); // final_lower carried up via the max-branch + + // bar4: still uptrend (close stays above the lower band) → dir stays -1, + // line keeps trailing the (rising) lower band. + auto r4 = st.compute(103.0, 102.0, 103.0); + CHECK(near(r4.direction, -1.0)); + CHECK(near(r4.value, 101.96875)); + + // bar5: crash. prev_dir == -1 and close (90.5) < final_lower → flip to + // DOWNTREND (+1) [line 183-184]; line now trails the UPPER band. + auto r5 = st.compute(100.0, 90.0, 90.5); + CHECK(near(r5.direction, 1.0)); + CHECK(near(r5.value, 98.515625)); + + // bar6: stays bearish (close below upper band) → dir stays +1. + auto r6 = st.compute(91.0, 89.0, 90.0); + CHECK(near(r6.direction, 1.0)); + CHECK(near(r6.value, 92.2578125)); + + // na input on any OHLC component → both fields na (lines 136-138). + ta::Supertrend st_na(0.5, 2); + auto rn = st_na.compute(na(), 1.0, 1.0); + CHECK(is_na(rn.value)); + CHECK(is_na(rn.direction)); +} + +// ============================================================================ +// SAR long: prev_close-na priming bar, first-trend-bar long init, the +// high>ep ep/af acceleration steps, and the low(), na(), na()))); + + // bar1: prev_close still na → priming bar returns na (line 331-336). + CHECK(is_na(sar.compute(11.0, 9.0, 10.0))); + + // bar2: init long (close 12 > prev_close 10). ep=high=13, sar=prev_low=9, + // af=0.02. new_sar = 9 + 0.02*(13-9) = 9.08, low(10) does not pierce it. + // first-trend-bar → no ep/af step. Clamp to prev_low(9) → 9.0. + CHECK(near(sar.compute(13.0, 10.0, 12.0), 9.0)); + + // bar3: not first bar. new_sar = 9 + 0.02*(13-9) = 9.08; low(12) safe. + // high(15) > ep(13) → ep=15, af=0.04 (acceleration step, lines 370-373). + // Clamp to min(prev_low=10, prev_prev_low=9) → 9.0. + CHECK(near(sar.compute(15.0, 12.0, 14.0), 9.0)); + + // bar4: new_sar = 9 + 0.04*(15-9) = 9.24; low(14) safe. high(17) > ep(15) + // → ep=17, af=0.06. Clamp min(prev_low=12, prev_prev_low=10) leaves 9.24. + CHECK(near(sar.compute(17.0, 14.0, 16.0), 9.24)); + + // bar5: new_sar = 9.24 + 0.06*(17-9.24) = 9.7056; low(8) < new_sar → + // long→short reversal (lines 350-357): new_sar = max(high=12, ep=17) = 17, + // af reset to 0.02, ep = low = 8. Clamp short to recent highs → 17.0. + CHECK(near(sar.compute(12.0, 8.0, 9.0), 17.0)); +} + +// ============================================================================ +// SAR short: first-trend-bar SHORT init (line 341-343) and the low= 19 so the max keeps 19.76. + CHECK(near(sar.compute(15.0, 12.0, 13.0), 19.76)); +} + +// ============================================================================ +// ATR / TR warmup + ATR::recompute warmup, and Variance na-guard + buffer pop. +// ============================================================================ + +static void test_atr_tr_warmup() { + std::printf("test_atr_tr_warmup\n"); + const double h[] = {10, 12, 14, 15}; + const double l[] = {8, 9, 10, 12}; + const double c[] = {9, 11, 13, 14}; + + // ATR(3): RMA warmup → na for first length-1 bars, finite from bar3. + // TRs: bar1 = high-low = 2; bar2 = max(3,|12-9|,|9-9|)=3; bar3 = + // max(4,|14-11|,|10-11|)=4 → RMA(3) at bar3 = (2+3+4)/3 = 3.0. + ta::ATR atr(3); + CHECK(is_na(atr.compute(h[0], l[0], c[0]))); + CHECK(is_na(atr.compute(h[1], l[1], c[1]))); + CHECK(near(atr.compute(h[2], l[2], c[2]), 3.0)); + // bar4: TR = max(3,|15-13|,|12-13|)=3 → RMA = (3 + 2*3)/3 = 3.0. + CHECK(near(atr.compute(h[3], l[3], c[3]), 3.0)); + + // ATR::recompute warmup path (lines 587-608): advance to a finite bar, + // then recompute the same bar — must match a fresh compute of the same + // OHLC. Also exercises ATR::recompute na-input and warmup-na returns. + { + ta::ATR a(3), b(3); + for (int i = 0; i < 3; ++i) { a.compute(h[i], l[i], c[i]); b.compute(h[i], l[i], c[i]); } + a.compute(h[3], l[3], c[3]); + double ar = a.recompute(16.0, 11.0, 15.0); + double br = b.compute(16.0, 11.0, 15.0); + CHECK(!is_na(ar)); + CHECK(near(ar, br)); + + // recompute na input → na. + ta::ATR cna(3); + cna.compute(10.0, 8.0, 9.0); + CHECK(is_na(cna.recompute(na(), na(), na()))); + + // recompute while still in warmup (only 2 bars total) → na. + ta::ATR cwarm(3); + cwarm.compute(10.0, 8.0, 9.0); + CHECK(is_na(cwarm.recompute(11.0, 9.0, 10.0))); + } + + // TR (handle_na=false): first bar na, then finite. (handle_na=true → high-low) + ta::TR tr_false(false); + CHECK(is_na(tr_false.compute(h[0], l[0], c[0]))); + CHECK(near(tr_false.compute(h[1], l[1], c[1]), 3.0)); + CHECK(near(tr_false.compute(h[2], l[2], c[2]), 4.0)); + + ta::TR tr_true(true); + CHECK(near(tr_true.compute(h[0], l[0], c[0]), 2.0)); // high - low on first bar + CHECK(near(tr_true.compute(h[1], l[1], c[1]), 3.0)); + + // Variance(3): na-guard + warmup na + buffer pop maintaining a size-3 + // window. Feeding {2,4,6,8,10}; window {2,4,6} → biased var = 8/3. + ta::Variance var(3); + CHECK(is_na(var.compute(2.0))); + CHECK(is_na(var.compute(4.0))); + CHECK(near(var.compute(6.0), 8.0 / 3.0, 1e-12)); // mean 4, ((-2)^2+0+2^2)/3 + // pop oldest (2): window {4,6,8} → mean 6 → 8/3 again. + CHECK(near(var.compute(8.0), 8.0 / 3.0, 1e-12)); + // pop oldest (4): window {6,8,10} → still 8/3. + CHECK(near(var.compute(10.0), 8.0 / 3.0, 1e-12)); + // na input → na (line 458-460), window untouched. + CHECK(is_na(var.compute(na()))); +} + +// ============================================================================ +// MACD: EMAs seed on the first non-na value (Pine ta.ema), so MACD is finite +// from bar 1 — exercises the fast/slow EMA path and the histogram = macd-signal +// branch in MACD::compute, plus the recompute mirror. +// ============================================================================ + +static void test_macd_seeded() { + std::printf("test_macd_seeded\n"); + ta::MACD macd(3, 5, 2); + // Bar 1: both EMAs seed to src=10 → macd_line = 0; signal EMA seeds to + // macd_line=0 → signal_line = 0, histogram = 0. + auto r0 = macd.compute(10.0); + CHECK(near(r0.macd_line, 0.0)); + CHECK(near(r0.signal_line, 0.0)); + CHECK(near(r0.histogram, 0.0)); + + // After a rising series macd_line > 0 (fast EMA leads slow EMA up) and + // histogram = macd_line - signal_line is finite. + ta::MACDResult last; + for (int i = 1; i <= 20; ++i) last = macd.compute(10.0 + i); + CHECK(std::isfinite(last.macd_line)); + CHECK(last.macd_line > 0.0); + CHECK(std::isfinite(last.histogram)); + CHECK(near(last.histogram, last.macd_line - last.signal_line, 1e-9)); + + // recompute on the same bar reproduces the last compute exactly. + auto rr = macd.recompute(30.0); + CHECK(near(rr.macd_line, last.macd_line)); + CHECK(near(rr.signal_line, last.signal_line)); + CHECK(near(rr.histogram, last.histogram)); +} + +int main() { + test_supertrend_flip(); + test_sar_long_then_flip(); + test_sar_short_init(); + test_atr_tr_warmup(); + test_macd_seeded(); + + std::printf("\nta_voltrend_edge: %d passed, %d failed\n", + tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +} diff --git a/tests/test_timeframe_aggregator_extra.cpp b/tests/test_timeframe_aggregator_extra.cpp new file mode 100644 index 0000000..37e744e --- /dev/null +++ b/tests/test_timeframe_aggregator_extra.cpp @@ -0,0 +1,196 @@ +/* + * test_timeframe_aggregator_extra.cpp — coverage densification for + * src/timeframe.cpp. + * + * Targets the lines the existing test_timeframe.cpp / test_calendar_aggregation_wm.cpp + * never exercise: + * + * - tf_change(): the prev_ms==0 / curr_ms==0 first-bar arm, the calendar-period + * branch (D/W/M via crosses_boundary), the intraday seconds-bucket branch, + * and the secs<=0 guard. + * - crosses_boundary(CalendarPeriod::NONE) -> always false. + * - TimeframeAggregator string ctor falling back to PASSTHROUGH when the + * requested target TF is finer than the input TF (tf_ratio < 0), plus the + * passthrough feed() path: every input bar emits one complete bar unchanged. + * + * Expected values are derived from UTC wall-clock arithmetic (verified against + * Python's datetime) and from the documented OHLCV aggregation rules + * (high=max, low=min, close=last, volume=sum) — not tautologies. + */ + +#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) + +static Bar make_bar(double o, double h, double l, double c, double v, + int64_t ts_ms) { + return Bar{o, h, l, c, v, ts_ms}; +} + +// ─── Verified UTC-millisecond timestamps (see Python cross-check) ──────────── +// 2024-01-15 is a Monday; weeks are Monday-start. +static constexpr int64_t MON_2024_01_15_00 = 1705276800000LL; // 2024-01-15 00:00 +static constexpr int64_t MON_2024_01_15_12 = 1705320000000LL; // 2024-01-15 12:00 +static constexpr int64_t TUE_2024_01_16_00 = 1705363200000LL; // 2024-01-16 00:00 +static constexpr int64_t FRI_2024_01_19_00 = 1705622400000LL; // 2024-01-19 00:00 (same wk) +static constexpr int64_t MON_2024_01_22_00 = 1705881600000LL; // 2024-01-22 00:00 (next wk) +static constexpr int64_t SUN_2024_01_28_00 = 1706400000000LL; // 2024-01-28 00:00 (same mo) +static constexpr int64_t THU_2024_02_01_00 = 1706745600000LL; // 2024-02-01 00:00 (next mo) + +// ─── tf_change ─────────────────────────────────────────────────────────────── + +static void test_tf_change_first_bar_arm() { + std::printf("test_tf_change_first_bar_arm\n"); + // prev_ms == 0 -> first-bar semantics: never a change (line 183). + CHECK(tf_change(0, MON_2024_01_15_00, "D") == false); + CHECK(tf_change(0, TUE_2024_01_16_00, "60") == false); + // curr_ms == 0 also short-circuits to false. + CHECK(tf_change(MON_2024_01_15_00, 0, "D") == false); +} + +static void test_tf_change_calendar_daily() { + std::printf("test_tf_change_calendar_daily\n"); + // Same calendar day (00:00 vs 12:00) -> no boundary cross. + CHECK(tf_change(MON_2024_01_15_00, MON_2024_01_15_12, "D") == false); + // Crossing into the next UTC day -> true. + CHECK(tf_change(MON_2024_01_15_00, TUE_2024_01_16_00, "D") == true); + // "1D" parses to the same DAY calendar period. + CHECK(tf_change(MON_2024_01_15_00, TUE_2024_01_16_00, "1D") == true); +} + +static void test_tf_change_calendar_weekly() { + std::printf("test_tf_change_calendar_weekly\n"); + // Mon -> Fri of the same Monday-start week -> no cross. + CHECK(tf_change(MON_2024_01_15_00, FRI_2024_01_19_00, "W") == false); + // Mon -> next Mon -> crosses the week boundary. + CHECK(tf_change(MON_2024_01_15_00, MON_2024_01_22_00, "W") == true); +} + +static void test_tf_change_calendar_monthly() { + std::printf("test_tf_change_calendar_monthly\n"); + // Two days inside January -> same month. + CHECK(tf_change(MON_2024_01_15_00, SUN_2024_01_28_00, "M") == false); + // January -> February -> crosses the month boundary. + CHECK(tf_change(MON_2024_01_15_00, THU_2024_02_01_00, "M") == true); +} + +static void test_tf_change_intraday_bucket() { + std::printf("test_tf_change_intraday_bucket\n"); + // tf "60" => 3600 s => 3,600,000 ms hour buckets. + // 00:00 and 00:30 share the 473688th hour bucket -> no change. + int64_t at_30min = MON_2024_01_15_00 + 30LL * 60 * 1000; + CHECK(tf_change(MON_2024_01_15_00, at_30min, "60") == false); + // 00:00 vs 01:00 -> next hour bucket (473689) -> change. + int64_t at_60min = MON_2024_01_15_00 + 60LL * 60 * 1000; + CHECK(tf_change(MON_2024_01_15_00, at_60min, "60") == true); + // tf "5" => 300 s => 300,000 ms buckets. 00:00 vs 00:04 same bucket. + int64_t at_4min = MON_2024_01_15_00 + 4LL * 60 * 1000; + CHECK(tf_change(MON_2024_01_15_00, at_4min, "5") == false); + // 00:00 vs 00:05 -> next 5-min bucket -> change. + int64_t at_5min = MON_2024_01_15_00 + 5LL * 60 * 1000; + CHECK(tf_change(MON_2024_01_15_00, at_5min, "5") == true); +} + +static void test_tf_change_secs_guard() { + std::printf("test_tf_change_secs_guard\n"); + // Bare "S" parses to 0 seconds (no canonical meaning) -> tf_to_seconds==0, + // so tf_change hits the `secs <= 0` guard and returns false even across an + // otherwise large timestamp jump. + CHECK(tf_to_seconds("S") == 0); + CHECK(tf_change(MON_2024_01_15_00, THU_2024_02_01_00, "S") == false); + // Empty string -> 0 seconds -> same guard. + CHECK(tf_change(MON_2024_01_15_00, THU_2024_02_01_00, "") == false); +} + +// ─── crosses_boundary NONE arm ──────────────────────────────────────────────── + +static void test_crosses_boundary_none() { + std::printf("test_crosses_boundary_none\n"); + // CalendarPeriod::NONE never reports a boundary cross, regardless of how far + // apart the two timestamps are (lines 174-175). + CHECK(crosses_boundary(MON_2024_01_15_00, THU_2024_02_01_00, + CalendarPeriod::NONE) == false); + CHECK(crosses_boundary(MON_2024_01_15_00, MON_2024_01_15_00, + CalendarPeriod::NONE) == false); + // calendar_period_for of a numeric (minute) TF is NONE. + CHECK(calendar_period_for("60") == CalendarPeriod::NONE); + CHECK(calendar_period_for("") == CalendarPeriod::NONE); +} + +// ─── PASSTHROUGH fallback via string ctor (target finer than input) ────────── + +static void test_passthrough_fallback_target_finer_than_input() { + std::printf("test_passthrough_fallback_target_finer_than_input\n"); + // target "5" is FINER than input "60" => tf_ratio("60","5") == -2 => the + // string ctor falls back to PASSTHROUGH (lines 220-222), NOT active. + CHECK(tf_ratio("60", "5") == -2); + TimeframeAggregator agg("5", "60"); + CHECK(!agg.is_active()); + + // In passthrough, EVERY input bar emits one complete bar, unchanged. + Bar b1 = make_bar(100, 105, 98, 102, 10, 1000); + auto r1 = agg.feed(b1); + CHECK(r1.is_complete); + CHECK(r1.sub_bar_count == 1); + CHECK(r1.bar.open == 100.0); + CHECK(r1.bar.high == 105.0); + CHECK(r1.bar.low == 98.0); + CHECK(r1.bar.close == 102.0); + CHECK(r1.bar.volume == 10.0); + CHECK(r1.bar.timestamp == 1000); + // last_completed mirrors the just-fed bar (no max/min/sum accumulation). + CHECK(agg.last_completed().close == 102.0); + CHECK(agg.current().close == 102.0); + + // A second, lower bar must NOT carry over the previous high/low/volume — + // passthrough is stateless per bar. + Bar b2 = make_bar(90, 92, 80, 85, 7, 2000); + auto r2 = agg.feed(b2); + CHECK(r2.is_complete); + CHECK(r2.sub_bar_count == 1); + CHECK(r2.bar.open == 90.0); + CHECK(r2.bar.high == 92.0); // not max(105,92) + CHECK(r2.bar.low == 80.0); // not min(98,80) + CHECK(r2.bar.close == 85.0); + CHECK(r2.bar.volume == 7.0); // not 10+7 + CHECK(r2.bar.timestamp == 2000); + CHECK(agg.last_completed().volume == 7.0); + CHECK(agg.current().open == 90.0); +} + +// ─── main ───────────────────────────────────────────────────────────────────── + +int main() { + std::printf("=== TimeframeAggregator Extra Coverage Tests ===\n\n"); + + test_tf_change_first_bar_arm(); + test_tf_change_calendar_daily(); + test_tf_change_calendar_weekly(); + test_tf_change_calendar_monthly(); + test_tf_change_intraday_bucket(); + test_tf_change_secs_guard(); + test_crosses_boundary_none(); + test_passthrough_fallback_target_finer_than_input(); + + std::printf("\n=== Results: %d passed, %d failed ===\n", + tests_passed, tests_failed); + return tests_failed > 0 ? 1 : 0; +}