From 010dd1d15ab114c8ef07ff2540cf776729c22cad Mon Sep 17 00:00:00 2001 From: PineForge Factorial Date: Mon, 24 Aug 2026 19:17:31 +0000 Subject: [PATCH] factorial: engine-symbol-r7 [engine] --- include/pineforge/engine.hpp | 38 +- include/pineforge/pineforge.h | 13 + include/pineforge/timeframe.hpp | 23 + scripts/check_c_abi_runtime.py | 2 + scripts/run_strategy.py | 62 ++- scripts/test_run_strategy_identity.py | 47 +- src/c_abi.cpp | 17 + src/engine_fills.cpp | 66 ++- src/engine_path_resolve.cpp | 14 +- src/engine_run.cpp | 45 +- src/engine_security.cpp | 34 +- src/session_time.cpp | 27 ++ src/timeframe.cpp | 158 ++++++- tests/CMakeLists.txt | 4 + ...storical_security_lookahead_projection.cpp | 72 ++- tests/test_htf_session_close_completion.cpp | 420 +++++++++++++++++ tests/test_path_resolve_extra.cpp | 52 +++ tests/test_prearmed_bracket_fill_bar.cpp | 433 ++++++++++++++++++ .../test_prearmed_market_parent_gap_exit.cpp | 19 +- ...est_security_range_start_bucket_gating.cpp | 353 ++++++++++++++ tests/test_session_predicates.cpp | 53 +++ tests/test_syminfo_type.cpp | 109 +++++ 22 files changed, 1967 insertions(+), 94 deletions(-) create mode 100644 tests/test_htf_session_close_completion.cpp create mode 100644 tests/test_prearmed_bracket_fill_bar.cpp create mode 100644 tests/test_security_range_start_bucket_gating.cpp create mode 100644 tests/test_syminfo_type.cpp diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 360f511..e19f2e2 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -1069,7 +1069,13 @@ class BacktestEngine { // Independent opt-in KI-55 HTF warmup parity. When enabled, // request.security series aggregate from security_range_start_ms_ instead // of the feed start and their embedded ta.ema na-warm per TV built-in - // semantics. Default OFF; touched only through feed_security_eval_state. + // semantics. The cut is taken per evaluator on HTF-BUCKET opens, not on + // input timestamps: an input bar is dropped when the D/W/M (or intraday + // grid) bucket it belongs to opened before the range start, so the first + // HTF bar every series sees is a whole bucket that opened at/after the + // range start (security_input_precedes_range_start). Default OFF; + // consulted only by feed_security_eval_state and the historical + // lookahead projection builder. bool security_range_start_na_warmup_ = false; int64_t security_range_start_ms_ = 0; // Opt-in historical-only request.security lookahead projection. TradingView @@ -2220,6 +2226,14 @@ class BacktestEngine { int security_lower_tf_sub_bar_index(int sec_id) const; void validate_security_timeframes(const std::string& input_tf); bool security_series_slot_is_new(int sec_id) const; + // KI-55 range-start gate for one evaluator: true when the input bar at + // `input_ts` belongs to an HTF bucket that opened before + // security_range_start_ms_ (always false while the flag is off). The + // progressive feed and the historical lookahead projection builder must + // agree on this predicate so projected child indexes line up with the + // per-state feed cursor. + bool security_input_precedes_range_start(const SecurityEvalState& state, + int64_t input_ts) const; void feed_security_eval_state(SecurityEvalState& state, const Bar& input_bar); virtual void configure_security_evaluators() {} @@ -2906,6 +2920,28 @@ class BacktestEngine { // would regress the crypto-on-shifted-chart case). void set_syminfo_timezone(const std::string& tz) { syminfo_.timezone = tz; } void set_syminfo_session(const std::string& s) { syminfo_.session = s; } + // ``syminfo.type`` ("crypto" default; "forex" / "stock" / "futures" / + // "index" / "fund" / "cfd" per TradingView). Scripts branch on it for + // instrument conventions — the canonical one being the pip size + // (``syminfo.type == "forex" ? 0.0001 : syminfo.mintick``), which on a + // 5-digit FX symbol under the crypto default computed every pip-scaled + // stop/target 10x too tight (finding 454). Empty is ignored. + void set_syminfo_type(const std::string& t) { if (!t.empty()) syminfo_.type = t; } + // Generic string-field injection for the remaining OHLCV-less syminfo + // members (ticker / tickerid / currency / basecurrency / description / + // volumetype / type). Unknown keys and empty values are ignored; returns + // true when a field was set. + bool set_syminfo_string(const std::string& key, const std::string& value) { + if (value.empty()) return false; + if (key == "type") { syminfo_.type = value; return true; } + if (key == "ticker") { syminfo_.ticker = value; return true; } + if (key == "tickerid") { syminfo_.tickerid = value; return true; } + if (key == "currency") { syminfo_.currency = value; return true; } + if (key == "basecurrency") { syminfo_.basecurrency = value; return true; } + if (key == "description") { syminfo_.description = value; return true; } + if (key == "volumetype") { syminfo_.volumetype = value; return true; } + return false; + } // Runtime syminfo injection (by design — the engine stores no instrument // metadata of its own; the harness supplies it per run). mintick drives the // directional fill snap + slippage*tick economics; pointvalue is the diff --git a/include/pineforge/pineforge.h b/include/pineforge/pineforge.h index e8f0e64..29e8678 100644 --- a/include/pineforge/pineforge.h +++ b/include/pineforge/pineforge.h @@ -611,6 +611,19 @@ PF_API void strategy_set_syminfo_timezone(pf_strategy_t s, const char* tz); * ignored. Call before #run_backtest*. */ PF_API void strategy_set_syminfo_session(pf_strategy_t s, const char* session); +/** Set the instrument class (``syminfo.type``: "forex", "stock", "crypto", + * "futures", "index", "fund", "cfd", ...; default "crypto"). Scripts branch + * on it for instrument conventions (e.g. the forex pip size). `NULL` / + * empty ignored. Call before #run_backtest*. */ +PF_API void strategy_set_syminfo_type(pf_strategy_t s, const char* type); + +/** Set one of the remaining string ``syminfo.*`` members by Pine member + * name: "ticker", "tickerid", "currency", "basecurrency", "description", + * "volumetype" (and "type"). Returns 0 when set, -1 for an unknown key, + * empty value or NULL. Call before #run_backtest*. */ +PF_API int strategy_set_syminfo_string(pf_strategy_t s, const char* key, + const char* value); + /** Set the instrument tick size (``syminfo.mintick``, default 0.01). Drives the * directional stop-entry snap and ``slippage = N*mintick`` economics. Set * per-instrument (e.g. 0.25 for ES, 0.00001 for FX). Non-positive ignored. diff --git a/include/pineforge/timeframe.hpp b/include/pineforge/timeframe.hpp index d71dc1a..32eb43e 100644 --- a/include/pineforge/timeframe.hpp +++ b/include/pineforge/timeframe.hpp @@ -138,6 +138,18 @@ int64_t session_period_close_ms(int64_t ms, const std::string& tz, const std::string& session, CalendarPeriod period); +/// Exclusive close (Unix ms) of the LAST TRADED session-day of the D/W/M +/// bar that contains `ms`: DAY is session_period_close_ms; WEEK / MONTH +/// step back from the period's last session-day over weekend TRADING dates +/// (Saturday / Sunday never hold a session on exchange-calendar symbols), +/// so an equity week ends Friday 16:00 ET, a month whose last calendar day +/// is a weekend ends on its last Friday, and the forex week ends Friday +/// 17:00 ET (the Friday-open session is Saturday's trading date). Exchange +/// holidays are not modelled. CalendarPeriod::NONE returns `ms`. +int64_t session_period_last_traded_close_ms(int64_t ms, const std::string& tz, + const std::string& session, + CalendarPeriod period); + // ─── TimeframeAggregator ─────────────────────────────────────────────────────── class TimeframeAggregator { @@ -173,6 +185,17 @@ class TimeframeAggregator { /// Whether aggregation is active (non-passthrough). bool is_active() const; + /// Open (Unix ms) of the target-TF bucket an input bar stamped `ms` + /// belongs to, on the aggregator's anchor clock (syminfo tz + session): + /// CALENDAR -> session_period_open_ms of the bar's D/W/M period (the + /// forex week opens Sunday 17:00 ET, its month on the session whose + /// close date is the 1st); RATIO -> the session-open-anchored intraday + /// grid bucket (the same key feed() splits on); PASSTHROUGH, or a + /// count-only ratio with no wall-clock width, -> `ms` itself. Pure + /// function of the configuration: it neither reads nor advances the + /// aggregation state, so callers may query it before feeding the bar. + int64_t bucket_open_ms(int64_t ms) const; + private: enum class Mode { PASSTHROUGH, RATIO, CALENDAR }; diff --git a/scripts/check_c_abi_runtime.py b/scripts/check_c_abi_runtime.py index aac3d68..be2175e 100644 --- a/scripts/check_c_abi_runtime.py +++ b/scripts/check_c_abi_runtime.py @@ -22,6 +22,8 @@ "strategy_set_chart_timezone", "strategy_set_syminfo_timezone", "strategy_set_syminfo_session", + "strategy_set_syminfo_type", + "strategy_set_syminfo_string", "strategy_set_syminfo_mintick", "strategy_set_syminfo_pointvalue", "strategy_set_syminfo_metadata", diff --git a/scripts/run_strategy.py b/scripts/run_strategy.py index daebb3a..52b7da2 100644 --- a/scripts/run_strategy.py +++ b/scripts/run_strategy.py @@ -1017,6 +1017,13 @@ def _num(v): chart_timezone=chart_tz, syminfo_timezone=str(runtime_overrides.get("timezone") or "") or None, syminfo_session=str(runtime_overrides.get("session") or "") or None, + syminfo_type=str(runtime_overrides.get("type") or "") or None, + syminfo_strings={ + k: str(runtime_overrides.get(k)) + for k in ("ticker", "tickerid", "currency", "basecurrency", + "description", "volumetype") + if runtime_overrides.get(k) + } or None, syminfo_metadata=syminfo_metadata, syminfo_mintick=_num(runtime_overrides.get("mintick")), syminfo_pointvalue=_num(runtime_overrides.get("pointvalue")), @@ -1125,6 +1132,16 @@ def _setup_signatures(self) -> None: if hasattr(L, "strategy_set_syminfo_session"): L.strategy_set_syminfo_session.argtypes = [ctypes.c_void_p, ctypes.c_char_p] L.strategy_set_syminfo_session.restype = None + # syminfo.type ("forex"/"stock"/"crypto"/...) + the generic string + # member setter (ticker/tickerid/currency/basecurrency/...). Older + # .so builds predate these exports — hasattr-guarded like the rest. + if hasattr(L, "strategy_set_syminfo_type"): + L.strategy_set_syminfo_type.argtypes = [ctypes.c_void_p, ctypes.c_char_p] + L.strategy_set_syminfo_type.restype = None + if hasattr(L, "strategy_set_syminfo_string"): + L.strategy_set_syminfo_string.argtypes = [ + ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p] + L.strategy_set_syminfo_string.restype = ctypes.c_int if hasattr(L, "strategy_set_syminfo_metadata"): L.strategy_set_syminfo_metadata.argtypes = [ ctypes.c_void_p, ctypes.c_char_p, ctypes.c_double] @@ -1151,6 +1168,8 @@ def run(self, bars_csv: Path, params: dict | None = None, chart_timezone: str | None = None, syminfo_timezone: str | None = None, syminfo_session: str | None = None, + syminfo_type: str | None = None, + syminfo_strings: dict | None = None, syminfo_metadata: dict | None = None, syminfo_mintick: float | None = None, syminfo_pointvalue: float | None = None, @@ -1243,6 +1262,17 @@ def run(self, bars_csv: Path, params: dict | None = None, self.lib.strategy_set_syminfo_timezone(state, str(syminfo_timezone).encode()) if syminfo_session and hasattr(self.lib, "strategy_set_syminfo_session"): self.lib.strategy_set_syminfo_session(state, str(syminfo_session).encode()) + # Instrument class (syminfo.type). Unset keeps the engine's + # "crypto" default byte-identical; per-symbol datasets pass + # TV's value ("forex" for OANDA:EURUSD, "stock" for NASDAQ:AAPL). + if syminfo_type and hasattr(self.lib, "strategy_set_syminfo_type"): + self.lib.strategy_set_syminfo_type(state, str(syminfo_type).encode()) + if syminfo_strings and hasattr(self.lib, "strategy_set_syminfo_string"): + for skey, sval in syminfo_strings.items(): + if sval is None or str(sval) == "": + continue + self.lib.strategy_set_syminfo_string( + state, str(skey).encode(), str(sval).encode()) if syminfo_metadata and hasattr(self.lib, "strategy_set_syminfo_metadata"): for mkey, mval in syminfo_metadata.items(): try: @@ -1515,6 +1545,32 @@ def _filter_trace_to_window(trace: list[dict], window: tuple[int, int] | None) - ] +def format_trade_qty(qty: float) -> str: + """Lot-faithful quantity text for the TV-alignable export. + + The previous ``f"{qty:g}"`` kept only 6 significant digits, so any + quantity with more digits than that was silently rewritten on the way + out: an OANDA:EURUSD all-in lot of 923941.16 units (TV export, 0.01 lot + step) printed as ``923941``, 897902.68 printed as ``897903`` (rounded UP), + 92293.36 as ``92293.4``, and anything >= 1e6 as ``1e+06``. The ledger held + the exact value; only the CSV lied, which put a spurious 0.01-0.5 unit + "qty miss" on 1933/2708 entries of the EURUSD KI-52 all-in probe and on + every giua64 entry while the fills themselves were exact. + + Eight decimals cover every lot step in use (1 share, 0.01 unit, 0.0001 + contract, satoshi-scale 1e-8) and absorb binary noise from + floor(q/step)*step (0.30000000000000004 -> ``0.3``); trailing zeros are + trimmed the way TradingView prints its ``Size (qty)`` column, so every + value the old formatter rendered faithfully renders byte-identically. + """ + if not math.isfinite(qty): + return f"{qty:g}" + text = f"{qty:.8f}".rstrip("0").rstrip(".") + if text in ("", "-0", "-"): + return "0" + return text + + def write_engine_trades_csv(trades: list[dict], path: Path) -> None: """Emit one row per trade *side* (exit then entry) in reverse-chronological order — byte-for-byte alignable with TradingView's `trades.csv` export. @@ -1552,7 +1608,7 @@ def write_engine_trades_csv(trades: list[dict], path: Path) -> None: n, side, _fmt_time_utc(t[time_key]), f"{t[price_key]:.6f}", - f"{t['qty']:g}", + format_trade_qty(float(t["qty"])), f"{t['pnl']:.6f}", f"{t['pnl_pct']:.4f}", f"{t['max_runup']:.6f}", @@ -1677,6 +1733,10 @@ def _run_via_docker(strategy_dir: Path, ohlcv_path: Path, params: dict, syminfo["timezone"] = run_kwargs["syminfo_timezone"] if run_kwargs.get("syminfo_session"): syminfo["session"] = run_kwargs["syminfo_session"] + if run_kwargs.get("syminfo_type"): + syminfo["type"] = run_kwargs["syminfo_type"] + for skey, sval in (run_kwargs.get("syminfo_strings") or {}).items(): + syminfo[skey] = sval if run_kwargs.get("syminfo_mintick") is not None: syminfo["mintick"] = run_kwargs["syminfo_mintick"] if run_kwargs.get("syminfo_pointvalue") is not None: diff --git a/scripts/test_run_strategy_identity.py b/scripts/test_run_strategy_identity.py index 8a2f953..483a076 100644 --- a/scripts/test_run_strategy_identity.py +++ b/scripts/test_run_strategy_identity.py @@ -9,7 +9,7 @@ from pathlib import Path from pf_release_run import report_trades_to_runstrategy_shape -from run_strategy import write_engine_trades_csv +from run_strategy import format_trade_qty, write_engine_trades_csv class EngineEntryIdentityCsvTests(unittest.TestCase): @@ -81,5 +81,50 @@ def test_release_report_mapping_preserves_incarnation(self) -> None: self.assertEqual(mapped[0]["entry_incarnation"], 73) +class EngineTradeQtyFormatTests(unittest.TestCase): + """The exported ``Qty`` column must carry the ledger quantity to the lot + step: ``%g``'s 6 significant digits truncated (and sometimes rounded UP) + every OANDA:EURUSD-scale lot (TV KI-52 all-in: 92293.36 units).""" + + def test_large_lots_keep_every_lot_step_digit(self) -> None: + self.assertEqual(format_trade_qty(923941.16), "923941.16") + self.assertEqual(format_trade_qty(897902.68), "897902.68") + self.assertEqual(format_trade_qty(92293.36), "92293.36") + self.assertEqual(format_trade_qty(1000000.0), "1000000") + self.assertEqual(format_trade_qty(8741.59), "8741.59") + + def test_binary_noise_and_trailing_zeros_are_trimmed(self) -> None: + self.assertEqual(format_trade_qty(0.30000000000000004), "0.3") + self.assertEqual(format_trade_qty(2.7051000000000001), "2.7051") + self.assertEqual(format_trade_qty(1.0), "1") + self.assertEqual(format_trade_qty(44.0), "44") + self.assertEqual(format_trade_qty(0.0001), "0.0001") + self.assertEqual(format_trade_qty(0.0), "0") + + def test_values_the_old_formatter_rendered_exactly_are_unchanged(self) -> None: + for qty in (55.2872, 5.4103, 0.0196, 44.9622, 48, 7.7232, 30.3796): + self.assertEqual(format_trade_qty(float(qty)), f"{float(qty):g}") + + def test_writer_uses_lot_faithful_qty(self) -> None: + trade = { + "entry_time": 1_735_689_600_000, + "exit_time": 1_735_689_660_000, + "entry_price": 1.08232, + "exit_price": 1.07836, + "pnl": -3658.81, + "pnl_pct": -0.37, + "is_long": True, + "max_runup": 0.0, + "max_drawdown": 0.0, + "qty": 923941.16, + } + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "engine_trades.csv" + write_engine_trades_csv([trade], path) + with path.open(newline="", encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + self.assertEqual([row["Qty"] for row in rows], ["923941.16", "923941.16"]) + + if __name__ == "__main__": unittest.main() diff --git a/src/c_abi.cpp b/src/c_abi.cpp index 35e6139..3480e11 100644 --- a/src/c_abi.cpp +++ b/src/c_abi.cpp @@ -257,6 +257,23 @@ PF_API void strategy_set_syminfo_session(pf_strategy_t s, const char* session) { static_cast(s)->set_syminfo_session(std::string(session)); } +/* Plumb the instrument class (syminfo.type: "forex" / "stock" / "crypto" / + * "futures" / ...) into syminfo_. Defaults to "crypto"; NULL/empty ignored. */ +PF_API void strategy_set_syminfo_type(pf_strategy_t s, const char* type) { + if (!s || !type) return; + static_cast(s)->set_syminfo_type(std::string(type)); +} + +/* Generic string-member injection (ticker / tickerid / currency / + * basecurrency / description / volumetype / type). Returns 0 when set, -1 + * for a NULL handle, unknown key or empty value. */ +PF_API int strategy_set_syminfo_string(pf_strategy_t s, const char* key, + const char* value) { + if (!s || !key || !value) return -1; + return static_cast(s)->set_syminfo_string( + std::string(key), std::string(value)) ? 0 : -1; +} + /* Inject the instrument tick size (syminfo.mintick). Drives the directional * stop-entry snap (long ceil / short floor) and slippage = N*mintick economics. * Defaults to 0.01 (crypto/equity); set per-instrument (e.g. 0.25 for ES, diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index ad415a9..bee6efd 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -2438,13 +2438,27 @@ void BacktestEngine::sort_orders_by_fill_phase(const Bar& bar) { for (const PendingOrder& order : pending_orders_) { auto parent = non_gap_limit_parents.find(order.from_entry); if (parent == non_gap_limit_parents.end()) continue; + // The child may have been (re-)issued on any bar from the + // parent's placement bar onward: a script that calls + // strategy.exit at global scope re-arms the same-id bracket + // every bar while the limit parent rests, so its created_bar + // trails the parent's by the time the parent fills while its + // created_seq (preserved across same-id replacement) still + // orders it after the parent. quantbyboji-nq-hma-midday-strategy + // (OANDA:EURUSD 15m, 2025-08-22 18:15Z): limit 1.17323 placed + // five bars earlier fills mid-path (open 1.17356), TV binds the + // 0.0098-tick loss leg to the fill and books it at 1.17322 on + // the same bar; a same-created_bar-only fence left the child in + // the open phase ahead of its parent, skipped while flat, and + // gap-filled it at the next open. The 140 sibling exits whose + // parent filled AT the open already shared the open phase. const bool exact_relative_child = order.type == OrderType::EXIT && !order.created_while_in_position && order.created_position_side == PositionSide::FLAT && !order.created_during_coof_recalc && exit_children_by_parent[order.from_entry] == 1 - && order.created_bar == parent->second.created_bar + && order.created_bar >= parent->second.created_bar && parent->second.created_seq < order.created_seq && std::isnan(order.limit_price) && std::isnan(order.stop_price) @@ -3071,9 +3085,10 @@ bool BacktestEngine::short_seed_collision_final_short_is_live( // Do not turn this into a general entry-bar wrong-side bypass. The exact // provenance below keeps freshly emitted/stale exits, priced parents, MARKET // pyramid adds, partial/sibling groups, POOC, COOF, and magnifier on their -// existing paths. Generated Pine already lowers flat -// strategy.position_avg_price to na, so an avg-derived flat bracket never -// reaches this helper with a finite leg. +// existing paths. A trail leg riding on the bracket is not a provenance +// difference (see the note at the trail check below). Generated Pine +// already lowers flat strategy.position_avg_price to na, so an avg-derived +// flat bracket never reaches this helper with a finite leg. bool BacktestEngine::prearmed_market_parent_bracket_gaps_at_open( const PendingOrder& order, const Bar& bar, bool* limit_leg) const { @@ -3090,18 +3105,37 @@ bool BacktestEngine::prearmed_market_parent_bracket_gaps_at_open( || order.requested_partial || order.qty_percent < 100.0 - kFullPercentEps || (!std::isfinite(order.stop_price) - && !std::isfinite(order.limit_price)) - || !std::isnan(order.trail_points) - || !std::isnan(order.trail_price)) { + && !std::isfinite(order.limit_price))) { return false; } - - // Exactly ONE marketable leg at the open. Test the actual W0 broker + // A trail leg (trail_points / trail_price) on the same bracket does not + // exclude it: the trail is dormant until its activation level is reached + // and the breached fixed leg is what fills at the open. Tape exemplar: + // stevenygabbyperez-fast-scalper-with-stops on NASDAQ:AAPL 15m — + // strategy.exit(stop=close*0.99, trail_points=...) armed with the MARKET + // entry, the RTH open gaps below the stop (2025-04-03: stop 221.59, open + // 205.54; 2026-04-27: stop 268.26, open 266.09). TV books the entry and + // 'Exit Long' at the open, PnL 0; the 11 same-bar stops of that script + // whose open did NOT breach the stop already matched on the path walk. + + // At least one marketable leg at the open. Test the actual W0 broker // predicate: equality is marketable, and slippage can make the booked - // entry price differ from the bar open. Dual-marketable brackets stay - // out until a dedicated priority oracle exists (no tape exemplar); a - // bracket with neither leg marketable keeps the ordinary entry-bar - // path walk / wrong-side gating. + // entry price differ from the bar open. A bracket with neither leg + // marketable keeps the ordinary entry-bar path walk / wrong-side gating. + // + // DUAL-marketable brackets (stop AND limit both marketable at the open) + // scratch at the open too. Tape exemplar: bprakaash-new-era-strategy-1-0 + // on OANDA:EURUSD 15m, 2025-07-03 / 07-24 / 08-07 / 09-09 13:30Z — a + // short whose signal-bar sl landed BELOW the close (so its target landed + // above it): stop 1.17528 < open 1.17646 < limit 1.17879 (07-03), + // stop == limit == open 1.16542 (08-07). TV books entry and 'TP/SL 1' + // exit at the same open, duration 0, PnL 0, in all four; the engine + // deferred the wrong-side legs to the next bar's open. The other 265 + // trades of that population have exactly zero dual-marketable opens. + // Both legs price at the open, so the leg choice is observable only + // through slippage / per-leg comments; the STOP leg is taken, matching + // try_exit_open_gap_fill's resting-bracket precedence (trail, stop, + // limit) for the same open-gap event on a later bar. const bool live_long = position_side_ == PositionSide::LONG; const bool stop_gapped = std::isfinite(order.stop_price) && (live_long ? bar.open <= order.stop_price @@ -3109,8 +3143,8 @@ bool BacktestEngine::prearmed_market_parent_bracket_gaps_at_open( const bool limit_marketable = std::isfinite(order.limit_price) && (live_long ? bar.open >= order.limit_price : bar.open <= order.limit_price); - if (stop_gapped == limit_marketable) return false; - if (limit_leg != nullptr) *limit_leg = limit_marketable; + if (!stop_gapped && !limit_marketable) return false; + if (limit_leg != nullptr) *limit_leg = limit_marketable && !stop_gapped; int matching_children = 0; for (const PendingOrder& pending : pending_orders_) { @@ -4077,7 +4111,7 @@ void BacktestEngine::apply_filled_order_to_state( // closing trade that no bar-boundary sample ever sees. double off = std::isnan(order.trail_offset) ? 0.0 - : std::ceil(order.trail_offset) * syminfo_mintick_; + : std::floor(order.trail_offset) * syminfo_mintick_; fold_exit_trail_peak_ = (position_side_ == PositionSide::LONG) ? fill_price + off : fill_price - off; diff --git a/src/engine_path_resolve.cpp b/src/engine_path_resolve.cpp index 3b4a39e..659551c 100644 --- a/src/engine_path_resolve.cpp +++ b/src/engine_path_resolve.cpp @@ -568,7 +568,19 @@ ExitTrailState compute_exit_trail_state(bool is_long, double trail_points, // it follows the existing activation-only path; positive offsets retain // the ordinary best-price-minus/plus-offset trailing behaviour. if (!std::isnan(trail_offset) && trail_offset != 0.0) { - s.trail_offset_price = std::ceil(trail_offset) * syminfo_mintick; + // A fractional trail_offset (ticks) is TRUNCATED to whole ticks by + // TradingView, not rounded up: the trailing level sits + // floor(offset) ticks behind the running extreme. Measured on every + // non-gap trailing exit whose level could be recovered from the TV + // tape (level = TV fill + slippage ticks): nils123456-orb-strat on + // BINANCE:ETHUSDT.P (trail_offset = price / mintick, slippage 0) + // 11/11 and legalrice2697-nse-elite-strategy-v6-full-system on + // OANDA:EURUSD (atr * 4 / mintick, slippage 2) 58/62 sat exactly + // ONE tick nearer the extreme than the ceil() level, for offsets + // with a fractional part both below and above .5 (never zero for + // the .5+ half, which rules out round-to-nearest). Whole-tick + // offsets are unchanged. + s.trail_offset_price = std::floor(trail_offset) * syminfo_mintick; } s.exits_at_activation = std::isnan(s.trail_offset_price); // An EXPLICIT trail_offset=0 is TV's one-shot exit-at-activation trail: diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 4d62ff7..a049068 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -1348,28 +1348,8 @@ void BacktestEngine::prepare_historical_security_lookahead_projections( return; } - // Range-start warmup drops every earlier input in - // feed_security_eval_state(). Build the projection from that exact same - // retained suffix and store child indexes relative to it: the per-state - // feed cursor likewise starts at zero on the first retained child because - // the early-return path never increments it. This composes the two - // independently opt-in historical semantics without exposing a pre-range - // aggregate or shifting the first projected bucket. - int projection_begin = 0; - if (security_range_start_na_warmup_) { - while (projection_begin < n_input - && input_bars[projection_begin].timestamp - < security_range_start_ms_) { - ++projection_begin; - } - if (projection_begin >= n_input) { - return; - } - } - historical_security_lookahead_projection_active_ = true; const int64_t input_ms = static_cast(input_seconds) * 1000; - const int projection_count = n_input - projection_begin; for (auto& state : security_eval_states_) { const int requested_seconds = tf_to_seconds(state.tf); @@ -1384,6 +1364,31 @@ void BacktestEngine::prepare_historical_security_lookahead_projections( continue; } + // Range-start warmup drops, PER EVALUATOR, every input bar whose HTF + // bucket opened before the range start (feed_security_eval_state). + // Build this evaluator's projection from that exact same retained + // suffix and store child indexes relative to it: its feed cursor + // likewise starts at zero on the first retained child because the + // early-return path never increments it. The cut differs between + // evaluators (a "W" series loses the whole straddling week, a "60" + // series only the straddling hour), so it cannot be hoisted. This + // composes the two independently opt-in historical semantics without + // exposing a pre-range aggregate or shifting the first projected + // bucket. An evaluator with no retained input gets no projection and + // falls through to its (equally empty) progressive path. + int projection_begin = 0; + if (security_range_start_na_warmup_) { + while (projection_begin < n_input + && security_input_precedes_range_start( + state, input_bars[projection_begin].timestamp)) { + ++projection_begin; + } + if (projection_begin >= n_input) { + continue; + } + } + const int projection_count = n_input - projection_begin; + const int expected_children = std::max( 1, requested_seconds / input_seconds); state.historical_projections.reserve(static_cast( diff --git a/src/engine_security.cpp b/src/engine_security.cpp index 3cb7a24..46e380a 100644 --- a/src/engine_security.cpp +++ b/src/engine_security.cpp @@ -298,22 +298,44 @@ bool BacktestEngine::security_series_slot_is_new(int sec_id) const { } +bool BacktestEngine::security_input_precedes_range_start( + const SecurityEvalState& state, int64_t input_ts) const { + if (!security_range_start_na_warmup_) { + return false; + } + // TradingView's deep-backtest request.security series are built from the + // HTF bars whose OPEN lies inside the loaded chart range: a bucket that + // opened before the range start is not a partial first bar, it is absent. + // Keying the cut on the bucket open (session_period_open_ms for D/W/M, + // the session-anchored intraday grid otherwise) reproduces that; keying + // on the input timestamp would let the pre-range remainder of that bucket + // pose as HTF bar 1 and shift every SMA-seeded EMA/RSI/ATR/Stoch by one + // bucket (OANDA:EURUSD 1700-1700: the week opening Sun 17:00 ET before + // the range start, the month opening Feb 28 17:00 ET before it). With a + // range start on the bucket grid — 24x7 UTC midnight for every intraday + // TF and D, Monday for W, the 1st for M — this is the timestamp cut. + // Lower-TF (passthrough) evaluators keep the timestamp cut exactly. + return state.aggregator.bucket_open_ms(input_ts) < security_range_start_ms_; +} + + void BacktestEngine::feed_security_eval_state(SecurityEvalState& state, const Bar& input_bar) { // Opt-in KI-55 HTF warmup parity (security_range_start_na_warmup run flag): // (a) start every request.security aggregation at range_start_ms, not the - // feed start — drop pre-range input bars so the aggregator, its TA - // members, and the exposed history all begin at the range start; + // feed start — drop every input bar whose HTF bucket opened before + // the range start (security_input_precedes_range_start) so the + // aggregator, its TA members, and the exposed history all begin at + // the first WHOLE bucket opening at/after the range start; // (b) its embedded lookback ta.ema na-warms per TV built-in semantics — // scoped by raising ta::ema_na_warmup_flag() for the duration of this // call, which covers every evaluate_security() dispatch below (each of // which is the only place the security's EMA members compute()); // (c) plain security expressions (e.g. `close`) read na until the first // COMPLETED HTF bar from the range start — a consequence of (a): under - // lookahead_off no evaluate_security() fires until the first bucket - // completes, and the partial first bucket counts as HTF bar 1. + // lookahead_off no evaluate_security() fires until that first whole + // bucket completes; a bucket straddling the range start never counts. // All three collapse to no-ops when the flag is unset (byte-identical). - if (security_range_start_na_warmup_ - && input_bar.timestamp < security_range_start_ms_) { + if (security_input_precedes_range_start(state, input_bar.timestamp)) { return; } struct SecurityNaWarmupScope { diff --git a/src/session_time.cpp b/src/session_time.cpp index 65b2da2..b6a034c 100644 --- a/src/session_time.cpp +++ b/src/session_time.cpp @@ -363,6 +363,18 @@ static bool is_allday_session(const std::string& session) { return (start4 == "0000" && (end4 == "2400" || end4 == "0000")); } +// True when the first "HHMM-HHMM" window of `windows` has start == end — +// TradingView's spelling of a 24-hour session ("1700-1700" on OANDA forex, +// "0000-0000"). Such a window spans the whole day, not zero minutes. +static bool first_window_is_full_day(const std::string& windows) { + std::size_t dash = windows.find('-'); + if (dash == std::string::npos || dash < 4 || windows.size() < dash + 5) + return false; + int sm = hhmm_to_minutes(windows.substr(dash - 4, 4)); + int em = hhmm_to_minutes(windows.substr(dash + 1, 4)); + return sm >= 0 && em >= 0 && sm == em; +} + } // anonymous namespace // --------------------------------------------------------------------------- @@ -410,6 +422,14 @@ bool local_time_in_session_windows(const std::string& windows_body, int em = hhmm_to_minutes(right); if (sm < 0 || em < 0) continue; + // A window whose start equals its end ("1700-1700" on OANDA forex, + // "0000-0000") is TradingView's 24-hour session: the market is + // open the whole day and every bar belongs to it. The half-open + // arithmetic below would otherwise make it EMPTY, so time(session) + // / time_close / session.ismarket returned na / false on every + // bar of a forex symbol (finding 455). + if (sm == em) + return true; bool in_win = (sm <= em) ? (mod >= sm && mod < em) : (mod >= sm || mod < em); if (in_win) @@ -467,6 +487,10 @@ bool pine_session_ispremarket(const std::string& session, int rth_open_min = hhmm_to_minutes(rth_open_str); if (rth_open_min < 0) return false; + // start == end is a 24-hour session (see local_time_in_session_windows): + // the market never closes, so there is no pre-market. + if (first_window_is_full_day(windows)) + return false; int pre_open_min = 4 * 60; @@ -500,6 +524,9 @@ bool pine_session_ispostmarket(const std::string& session, int rth_close_min = hhmm_to_minutes(rth_close_str); if (rth_close_min < 0) return false; + // start == end is a 24-hour session: no post-market either. + if (first_window_is_full_day(windows)) + return false; int post_close_min = 20 * 60; diff --git a/src/timeframe.cpp b/src/timeframe.cpp index c6b6951..da99560 100644 --- a/src/timeframe.cpp +++ b/src/timeframe.cpp @@ -389,34 +389,78 @@ int64_t session_period_open_ms(int64_t ms, const std::string& tz, tz, session); } +/// Epoch day of the first session-day of the W/M period FOLLOWING the one +/// that contains session-day `d`. +static int64_t session_period_next_first_day(int64_t d, const std::string& session, + CalendarPeriod period) { + const int64_t first = session_period_first_day(d, session, period); + if (period == CalendarPeriod::WEEK) return first + 7; + // First session-day of the NEXT month: step a trading date into the + // following month and re-anchor. + const int shift = session_trading_date_shift_days(session); + const int64_t td = first + shift; + time_t secs = static_cast(td * kSecPerDay); + struct tm g {}; + gmtime_r(&secs, &g); + int y = g.tm_year + 1900, m = g.tm_mon + 2; // next month, 1-based + if (m > 12) { m = 1; ++y; } + return days_from_civil(y, m, 1) - shift; +} + +/// Exclusive close (real epoch ms) of session-day `d`: its open plus the +/// session length (16:00 ET on equities, the next 17:00 ET on forex). +static int64_t session_day_close_real_ms(int64_t d, const std::string& tz, + const std::string& session) { + // Session close on the same wall clock as the open (a DST step inside + // a session never happens while a market is open). + return session_day_open_real_ms(d, tz, session) + + static_cast(session_length_minutes(session)) * 60000; +} + int64_t session_period_close_ms(int64_t ms, const std::string& tz, const std::string& session, CalendarPeriod period) { if (period == CalendarPeriod::NONE) return ms; const int64_t d = session_day_index(ms, tz, session); - if (period == CalendarPeriod::DAY) { - // Session close on the same wall clock as the open (a DST step - // inside a session never happens while a market is open). - return session_day_open_real_ms(d, tz, session) - + static_cast(session_length_minutes(session)) * 60000; - } - const int64_t first = session_period_first_day(d, session, period); - int64_t next_first; - if (period == CalendarPeriod::WEEK) { - next_first = first + 7; - } else { - // First session-day of the NEXT month: step a trading date into the - // following month and re-anchor. - const int shift = session_trading_date_shift_days(session); - const int64_t td = first + shift; - time_t secs = static_cast(td * kSecPerDay); - struct tm g {}; - gmtime_r(&secs, &g); - int y = g.tm_year + 1900, m = g.tm_mon + 2; // next month, 1-based - if (m > 12) { m = 1; ++y; } - next_first = days_from_civil(y, m, 1) - shift; + if (period == CalendarPeriod::DAY) return session_day_close_real_ms(d, tz, session); + return session_day_open_real_ms(session_period_next_first_day(d, session, period), + tz, session); +} + +/// True when the run declares a real exchange session (equities RTH, forex +/// 1700-1700). ""/"24x7" feeds anchor nothing and keep every integer fast +/// path — the corpus regime must stay bit-identical. +static bool has_trading_session(const std::string& session) { + return !session.empty() && session != "24x7"; +} + +/// Exclusive close (real epoch ms) of the LAST TRADED session-day of the +/// D/W/M period containing `ms`. TradingView finalizes a D/W/M bar on the +/// last chart bar that belongs to it, and on exchange-calendar symbols the +/// period's last calendar day is frequently not a trading day: an equity +/// week ends Friday 16:00 (Saturday holds no session), a month closing on a +/// weekend ends on its last Friday, and the forex week's Friday-17:00-ET +/// open (Saturday trading date) never trades, so the week ends Friday +/// 17:00 ET. Weekend TRADING dates (Sat/Sun) are skipped; exchange +/// holidays are not modelled (the period then completes lazily on the next +/// period's first bar, exactly as before). +int64_t session_period_last_traded_close_ms(int64_t ms, const std::string& tz, + const std::string& session, + CalendarPeriod period) { + if (period == CalendarPeriod::NONE) return ms; + // ""/"24x7" markets trade every calendar day: nothing to skip. + if (!has_trading_session(session)) return session_period_close_ms(ms, tz, session, period); + const int64_t d = session_day_index(ms, tz, session); + if (period == CalendarPeriod::DAY) return session_day_close_real_ms(d, tz, session); + const int shift = session_trading_date_shift_days(session); + int64_t last = session_period_next_first_day(d, session, period) - 1; + for (int guard = 0; guard < 7; ++guard) { + const int64_t td = last + shift; // trading date + const int wday = static_cast(((td + 4) % 7 + 7) % 7); // 0=Sun..6=Sat + if (wday != 0 && wday != 6) break; + --last; } - return session_day_open_real_ms(next_first, tz, session); + return session_day_close_real_ms(last, tz, session); } /// Period key for D/W/M attribution by SESSION-DAY: every bar belongs to the @@ -609,7 +653,8 @@ AggregatedBar feed_passthrough_mode(const Bar& input_bar, FeedState s) { } AggregatedBar feed_ratio_mode(const Bar& input_bar, FeedState s, - int ratio, int64_t target_seconds) { + int ratio, int64_t target_seconds, + int64_t input_seconds) { AggregatedBar result; if (target_seconds > 0 && s.sub_bar_count > 0) { // Time-bucket aware ratio mode: @@ -641,6 +686,23 @@ AggregatedBar feed_ratio_mode(const Bar& input_bar, FeedState s, feed_merge_into_current(s, input_bar); bool complete = (s.sub_bar_count == ratio); + // Session-close completion: an intraday bucket that straddles the + // session close (RTH '240' 13:30-17:30 holds 10 of 16 sub-bars, + // '60' 15:30-16:30 two of four) never reaches its count. TradingView + // clips the bucket at the session close and finalizes it on the + // session's LAST chart bar (15:45 ET), not on the next session's + // first bar where the boundary test above would catch it a session + // late. Declared sessions only: ""/"24x7" feeds keep the count/ + // boundary rules bit-for-bit, and multi-day ratio targets ("2D") + // are calendar-sized, not session-sized. + if (!complete && input_seconds > 0 && target_seconds < kSecPerDay + && has_trading_session(asess)) { + const int64_t next_ms = input_bar.timestamp + input_seconds * 1000; + if (next_ms >= session_period_last_traded_close_ms( + input_bar.timestamp, atz, asess, CalendarPeriod::DAY)) { + complete = true; + } + } if (complete) { s.last_completed_bar = s.current_bar; s.has_completed = true; @@ -770,6 +832,30 @@ AggregatedBar feed_calendar_mode(const Bar& input_bar, FeedState s, } } } + if (!complete + && (cal_period == CalendarPeriod::WEEK + || cal_period == CalendarPeriod::MONTH) + && has_trading_session(asess)) { + // W/M on an exchange-calendar session: the rule above asks + // whether the same wall clock TOMORROW starts a new period, but + // Friday + 24h is Saturday — still this week (and a weekend + // month-end is still this month) — so the week completed on + // Monday 09:30 instead of Friday 15:45, where TradingView + // finalizes it (its last chart bar). Complete when the bar's + // end reaches the close of the period's last TRADED session-day + // (weekend trading dates skipped). + // + // Wrapped sessions (forex 1700-1700) need the same rule: the + // next-bar crossing above sees Friday 17:00 ET as the + // Friday-open session-day (Saturday's trading date, same + // week/month), so W/M completed on Sunday 17:00 and a + // crossover filled 17:15 where TradingView signals on Friday + // 16:45 and fills Sunday 17:00. The last traded session-day of + // the forex week is the Thursday-open one, closing Friday + // 17:00 ET. + complete = next_ms >= session_period_last_traded_close_ms( + input_bar.timestamp, atz, asess, cal_period); + } } if (complete) { s.last_completed_bar = s.current_bar; @@ -803,7 +889,8 @@ AggregatedBar TimeframeAggregator::feed(const Bar& input_bar) { case Mode::PASSTHROUGH: return feed_passthrough_mode(input_bar, s); case Mode::RATIO: - return feed_ratio_mode(input_bar, s, ratio_, target_seconds_); + return feed_ratio_mode(input_bar, s, ratio_, target_seconds_, + input_seconds_); case Mode::CALENDAR: return feed_calendar_mode(input_bar, s, cal_period_, input_seconds_); } @@ -828,4 +915,27 @@ bool TimeframeAggregator::is_active() const { return mode_ != Mode::PASSTHROUGH; } +int64_t TimeframeAggregator::bucket_open_ms(int64_t ms) const { + switch (mode_) { + case Mode::CALENDAR: + return session_period_open_ms(ms, anchor_tz_, anchor_session_, + cal_period_); + case Mode::RATIO: { + if (target_seconds_ <= 0) return ms; // count-only ratio: no grid + // Same grid feed_ratio_mode keys on: exchange-tz ms since + // local-midnight + session-open, floored to the bucket width. + // Floor (not truncate) so the open never lands after `ms` on a + // negative clock; for every real feed the two agree. + const int64_t bucket_ms = target_seconds_ * 1000; + const int64_t clock = intraday_clock_ms(ms, anchor_tz_, anchor_session_); + int64_t open_clock = (clock / bucket_ms) * bucket_ms; + if (clock < 0 && clock % bucket_ms != 0) open_clock -= bucket_ms; + return ms - (clock - open_clock); + } + case Mode::PASSTHROUGH: + return ms; + } + return ms; +} + } // namespace pineforge diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a0b5d0b..62b16e9 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -31,6 +31,7 @@ set(TEST_SOURCES test_historical_security_lookahead_projection test_pooc_global_full_exit test_security_range_start_na_warmup + test_security_range_start_bucket_gating test_chart_ema_na_warmup test_security_tf_validation test_security_lower_tf_input_passthrough @@ -53,6 +54,7 @@ set(TEST_SOURCES test_generic_matrix_bool_dispatch test_map test_session_predicates + test_syminfo_type test_vwap_bands test_get_input_int64 test_get_input_source @@ -66,6 +68,7 @@ set(TEST_SOURCES test_handle_reuse_reset test_calendar_aggregation_wm test_calendar_wm_open_utc_fastpath + test_htf_session_close_completion test_adversarial_ohlcv test_report_trace test_path_resolve_extra @@ -125,6 +128,7 @@ set(TEST_SOURCES test_pooc_position_visibility test_prearmed_exit_path_cursor test_prearmed_market_parent_gap_exit + test_prearmed_bracket_fill_bar test_relative_exit_after_limit_parent test_short_seed_close_collision test_short_seed_collision_percent diff --git a/tests/test_historical_security_lookahead_projection.cpp b/tests/test_historical_security_lookahead_projection.cpp index 91cf2db..c30679e 100644 --- a/tests/test_historical_security_lookahead_projection.cpp +++ b/tests/test_historical_security_lookahead_projection.cpp @@ -245,12 +245,12 @@ void test_range_start_warmup_composes_with_projection() { ProjectionHarness harness; harness.set_syminfo_metadata( "historical_security_lookahead_projection", 1.0); - // Drop the first 15m input bar. The finite projection must aggregate only - // the retained range-start feed: a three-child historical 60m bucket, - // followed by the available two-child tail. The skipped chart child stays - // na because neither the range-start evaluator nor the projection has run. + // Range start on the 60m grid (the 01:00 bucket open): nothing precedes + // it, so the finite projection aggregates the same feed as the plain + // projection — the four-child historical 60m bucket, then the available + // two-child tail — through the shared range-start suffix logic. harness.set_syminfo_metadata( - "security_range_start_na_warmup", 4'500'000.0); + "security_range_start_na_warmup", 3'600'000.0); const auto bars = make_feed(); harness.run(bars.data(), static_cast(bars.size()), "15", "15"); @@ -259,21 +259,19 @@ void test_range_start_warmup_composes_with_projection() { "range-start feed projects once per retained HTF bucket"); if (harness.dispatches.size() == 2) { const Dispatch& historical = harness.dispatches[0]; - CHECK(historical.complete, "trimmed historical bucket is complete"); - CHECK(same(historical.bar.open, 10.0), "trimmed projection open"); - CHECK(same(historical.bar.high, 44.0), "trimmed projection high"); - CHECK(same(historical.bar.low, 6.0), "trimmed projection low"); - CHECK(same(historical.bar.close, 40.0), "trimmed projection close"); - CHECK(same(historical.bar.volume, 9.0), "trimmed projection volume"); + CHECK(historical.complete, "grid-aligned historical bucket is complete"); + CHECK(same(historical.bar.open, 10.0), "grid-aligned projection open"); + CHECK(same(historical.bar.high, 44.0), "grid-aligned projection high"); + CHECK(same(historical.bar.low, 6.0), "grid-aligned projection low"); + CHECK(same(historical.bar.close, 40.0), "grid-aligned projection close"); + CHECK(same(historical.bar.volume, 10.0), "grid-aligned projection volume"); const Dispatch& tail = harness.dispatches[1]; - CHECK(!tail.complete, "trimmed tail remains incomplete"); - CHECK(same(tail.bar.close, 60.0), "trimmed tail available close"); + CHECK(!tail.complete, "grid-aligned tail remains incomplete"); + CHECK(same(tail.bar.close, 60.0), "grid-aligned tail available close"); } - const double expected_chart[] = { - na(), 40.0, 40.0, 40.0, 60.0, 60.0, - }; + const double expected_chart[] = {40.0, 40.0, 40.0, 40.0, 60.0, 60.0}; CHECK(harness.chart_values.size() == 6, "range-start composition preserves every chart child"); for (std::size_t i = 0; @@ -283,6 +281,47 @@ void test_range_start_warmup_composes_with_projection() { } } +void test_range_start_inside_bucket_drops_whole_bucket_from_projection() { + ProjectionHarness harness; + harness.set_syminfo_metadata( + "historical_security_lookahead_projection", 1.0); + // Range start INSIDE the 01:00 bucket (01:15). The KI-55 cut is taken on + // HTF-bucket opens (finding 452): the whole 01:00 bucket opened before the + // range start and is absent, not a three-child partial. The projection is + // built from that same retained suffix — only the two-child 02:00 tail — + // and its child indexes line up with the per-state feed cursor, so the + // four skipped chart children stay na and the tail projects on its first + // retained child. + harness.set_syminfo_metadata( + "security_range_start_na_warmup", 4'500'000.0); + const auto bars = make_feed(); + harness.run(bars.data(), static_cast(bars.size()), "15", "15"); + + CHECK(harness.last_error().empty(), "mid-bucket composition run succeeds"); + CHECK(harness.dispatches.size() == 1, + "mid-bucket range start projects only the retained tail bucket"); + if (harness.dispatches.size() == 1) { + const Dispatch& tail = harness.dispatches[0]; + CHECK(!tail.complete, "retained tail remains incomplete"); + CHECK(same(tail.bar.open, 40.0), "retained tail open is the 02:00 child"); + CHECK(same(tail.bar.high, 66.0), "retained tail high"); + CHECK(same(tail.bar.low, 34.0), "retained tail low"); + CHECK(same(tail.bar.close, 60.0), "retained tail available close"); + CHECK(same(tail.bar.volume, 11.0), "retained tail volume"); + } + + const double expected_chart[] = { + na(), na(), na(), na(), 60.0, 60.0, + }; + CHECK(harness.chart_values.size() == 6, + "mid-bucket composition preserves every chart child"); + for (std::size_t i = 0; + i < harness.chart_values.size() && i < 6; ++i) { + CHECK(same(harness.chart_values[i], expected_chart[i]), + "mid-bucket projected chart sequence"); + } +} + void test_stream_warmup_and_continuation_stay_progressive() { ProjectionHarness harness; harness.set_syminfo_metadata( @@ -337,6 +376,7 @@ int main() { test_heikinashi_ignores_projection_flag(); test_input_tf_below_script_tf_ignores_projection_flag(); test_range_start_warmup_composes_with_projection(); + test_range_start_inside_bucket_drops_whole_bucket_from_projection(); test_stream_warmup_and_continuation_stay_progressive(); if (failures != 0) { std::printf("%d check(s) FAILED\n", failures); diff --git a/tests/test_htf_session_close_completion.cpp b/tests/test_htf_session_close_completion.cpp new file mode 100644 index 0000000..6418b2a --- /dev/null +++ b/tests/test_htf_session_close_completion.cpp @@ -0,0 +1,420 @@ +// request.security HTF buckets on exchange-calendar sessions complete on the +// period's LAST chart bar, the way TradingView finalizes them (findings +// 451/452): +// +// A. an intraday bucket that straddles the RTH close ('240' 13:30-17:30 +// holds 10 of 16 fifteen-minute sub-bars) completes on the 15:45 ET bar, +// not on the next session's 09:30 bar; +// B. an equity week completes on Friday 15:45 ET (Friday + 24h is Saturday, +// still the same week, so the old same-wall-clock-tomorrow test missed +// it) and a month whose last calendar day is a weekend completes on its +// last Friday; +// C. the forex (1700-1700) week / month completes on Friday 16:45 ET, not on +// Sunday 17:00 (the Friday-17:00 instant is the Friday-open session-day — +// Saturday's trading date — so the next-bar crossing never fired). +// +// 24x7 / UTC feeds must stay bit-identical: the count / boundary / projection +// rules are the only ones that run there. +#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) + +#define CHECK_EQ_MS(actual, expected) \ + do { \ + const int64_t _a = (actual), _e = (expected); \ + if (_a != _e) { \ + std::printf(" FAIL %s:%d %s == %s (got %lld, want %lld)\n", \ + __FILE__, __LINE__, #actual, #expected, \ + (long long)_a, (long long)_e); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +namespace { + +// Unix ms of a UTC civil date-time (Howard Hinnant's days_from_civil). +int64_t utc_ms(int y, int m, int d, int h = 0, int mi = 0) { + y -= (m <= 2); + long era = (y >= 0 ? y : y - 399) / 400; + unsigned yoe = (unsigned)(y - era * 400); + unsigned doy = (153u * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; + unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + long days = era * 146097L + (long)doe - 719468L; + return (static_cast(days) * 86400 + h * 3600 + mi * 60) * 1000; +} + +// Every date below is in EDT (UTC-4); the tests never straddle a DST edge. +int64_t edt_ms(int y, int m, int d, int h, int mi) { + return utc_ms(y, m, d, h + 4, mi); +} + +const std::string NY = "America/New_York"; +const std::string RTH = "0930-1600"; +const std::string FX = "1700-1700"; +const int64_t k15m = 15 * 60 * 1000; + +Bar bar_at(int64_t ts) { + Bar b; + b.timestamp = ts; + b.open = 100.0; b.high = 101.0; b.low = 99.0; b.close = 100.0; + b.volume = 1.0; + return b; +} + +struct Completion { + int64_t at; // timestamp of the input bar that finalized the bucket + int64_t bucket_ts; // timestamp of the completed HTF bar (its first sub-bar) + int subs; +}; + +// Feed 15m bars and record every completion. +std::vector drive(TimeframeAggregator& agg, const std::vector& ts) { + std::vector out; + for (int64_t t : ts) { + AggregatedBar r = agg.feed(bar_at(t)); + if (r.is_complete) out.push_back({t, r.bar.timestamp, r.sub_bar_count}); + } + return out; +} + +// RTH 15m grid for one date: 09:30 .. 15:45 ET (26 bars). +void rth_day(std::vector& v, int y, int m, int d) { + for (int i = 0; i < 26; ++i) v.push_back(edt_ms(y, m, d, 9, 30) + i * k15m); +} + +// Forex 15m grid for one session-day: 17:00 ET on (y,m,d) .. 16:45 ET next day. +void fx_session(std::vector& v, int y, int m, int d) { + for (int i = 0; i < 96; ++i) v.push_back(edt_ms(y, m, d, 17, 0) + i * k15m); +} + +bool has_completion_at(const std::vector& c, int64_t at) { + for (const auto& x : c) if (x.at == at) return true; + return false; +} + +const Completion* completion_at(const std::vector& c, int64_t at) { + for (const auto& x : c) if (x.at == at) return &x; + return nullptr; +} + +} // namespace + +// ─── helper ─────────────────────────────────────────────────────────────────── + +static void test_last_traded_close_helper() { + std::printf("test_last_traded_close_helper\n"); + // Equity week containing Wed 2025-06-04 closes Fri 2025-06-06 16:00 ET. + CHECK_EQ_MS(session_period_last_traded_close_ms(edt_ms(2025, 6, 4, 11, 0), NY, RTH, + CalendarPeriod::WEEK), + edt_ms(2025, 6, 6, 16, 0)); + // DAY is the plain session close. + CHECK_EQ_MS(session_period_last_traded_close_ms(edt_ms(2025, 6, 4, 11, 0), NY, RTH, + CalendarPeriod::DAY), + edt_ms(2025, 6, 4, 16, 0)); + // August 2025 ends on a Sunday: last traded session-day is Fri 08-29. + CHECK_EQ_MS(session_period_last_traded_close_ms(edt_ms(2025, 8, 12, 11, 0), NY, RTH, + CalendarPeriod::MONTH), + edt_ms(2025, 8, 29, 16, 0)); + // October 2025 ends on a Friday: nothing to skip. + CHECK_EQ_MS(session_period_last_traded_close_ms(edt_ms(2025, 10, 7, 11, 0), NY, RTH, + CalendarPeriod::MONTH), + edt_ms(2025, 10, 31, 16, 0)); + // Forex week: Sun 17:00 ET .. Fri 17:00 ET (Friday-open session is + // Saturday's trading date and never trades). + CHECK_EQ_MS(session_period_last_traded_close_ms(edt_ms(2025, 6, 4, 3, 0), NY, FX, + CalendarPeriod::WEEK), + edt_ms(2025, 6, 6, 17, 0)); + // Forex daily: the session-day containing Fri 16:45 closes Fri 17:00. + CHECK_EQ_MS(session_period_last_traded_close_ms(edt_ms(2025, 6, 6, 16, 45), NY, FX, + CalendarPeriod::DAY), + edt_ms(2025, 6, 6, 17, 0)); + // May 2025 ends on a Saturday: the forex month closes Fri 05-30 17:00 ET. + CHECK_EQ_MS(session_period_last_traded_close_ms(edt_ms(2025, 5, 14, 3, 0), NY, FX, + CalendarPeriod::MONTH), + edt_ms(2025, 5, 30, 17, 0)); + // 24x7 / UTC: no weekend to skip — identical to session_period_close_ms. + const int64_t t = utc_ms(2025, 6, 4, 11, 0); + CHECK_EQ_MS(session_period_last_traded_close_ms(t, "UTC", "", CalendarPeriod::WEEK), + session_period_close_ms(t, "UTC", "", CalendarPeriod::WEEK)); + CHECK_EQ_MS(session_period_last_traded_close_ms(t, "UTC", "", CalendarPeriod::WEEK), + utc_ms(2025, 6, 9, 0, 0)); + CHECK_EQ_MS(session_period_last_traded_close_ms(t, "UTC", "24x7", CalendarPeriod::MONTH), + utc_ms(2025, 7, 1, 0, 0)); +} + +// ─── A: RTH intraday buckets ────────────────────────────────────────────────── + +static void test_rth_240_completes_at_1545() { + std::printf("test_rth_240_completes_at_1545\n"); + TimeframeAggregator agg("240", "15", NY, RTH); + std::vector ts; + rth_day(ts, 2025, 6, 2); // Monday + rth_day(ts, 2025, 6, 3); // Tuesday + auto c = drive(agg, ts); + // Monday: 09:30-13:30 completes by count at 13:15; 13:30-17:30 completes + // at the session's last bar 15:45 with 10 sub-bars. + const Completion* first = completion_at(c, edt_ms(2025, 6, 2, 13, 15)); + CHECK(first != nullptr); + if (first) { CHECK(first->subs == 16); CHECK_EQ_MS(first->bucket_ts, edt_ms(2025, 6, 2, 9, 30)); } + const Completion* last = completion_at(c, edt_ms(2025, 6, 2, 15, 45)); + CHECK(last != nullptr); + if (last) { CHECK(last->subs == 10); CHECK_EQ_MS(last->bucket_ts, edt_ms(2025, 6, 2, 13, 30)); } + // Nothing completes on Tuesday 09:30 (pre-fix: the lazy boundary + // completion of Monday's 13:30 bucket landed here). + CHECK(!has_completion_at(c, edt_ms(2025, 6, 3, 9, 30))); + // Tuesday behaves like Monday. + CHECK(has_completion_at(c, edt_ms(2025, 6, 3, 13, 15))); + CHECK(has_completion_at(c, edt_ms(2025, 6, 3, 15, 45))); + CHECK(c.size() == 4); +} + +static void test_rth_60_and_45_complete_at_1545() { + std::printf("test_rth_60_and_45_complete_at_1545\n"); + { + TimeframeAggregator agg("60", "15", NY, RTH); + std::vector ts; + rth_day(ts, 2025, 6, 2); + rth_day(ts, 2025, 6, 3); + auto c = drive(agg, ts); + // 09:30-10:30 .. 14:30-15:30 by count (6), 15:30-16:30 at 15:45 (2 subs). + const Completion* last = completion_at(c, edt_ms(2025, 6, 2, 15, 45)); + CHECK(last != nullptr); + if (last) { CHECK(last->subs == 2); CHECK_EQ_MS(last->bucket_ts, edt_ms(2025, 6, 2, 15, 30)); } + CHECK(!has_completion_at(c, edt_ms(2025, 6, 3, 9, 30))); + CHECK(c.size() == 14); + } + { + TimeframeAggregator agg("45", "15", NY, RTH); + std::vector ts; + rth_day(ts, 2025, 6, 2); + rth_day(ts, 2025, 6, 3); + auto c = drive(agg, ts); + // 09:30, 10:15, .., 14:45-15:30 by count (8), 15:30-16:15 at 15:45 (2 subs). + const Completion* last = completion_at(c, edt_ms(2025, 6, 2, 15, 45)); + CHECK(last != nullptr); + if (last) { CHECK(last->subs == 2); CHECK_EQ_MS(last->bucket_ts, edt_ms(2025, 6, 2, 15, 30)); } + CHECK(!has_completion_at(c, edt_ms(2025, 6, 3, 9, 30))); + CHECK(c.size() == 18); + } +} + +// ─── B: equity W / M ────────────────────────────────────────────────────────── + +static void test_rth_week_completes_friday_1545() { + std::printf("test_rth_week_completes_friday_1545\n"); + TimeframeAggregator agg("W", "15", NY, RTH); + std::vector ts; + for (int d = 2; d <= 6; ++d) rth_day(ts, 2025, 6, d); // Mon 06-02 .. Fri 06-06 + rth_day(ts, 2025, 6, 9); // Mon 06-09 + auto c = drive(agg, ts); + const Completion* w = completion_at(c, edt_ms(2025, 6, 6, 15, 45)); + CHECK(w != nullptr); + if (w) { CHECK(w->subs == 130); CHECK_EQ_MS(w->bucket_ts, edt_ms(2025, 6, 2, 9, 30)); } + // Never on an interior session close, never on Monday's first bar. + for (int d = 2; d <= 5; ++d) CHECK(!has_completion_at(c, edt_ms(2025, 6, d, 15, 45))); + CHECK(!has_completion_at(c, edt_ms(2025, 6, 9, 9, 30))); + CHECK(c.size() == 1); + // The new week is a fresh bucket. + CHECK_EQ_MS(agg.current().timestamp, edt_ms(2025, 6, 9, 9, 30)); +} + +static void test_rth_month_ending_on_weekend_completes_last_friday() { + std::printf("test_rth_month_ending_on_weekend_completes_last_friday\n"); + TimeframeAggregator agg("M", "15", NY, RTH); + std::vector ts; + for (int d = 25; d <= 29; ++d) rth_day(ts, 2025, 8, d); // Mon 08-25 .. Fri 08-29 + rth_day(ts, 2025, 9, 2); // Tue 09-02 (Labor Day gap) + auto c = drive(agg, ts); + const Completion* m = completion_at(c, edt_ms(2025, 8, 29, 15, 45)); + CHECK(m != nullptr); + if (m) { CHECK(m->subs == 130); CHECK_EQ_MS(m->bucket_ts, edt_ms(2025, 8, 25, 9, 30)); } + CHECK(!has_completion_at(c, edt_ms(2025, 9, 2, 9, 30))); + CHECK(c.size() == 1); +} + +static void test_rth_month_ending_on_weekday_unchanged() { + std::printf("test_rth_month_ending_on_weekday_unchanged\n"); + // October 2025 ends on a Friday: the pre-existing eager rule already + // completed it at 15:45; the new rule must not double-complete. + TimeframeAggregator agg("M", "15", NY, RTH); + std::vector ts; + rth_day(ts, 2025, 10, 30); + rth_day(ts, 2025, 10, 31); + rth_day(ts, 2025, 11, 3); + auto c = drive(agg, ts); + CHECK(has_completion_at(c, edt_ms(2025, 10, 31, 15, 45))); + CHECK(c.size() == 1); +} + +static void test_rth_daily_rule_unchanged() { + std::printf("test_rth_daily_rule_unchanged\n"); + TimeframeAggregator agg("D", "15", NY, RTH); + std::vector ts; + rth_day(ts, 2025, 6, 5); + rth_day(ts, 2025, 6, 6); + rth_day(ts, 2025, 6, 9); + auto c = drive(agg, ts); + // The pre-existing eager DAY rule completes every session on its 15:45 + // last bar — all three fed days finalize. + CHECK(c.size() == 3); + CHECK(has_completion_at(c, edt_ms(2025, 6, 5, 15, 45))); + CHECK(has_completion_at(c, edt_ms(2025, 6, 6, 15, 45))); + CHECK(has_completion_at(c, edt_ms(2025, 6, 9, 15, 45))); +} + +// ─── C: forex W / M ─────────────────────────────────────────────────────────── + +static void test_fx_week_completes_friday_1645() { + std::printf("test_fx_week_completes_friday_1645\n"); + TimeframeAggregator agg("W", "15", NY, FX); + std::vector ts; + for (int d = 1; d <= 5; ++d) fx_session(ts, 2025, 6, d); // Sun 06-01 17:00 .. Fri 06-06 16:45 + fx_session(ts, 2025, 6, 8); // Sun 06-08 17:00 .. + auto c = drive(agg, ts); + const Completion* w = completion_at(c, edt_ms(2025, 6, 6, 16, 45)); + CHECK(w != nullptr); + if (w) { CHECK(w->subs == 480); CHECK_EQ_MS(w->bucket_ts, edt_ms(2025, 6, 1, 17, 0)); } + // Not on an interior session close (Thu 16:45), not on Sunday 17:00. + CHECK(!has_completion_at(c, edt_ms(2025, 6, 5, 16, 45))); + CHECK(!has_completion_at(c, edt_ms(2025, 6, 8, 17, 0))); + CHECK(c.size() == 1); + CHECK_EQ_MS(agg.current().timestamp, edt_ms(2025, 6, 8, 17, 0)); +} + +static void test_fx_month_ending_on_weekend_completes_friday_1645() { + std::printf("test_fx_month_ending_on_weekend_completes_friday_1645\n"); + // May 2025 ends on Saturday: the last traded session-day opens Thu 05-29 + // 17:00 and closes Fri 05-30 17:00 ET. + TimeframeAggregator agg("M", "15", NY, FX); + std::vector ts; + for (int d = 25; d <= 29; ++d) fx_session(ts, 2025, 5, d); + fx_session(ts, 2025, 6, 1); // Sun 06-01 17:00: June + auto c = drive(agg, ts); + const Completion* m = completion_at(c, edt_ms(2025, 5, 30, 16, 45)); + CHECK(m != nullptr); + if (m) CHECK_EQ_MS(m->bucket_ts, edt_ms(2025, 5, 25, 17, 0)); + CHECK(!has_completion_at(c, edt_ms(2025, 6, 1, 17, 0))); + CHECK(c.size() == 1); +} + +static void test_fx_month_ending_on_weekday_unchanged() { + std::printf("test_fx_month_ending_on_weekday_unchanged\n"); + // April 2025 ends on Wednesday: the session opening Tue 04-29 17:00 is + // the 30th's trading date; the next bar (Wed 17:00) is May. The + // next-bar crossing already completed it at Wed 16:45 — no double. + TimeframeAggregator agg("M", "15", NY, FX); + std::vector ts; + fx_session(ts, 2025, 4, 28); + fx_session(ts, 2025, 4, 29); + fx_session(ts, 2025, 4, 30); + auto c = drive(agg, ts); + CHECK(has_completion_at(c, edt_ms(2025, 4, 30, 16, 45))); + CHECK(c.size() == 1); +} + +static void test_fx_daily_unchanged() { + std::printf("test_fx_daily_unchanged\n"); + TimeframeAggregator agg("D", "15", NY, FX); + std::vector ts; + fx_session(ts, 2025, 6, 4); + fx_session(ts, 2025, 6, 5); + fx_session(ts, 2025, 6, 8); + auto c = drive(agg, ts); + // Every forex session finalizes on its 16:45 last bar (existing DAY rule). + CHECK(c.size() == 3); + CHECK(has_completion_at(c, edt_ms(2025, 6, 5, 16, 45))); + CHECK(has_completion_at(c, edt_ms(2025, 6, 6, 16, 45))); + CHECK(has_completion_at(c, edt_ms(2025, 6, 9, 16, 45))); // Sun-open session closes Mon 16:45 +} + +static void test_fx_240_unchanged() { + std::printf("test_fx_240_unchanged\n"); + // Forex 4h buckets are anchored 17:00 ET and always hold 16 sub-bars; + // the session-close rule fires on the same bar as the count rule. + TimeframeAggregator agg("240", "15", NY, FX); + std::vector ts; + fx_session(ts, 2025, 6, 5); + fx_session(ts, 2025, 6, 8); + auto c = drive(agg, ts); + CHECK(c.size() == 12); + for (const auto& x : c) CHECK(x.subs == 16); + CHECK(has_completion_at(c, edt_ms(2025, 6, 6, 16, 45))); + CHECK(!has_completion_at(c, edt_ms(2025, 6, 8, 17, 0))); +} + +// ─── 24x7 identity ──────────────────────────────────────────────────────────── + +static void test_24x7_identity() { + std::printf("test_24x7_identity\n"); + // UTC 15m grid Fri 2025-06-06 00:00 .. Mon 2025-06-09 04:00, with the + // 23:45 bar of Friday missing so the 20:00-00:00 '240' bucket never + // reaches its count. + std::vector ts; + for (int64_t t = utc_ms(2025, 6, 6); t < utc_ms(2025, 6, 9, 4, 0); t += k15m) { + if (t == utc_ms(2025, 6, 6, 23, 45)) continue; + ts.push_back(t); + } + const std::string forms[3][2] = {{"UTC", ""}, {"UTC", "24x7"}, {"", ""}}; + for (const auto& f : forms) { + TimeframeAggregator w(std::string("W"), std::string("15"), f[0], f[1]); + auto cw = drive(w, ts); + // Projection rule: Sun 23:45 + 15m crosses into Monday. + CHECK(cw.size() == 1); + CHECK(has_completion_at(cw, utc_ms(2025, 6, 8, 23, 45))); + CHECK(!has_completion_at(cw, utc_ms(2025, 6, 6, 23, 30))); + + TimeframeAggregator r(std::string("240"), std::string("15"), f[0], f[1]); + auto cr = drive(r, ts); + // The short bucket completes lazily on the next bucket's first bar. + const Completion* lazy = completion_at(cr, utc_ms(2025, 6, 7, 0, 0)); + CHECK(lazy != nullptr); + if (lazy) { CHECK(lazy->subs == 15); CHECK_EQ_MS(lazy->bucket_ts, utc_ms(2025, 6, 6, 20, 0)); } + CHECK(!has_completion_at(cr, utc_ms(2025, 6, 6, 23, 30))); + } + // tz-less constructor: same completions as the UTC forms. + TimeframeAggregator w0("W", "15"); + auto c0 = drive(w0, ts); + CHECK(c0.size() == 1 && has_completion_at(c0, utc_ms(2025, 6, 8, 23, 45))); + TimeframeAggregator r0("240", "15"); + auto cr0 = drive(r0, ts); + CHECK(has_completion_at(cr0, utc_ms(2025, 6, 7, 0, 0))); + CHECK(!has_completion_at(cr0, utc_ms(2025, 6, 6, 23, 30))); +} + +int main() { + test_last_traded_close_helper(); + test_rth_240_completes_at_1545(); + test_rth_60_and_45_complete_at_1545(); + test_rth_week_completes_friday_1545(); + test_rth_month_ending_on_weekend_completes_last_friday(); + test_rth_month_ending_on_weekday_unchanged(); + test_rth_daily_rule_unchanged(); + test_fx_week_completes_friday_1645(); + test_fx_month_ending_on_weekend_completes_friday_1645(); + test_fx_month_ending_on_weekday_unchanged(); + test_fx_daily_unchanged(); + test_fx_240_unchanged(); + test_24x7_identity(); + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +} diff --git a/tests/test_path_resolve_extra.cpp b/tests/test_path_resolve_extra.cpp index 7d28a46..fe7a97d 100644 --- a/tests/test_path_resolve_extra.cpp +++ b/tests/test_path_resolve_extra.cpp @@ -475,6 +475,57 @@ static void test_zero_offset_trail_never_retro_arms() { // 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. +// A FRACTIONAL trail_offset (ticks) is truncated to whole ticks — the +// level trails floor(offset) ticks behind the running extreme. TV evidence: +// nils123456-orb-strat (ETHUSDT.P, trail_offset = price / mintick, +// slippage 0) 11/11 and legalrice2697 (OANDA:EURUSD, atr * 4 / mintick, +// slippage 2) 58/62 non-gap trailing exits sat exactly one tick nearer the +// extreme than the previous ceil() level, for fractional parts on both +// sides of .5 (so it is not round-to-nearest either). +static void test_fractional_trail_offset_truncates() { + std::printf("test_fractional_trail_offset_truncates\n"); + // Same bar / activation as the LONG case above (peak 102 on the L->H + // leg); trail_offset = 50.7 ticks -> floor -> 0.50 -> fill @ 101.50, + // not 102 - 0.51 = 101.49 (ceil) and not round-to-nearest (51 -> 101.49). + Bar trail_long = mk(100.5, 102, 100, 100.2); + ExitPathFill fl_hi = resolve_exit_path_fill( + trail_long, PositionSide::LONG, kNaN, kNaN, + /*trail_points=*/100, /*trail_price=*/kNaN, /*trail_offset=*/50.7, /*entry=*/100, + /*best_start=*/kNaN, false, false, kMintick); + CHECK(fl_hi.should_fill == true); + CHECK(near(fl_hi.fill_price, 101.50)); + // Fractional part below .5 truncates the same way (50.2 -> 50 ticks). + ExitPathFill fl_lo = resolve_exit_path_fill( + trail_long, PositionSide::LONG, kNaN, kNaN, + /*trail_points=*/100, /*trail_price=*/kNaN, /*trail_offset=*/50.2, /*entry=*/100, + /*best_start=*/kNaN, false, false, kMintick); + CHECK(fl_lo.should_fill == true); + CHECK(near(fl_lo.fill_price, 101.50)); + // SHORT mirror: best 98 + floor(50.7) ticks = 98.50. + 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_price=*/kNaN, /*trail_offset=*/50.7, /*entry=*/100, + /*best_start=*/kNaN, false, false, kMintick); + CHECK(fs.should_fill == true); + CHECK(near(fs.fill_price, 98.50)); + // Whole-tick offsets are unchanged (50 -> 0.50). + ExitPathFill fl_int = resolve_exit_path_fill( + trail_long, PositionSide::LONG, kNaN, kNaN, + /*trail_points=*/100, /*trail_price=*/kNaN, /*trail_offset=*/50.0, /*entry=*/100, + /*best_start=*/kNaN, false, false, kMintick); + CHECK(near(fl_int.fill_price, 101.50)); + // Sub-tick offsets (0 < offset < 1) truncate to zero ticks: the level + // rides the extreme itself once armed (distinct from an explicit 0, + // which is the exit-at-activation shape). peak 102 -> fill @ 102.00. + ExitPathFill fl_sub = resolve_exit_path_fill( + trail_long, PositionSide::LONG, kNaN, kNaN, + /*trail_points=*/100, /*trail_price=*/kNaN, /*trail_offset=*/0.6, /*entry=*/100, + /*best_start=*/kNaN, false, false, kMintick); + CHECK(fl_sub.should_fill == true); + CHECK(near(fl_sub.fill_price, 102.00)); +} + 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 @@ -543,6 +594,7 @@ int main() { test_path_cross_kind_priority_order(); test_resolve_exit_trail_fills(); test_zero_offset_trail_never_retro_arms(); + test_fractional_trail_offset_truncates(); 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_prearmed_bracket_fill_bar.cpp b/tests/test_prearmed_bracket_fill_bar.cpp new file mode 100644 index 0000000..a078649 --- /dev/null +++ b/tests/test_prearmed_bracket_fill_bar.cpp @@ -0,0 +1,433 @@ +/* + * Prearmed strategy.exit brackets resolve on their parent's FILL bar. + * + * Three tape-pinned shapes, all order-lifecycle semantics: + * + * (1a) DUAL-MARKETABLE bracket. bprakaash-new-era-strategy-1-0 + * (OANDA:EURUSD 15m, 2025-07-03 / 07-24 / 08-07 / 09-09 13:30Z): + * strategy.entry("Short", qty=1) + strategy.exit("TP/SL 1", "Short", + * qty=1, stop=sl, limit=target) armed on the signal bar with sl BELOW + * the close (so target lands above it). At the fill open both legs are + * marketable (stop 1.17528 < open 1.17646 < limit 1.17879; on 08-07 + * stop == limit == open). TV fills the entry at the open and one leg + * at the same open: exit px == entry px, duration 0, PnL 0. Before + * this pin the engine held dual-marketable brackets off the open + * scratch ("no tape exemplar") and gap-filled them the next bar. + * + * (1b) TRAIL-carrying leg. stevenygabbyperez-fast-scalper-with-stops + * (NASDAQ:AAPL 15m, 2025-04-03 / 2026-04-27 13:30Z): + * strategy.exit(stop=close*0.99, trail_points=...) armed with a MARKET + * entry; the RTH open gaps below the stop. TV: entry + 'Exit Long' at + * the open (205.54 / 266.09), PnL 0. The trail leg is dormant until + * activation and does not change the breached stop's fill. + * + * (2) RELATIVE-TICKS bracket of a parent that fills INTRABAR. + * quantbyboji-nq-hma-midday-strategy (OANDA:EURUSD 15m, 2025-08-22 + * 18:15Z): resting limit 1.17323 fills mid-path (open 1.17356), the + * loss leg binds to the fill price and resolves on the remaining path + * of the same bar (exit 1.17322). 140/141 sibling exits whose parent + * filled at the open already matched; only the mid-path fill deferred + * the child to the next bar's open. + */ + +#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-9) { + return std::fabs(a - b) <= tol; +} + +static constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +static Bar bar(int64_t ts, double o, double h, double l, double c) { + return {o, h, l, c, 1'000.0, ts}; +} + +// ── (1a) dual-marketable bracket ─────────────────────────────────────── + +enum class DualCell { + ShortBothInside, // stop below the open, limit above it (07-03 shape) + ShortBothEqualOpen, // stop == limit == open (08-07 shape) + LongBothInside, // mirror + ShortStopOnlyGap, // control: single-leg gap keeps its existing path +}; + +class DualMarketableBracket final : public BacktestEngine { +public: + DualMarketableBracket(DualCell cell, bool reversal) + : cell_(cell), reversal_(reversal) { + initial_capital_ = 100'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + slippage_ = 0; + pyramiding_ = 1; + } + + bool opens_long() const { return cell_ == DualCell::LongBothInside; } + double live_qty() const { return position_qty_; } + bool is_flat() const { return position_side_ == PositionSide::FLAT; } + + void on_bar(const Bar&) override { + const int arm_bar = reversal_ ? 1 : 0; + if (reversal_ && bar_index_ == 0) { + strategy_entry("OLD", !opens_long(), kNaN, kNaN, 1.0, "seed"); + return; + } + if (bar_index_ != arm_bar) return; + double stop_px; + double limit_px; + switch (cell_) { + case DualCell::ShortBothInside: + stop_px = 95.0; // short buy-stop below the 100 open + limit_px = 110.0; // short buy-limit above the 100 open + break; + case DualCell::ShortBothEqualOpen: + stop_px = 100.0; + limit_px = 100.0; + break; + case DualCell::LongBothInside: + stop_px = 105.0; // long sell-stop above the 100 open + limit_px = 90.0; // long sell-limit below the 100 open + break; + case DualCell::ShortStopOnlyGap: + stop_px = 95.0; + limit_px = 80.0; // not marketable at the open + break; + } + // bprakaash shape: explicit qty on both the entry and the exit. + strategy_entry(opens_long() ? "Long" : "Short", opens_long(), + kNaN, kNaN, 1.0, "signal"); + strategy_exit("TP/SL 1", opens_long() ? "Long" : "Short", + limit_px, stop_px, + kNaN, kNaN, kNaN, /*qty_percent=*/100.0, "bracket", + /*qty=*/1.0); + } + +private: + DualCell cell_; + bool reversal_; +}; + +static void check_dual_marketable_scratches_at_open(DualCell cell, + bool reversal) { + DualMarketableBracket probe(cell, reversal); + std::vector bars = { + bar(1'000, 100.0, 101.0, 99.0, 100.0), + bar(2'000, 100.0, 101.0, 99.0, 100.0), + bar(3'000, 100.0, 101.0, 99.0, 100.0), + bar(4'000, 100.0, 101.0, 99.0, 100.0), + }; + probe.run(bars.data(), static_cast(bars.size())); + + CHECK(probe.last_error().empty()); + const int fill_bar = reversal ? 2 : 1; + const int expected_trades = reversal ? 2 : 1; + CHECK(probe.trade_count() == expected_trades); + if (probe.trade_count() != expected_trades) return; + const Trade& t = probe.get_trade(expected_trades - 1); + CHECK(t.is_long == probe.opens_long()); + CHECK(t.entry_bar_index == fill_bar); + CHECK(t.exit_bar_index == fill_bar); + CHECK(near(t.entry_price, 100.0)); + CHECK(near(t.exit_price, 100.0)); + CHECK(near(t.qty, 1.0)); + CHECK(near(t.pnl, 0.0)); + CHECK(t.exit_id == "TP/SL 1"); + CHECK(probe.is_flat()); + CHECK(near(probe.live_qty(), 0.0)); +} + +// Control: a correctly-sided explicit-qty bracket keeps its ordinary path +// (the 265 bprakaash trades that already matched). +static void check_explicit_qty_bracket_no_gap_control() { + DualMarketableBracket probe(DualCell::ShortStopOnlyGap, false); + std::vector bars = { + bar(1'000, 100.0, 101.0, 99.0, 100.0), + bar(2'000, 92.0, 93.0, 91.0, 92.0), // opens below stop 95: no gap + bar(3'000, 92.0, 93.0, 91.0, 92.0), + bar(4'000, 92.0, 97.0, 91.0, 92.0), // stop 95 crossed + }; + probe.run(bars.data(), static_cast(bars.size())); + + CHECK(probe.last_error().empty()); + CHECK(probe.trade_count() == 1); + if (probe.trade_count() != 1) return; + const Trade& t = probe.get_trade(0); + CHECK(t.entry_bar_index == 1); + CHECK(t.exit_bar_index == 3); + CHECK(near(t.entry_price, 92.0)); + CHECK(near(t.exit_price, 95.0)); +} + +// ── (1b) trail-carrying leg ──────────────────────────────────────────── + +class TrailBracket final : public BacktestEngine { +public: + TrailBracket(bool opens_long, bool reversal, bool percent_sizing) + : opens_long_(opens_long), reversal_(reversal) { + initial_capital_ = 100'000.0; + if (percent_sizing) { + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + } else { + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + } + commission_value_ = 0.0; + slippage_ = 0; + pyramiding_ = 1; + } + + double live_qty() const { return position_qty_; } + bool is_flat() const { return position_side_ == PositionSide::FLAT; } + + void on_bar(const Bar& b) override { + const int arm_bar = reversal_ ? 1 : 0; + if (reversal_ && bar_index_ == 0) { + strategy_entry("OLD", !opens_long_, kNaN, kNaN, kNaN, "seed"); + return; + } + if (bar_index_ != arm_bar) return; + // stevenygabbyperez shape: stop from the signal close plus a + // trail_points activation, default (percent) sizing. + strategy_entry(opens_long_ ? "Long" : "Short", opens_long_, + kNaN, kNaN, kNaN, "signal"); + strategy_exit(opens_long_ ? "Exit Long" : "Exit Short", + opens_long_ ? "Long" : "Short", + /*limit=*/kNaN, + /*stop=*/opens_long_ ? b.close * 0.99 : b.close * 1.01, + /*trail_points=*/b.close * 0.02 / syminfo_mintick_, + kNaN, kNaN, 100.0, "bracket"); + } + +private: + bool opens_long_; + bool reversal_; +}; + +static void check_trail_stop_gap(bool opens_long, bool reversal, + bool percent_sizing) { + TrailBracket probe(opens_long, reversal, percent_sizing); + std::vector bars = { + bar(1'000, 224.0, 224.5, 223.5, 224.0), + bar(2'000, 224.0, 224.5, 223.5, 224.0), + bar(3'000, 224.0, 224.5, 223.5, 224.0), + bar(4'000, 224.0, 224.5, 223.5, 224.0), + }; + // -8% gap through the 0.99*close stop (long) / +8% through the + // 1.01*close stop (short). + const int fill_bar = reversal ? 2 : 1; + const double open = opens_long ? 205.54 : 242.0; + bars[fill_bar] = bar(bars[fill_bar].timestamp, open, + open + 2.0, open - 3.0, open - 2.6); + probe.run(bars.data(), static_cast(bars.size())); + + CHECK(probe.last_error().empty()); + // The 100%-equity seed of the percent cell also books a same-bar + // margin-call slice against its own adverse tick; the scratch under + // test is always the LAST trade. + const int expected_trades = reversal ? 2 : 1; + CHECK(probe.trade_count() >= expected_trades); + if (probe.trade_count() < expected_trades) return; + const Trade& t = probe.get_trade(probe.trade_count() - 1); + CHECK(t.is_long == opens_long); + CHECK(t.entry_bar_index == fill_bar); + CHECK(t.exit_bar_index == fill_bar); + CHECK(near(t.entry_price, open)); + CHECK(near(t.exit_price, open)); + CHECK(near(t.pnl, 0.0)); + CHECK(t.exit_id == (opens_long ? "Exit Long" : "Exit Short")); + CHECK(probe.is_flat()); + CHECK(near(probe.live_qty(), 0.0)); +} + +// Control: no gap through the stop — the stop leg walks the entry-bar path +// and fills at its level (the 11 stevenygabbyperez same-bar stops that +// already matched), the trail never activates. +static void check_trail_stop_intrabar_control() { + TrailBracket probe(true, false, false); + std::vector bars = { + bar(1'000, 224.0, 224.5, 223.5, 224.0), + // stop = 221.76; open above it, low below it. + bar(2'000, 224.0, 224.5, 220.0, 221.0), + bar(3'000, 221.0, 222.0, 220.0, 221.0), + }; + probe.run(bars.data(), static_cast(bars.size())); + + CHECK(probe.last_error().empty()); + CHECK(probe.trade_count() == 1); + if (probe.trade_count() != 1) return; + const Trade& t = probe.get_trade(0); + CHECK(t.entry_bar_index == 1); + CHECK(t.exit_bar_index == 1); + CHECK(near(t.entry_price, 224.0)); + CHECK(near(t.exit_price, 221.76, 1e-6)); +} + +// ── (2) relative-ticks bracket, parent fills intrabar ───────────────── + +class IntrabarLimitParentTicks final : public BacktestEngine { +public: + explicit IntrabarLimitParentTicks(double loss_ticks, double profit_ticks, + bool reissue_every_bar) + : loss_ticks_(loss_ticks), profit_ticks_(profit_ticks), + reissue_every_bar_(reissue_every_bar) { + initial_capital_ = 100'000.0; + default_qty_type_ = QtyType::FIXED; + default_qty_value_ = 1.0; + commission_value_ = 0.0; + slippage_ = 0; + pyramiding_ = 1; + set_syminfo_mintick(0.00001); + } + + double live_qty() const { return position_qty_; } + bool is_flat() const { return position_side_ == PositionSide::FLAT; } + + void on_bar(const Bar&) override { + if (bar_index_ == 0) { + strategy_entry("long", true, /*limit=*/1.17323, kNaN, 1.0, + "resting limit"); + } + // quantbyboji shape: the ticks bracket is (re-)issued at global + // scope on EVERY bar while the limit parent rests, so its + // created_bar trails the parent's by the time the parent fills. + if (bar_index_ == 0 || reissue_every_bar_) { + strategy_exit("long", "long", kNaN, kNaN, kNaN, kNaN, kNaN, + 100.0, "exit long", /*qty=*/1.0, "", + profit_ticks_, loss_ticks_); + } + } + +private: + double loss_ticks_; + double profit_ticks_; + bool reissue_every_bar_; +}; + +// The tape bracket: ta.atr(...)*mult/0.25 gave 0.00984 ticks for BOTH legs +// (a sub-tick offset). TV books the loss leg at 1.17322 — the level +// 1.17323 - 0.0000000984 lands on the tick below the fill — and the profit +// leg above the fill is never reached on the remaining path. +static constexpr double kTapeTicks = 0.00984241; + +static std::vector intrabar_parent_bars(double fill_bar_open, + double fill_bar_low) { + return { + bar(1'000, 1.17367, 1.17395, 1.17348, 1.17356), // signal bar + bar(2'000, 1.17356, 1.17380, 1.17340, 1.17370), // parent rests + bar(3'000, 1.17370, 1.17390, 1.17345, 1.17360), // parent rests + bar(4'000, 1.17360, 1.17372, 1.17348, 1.17356), // parent rests + // The tape bar (2025-08-22 18:15Z): open above the 1.17323 limit, + // the path reaches the low so the limit fills mid-path and the + // loss leg is crossed on the remaining path of the SAME bar. + bar(5'000, fill_bar_open, 1.17364, fill_bar_low, 1.17305), + bar(6'000, 1.17307, 1.17326, 1.17249, 1.17260), + }; +} + +static void check_intrabar_limit_parent_ticks(bool reissue_every_bar) { + IntrabarLimitParentTicks probe(kTapeTicks, kTapeTicks, reissue_every_bar); + std::vector bars = intrabar_parent_bars(1.17356, 1.17288); + probe.run(bars.data(), static_cast(bars.size())); + + CHECK(probe.last_error().empty()); + CHECK(probe.trade_count() == 1); + if (probe.trade_count() != 1) return; + const Trade& t = probe.get_trade(0); + CHECK(t.is_long); + CHECK(t.entry_bar_index == 4); + CHECK(t.exit_bar_index == 4); + CHECK(near(t.entry_price, 1.17323, 1e-9)); + CHECK(near(t.exit_price, 1.17322, 1e-9)); + CHECK(near(t.qty, 1.0)); + CHECK(near(t.pnl, -0.00001, 1e-9)); + CHECK(t.exit_id == "long"); + CHECK(probe.is_flat()); +} + +// Control: parent fills AT the open (open <= limit) — the already-matching +// 140-trade population — the bracket walks the whole bar. +static void check_open_fill_limit_parent_ticks_control() { + IntrabarLimitParentTicks probe(kTapeTicks, kTapeTicks, true); + std::vector bars = intrabar_parent_bars(1.17320, 1.17288); + probe.run(bars.data(), static_cast(bars.size())); + + CHECK(probe.last_error().empty()); + CHECK(probe.trade_count() == 1); + if (probe.trade_count() != 1) return; + const Trade& t = probe.get_trade(0); + CHECK(t.entry_bar_index == 4); + CHECK(t.exit_bar_index == 4); + CHECK(near(t.entry_price, 1.17320, 1e-9)); + CHECK(near(t.exit_price, 1.17319, 1e-9)); +} + +// Control: neither leg is reached on the remaining path (the limit fills +// at the bar's low and the bar closes there) — the bracket rests into the +// next bar and gap-fills at its open. +static void check_intrabar_limit_parent_ticks_unreached_control() { + IntrabarLimitParentTicks probe(kTapeTicks, kTapeTicks, true); + std::vector bars = intrabar_parent_bars(1.17356, 1.17323); + bars[4].close = 1.17323; + probe.run(bars.data(), static_cast(bars.size())); + + CHECK(probe.last_error().empty()); + CHECK(probe.trade_count() == 1); + if (probe.trade_count() != 1) return; + const Trade& t = probe.get_trade(0); + CHECK(t.entry_bar_index == 4); + CHECK(t.exit_bar_index == 5); + CHECK(near(t.entry_price, 1.17323, 1e-9)); + // Next bar opens at 1.17307, below the 1.17322 stop: gap fill. + CHECK(near(t.exit_price, 1.17307, 1e-9)); +} + +int main() { + std::printf("prearmed bracket legs resolve on the parent's fill bar\n"); + + // (1a) dual-marketable bracket (bprakaash) + check_dual_marketable_scratches_at_open(DualCell::ShortBothInside, false); + check_dual_marketable_scratches_at_open(DualCell::ShortBothEqualOpen, false); + check_dual_marketable_scratches_at_open(DualCell::LongBothInside, false); + check_dual_marketable_scratches_at_open(DualCell::ShortBothInside, true); + check_dual_marketable_scratches_at_open(DualCell::LongBothInside, true); + check_explicit_qty_bracket_no_gap_control(); + + // (1b) trail-carrying leg (stevenygabbyperez) + check_trail_stop_gap(true, false, false); + check_trail_stop_gap(false, false, false); + check_trail_stop_gap(true, true, false); + check_trail_stop_gap(true, true, true); + check_trail_stop_intrabar_control(); + + // (2) relative-ticks bracket of an intrabar limit parent (quantbyboji) + check_intrabar_limit_parent_ticks(/*reissue_every_bar=*/true); + check_intrabar_limit_parent_ticks(/*reissue_every_bar=*/false); + check_open_fill_limit_parent_ticks_control(); + check_intrabar_limit_parent_ticks_unreached_control(); + + std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +} diff --git a/tests/test_prearmed_market_parent_gap_exit.cpp b/tests/test_prearmed_market_parent_gap_exit.cpp index f2ef724..dac73e2 100644 --- a/tests/test_prearmed_market_parent_gap_exit.cpp +++ b/tests/test_prearmed_market_parent_gap_exit.cpp @@ -222,7 +222,7 @@ enum class LimitCell { ReversalLongLimitEq, // rhyme17 2025-04-07 shape: limit == open exactly ReversalLongLimitPostOpen, // correctly-sided limit, fills later at level ReversalShortLimitPostOpen, // correctly-sided limit, fills later at level - ReversalDualMarketable, // stop AND limit marketable: no open scratch + ReversalDualMarketable, // stop AND limit marketable: open scratch }; class PrearmedLimitBracketProbe final : public BacktestEngine { @@ -365,11 +365,13 @@ static void check_reversal_limit(LimitCell cell, bool new_is_long, } // Dual-marketable bracket (stop gapped AND limit marketable at the open): -// stays OFF the open-scratch path — no duration-0 trade on the entry bar. -// The wrong-side stop is skipped on the entry bar and the order fires via -// the ordinary resting-order gap on the NEXT bar's open (pre-existing -// behavior, unchanged by the limit-leg extension). -static void check_reversal_dual_marketable_holds_entry_bar() { +// scratches at the open like the single-leg cells. Pinned by +// bprakaash-new-era-strategy-1-0 (OANDA:EURUSD 15m, 2025-07-03 / 07-24 / +// 08-07 / 09-09 13:30Z): TV books entry and exit at the same open, +// duration 0, PnL 0. (Before that exemplar the cell held the entry bar and +// fired on the NEXT bar's open; see test_prearmed_bracket_fill_bar.cpp for +// the tape-shaped cells.) +static void check_reversal_dual_marketable_scratches_at_open() { PrearmedLimitBracketProbe probe(LimitCell::ReversalDualMarketable); std::vector bars = { bar(1'000, 100.0, 101.0, 99.0, 100.0), @@ -384,9 +386,10 @@ static void check_reversal_dual_marketable_holds_entry_bar() { if (probe.trade_count() != 2) return; const Trade& fresh = probe.get_trade(1); CHECK(fresh.entry_bar_index == 2); - CHECK(fresh.exit_bar_index == 3); // NOT the entry bar + CHECK(fresh.exit_bar_index == 2); // the entry bar CHECK(near(fresh.entry_price, 100.0)); CHECK(near(fresh.exit_price, 100.0)); + CHECK(near(fresh.pnl, 0.0)); } // Ordinary non-reversal exit re-issue control: the position has been open @@ -529,7 +532,7 @@ int main() { check_reversal_limit(LimitCell::ReversalLongLimitEq, true, false); check_reversal_limit(LimitCell::ReversalLongLimitPostOpen, true, true); check_reversal_limit(LimitCell::ReversalShortLimitPostOpen, false, true); - check_reversal_dual_marketable_holds_entry_bar(); + check_reversal_dual_marketable_scratches_at_open(); check_ongoing_position_reissue_keeps_entry(); check_partial_limit_does_not_scratch_parent_open(); diff --git a/tests/test_security_range_start_bucket_gating.cpp b/tests/test_security_range_start_bucket_gating.cpp new file mode 100644 index 0000000..0114e00 --- /dev/null +++ b/tests/test_security_range_start_bucket_gating.cpp @@ -0,0 +1,353 @@ +// test_security_range_start_bucket_gating — pins the KI-55 range-start cut on +// HTF-BUCKET opens (finding 452, rank 2). +// +// TradingView's deep-backtest request.security series are built from the HTF +// bars whose OPEN lies inside the loaded chart range. A bucket that opened +// before the range start is absent — it is not a partial first bar. The engine +// flag ``security_range_start_na_warmup`` therefore drops, per evaluator, every +// input bar whose D/W/M (or intraday-grid) bucket opened before the range +// start, so the first HTF bar every series sees is a whole bucket that opened +// at/after the range start. On OANDA:EURUSD (America/New_York, 1700-1700) with +// the lab's pad epoch 2025-03-31 00:00 UTC that means: +// D first bar = the session opening Mon 2025-03-31 17:00 EDT (trading date +// Apr 1), not the remainder of the Sunday session; +// W first bar = the week opening Sun 2025-04-06 17:00 EDT (the week that +// opened Sun Mar 30 17:00 EDT straddles the range start and is dropped); +// M first bar = April (opens Mon Mar 31 17:00 EDT); the March remainder is +// dropped. +// heneralmomo25-selda-97ma's weekly EMA26 re-simulation reproduces TV 29/29 +// only under exactly that weekly series (SMA-seeded from the Apr-6 week). +// +// With a range start on the bucket grid — 24x7 UTC midnight for intraday TFs +// and D, Monday for W, the 1st for M — the cut is the plain timestamp cut, so +// the existing corpus pins (test_security_range_start_na_warmup) hold. +// +// It FAILS without the fix: the timestamp cut keeps the straddling remainder +// as HTF bar 1, so every "first completed bucket" assertion below reports the +// pre-range bucket's open instead. + +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace pineforge; + +static int failures = 0; + +#define CHECK(cond, tag) do { \ + if (!(cond)) { \ + std::printf("FAIL: %s (line %d)\n", (tag), __LINE__); \ + ++failures; \ + } \ +} while (0) + +#define CHECK_EQ_MS(actual, expected, tag) do { \ + const int64_t _a = (actual), _e = (expected); \ + if (_a != _e) { \ + std::printf("FAIL: %s (line %d): got %lld want %lld\n", (tag), __LINE__, \ + (long long)_a, (long long)_e); \ + ++failures; \ + } \ +} while (0) + +// Unix ms of a UTC civil date-time (Howard Hinnant's days_from_civil). +static int64_t utc_ms(int y, int m, int d, int h = 0, int mi = 0) { + y -= (m <= 2); + long era = (y >= 0 ? y : y - 399) / 400; + unsigned yoe = (unsigned)(y - era * 400); + unsigned doy = (153u * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; + unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + long days = era * 146097L + (long)doe - 719468L; + return (static_cast(days) * 86400 + h * 3600 + mi * 60) * 1000; +} + +static const std::string NY = "America/New_York"; +static const std::string FX = "1700-1700"; + +// ─── TimeframeAggregator::bucket_open_ms ───────────────────────────────────── + +static void test_bucket_open_utc_grid() { + TimeframeAggregator pass; + CHECK_EQ_MS(pass.bucket_open_ms(123456789), 123456789, + "passthrough returns the input timestamp"); + + TimeframeAggregator hour("60", "15"); + CHECK_EQ_MS(hour.bucket_open_ms(4'500'000), 3'600'000, + "60m grid: 01:15 belongs to the 01:00 bucket"); + CHECK_EQ_MS(hour.bucket_open_ms(3'600'000), 3'600'000, + "60m grid: 01:00 opens its own bucket"); + CHECK_EQ_MS(hour.bucket_open_ms(7'199'999), 3'600'000, + "60m grid: 01:59:59.999 still the 01:00 bucket"); + + TimeframeAggregator day("D", "15"); + CHECK_EQ_MS(day.bucket_open_ms(utc_ms(2025, 3, 31, 0, 15)), utc_ms(2025, 3, 31), + "UTC day opens at midnight"); + TimeframeAggregator week("W", "15"); + // Wed 2025-04-02 -> Monday 2025-03-31 (24x7 Monday-start week). + CHECK_EQ_MS(week.bucket_open_ms(utc_ms(2025, 4, 2, 5, 0)), utc_ms(2025, 3, 31), + "UTC week opens Monday 00:00"); + CHECK_EQ_MS(week.bucket_open_ms(utc_ms(2025, 3, 31)), utc_ms(2025, 3, 31), + "UTC Monday 00:00 opens its week"); + TimeframeAggregator month("M", "15"); + CHECK_EQ_MS(month.bucket_open_ms(utc_ms(2025, 3, 31, 23, 45)), utc_ms(2025, 3, 1), + "UTC month opens on the 1st"); + CHECK_EQ_MS(month.bucket_open_ms(utc_ms(2025, 4, 1)), utc_ms(2025, 4, 1), + "UTC 1st 00:00 opens its month"); + std::printf("test_bucket_open_utc_grid: %s\n", failures ? "FAIL" : "ok"); +} + +static void test_bucket_open_forex_session() { + int before = failures; + // Sun 2025-03-30 20:15 EDT (= 2025-03-31 00:15 UTC): every period that + // contains it opened at Sun 17:00 EDT = 2025-03-30 21:00 UTC. + const int64_t sun_2015 = utc_ms(2025, 3, 31, 0, 15); + const int64_t sun_open = utc_ms(2025, 3, 30, 21, 0); + + TimeframeAggregator h4("240", "15", NY, FX); + CHECK_EQ_MS(h4.bucket_open_ms(sun_2015), sun_open, + "240 grid anchored at the 17:00 session open"); + CHECK_EQ_MS(h4.bucket_open_ms(utc_ms(2025, 3, 31, 1, 0)), utc_ms(2025, 3, 31, 1, 0), + "240 grid: 21:00 EDT opens the next bucket"); + TimeframeAggregator h1("60", "15", NY, FX); + CHECK_EQ_MS(h1.bucket_open_ms(sun_2015), utc_ms(2025, 3, 31, 0, 0), + "60 grid: 20:15 EDT belongs to the 20:00 EDT bucket"); + + TimeframeAggregator day("D", "15", NY, FX); + CHECK_EQ_MS(day.bucket_open_ms(sun_2015), sun_open, "forex day opens Sun 17:00 EDT"); + CHECK_EQ_MS(day.bucket_open_ms(utc_ms(2025, 3, 31, 20, 45)), sun_open, + "Mon 16:45 EDT is still the Sunday session"); + CHECK_EQ_MS(day.bucket_open_ms(utc_ms(2025, 3, 31, 21, 0)), utc_ms(2025, 3, 31, 21, 0), + "Mon 17:00 EDT opens the next session"); + + TimeframeAggregator week("W", "15", NY, FX); + CHECK_EQ_MS(week.bucket_open_ms(sun_2015), sun_open, "forex week opens Sun 17:00 EDT"); + CHECK_EQ_MS(week.bucket_open_ms(utc_ms(2025, 4, 4, 20, 45)), sun_open, + "Fri 16:45 EDT closes the week that opened Sun Mar 30"); + CHECK_EQ_MS(week.bucket_open_ms(utc_ms(2025, 4, 6, 21, 0)), utc_ms(2025, 4, 6, 21, 0), + "Sun Apr 6 17:00 EDT opens the next week"); + + TimeframeAggregator month("M", "15", NY, FX); + // March = sessions whose trading date is in March: opens on the + // session-day of trading date Mar 1, i.e. Fri Feb 28 17:00 EST (UTC-5). + CHECK_EQ_MS(month.bucket_open_ms(sun_2015), utc_ms(2025, 2, 28, 22, 0), + "forex March opened Fri Feb 28 17:00 EST"); + CHECK_EQ_MS(month.bucket_open_ms(utc_ms(2025, 3, 31, 20, 45)), utc_ms(2025, 2, 28, 22, 0), + "Mon Mar 31 16:45 EDT is still March"); + CHECK_EQ_MS(month.bucket_open_ms(utc_ms(2025, 3, 31, 21, 0)), utc_ms(2025, 3, 31, 21, 0), + "Mon Mar 31 17:00 EDT (trading date Apr 1) opens April"); + std::printf("test_bucket_open_forex_session: %s\n", + (failures > before) ? "FAIL" : "ok"); +} + +// ─── End-to-end: first completed HTF bucket under the flag ─────────────────── + +// Three lookahead_off evaluators on one input feed; records the OPEN +// timestamp (= aggregated bar timestamp) of every completed HTF bar per id. +class BucketGateHarness : public BacktestEngine { +public: + std::vector completed[3]; + std::vector completed_close[3]; + + explicit BucketGateHarness(const char* input_tf, + const char* tf0, const char* tf1, const char* tf2) { + register_security_eval(0, tf0, input_tf, false, false); + register_security_eval(1, tf1, input_tf, false, false); + register_security_eval(2, tf2, input_tf, false, false); + } + void evaluate_security(int sec_id, const Bar& bar, bool is_complete) override { + if (!is_complete || sec_id < 0 || sec_id > 2) return; + completed[sec_id].push_back(bar.timestamp); + completed_close[sec_id].push_back(bar.close); + } + void on_bar(const Bar&) override {} +}; + +// 15m OANDA:EURUSD-shaped feed: Sun 17:00 EDT .. Fri 17:00 EDT, every week +// from Sun 2025-03-30 through Fri 2025-05-09 (EDT throughout: no DST edge). +static std::vector make_forex_15m_feed() { + std::vector bars; + const int64_t begin = utc_ms(2025, 3, 30, 21, 0); + const int64_t end = utc_ms(2025, 5, 9, 21, 0); + for (int64_t t = begin; t < end; t += 900'000) { + const int64_t local = t - 4 * 3'600'000; // EDT + const int64_t day = local / 86'400'000; // epoch day + const int wday = static_cast((day + 4) % 7); // 0 = Sun + const int hour = static_cast((local % 86'400'000) / 3'600'000); + const bool closed = (wday == 5 && hour >= 17) || wday == 6 + || (wday == 0 && hour < 17); + if (closed) continue; + const double px = 1.0 + static_cast(bars.size()) * 1e-5; + bars.push_back(Bar{px, px, px, px, 1.0, t}); + } + return bars; +} + +static void test_forex_flag_on_first_whole_bucket() { + int before = failures; + BucketGateHarness h("15", "D", "W", "M"); + h.set_syminfo_timezone(NY); + h.set_syminfo_session(FX); + // Lab pad epoch for a TV range starting 2025-04-01: 2025-03-31 00:00 UTC + // = Sun 2025-03-30 20:00 EDT, inside the Sunday session / week / March. + h.set_syminfo_metadata("security_range_start_na_warmup", + static_cast(utc_ms(2025, 3, 31))); + auto bars = make_forex_15m_feed(); + h.run(bars.data(), static_cast(bars.size()), "15", "15"); + CHECK(h.last_error().empty(), "forex flag-on run succeeds"); + + CHECK(!h.completed[0].empty(), "D completed at least once"); + if (!h.completed[0].empty()) { + CHECK_EQ_MS(h.completed[0].front(), utc_ms(2025, 3, 31, 21, 0), + "D: first bar is the Mon 17:00 EDT session (Sunday remainder dropped)"); + } + CHECK(!h.completed[1].empty(), "W completed at least once"); + if (!h.completed[1].empty()) { + CHECK_EQ_MS(h.completed[1].front(), utc_ms(2025, 4, 6, 21, 0), + "W: first bar is the week opening Sun Apr 6 17:00 EDT"); + // Full 5-session weeks follow at 7-day spacing. + if (h.completed[1].size() >= 2) { + CHECK_EQ_MS(h.completed[1][1], utc_ms(2025, 4, 13, 21, 0), + "W: second bar opens Sun Apr 13 17:00 EDT"); + } + } + CHECK(!h.completed[2].empty(), "M completed at least once (April)"); + if (!h.completed[2].empty()) { + CHECK_EQ_MS(h.completed[2].front(), utc_ms(2025, 3, 31, 21, 0), + "M: first bar is April (opens Mon Mar 31 17:00 EDT); March remainder dropped"); + CHECK(h.completed[2].size() == 1, "M: only April completes inside the feed"); + } + std::printf("test_forex_flag_on_first_whole_bucket: %s\n", + (failures > before) ? "FAIL" : "ok"); +} + +static void test_forex_flag_off_unchanged() { + int before = failures; + BucketGateHarness h("15", "D", "W", "M"); + h.set_syminfo_timezone(NY); + h.set_syminfo_session(FX); + auto bars = make_forex_15m_feed(); + h.run(bars.data(), static_cast(bars.size()), "15", "15"); + CHECK(h.last_error().empty(), "forex flag-off run succeeds"); + // Feed start = Sun Mar 30 17:00 EDT: every series begins there. + for (int i = 0; i < 3; ++i) { + CHECK(!h.completed[i].empty(), "flag-off: series completed"); + if (!h.completed[i].empty()) { + CHECK_EQ_MS(h.completed[i].front(), utc_ms(2025, 3, 30, 21, 0), + "flag-off: first bucket opens at the feed start"); + } + } + CHECK(h.completed[2].size() == 2, "flag-off: March (partial) and April complete"); + std::printf("test_forex_flag_off_unchanged: %s\n", + (failures > before) ? "FAIL" : "ok"); +} + +// 24x7 UTC hourly feed Mon 2025-03-24 00:00 .. Sun 2025-05-04 23:00 (long +// enough for April to complete on May 1). +static std::vector make_utc_hourly_feed() { + std::vector bars; + for (int64_t t = utc_ms(2025, 3, 24); t < utc_ms(2025, 5, 5); t += 3'600'000) { + const double px = 100.0 + static_cast(bars.size()); + bars.push_back(Bar{px, px, px, px, 1.0, t}); + } + return bars; +} + +static void test_utc_grid_aligned_range_start_is_timestamp_cut() { + int before = failures; + BucketGateHarness h("60", "240", "D", "W"); + // Mon 2025-03-31 00:00 UTC sits on every grid (4h, D, Monday). + h.set_syminfo_metadata("security_range_start_na_warmup", + static_cast(utc_ms(2025, 3, 31))); + auto bars = make_utc_hourly_feed(); + h.run(bars.data(), static_cast(bars.size()), "60", "60"); + CHECK(h.last_error().empty(), "utc aligned run succeeds"); + for (int i = 0; i < 3; ++i) { + CHECK(!h.completed[i].empty(), "utc aligned: series completed"); + if (!h.completed[i].empty()) { + CHECK_EQ_MS(h.completed[i].front(), utc_ms(2025, 3, 31), + "utc aligned: first bucket opens exactly at the range start"); + } + } + std::printf("test_utc_grid_aligned_range_start_is_timestamp_cut: %s\n", + (failures > before) ? "FAIL" : "ok"); +} + +static void test_utc_straddling_buckets_are_dropped() { + int before = failures; + BucketGateHarness h("60", "240", "W", "M"); + // Wed 2025-03-26 02:00 UTC: inside the 00:00-04:00 4h bucket, inside the + // week that opened Mon Mar 24, inside March. + h.set_syminfo_metadata("security_range_start_na_warmup", + static_cast(utc_ms(2025, 3, 26, 2, 0))); + auto bars = make_utc_hourly_feed(); + h.run(bars.data(), static_cast(bars.size()), "60", "60"); + CHECK(h.last_error().empty(), "utc straddle run succeeds"); + CHECK(!h.completed[0].empty(), "240 completed"); + if (!h.completed[0].empty()) { + CHECK_EQ_MS(h.completed[0].front(), utc_ms(2025, 3, 26, 4, 0), + "240: the straddling 00:00 bucket is dropped, first bar opens 04:00"); + } + CHECK(!h.completed[1].empty(), "W completed"); + if (!h.completed[1].empty()) { + CHECK_EQ_MS(h.completed[1].front(), utc_ms(2025, 3, 31), + "W: the straddling Mar-24 week is dropped, first bar is Mon Mar 31"); + } + CHECK(!h.completed[2].empty(), "M completed"); + if (!h.completed[2].empty()) { + CHECK_EQ_MS(h.completed[2].front(), utc_ms(2025, 4, 1), + "M: the March remainder is dropped, first bar is April"); + } + std::printf("test_utc_straddling_buckets_are_dropped: %s\n", + (failures > before) ? "FAIL" : "ok"); +} + +// Mirrors test_security_range_start_na_warmup's hour feed with the range +// start moved INSIDE hour 1: hour 1 is now dropped whole (its open precedes +// the range start), so the first completed HTF close is hour 2's. +static void test_intraday_mid_bucket_range_start_drops_whole_bucket() { + int before = failures; + BucketGateHarness h("15", "60", "60", "60"); + const double hour_close[8] = {999.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0}; + std::vector bars; + for (int hr = 0; hr < 8; ++hr) { + for (int q = 0; q < 4; ++q) { + const double c = hour_close[hr]; + bars.push_back(Bar{c, c, c, c, 1.0, + static_cast(hr) * 3'600'000 + + static_cast(q) * 900'000}); + } + } + h.set_syminfo_metadata("security_range_start_na_warmup", 3'600'000.0 + 900'000.0); + h.run(bars.data(), static_cast(bars.size()), "15", "15"); + CHECK(h.last_error().empty(), "mid-bucket run succeeds"); + CHECK(h.completed_close[0].size() == 6, + "mid-bucket: hours 2..7 complete (hour 1 dropped whole, not kept partial)"); + if (!h.completed_close[0].empty()) { + CHECK(h.completed_close[0].front() == 20.0, + "mid-bucket: first completed HTF close is hour 2's"); + } + std::printf("test_intraday_mid_bucket_range_start_drops_whole_bucket: %s\n", + (failures > before) ? "FAIL" : "ok"); +} + +int main() { + test_bucket_open_utc_grid(); + test_bucket_open_forex_session(); + test_forex_flag_on_first_whole_bucket(); + test_forex_flag_off_unchanged(); + test_utc_grid_aligned_range_start_is_timestamp_cut(); + test_utc_straddling_buckets_are_dropped(); + test_intraday_mid_bucket_range_start_drops_whole_bucket(); + if (failures) { + std::printf("%d check(s) FAILED\n", failures); + return 1; + } + std::printf("test_security_range_start_bucket_gating passed.\n"); + return 0; +} diff --git a/tests/test_session_predicates.cpp b/tests/test_session_predicates.cpp index 59efbe9..9a3ef55 100644 --- a/tests/test_session_predicates.cpp +++ b/tests/test_session_predicates.cpp @@ -192,6 +192,58 @@ static void test_firstlastbar_transitions() { prev_in = in_session; } +// A session window whose start equals its end ("1700-1700" — TradingView's +// spelling of OANDA forex's 24-hour session; "0000-0000") spans the WHOLE +// day. The half-open [start, end) arithmetic used to make it EMPTY, so every +// bar of a forex symbol read session.ismarket == false and time(session) / +// time_close == na (finding 455: a strategy gating its 'Session Close' exit +// on minute(time_close) never fired on EURUSD). +static void test_start_equals_end_is_full_day() { + const std::string tz = "America/New_York"; + // 2026-04-07 (Tue, EDT = UTC-4), exact epoch ms: + const int64_t kTs_0930_ET = 1775568600000LL; // 13:30 UTC + const int64_t kTs_1030_ET = 1775572200000LL; // 14:30 UTC + const int64_t kTs_1515_ET = 1775589300000LL; // 19:15 UTC + const int64_t kTs_1630_ET = 1775593800000LL; // 20:30 UTC + const int64_t kTs_0500_ET = 1775552400000LL; // 09:00 UTC (pre-market hours) + const int64_t kTs_1730_ET = 1775597400000LL; // 21:30 UTC (post-market hours) + const int64_t kTs_SAT_1030_ET = 1775917800000LL; // 2026-04-11 Sat + // Sweep a full local day at 1-minute grain: every minute is in session. + int in_1700 = 0, in_0000 = 0, in_days = 0; + for (int m = 0; m < 1440; ++m) { + int64_t ts = kTs_0930_ET + static_cast(m) * 60000LL; + if (pine_session_ismarket("1700-1700", tz, ts)) ++in_1700; + if (pine_session_ismarket("0000-0000", tz, ts)) ++in_0000; + if (pine_session_ismarket("1700-1700:1234567", tz, ts)) ++in_days; + } + CHECK(in_1700 == 1440); + CHECK(in_0000 == 1440); + CHECK(in_days == 1440); + // time(session) / time_close(session) resolve for every bar instead of na. + CHECK(pine_time(kTs_1030_ET, "15", "1700-1700", tz, "15") == kTs_1030_ET); + CHECK(pine_time_close(kTs_1030_ET, "15", "1700-1700", tz, "15") + == kTs_1030_ET + 15 * 60000LL); + // 15:15 ET bar on a 15m chart: time_close is 15:30 ET (the exemplar's + // minute(time_close) >= 30 session-close gate). + CHECK(pine_time_close(kTs_1515_ET, "15", "1700-1700", tz, "15") + == kTs_1515_ET + 15 * 60000LL); + CHECK(pine_time(kTs_1730_ET, "15", "1700-1700", tz, "15") == kTs_1730_ET); + // A 24-hour session has no pre-/post-market. + CHECK(!pine_session_ispremarket("1700-1700", tz, kTs_0500_ET)); + CHECK(!pine_session_ispostmarket("1700-1700", tz, kTs_1730_ET)); + CHECK(pine_session_ispremarket("0930-1600", tz, kTs_0500_ET)); // control + CHECK(pine_session_ispostmarket("0930-1600", tz, kTs_1730_ET)); // control + // Day-of-week filter still applies: Saturday is out even for 1700-1700. + CHECK(!pine_session_ismarket("1700-1700:23456", tz, kTs_SAT_1030_ET)); + CHECK(pine_session_ismarket("1700-1700:23456", tz, kTs_1030_ET)); + // Ordinary and wrapped windows are untouched. + CHECK(pine_session_ismarket("0930-1600", tz, kTs_1030_ET)); + CHECK(!pine_session_ismarket("0930-1600", tz, kTs_1630_ET)); + CHECK(pine_session_ismarket("1700-1600", tz, kTs_1030_ET)); + CHECK(!pine_session_ismarket("1700-1600", tz, kTs_1630_ET)); + CHECK(pine_session_ismarket("1700-1600", tz, kTs_1730_ET)); +} + int main() { test_ismarket_inside_rth(); test_ismarket_outside_rth_close(); @@ -208,6 +260,7 @@ int main() { test_hhmm_to_minutes_basic(); test_ismarket_weekend_filter(); test_firstlastbar_transitions(); + test_start_equals_end_is_full_day(); std::printf("\nsession_predicates: %d passed, %d failed\n", tests_passed, tests_failed); diff --git a/tests/test_syminfo_type.cpp b/tests/test_syminfo_type.cpp new file mode 100644 index 0000000..a768b1b --- /dev/null +++ b/tests/test_syminfo_type.cpp @@ -0,0 +1,109 @@ +// syminfo.type / string-member injection (finding 454). +// +// The engine stores no instrument metadata of its own: syminfo.type defaults +// to "crypto" and until now had NO setter, so a script's instrument branch +// (`syminfo.type == "forex" ? 0.0001 : syminfo.mintick` — the canonical pip +// idiom) could never take the forex path even when the harness supplied a +// 5-digit FX mintick. Pins: default, setter, empty-ignored, the generic +// string setter's key routing, and the C ABI entry points. +#include +#include +#include + +#include +#include + +using namespace pineforge; + +namespace { + +struct TypeHarness : public BacktestEngine { + void on_bar(const Bar& /*bar*/) override {} + const SymInfo& sym() const { return syminfo_; } + // The pip idiom every FX script spells out; evaluated the way the codegen + // emits it (syminfo_.type / syminfo_.mintick member reads). + double pip() const { + return syminfo_.type == "forex" ? 0.0001 : syminfo_.mintick; + } +}; + +int tests_run = 0; +int tests_passed = 0; + +#define CHECK(cond, msg) do { \ + ++tests_run; \ + if (cond) { ++tests_passed; printf(" PASS: %s\n", msg); } \ + else { printf(" FAIL: %s\n", msg); } \ +} while (0) + +void test_default_is_crypto() { + TypeHarness h; + CHECK(h.sym().type == "crypto", "syminfo.type defaults to \"crypto\""); + CHECK(h.sym().ticker == "UNKNOWN", "syminfo.ticker default unchanged"); + CHECK(h.sym().currency == "USD", "syminfo.currency default unchanged"); + CHECK(h.sym().basecurrency.empty(), "syminfo.basecurrency default unchanged"); +} + +void test_set_type() { + TypeHarness h; + h.set_syminfo_mintick(0.00001); + CHECK(h.pip() == 0.00001, "crypto default: pip idiom falls through to mintick"); + h.set_syminfo_type("forex"); + CHECK(h.sym().type == "forex", "set_syminfo_type lands on syminfo_.type"); + CHECK(h.pip() == 0.0001, "forex: pip idiom takes the 0.0001 branch"); + h.set_syminfo_type(""); + CHECK(h.sym().type == "forex", "empty type is ignored"); + h.set_syminfo_type("stock"); + CHECK(h.sym().type == "stock", "type overwrite wins"); +} + +void test_set_string_members() { + TypeHarness h; + CHECK(h.set_syminfo_string("ticker", "EURUSD"), "ticker set"); + CHECK(h.set_syminfo_string("tickerid", "OANDA:EURUSD"), "tickerid set"); + CHECK(h.set_syminfo_string("currency", "USD"), "currency set"); + CHECK(h.set_syminfo_string("basecurrency", "EUR"), "basecurrency set"); + CHECK(h.set_syminfo_string("description", "Euro / U.S. Dollar"), "description set"); + CHECK(h.set_syminfo_string("volumetype", "tick"), "volumetype set"); + CHECK(h.set_syminfo_string("type", "forex"), "type via generic setter"); + CHECK(h.sym().ticker == "EURUSD" && h.sym().tickerid == "OANDA:EURUSD" + && h.sym().currency == "USD" && h.sym().basecurrency == "EUR" + && h.sym().description == "Euro / U.S. Dollar" + && h.sym().volumetype == "tick" && h.sym().type == "forex", + "all generic string members read back"); + CHECK(!h.set_syminfo_string("mintick", "0.01"), "numeric member rejected by string setter"); + CHECK(!h.set_syminfo_string("nonsense", "x"), "unknown key rejected"); + CHECK(!h.set_syminfo_string("ticker", ""), "empty value rejected"); + CHECK(h.sym().ticker == "EURUSD", "rejected empty value leaves field intact"); +} + +void test_c_abi() { + TypeHarness h; + pf_strategy_t s = static_cast(static_cast(&h)); + strategy_set_syminfo_type(s, "forex"); + CHECK(h.sym().type == "forex", "C ABI strategy_set_syminfo_type"); + strategy_set_syminfo_type(s, nullptr); + CHECK(h.sym().type == "forex", "C ABI NULL type ignored"); + strategy_set_syminfo_type(nullptr, "stock"); // must not crash + CHECK(strategy_set_syminfo_string(s, "basecurrency", "EUR") == 0, + "C ABI strategy_set_syminfo_string returns 0 on success"); + CHECK(h.sym().basecurrency == "EUR", "C ABI string member lands"); + CHECK(strategy_set_syminfo_string(s, "bogus", "x") == -1, + "C ABI unknown key returns -1"); + CHECK(strategy_set_syminfo_string(s, nullptr, "x") == -1, + "C ABI NULL key returns -1"); + CHECK(strategy_set_syminfo_string(nullptr, "type", "x") == -1, + "C ABI NULL handle returns -1"); +} + +} // namespace + +int main() { + printf("test_syminfo_type\n"); + test_default_is_crypto(); + test_set_type(); + test_set_string_members(); + test_c_abi(); + printf("%d/%d passed\n", tests_passed, tests_run); + return tests_passed == tests_run ? 0 : 1; +}