From d37cb6704c80653cc1af34fb44b10132f2999ecb Mon Sep 17 00:00:00 2001 From: PineForge Factorial Date: Mon, 24 Aug 2026 03:19:45 +0000 Subject: [PATCH] factorial: engine-symbol-r5 [engine] --- include/pineforge/engine.hpp | 8 + include/pineforge/session_time.hpp | 57 +++- include/pineforge/ta.hpp | 40 ++- include/pineforge/timeframe.hpp | 41 +++ src/engine_fills.cpp | 244 +++++++++++++++++ src/engine_orders.cpp | 5 + src/engine_run.cpp | 16 ++ src/session_time.cpp | 142 ++++++++-- src/ta_extremes_volume.cpp | 95 +++++-- src/timeframe.cpp | 206 ++++++++++++--- tests/CMakeLists.txt | 3 + tests/test_margin_call.cpp | 12 +- tests/test_margin_call_gap_open.cpp | 301 +++++++++++++++++++++ tests/test_session_day_anchors.cpp | 360 ++++++++++++++++++++++++++ tests/test_session_time.cpp | 73 ++++++ tests/test_zero_lot_entry_decline.cpp | 349 +++++++++++++++++++++++++ 16 files changed, 1862 insertions(+), 90 deletions(-) create mode 100644 tests/test_margin_call_gap_open.cpp create mode 100644 tests/test_session_day_anchors.cpp create mode 100644 tests/test_zero_lot_entry_decline.cpp diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 01ab2c1..325824b 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -1356,6 +1356,14 @@ class BacktestEngine { // short in ordinary historical dispatch; leveraged shapes stay fail-closed. // Returns true when it emits a broker liquidation row. bool process_carried_position_fx_rollover(const Bar& bar); + // finding-430: forced liquidation at the bar OPEN. A carried position + // with a finite liquidation price that already breaches the margin + // requirement at the open is sliced AT THE OPEN (quantity computed at + // the open price), before any resting order is evaluated there; the + // survivor keeps its ordinary adverse-extreme check, so one bar can book + // an open slice AND an extreme slice. Bars whose open does not breach + // are untouched. Returns true when a "Margin call" row was booked. + bool margin_call_slice_at_bar_open(const Bar& bar); // --- Slippage helper --- double round_to_mintick(double price) const { diff --git a/include/pineforge/session_time.hpp b/include/pineforge/session_time.hpp index c1de81b..c4cece6 100644 --- a/include/pineforge/session_time.hpp +++ b/include/pineforge/session_time.hpp @@ -14,19 +14,72 @@ std::string normalize_timezone_for_posix(const std::string& tz); // Pine time(timeframe, session?, timezone?) and time_close(...). // Returns Unix milliseconds, or na() when the bar is outside the // requested session (TradingView semantics for filtered sessions). +// +// `syminfo_tz` is the symbol's exchange timezone (Pine `syminfo.timezone`; +// codegen passes `syminfo_.timezone`). It is the DEFAULT zone the `session` +// window is interpreted in when `tz` is empty — Pine reads a tz-less session +// in the exchange timezone, not UTC. An explicit `tz` always wins. It does +// NOT influence the timeframe open/close computation (explicit `tz`, else +// UTC, exactly as before); an empty `syminfo_tz` reproduces the historical +// UTC session default byte-for-byte. +// +// Feature macro: generated code tests this to decide whether the trailing +// `syminfo_tz` argument exists, so one transpiler output compiles against +// both this header and older ones (`PF_PINE_TIME_SYMINFO_TZ_ARG(x)` in the +// emitted prelude expands to `, (x)` only when it is defined). // --------------------------------------------------------------------------- +#define PF_PINE_TIME_HAS_SYMINFO_TZ 1 + +int64_t pine_time(int64_t bar_ms, + const std::string& tf, + const std::string& session, + const std::string& tz, + const std::string& chart_tf, + const std::string& syminfo_tz = std::string()); + +int64_t pine_time_close(int64_t bar_ms, + const std::string& tf, + const std::string& session, + const std::string& tz, + const std::string& chart_tf, + const std::string& syminfo_tz = std::string()); + +// Symbol-clock forms. `sym_tz` / `sym_session` are Pine's syminfo.timezone +// and syminfo.session (the engine's runtime overrides). For a D/W/M `tf` +// called WITHOUT a valid session argument the returned open / close is the +// SYMBOL's daily/weekly/monthly bar (17:00 ET on OANDA forex, 09:30 ET RTH +// on NASDAQ equities, 00:00 UTC on a 24x7 UTC symbol — see +// session_period_open_ms in timeframe.hpp). A valid `session` argument +// defines the day in its own `tz` exactly as the forms above do +// (TradingView rolls `time("D", "0000-2359", "America/New_York")` at New +// York midnight on a UTC symbol — measured), and its window is read in the +// explicit `tz`, else `sym_tz` (the syminfo default above), else UTC. +// Intraday tfs are unchanged. +// With sym_tz="UTC" and an empty / "24x7" sym_session these are +// bit-identical to the five-argument forms above. +// +// Feature macro: generated code tests this to decide whether the trailing +// (sym_tz, sym_session) pair exists (`PF_PINE_TIME_SESSION_DAY_ARGS(tz, s)` +// in the emitted prelude expands to `, tz, s` only when it is defined, so +// the same generated.cpp collapses to the 5-arg call on older engines). +#define PF_PINE_TIME_HAS_SESSION_DAY 1 + int64_t pine_time(int64_t bar_ms, const std::string& tf, const std::string& session, const std::string& tz, - const std::string& chart_tf); + const std::string& chart_tf, + const std::string& sym_tz, + const std::string& sym_session); int64_t pine_time_close(int64_t bar_ms, const std::string& tf, const std::string& session, const std::string& tz, - const std::string& chart_tf); + const std::string& chart_tf, + const std::string& sym_tz, + const std::string& sym_session); // --------------------------------------------------------------------------- // Low-level session helpers (exposed for engine_run.cpp, unit tests, diff --git a/include/pineforge/ta.hpp b/include/pineforge/ta.hpp index b4f30be..230f3ae 100644 --- a/include/pineforge/ta.hpp +++ b/include/pineforge/ta.hpp @@ -947,11 +947,14 @@ class VWAP { // Sum of (price^2 * volume) for running variance computation used by // compute_bands(). Variance = cum_pv_sq_ / cum_vol_ - mean^2. double cum_pv_sq_ = 0.0; - // Anchor day index (Unix-day = timestamp_ms / 86_400_000). On the - // first compute() call we record this from the bar timestamp; on - // every subsequent compute() the cumulator is reset whenever the - // day index advances. Pine v6 `ta.vwap(source)` defaults to a Daily - // anchor (`anchor = timeframe.change("1D")`); engine matches that. + // Anchor day index: the SESSION day of the bar (session_day_index — + // timestamp_ms / 86_400_000 on a UTC/24x7 symbol, the 17:00-ET-keyed + // trading day on forex, the 09:30 RTH day on equities). On the first + // compute() call we record this from the bar timestamp; on every + // subsequent compute() the cumulator is reset whenever the day index + // advances. Pine v6 `ta.vwap(source)` defaults to a Daily anchor + // (`anchor = timeframe.change("1D")` on the SYMBOL's daily bar); the + // engine matches that when the codegen threads syminfo tz + session. int64_t anchor_day_ = std::numeric_limits::min(); // Mirror the initial committed state (see RMA::RMA) so a recompute() @@ -959,14 +962,33 @@ class VWAP { double saved_cum_pv_ = 0.0, saved_cum_vol_ = 0.0, saved_cum_pv_sq_ = 0.0; int64_t saved_anchor_day_ = std::numeric_limits::min(); + void roll_anchor(int64_t day); + public: VWAP() = default; + // tz-less forms key the anchor on the UTC day (the 24x7 corpus regime); + // the tz/session forms key it on the symbol's session day and reduce to + // the same integer math for tz="UTC" + empty/"24x7" session. + // Feature macro PF_VWAP_HAS_SESSION_ANCHOR (below the class) lets + // generated code compile the (tz, session) tail out on older engines. double compute(double src, double volume, int64_t timestamp_ms); double recompute(double src, double volume, int64_t timestamp_ms); + double compute(double src, double volume, int64_t timestamp_ms, + const std::string& tz, const std::string& session); + double recompute(double src, double volume, int64_t timestamp_ms, + const std::string& tz, const std::string& session); VWAPBandsResult compute_bands(double src, double volume, int64_t timestamp_ms, double stdev_mult); VWAPBandsResult recompute_bands(double src, double volume, int64_t timestamp_ms, double stdev_mult); + VWAPBandsResult compute_bands(double src, double volume, int64_t timestamp_ms, double stdev_mult, + const std::string& tz, const std::string& session); + VWAPBandsResult recompute_bands(double src, double volume, int64_t timestamp_ms, double stdev_mult, + const std::string& tz, const std::string& session); }; +// Feature macro: the emitted prelude's PF_VWAP_SESSION_ANCHOR_ARGS(tz, s) +// expands to `, tz, s` only when this is defined (see VWAP overloads above). +#define PF_VWAP_HAS_SESSION_ANCHOR 1 + // --- VWAP Bands wrapper class (3-tuple form: ta.vwap(src, anchor, stdev_mult)) --- // Wraps VWAP and routes compute/recompute to compute_bands/recompute_bands with // a fixed stdev_mult supplied at construction time. This lets the codegen use @@ -982,6 +1004,14 @@ class VWAPBands { VWAPBandsResult recompute(double src, double volume, int64_t timestamp_ms) { return vwap_.recompute_bands(src, volume, timestamp_ms, stdev_mult_); } + VWAPBandsResult compute(double src, double volume, int64_t timestamp_ms, + const std::string& tz, const std::string& session) { + return vwap_.compute_bands(src, volume, timestamp_ms, stdev_mult_, tz, session); + } + VWAPBandsResult recompute(double src, double volume, int64_t timestamp_ms, + const std::string& tz, const std::string& session) { + return vwap_.recompute_bands(src, volume, timestamp_ms, stdev_mult_, tz, session); + } }; // --- Statistical --- diff --git a/include/pineforge/timeframe.hpp b/include/pineforge/timeframe.hpp index d979cbf..d71dc1a 100644 --- a/include/pineforge/timeframe.hpp +++ b/include/pineforge/timeframe.hpp @@ -97,6 +97,47 @@ bool crosses_boundary(int64_t prev_ms, int64_t curr_ms, CalendarPeriod period, bool tf_change(int64_t prev_ms, int64_t curr_ms, const std::string& tf, const std::string& tz, const std::string& session); +// ─── Symbol-clock D/W/M bar anchors ──────────────────────────────────────────── +// +// TradingView's daily bar is the SESSION day, not the UTC (nor the local +// calendar) day: OANDA:EURUSD (America/New_York, 1700-1700) opens its daily +// bar at 17:00 ET and its week at Sunday 17:00 ET; NASDAQ:AAPL (0930-1600) +// opens at 09:30 ET Monday..Friday; a 24x7 UTC symbol opens at 00:00 UTC. +// Every chart-level consumer of "the symbol's daily bar" — ta.vwap's default +// anchor, timeframe.change("1D"), time("D")/time_close("D") — has to key on +// this clock, the same one request.security aggregation already uses +// (crosses_boundary / session_period_key above). With tz="UTC" and an +// empty / "24x7" session all of these reduce to plain epoch integer math, +// bit-identical to the UTC forms the corpus pins. +// +// Period attribution follows TV's trading-date rule: a session that wraps +// midnight (1700-1700) is the trading day of the date it CLOSES on (the +// bar opening Sunday 17:00 ET is Monday's daily bar), so forex weeks open on +// the Sunday session and a month opens on the session whose close date is +// the 1st. Equity / 24x7 sessions close on their open date, so their weeks +// stay Monday-partitioned and months calendar-partitioned exactly as before. +// W/M opens are nominal (the Monday / 1st session-day); they do not consult +// a holiday calendar. + +/// Ordinal of the session day containing `ms` (days since epoch on the +/// session clock). UTC + no session: ms / kMsPerDay. +int64_t session_day_index(int64_t ms, const std::string& tz, + const std::string& session); + +/// Open (Unix ms) of the symbol's D/W/M bar that contains `ms`. +/// CalendarPeriod::NONE returns `ms` unchanged. +int64_t session_period_open_ms(int64_t ms, const std::string& tz, + const std::string& session, + CalendarPeriod period); + +/// Exclusive close (Unix ms) of the symbol's D/W/M bar that contains `ms`: +/// DAY -> session-day open + session length (16:00 ET on equities, the next +/// 17:00 ET on forex, next midnight on 24x7); WEEK / MONTH -> the open of +/// the next period's first session-day. CalendarPeriod::NONE returns `ms`. +int64_t session_period_close_ms(int64_t ms, const std::string& tz, + const std::string& session, + CalendarPeriod period); + // ─── TimeframeAggregator ─────────────────────────────────────────────────────── class TimeframeAggregator { diff --git a/src/engine_fills.cpp b/src/engine_fills.cpp index 1e203c0..af89e29 100644 --- a/src/engine_fills.cpp +++ b/src/engine_fills.cpp @@ -1473,6 +1473,171 @@ bool BacktestEngine::margin_call_1x_long_opening_slice_before_priced_exit( return true; } +// finding-430 (margin call on gap-open bars). TradingView's broker emulator +// evaluates the margin requirement at every point of the synthesized +// intrabar path, and the bar OPEN is the first such point. When a CARRIED +// leveraged position (a short, or a leveraged long — anything with a finite +// liquidation price) already breaches the requirement at the open, TV books +// the forced-liquidation slice AT THE OPEN, with the quantity computed at +// the open price: +// +// qty_liq = 4 * floor((qty * P - equity(P)) / P) P = bar.open +// +// (the usual floor-before-4x nibble with the sub-lot one-contract fallback), +// and then re-checks the SURVIVOR at the bar's adverse extreme — so a single +// bar can carry two "Margin call" rows: the open slice and the extreme +// slice. The engine's process_margin_call ran once, at the end of the bar, +// at the adverse extreme only, so an open-breach bar was liquidated at the +// wrong price and with the wrong (extreme-computed) quantity. +// +// Fitted on the NASDAQ:AAPL 15m tapes (Lab findings 430/431, 8,414 +// "Margin call" events over 90 slugs, prices half-up-rounded to mintick): +// every event whose position was already in deficit at the open fills AT +// THE OPEN (1,020 open-gap events; the 7 remaining "extreme while the open +// breached" events are the second slice of an open+extreme pair whose open +// slice was the one-share fallback), 7,303 non-gap events fill at the +// adverse extreme as before, and the open/extreme quantity rule is exact on +// 8,403/8,414 (the 11 misses are one 2x-equity pyramid script). Exemplars: +// dthomas1026 2025-04-23 13:30 UTC O=206.00/H=207.50 -> TV 4@206.00 (the +// engine printed 8@207.50); benblackdiamond 2025-05-12 13:30 UTC -> TV +// 1116@211.05 (open) + 3864 remainder (engine 1156@211.26 at the high); +// alpha-wizard-wave-oscillator 2025-10-27 13:30 UTC -> TV 88@264.93 (open) +// AND 12@266.66 (high) on the same bar. +// +// Placement: the open is the earliest point on the path, so the slice runs +// at the broker-open boundary of dispatch_bar (right after the carried +// FX rollover, BEFORE any resting order is evaluated at the open) and at +// the first sub-bar open of the real-bar magnifier. None of the 507 carried +// open-slice events in the tapes shares its bar with another exit AT the +// open, so the open-slice-before-open-fills ordering is a modelling choice +// consistent with TV's path chronology rather than a tape-pinned one. The +// slice deliberately does NOT mark last_margin_call_event_bar_ / +// intrabar_exit_margin_call_bar_: the survivor keeps its ordinary +// adverse-extreme check (the chronological pre-exit hook or the end-of-bar +// process_margin_call), which is TV's second same-bar slice. Bars whose +// open does not breach are untouched, so on-tick feeds without open gaps +// (the ETH corpus) stay bit-identical. The 1x long has no adverse-price +// liquidation and keeps its fill-time affordability event; a COOF bar keeps +// the established once-per-script-bar placement (no exemplar). +bool BacktestEngine::margin_call_slice_at_bar_open(const Bar& bar) { + if (!margin_call_enabled_) return false; + if (position_side_ == PositionSide::FLAT) return false; + if (coof_scheduler_active_) return false; + // Carried positions only. A position filled at this bar's open is + // checked by its own opening-affordability event; the broker-open + // boundary runs before any fill of this bar, so this is a structural + // guard rather than a reachable branch. + if (position_open_bar_ >= bar_index_) return false; + const bool long_full_margin = + (position_side_ == PositionSide::LONG) + && std::isfinite(margin_long_) + && std::abs(margin_long_ / 100.0 - 1.0) < 1e-12; + if (long_full_margin) return false; + const double liq = compute_liquidation_price(); + if (std::isnan(liq)) return false; + + const double open = bar.open; + if (!std::isfinite(open) || !(open > 0.0)) return false; + const double pv = syminfo_.pointvalue; + const double qty = position_qty_; + const double margin_pct = (position_side_ == PositionSide::LONG) + ? margin_long_ : margin_short_; + const double m = margin_pct / 100.0; + if (!(m > 0.0)) return false; + if (!std::isfinite(qty) || !(qty > 0.0) + || !std::isfinite(position_entry_price_) + || !std::isfinite(pv) || !std::isfinite(initial_capital_) + || !std::isfinite(net_profit_sum_)) { + return false; + } + + // Deficit at the open: the same fee-net eq/req arithmetic as the + // adverse-extreme cascade, evaluated at the open price. + const double fx = active_account_currency_fx(); + if (!std::isfinite(fx) || !(fx > 0.0)) return false; + const double equity_open = percent_commission_live_equity(open); + if (!std::isfinite(equity_open)) return false; + const double margin_per_unit_open = open * pv * fx * m; + if (!std::isfinite(margin_per_unit_open) || !(margin_per_unit_open > 0.0)) { + return false; + } + const double req_margin_open = qty * margin_per_unit_open; + if (equity_open >= req_margin_open) return false; + double q_min = qty - equity_open / margin_per_unit_open; + if (!std::isfinite(q_min) || q_min <= kQtyEpsilon) return false; + + // Slice quantity: floor-before-4x, representation guards, and the + // floor-zero one-contract fallback — identical to the cascade (see + // process_margin_call for the fitted evidence on each rule). + const double raw_q_min = q_min; + if (qty_step_ > 0.0) { + double step_count = q_min / qty_step_; + if (margin_zero_cover_full_liquidation_) { + const double nearest_step = std::round(step_count); + if (std::abs(step_count - nearest_step) < 1e-6) { + step_count = nearest_step; + } + } + q_min = std::floor(step_count) * qty_step_; + } + double qty_liq = 4.0 * q_min; + if (qty_step_ > 0.0) { + double floored = std::floor(qty_liq / qty_step_ + 1e-6) * qty_step_; + if (floored <= kQtyEpsilon) { + double one_contract_fallback = + std::numeric_limits::quiet_NaN(); + if (qty_step_ <= 1.0 + && raw_q_min > kQtyEpsilon + && raw_q_min < 1.0) { + const double candidate = std::min(1.0, qty); + const bool full_position_cap = + candidate >= qty - kQtyEpsilon; + const double gridded = apply_exit_qty_step(candidate); + const double grid_guard = std::max( + 1e-12, std::abs(candidate) * 1e-12); + if (full_position_cap + || std::abs(gridded - candidate) <= grid_guard) { + one_contract_fallback = candidate; + } + } + if (std::isfinite(one_contract_fallback)) { + floored = one_contract_fallback; + } else if (margin_zero_cover_full_liquidation_) { + floored = qty; + } else { + return false; + } + } + qty_liq = floored; + } + if (qty_liq >= qty - kQtyEpsilon) qty_liq = qty; + if (!std::isfinite(qty_liq) || qty_liq <= kQtyEpsilon) return false; + + // The raw open is the fill base; the close helper applies the exit + // side's own snap/slippage exactly as the adverse-extreme path does. + const size_t trades_before = trades_.size(); + if (qty_liq >= qty - kQtyEpsilon) { + execute_market_exit(open); + } else { + execute_partial_exit_qty( + open, qty_liq, PositionReductionCause::MARGIN_CALL); + } + if (trades_.size() == trades_before) return false; + + ++broker_fill_event_seq_; + for (size_t ti = trades_before; ti < trades_.size(); ++ti) { + trades_[ti].exit_comment = "Margin call"; + trades_[ti].exit_id = "__margin_call__"; + } + // finding-311 REVIVE-B applies to this partial exactly as to the + // extreme-priced one: dormant brackets of the survivor re-register, and + // one already marketable at the open closes the remainder there. + if (position_side_ != PositionSide::FLAT) { + revive_position_brackets_after_margin_call_partial(open); + } + return true; +} + // ──────────────────────────────────────────────────────────────────── // process_pending_orders helpers // ──────────────────────────────────────────────────────────────────── @@ -3419,6 +3584,85 @@ void BacktestEngine::apply_filled_order_to_state( decline_and_cancel(); return; } + // Zero-lot entry decline (finding: 3commas HA-RSI fade short on + // NASDAQ:AAPL 15m, qty_step 1 share). TradingView floors every order + // quantity to the instrument's lot step and an entry whose floored + // quantity is ZERO is simply not placed: no fill, no trade row, no open + // trade — strategy.opentrades stays 0, the position stays flat, and the + // next signal whose quantity survives the floor fills normally. TV tape: + // 2025-12-02 15:45 UTC close 286.96, qty = 280/close = 0.9757 -> 0 shares, + // no row; the next TV entry is 2025-12-09 18:15 @ 278.35 qty 1 (280/278.38 + // = 1.0058 -> 1). The engine used to hand the floored 0 straight to + // open_fresh_position, creating a PHANTOM position with position_qty_ == 0: + // strategy.position_size reads 0 (the script believes it is flat and never + // places its strategy.exit bracket) while strategy.opentrades reads 1 and + // pyramiding=1 is saturated, so every later entry is dropped for the rest + // of the tape (26 TV trades -> 2 engine trades; 172 later entry signals, + // 0 admitted). The same shape reaches CASH default sizing (frozen + // quantity floored to 0 — KI-72 above only covers percent_of_equity) and + // an explicit qty <= 0 (apply_qty_step returns it UNFLOORED). It also + // reaches same-direction ADDS: the 3commas pyramiding DCA family sizes + // safety orders as usdt/close, and on AAPL those floor to 0 — the engine + // booked 15 qty-0 add rows per slug (bch-overbought-rsi-fade-short- + // indicator, dot-rsi-reversal-dca-short-indicator: 54 engine trades vs 39 + // TV) and each phantom add burned a pyramiding slot TV never spends. + // + // Decline cleanly, exactly like the KI-72 non-positive frozen quantity: + // consume the order, no fill, no trade row. The quantity tested is the one + // the market/priced-entry kernel would actually open with (frozen default, + // stop-placement snapshot, or calc_qty_for_type at the slipped fill). + // Scope: MARKET / priced ENTRY orders that would OPEN or ADD (flat or + // same-direction at the fill) — the add path is gated here too, upstream + // of add_to_pyramid_market, so a declined zero-lot add consumes no + // pyramiding slot (add_to_pyramid_market keeps a no-op safety net). A + // reversal keeps its existing path (its close leg is TV-pinned; a + // zero-qty reopen after it is not), and the + // KI-65 paired flat transaction is left alone (own qty > eps by + // construction). A priced entry carrying a deferred-flip carry + // (tv_carry_qty > 0) opens carry + own, never zero, so it is untouched. + if ((order.type == OrderType::MARKET || order.type == OrderType::ENTRY) + && !pending_flat_market_pair_is_live(order)) { + const PositionSide requested_side = + order.is_long ? PositionSide::LONG : PositionSide::SHORT; + const bool opposite_at_fill = + position_side_ != PositionSide::FLAT + && position_side_ != requested_side; + if (!opposite_at_fill) { + double opening_qty; + if (!std::isnan(order.frozen_default_qty)) { + opening_qty = order.frozen_default_qty; + } else if (order.type == OrderType::ENTRY + && use_stop_placement_open_qty(order, fill_price, bar)) { + opening_qty = order.stop_placement_open_qty; + } else { + opening_qty = calc_qty_for_type( + apply_fill_slippage(fill_price, order.is_long), + order.qty, order.qty_type); + } + // The deferred-flip carry is applied by enter_market_from_flat + // ONLY to a priced entry firing from FLAT whose placement side was + // the OPPOSITE of the requested side. A same-direction priced add + // (DCA safety limit/stop placed while already in the position) + // snapshots the live position into tv_carry_qty as well, but the + // add kernel never applies it — so it must not exempt a zero-lot + // add here (it would otherwise open a qty-0 pyramid lot and burn a + // pyramiding slot TV never spends). + const bool deferred_flip_carry = + order.type == OrderType::ENTRY + && order.tv_carry_qty > 0.0 + && position_side_ == PositionSide::FLAT + && ((order.created_position_side == PositionSide::LONG) + ? !order.is_long : order.is_long); + if (deferred_flip_carry) { + opening_qty = std::abs(opening_qty) + order.tv_carry_qty; + } + if (std::isfinite(opening_qty) + && std::abs(opening_qty) <= kQtyEpsilon) { + decline_and_cancel(); + return; + } + } + } // sizing_equity > 0 and frozen_default_qty > 0 are part of the invariant, // not paranoia: apply_qty_step returns qty UNFLOORED for qty <= 0 // (engine.hpp), so on a bankrupt account the frozen quantity is negative, diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index c734dc1..31d36c8 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -859,6 +859,11 @@ void BacktestEngine::add_to_pyramid_market(const std::string& id, bool is_long, return; } double new_qty = calc_qty_for_type(fill_price, explicit_qty, explicit_qty_type); + // Zero-lot add safety net. The fill kernel (apply_filled_order_to_state's + // zero-lot decline) consumes such an order before it reaches here; should + // any path bypass that gate, never materialize a qty-0 pyramid lot nor + // spend a pyramiding slot on it — TV does not place the order at all. + if (!(new_qty > kQtyEpsilon)) return; double total_qty = position_qty_ + new_qty; position_entry_price_ = (position_entry_price_ * position_qty_ + fill_price * new_qty) / total_qty; position_qty_ = total_qty; diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 3acdc97..3e52858 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -118,6 +118,12 @@ void BacktestEngine::dispatch_bar() { script_bar.open, 0.0, script_bar.timestamp}; try { process_carried_position_fx_rollover(script_bar); + // finding-430: a carried leveraged position already in margin + // deficit at the open is sliced here, at the open price, before + // any resting order sees the bar. The survivor's adverse-extreme + // check (pre-exit hook / end-of-bar process_margin_call) is + // unchanged and may book TV's second same-bar slice. + margin_call_slice_at_bar_open(script_bar); } catch (...) { current_bar_ = script_bar; throw; @@ -815,6 +821,16 @@ void BacktestEngine::run_magnified_bar(const std::vector& sub_bars, int64_t mean_vol = sum_vol / total_sub; } + // finding-430: the script bar's open is the first point of every + // sub-bar path. A carried leveraged position already in deficit there is + // sliced at the open before the first sub-bar's samples are walked. + { + const Bar open_point{bar_open, bar_open, bar_open, bar_open, 0.0, + timestamp}; + current_bar_ = open_point; + margin_call_slice_at_bar_open(open_point); + } + for (int si = 0; si < total_sub; ++si) { const Bar& sb = sub_bars[si]; cumulative_vol += sb.volume; diff --git a/src/session_time.cpp b/src/session_time.cpp index 39cfdf2..65b2da2 100644 --- a/src/session_time.cpp +++ b/src/session_time.cpp @@ -543,60 +543,158 @@ static bool session_arg_is_timezone(const std::string& s) { return false; } -// Resolve the (session, tz) pair for a time()/time_close() call. When a 2-arg -// call passes a timezone-looking string in the session slot (and no explicit -// tz), TV treats it as an invalid session and ignores it: drop the session -// (no filter) but leave tz at the chart/UTC default — do NOT adopt the string -// as the timezone. The daily boundary then rolls at the chart/exchange (UTC) -// timezone, matching TradingView. +// Resolve the session / timezone triple for a time()/time_close() call. +// +// Two distinct timezones come out of this: +// +// session_tz_out — the zone the SESSION window ("0930-1600") is read in. +// Pine: "To interpret the time zone of the specified session, time() and +// time_close() use the time zone of the exchange by default, unless a +// timezone argument is specified" (i.e. the default is syminfo.timezone). +// So an explicit tz argument wins; otherwise the caller-supplied +// syminfo_tz (codegen passes ``syminfo_.timezone``); otherwise UTC (the +// historical engine default, still what an unknown/empty syminfo yields). +// +// tf_tz_out — the zone the TIMEFRAME open/close is computed in. This is +// deliberately left exactly as before: explicit tz argument, else UTC. +// The D/W/M calendar path of compute_tf_open_ms is owned elsewhere and +// must not silently start rolling at syminfo.timezone just because a +// session was supplied; intraday timeframes are UTC-bucketed and never +// depended on tz anyway. +// +// When a 2-arg call passes a timezone-looking string in the session slot (and +// no explicit tz), TV treats it as an invalid session and ignores it: drop the +// session (no filter) but leave tz at the chart/UTC default — do NOT adopt the +// string as the timezone. The daily boundary then rolls at the chart/exchange +// (UTC) timezone, matching TradingView. static void resolve_session_tz(const std::string& session, const std::string& tz_in, + const std::string& syminfo_tz, std::string& sess_out, - std::string& tz_out) { + std::string& session_tz_out, + std::string& tf_tz_out) { sess_out = session; - tz_out = tz_in; - if (tz_out.empty() && session_arg_is_timezone(sess_out)) { + tf_tz_out = tz_in; + if (tf_tz_out.empty() && session_arg_is_timezone(sess_out)) { sess_out.clear(); } - if (tz_out.empty()) - tz_out = "UTC"; + if (tf_tz_out.empty()) + tf_tz_out = "UTC"; + session_tz_out = tz_in; + if (session_tz_out.empty()) + session_tz_out = syminfo_tz; + if (session_tz_out.empty()) + session_tz_out = "UTC"; } int64_t pine_time(int64_t bar_ms, const std::string& tf_in, const std::string& session, const std::string& tz_in, - const std::string& chart_tf) { + const std::string& chart_tf, + const std::string& syminfo_tz) { std::string tf = tf_in.empty() ? chart_tf : tf_in; if (tf.empty()) tf = "1"; - std::string sess, tz; - resolve_session_tz(session, tz_in, sess, tz); + std::string sess, session_tz, tf_tz; + resolve_session_tz(session, tz_in, syminfo_tz, sess, session_tz, tf_tz); - if (!sess.empty() && !passes_session_filter(sess, tz, bar_ms)) + if (!sess.empty() && !passes_session_filter(sess, session_tz, bar_ms)) return na(); - return compute_tf_open_ms(bar_ms, tf, tz); + return compute_tf_open_ms(bar_ms, tf, tf_tz); } int64_t pine_time_close(int64_t bar_ms, const std::string& tf_in, const std::string& session, const std::string& tz_in, - const std::string& chart_tf) { + const std::string& chart_tf, + const std::string& syminfo_tz) { std::string tf = tf_in.empty() ? chart_tf : tf_in; if (tf.empty()) tf = "1"; - std::string sess, tz; - resolve_session_tz(session, tz_in, sess, tz); + std::string sess, session_tz, tf_tz; + resolve_session_tz(session, tz_in, syminfo_tz, sess, session_tz, tf_tz); - if (!sess.empty() && !passes_session_filter(sess, tz, bar_ms)) + if (!sess.empty() && !passes_session_filter(sess, session_tz, bar_ms)) return na(); - int64_t t_open = compute_tf_open_ms(bar_ms, tf, tz); - return compute_tf_close_ms(t_open, tf, tz); + int64_t t_open = compute_tf_open_ms(bar_ms, tf, tf_tz); + return compute_tf_close_ms(t_open, tf, tf_tz); +} + +// Symbol-clock forms. Without a session argument the D/W/M bar open is the +// SYMBOL's bar (session-day keyed — 17:00 ET on OANDA forex, 09:30 ET RTH on +// equities, UTC midnight on a 24x7 UTC symbol), never a UTC calendar-day +// floor. A VALID session argument defines the day itself: TradingView keys +// the period on the session's timezone (`time("D", "0000-2359", +// "America/New_York")` on a UTC crypto symbol rolls at New York midnight — +// measured: lukeborgerding-orb-avwap-retest anchors a manual VWAP on +// ta.change() of exactly that and only matches TV's tape 100% with the NY +// roll, 18% with the symbol's UTC roll), so that path keeps the tz-only +// calendar floor of the five-argument forms. A timezone-looking string in the +// session slot is an invalid session (dropped by resolve_session_tz) and +// falls back to the symbol clock. Intraday tfs keep the epoch-grid bucket. +static bool symbol_clock_applies(const std::string& resolved_session, + CalendarPeriod cp) { + return cp != CalendarPeriod::NONE && resolved_session.empty(); +} + +int64_t pine_time(int64_t bar_ms, + const std::string& tf_in, + const std::string& session, + const std::string& tz_in, + const std::string& chart_tf, + const std::string& sym_tz, + const std::string& sym_session) { + std::string tf = tf_in.empty() ? chart_tf : tf_in; + if (tf.empty()) + tf = "1"; + + // Composition with the syminfo session-tz default: the session window + // is read in the explicit tz, else syminfo.timezone (sym_tz), else UTC; + // a VALID session keeps the tf-open in the explicit-tz-else-UTC calendar + // (tf_tz), while no valid session takes the symbol's own D/W/M bar. + std::string sess, session_tz, tf_tz; + resolve_session_tz(session, tz_in, sym_tz, sess, session_tz, tf_tz); + + if (!sess.empty() && !passes_session_filter(sess, session_tz, bar_ms)) + return na(); + + const CalendarPeriod cp = calendar_period_for(tf); + if (symbol_clock_applies(sess, cp)) + return session_period_open_ms(bar_ms, sym_tz, sym_session, cp); + return compute_tf_open_ms(bar_ms, tf, tf_tz); +} + +int64_t pine_time_close(int64_t bar_ms, + const std::string& tf_in, + const std::string& session, + const std::string& tz_in, + const std::string& chart_tf, + const std::string& sym_tz, + const std::string& sym_session) { + std::string tf = tf_in.empty() ? chart_tf : tf_in; + if (tf.empty()) + tf = "1"; + + std::string sess, session_tz, tf_tz; + resolve_session_tz(session, tz_in, sym_tz, sess, session_tz, tf_tz); + + if (!sess.empty() && !passes_session_filter(sess, session_tz, bar_ms)) + return na(); + + const CalendarPeriod cp = calendar_period_for(tf); + if (symbol_clock_applies(sess, cp)) { + // Calendar periods report the period END (last ms), matching the + // tz-only forms above; intraday closes stay the exact boundary. + return session_period_close_ms(bar_ms, sym_tz, sym_session, cp) - 1; + } + int64_t t_open = compute_tf_open_ms(bar_ms, tf, tf_tz); + return compute_tf_close_ms(t_open, tf, tf_tz); } int64_t pine_time_tradingday(int64_t bar_ms, diff --git a/src/ta_extremes_volume.cpp b/src/ta_extremes_volume.cpp index 837cde2..5f0bec0 100644 --- a/src/ta_extremes_volume.cpp +++ b/src/ta_extremes_volume.cpp @@ -446,18 +446,17 @@ double III::compute(double high, double low, double close, double volume) { // --- VWAP --- // Pine v6 `ta.vwap(source)` defaults to a Daily anchor — the cumulator -// resets at the start of every UTC day. Earlier the engine treated VWAP -// as a single chart-wide cumulator, which produced values that drifted -// from TV by ~30% on intra-day bars (and progressively further as the -// day advanced). Resetting on UTC-day boundaries restores parity. The -// `anchor_day_` member is initialised lazily on the first non-NA bar. -double VWAP::compute(double src, double volume, int64_t timestamp_ms) { - saved_cum_pv_ = cum_pv_; - saved_cum_vol_ = cum_vol_; - saved_cum_pv_sq_ = cum_pv_sq_; - saved_anchor_day_ = anchor_day_; - if (is_na(src) || is_na(volume)) return na(); - int64_t day = timestamp_ms / kMsPerDay; +// resets at the start of every SESSION day of the symbol (the UTC day on +// a 24x7 UTC symbol; 17:00 ET on OANDA forex; 09:30 ET RTH on NASDAQ +// equities — `anchor = timeframe.change("1D")` evaluates on the symbol's +// own daily bar). Earlier the engine treated VWAP as a single chart-wide +// cumulator, which produced values that drifted from TV by ~30% on +// intra-day bars; then it reset on UTC-day boundaries, which is right for +// the crypto corpus only. The tz-less overloads keep that UTC keying; the +// tz/session overloads key on session_day_index, which is the identical +// integer division for tz="UTC" + no session. `anchor_day_` is initialised +// lazily on the first non-NA bar. +void VWAP::roll_anchor(int64_t day) { if (anchor_day_ == std::numeric_limits::min()) { anchor_day_ = day; } else if (day != anchor_day_) { @@ -466,6 +465,30 @@ double VWAP::compute(double src, double volume, int64_t timestamp_ms) { cum_pv_sq_ = 0.0; anchor_day_ = day; } +} + +double VWAP::compute(double src, double volume, int64_t timestamp_ms) { + saved_cum_pv_ = cum_pv_; + saved_cum_vol_ = cum_vol_; + saved_cum_pv_sq_ = cum_pv_sq_; + saved_anchor_day_ = anchor_day_; + if (is_na(src) || is_na(volume)) return na(); + roll_anchor(timestamp_ms / kMsPerDay); + cum_pv_ += src * volume; + cum_pv_sq_ += src * src * volume; + cum_vol_ += volume; + if (cum_vol_ == 0.0) return na(); + return cum_pv_ / cum_vol_; +} + +double VWAP::compute(double src, double volume, int64_t timestamp_ms, + const std::string& tz, const std::string& session) { + saved_cum_pv_ = cum_pv_; + saved_cum_vol_ = cum_vol_; + saved_cum_pv_sq_ = cum_pv_sq_; + saved_anchor_day_ = anchor_day_; + if (is_na(src) || is_na(volume)) return na(); + roll_anchor(session_day_index(timestamp_ms, tz, session)); cum_pv_ += src * volume; cum_pv_sq_ += src * src * volume; cum_vol_ += volume; @@ -473,21 +496,33 @@ double VWAP::compute(double src, double volume, int64_t timestamp_ms) { return cum_pv_ / cum_vol_; } +VWAPBandsResult VWAP::compute_bands(double src, double volume, int64_t timestamp_ms, double stdev_mult, + const std::string& tz, const std::string& session) { + saved_cum_pv_ = cum_pv_; + saved_cum_vol_ = cum_vol_; + saved_cum_pv_sq_ = cum_pv_sq_; + saved_anchor_day_ = anchor_day_; + if (is_na(src) || is_na(volume)) return {na(), na(), na()}; + roll_anchor(session_day_index(timestamp_ms, tz, session)); + cum_pv_ += src * volume; + cum_pv_sq_ += src * src * volume; + cum_vol_ += volume; + if (cum_vol_ == 0.0) return {na(), na(), na()}; + double mean = cum_pv_ / cum_vol_; + double variance = cum_pv_sq_ / cum_vol_ - mean * mean; + if (variance < 0.0) variance = 0.0; // guard against floating-point underflow + double stdev = std::sqrt(variance); + double band_offset = stdev_mult * stdev; + return {mean, mean + band_offset, mean - band_offset}; +} + VWAPBandsResult VWAP::compute_bands(double src, double volume, int64_t timestamp_ms, double stdev_mult) { saved_cum_pv_ = cum_pv_; saved_cum_vol_ = cum_vol_; saved_cum_pv_sq_ = cum_pv_sq_; saved_anchor_day_ = anchor_day_; if (is_na(src) || is_na(volume)) return {na(), na(), na()}; - int64_t day = timestamp_ms / kMsPerDay; - if (anchor_day_ == std::numeric_limits::min()) { - anchor_day_ = day; - } else if (day != anchor_day_) { - cum_pv_ = 0.0; - cum_vol_ = 0.0; - cum_pv_sq_ = 0.0; - anchor_day_ = day; - } + roll_anchor(timestamp_ms / kMsPerDay); cum_pv_ += src * volume; cum_pv_sq_ += src * src * volume; cum_vol_ += volume; @@ -743,6 +778,24 @@ VWAPBandsResult VWAP::recompute_bands(double src, double volume, int64_t timesta return compute_bands(src, volume, timestamp_ms, stdev_mult); } +double VWAP::recompute(double src, double volume, int64_t timestamp_ms, + const std::string& tz, const std::string& session) { + cum_pv_ = saved_cum_pv_; + cum_vol_ = saved_cum_vol_; + cum_pv_sq_ = saved_cum_pv_sq_; + anchor_day_ = saved_anchor_day_; + return compute(src, volume, timestamp_ms, tz, session); +} + +VWAPBandsResult VWAP::recompute_bands(double src, double volume, int64_t timestamp_ms, double stdev_mult, + const std::string& tz, const std::string& session) { + cum_pv_ = saved_cum_pv_; + cum_vol_ = saved_cum_vol_; + cum_pv_sq_ = saved_cum_pv_sq_; + anchor_day_ = saved_anchor_day_; + return compute_bands(src, volume, timestamp_ms, stdev_mult, tz, session); +} + // --- Mode --- double Mode::recompute(double src) { if (buffer_.empty()) return compute(src); diff --git a/src/timeframe.cpp b/src/timeframe.cpp index d94266e..c6b6951 100644 --- a/src/timeframe.cpp +++ b/src/timeframe.cpp @@ -163,9 +163,21 @@ static int64_t tz_offset_ms(int64_t ms, const std::string& tz) { // transitions land on wall-clock hours while session markets are closed // (02:00 local Sunday), so the first bar seen on each UTC date carries // the offset every traded bar of that date uses. - thread_local int64_t cache_key = -1, cache_val = 0; + // + // A few direct-mapped slots instead of one: the session-day anchors + // below resolve the offset of the period OPEN's UTC day as well as the + // query bar's (a Tuesday 03:00 ET bar belongs to the session that opened + // Monday 17:00 ET, a different UTC date), and a single slot would thrash + // between the two on every call. Pure cache — values are identical. + // Slots are keyed by (UTC day, tz): a process that touches two zones + // (unit tests; multi-symbol hosts) must not read one zone's offset for + // the other. + struct Slot { int64_t key = -1, val = 0; std::string tz; }; + constexpr int kSlots = 8; + thread_local Slot cache[kSlots]; const int64_t dayk = utc_floor_day_ms(ms); - if (dayk == cache_key) return cache_val; + Slot& slot = cache[static_cast(((dayk / kMsPerDay) % kSlots + kSlots) % kSlots)]; + if (dayk == slot.key && slot.tz == tz) return slot.val; // Sample at MID-UTC-day: pairing the LOCAL midnight with the query // instant's UTC floor is wrong whenever the local date straddles UTC // midnight (FX evening bars are still the previous local date), which @@ -173,9 +185,10 @@ static int64_t tz_offset_ms(int64_t ms, const std::string& tz) { // candle in two. Midday always belongs to the local date whose true // zone offset this cache stores. int64_t day_open = calendar_day_open_local_ms(dayk + kMsPerDay / 2, tz); - cache_val = day_open - dayk; - cache_key = dayk; - return cache_val; + slot.val = day_open - dayk; + slot.key = dayk; + slot.tz = tz; + return slot.val; } /// Minutes from symbol-local midnight to the first session window's open @@ -251,50 +264,173 @@ static const std::string& empty_session() { return s; } -/// Monday that starts the ISO week of a wall-clock decomposition -/// (continuous across year boundaries; see the historical note below). -static long monday_epoch_day_of(const struct tm& t) { - int y = t.tm_year + 1900, m = t.tm_mon + 1, d = t.tm_mday; +/// Epoch day (days since 1970-01-01) of a proleptic-Gregorian civil date +/// (Howard Hinnant's days_from_civil; m is 1-based). +static long days_from_civil(int y, int m, int d) { 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 day = era * 146097L + (long)doe - 719468L; + return era * 146097L + (long)doe - 719468L; +} + +/// Monday that starts the ISO week of a wall-clock decomposition +/// (continuous across year boundaries; see the historical note below). +static long monday_epoch_day_of(const struct tm& t) { + long day = days_from_civil(t.tm_year + 1900, t.tm_mon + 1, t.tm_mday); int dow = (t.tm_wday + 6) % 7; // Mon=0..Sun=6 return day - dow; } +// ─── Session-day anchors (shared by security keys, VWAP, tf_change, time()) ── + +static int64_t floor_div_day(int64_t ms) { + int64_t d = ms / kMsPerDay; + if (ms < 0 && ms % kMsPerDay != 0) --d; + return d; +} + +static bool is_utc_tz(const std::string& tz) { + return tz.empty() || tz == "UTC" || tz == "Etc/UTC"; +} + +/// Length of the (first) session window in minutes: 0930-1600 -> 390, +/// 1700-1700 -> 1440 (wraps midnight), 24x7/empty -> 1440. +static int session_length_minutes(const std::string& session) { + if (session.empty() || session == "24x7") return 1440; + int values[2] = {-1, -1}; + int idx = 0, digits = 0, value = 0; + for (char c : session) { + if (c >= '0' && c <= '9') { + value = value * 10 + (c - '0'); + if (++digits == 4) { + if (idx < 2) values[idx] = value; + ++idx; + digits = 0; + value = 0; + } + } else if (digits > 0) { + digits = 0; value = 0; + } + if (idx >= 2) break; + } + if (values[0] < 0 || values[1] < 0) return 1440; + int start = (values[0] / 100) * 60 + (values[0] % 100); + int end = (values[1] / 100) * 60 + (values[1] % 100); + int len = end - start; + if (len <= 0) len += 1440; // wraps midnight (1700-1700, 1800-1700) + return len; +} + +/// Days from a session-day's nominal OPEN date to its TradingView trading +/// date: the local date the session closes on (last instant before the +/// close). 1700-1700 -> +1 (the Sunday-17:00 open is Monday's bar); +/// 0930-1600, 0000-0000 and 24x7 -> 0. +static int session_trading_date_shift_days(const std::string& session) { + const int start = session_open_offset_minutes(session); + const int len = session_length_minutes(session); + return (start + len - 1) / 1440; +} + +/// Wall-clock instant (UTC-encoded local time) at which session-day `d` +/// opens: d * day + session open. Pure integer math on the exchange clock. +static int64_t session_day_open_nominal_ms(int64_t d, const std::string& session) { + return d * kMsPerDay + + static_cast(session_open_offset_minutes(session)) * 60000; +} + +/// Real epoch of session-day `d`'s open: the nominal wall-clock instant +/// mapped back through the zone offset in force on THAT date (resolved +/// twice so an east-of-UTC zone whose local open lands on the previous UTC +/// date reads its own offset, not the nominal date's). +static int64_t session_day_open_real_ms(int64_t d, const std::string& tz, + const std::string& session) { + const int64_t nominal = session_day_open_nominal_ms(d, session); + if (is_utc_tz(tz)) return nominal; + int64_t real = nominal + tz_offset_ms(nominal, tz); + real = nominal + tz_offset_ms(real, tz); + return real; +} + +int64_t session_day_index(int64_t ms, const std::string& tz, + const std::string& session) { + return floor_div_day(intraday_clock_ms(ms, tz, session)); +} + +/// Epoch day (days since 1970-01-01) of the first session-day of the +/// D/W/M period containing session-day `d`. +static int64_t session_period_first_day(int64_t d, const std::string& session, + CalendarPeriod period) { + if (period == CalendarPeriod::DAY || period == CalendarPeriod::NONE) return d; + const int shift = session_trading_date_shift_days(session); + const int64_t td = d + shift; // trading date (epoch day) + if (period == CalendarPeriod::WEEK) { + // 1970-01-01 (day 0) was a Thursday: tm_wday 4. Monday-start weeks + // over trading dates: forex Sun-17:00 opens are Monday's trading + // date and start the week; equities / 24x7 start Monday 09:30 / 00:00. + const int wday = static_cast(((td + 4) % 7 + 7) % 7); // 0=Sun + const int days_from_mon = (wday + 6) % 7; + return td - days_from_mon - shift; + } + // MONTH: first-of-month of the trading date. + time_t secs = static_cast(td * kSecPerDay); + struct tm g {}; + gmtime_r(&secs, &g); + return td - (g.tm_mday - 1) - shift; +} + +int64_t session_period_open_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); + return session_day_open_real_ms(session_period_first_day(d, session, period), + tz, session); +} + +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; + } + return session_day_open_real_ms(next_first, tz, session); +} + /// Period key for D/W/M attribution by SESSION-DAY: every bar belongs to the /// session that contains it, and that session belongs to the calendar period -/// of its OPENING date. Forex weeks therefore start at the weekend-open -/// session (TV's Sun-17:00-ET week), while equity/24x7 feeds — whose sessions -/// open and close on one local date — resolve exactly as before (weeks stay -/// Monday-partitioned over traded days; months unchanged). +/// of its TRADING date (see session_trading_date_shift_days). Forex weeks +/// therefore start at the weekend-open session (TV's Sun-17:00-ET week) and +/// forex months at the session closing on the 1st, while equity/24x7 feeds — +/// whose sessions open and close on one local date — resolve exactly as +/// before (weeks stay Monday-partitioned over traded days; months unchanged). static long session_period_key(int64_t ms, const std::string& tz, const std::string& session, CalendarPeriod period) { - int64_t ic = intraday_clock_ms(ms, tz, session); - long day_idx = ic / kMsPerDay; - if (ic < 0 && ic % kMsPerDay != 0) --day_idx; // floor division - // Real epoch of this session's open: shifted day start, mapped back - // through the zone offset (the session's bars share one offset). - const int64_t open_real = day_idx * kMsPerDay - + tz_offset_ms(ms, tz) - + static_cast(session_open_offset_minutes(session)) * 60000; - // Local calendar date of the open: - const int64_t lmid = calendar_day_open_local_ms(open_real, tz); - time_t secs = static_cast(lmid / 1000); - struct tm g {}; - gmtime_r(&secs, &g); - if (period == CalendarPeriod::MONTH) { - return static_cast(g.tm_year) * 12 + g.tm_mon; - } - // WEEK: Sunday-start keys. A session opening Sunday begins the week; - // Mon..Fri sessions subtract their weekday back to the same Sunday. - // Equity/24x7 feeds trade Mon..Fri only, so the partition equals the - // previous Monday-start grouping bit-for-bit. - return day_idx - g.tm_wday; + const int64_t day_idx = session_day_index(ms, tz, session); + return static_cast(session_period_first_day(day_idx, session, period)); } bool crosses_boundary(int64_t prev_ms, int64_t curr_ms, CalendarPeriod period) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 92e865a..9e2abca 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -20,6 +20,7 @@ set(TEST_SOURCES test_matrix_na test_matrix_snapshot_compat test_session_time + test_session_day_anchors test_time_tradingday test_chart_timezone test_intraday_rollover_chart_tz @@ -85,6 +86,7 @@ set(TEST_SOURCES test_stop_decline_continue_path test_frozen_flat_gap_reject test_explicit_qty_fill_admission + test_zero_lot_entry_decline test_pooc_coof_reversal_gross_admission test_limit_fill_slippage test_strategy_commands_extra @@ -113,6 +115,7 @@ set(TEST_SOURCES test_margin_call_intrabar_chronology test_margin_call_trail_exit_chronology test_margin_call_1x_long_entry_fill + test_margin_call_gap_open test_percent_equity_open_entry_fee test_streaming test_calc_on_order_fills diff --git a/tests/test_margin_call.cpp b/tests/test_margin_call.cpp index 3c28cba..5fed758 100644 --- a/tests/test_margin_call.cpp +++ b/tests/test_margin_call.cpp @@ -120,12 +120,14 @@ static void test_short_margin_call() { // bar0 entry @ close=100 (qty = 1000/100 = 10, notional 1000 = equity). // liqPrice (short, 100% margin) = ((1000/10) + 100) / 2 = 100. - // bar1 small rise: high=105 > liq=100 -> partial 4x liquidation @ high=105. + // bar1 opens AT liq=100 (no open-point deficit — finding-430 slices a + // gap-open breach at the open) and rises: high=105 > liq=100 -> partial + // 4x liquidation @ high=105. // equity@105 = 1000 - (105-100)*10 = 950; reqMargin@105 = 10*105 = 1050. // qmin = 10 - 950/105 = 0.952381; 4x = 3.809524 (< 10) -> partial fill. std::vector bars = { mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), // 0: short fills @100 - mk_bar(2000, 101.0, 105.0, 100.5, 104.0, 1.0), // 1: high 105 -> margin call + mk_bar(2000, 100.0, 105.0, 99.5, 104.0, 1.0), // 1: high 105 -> margin call mk_bar(3000, 104.0, 130.0, 103.0, 128.0, 1.0), // 2: high 130 -> further call mk_bar(4000, 128.0, 140.0, 127.0, 139.0, 1.0), // 3: keep rising }; @@ -172,7 +174,7 @@ static void test_short_margin_call_disabled() { std::printf("test_short_margin_call_disabled\n"); std::vector bars = { mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), - mk_bar(2000, 101.0, 105.0, 100.5, 104.0, 1.0), + mk_bar(2000, 100.0, 105.0, 99.5, 104.0, 1.0), mk_bar(3000, 104.0, 200.0, 103.0, 199.0, 1.0), // huge adverse move }; ShortLiqProbe eng(/*disable_mc=*/true); @@ -202,7 +204,7 @@ static void test_short_margin_call_qty_step() { // (bar1 high = 105). std::vector bars = { mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), // 0: short fills @100 - mk_bar(2000, 101.0, 105.0, 100.5, 104.0, 1.0), // 1: high 105 -> margin call + mk_bar(2000, 100.0, 105.0, 99.5, 104.0, 1.0), // 1: high 105 -> margin call mk_bar(3000, 104.0, 130.0, 103.0, 128.0, 1.0), // 2: high 130 -> further call mk_bar(4000, 128.0, 140.0, 127.0, 139.0, 1.0), // 3: keep rising }; @@ -615,7 +617,7 @@ static void test_short_margin_call_account_fx() { constexpr double account_fx = 2.0; std::vector bars = { mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), - mk_bar(2000, 101.0, 105.0, 100.5, 104.0, 1.0), + mk_bar(2000, 100.0, 105.0, 99.5, 104.0, 1.0), }; ShortLiqProbe eng(/*disable_mc=*/false, /*qty_step=*/0.0, account_fx); eng.run(bars.data(), (int)bars.size()); diff --git a/tests/test_margin_call_gap_open.cpp b/tests/test_margin_call_gap_open.cpp new file mode 100644 index 0000000..daab3f9 --- /dev/null +++ b/tests/test_margin_call_gap_open.cpp @@ -0,0 +1,301 @@ +/* + * test_margin_call_gap_open.cpp — finding-430: forced liquidation at the + * bar OPEN when a carried leveraged position already breaches the margin + * requirement there (TradingView's broker emulator checks margin at every + * point of the intrabar path, and the open is the first one). + * + * A. Gap-open breach, no further breach at the extreme: exactly ONE + * "Margin call" row, filled AT THE OPEN, with the quantity computed at + * the open price (4x the open-priced shortfall). + * B. Gap-open breach AND a deeper breach at the adverse extreme: TWO + * "Margin call" rows on the same bar — the open slice first, then the + * survivor's extreme slice with the quantity computed at the extreme + * on the post-slice position. + * C. No open breach (open below the liquidation price, high above it): + * the established single adverse-extreme slice, bit-identical to the + * pre-fix engine (regression guard for on-tick feeds without gaps). + * D. Whole-share lot grid (qty_step = 1, the NASDAQ:AAPL tape shape): + * floor-before-4x at the open price and the one-contract fallback when + * the open-priced shortfall floors to zero. + * E. A leveraged LONG gapping DOWN through its liquidation price is + * sliced at the open on the same terms. + * F. The emulator switch (set_margin_call_enabled(false)) disables the + * open slice together with the rest of the forced-liquidation family. + */ + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace pineforge; + +static int tests_passed = 0; +static int tests_failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +static bool near(double a, double b, double tol = 1e-6) { + return std::fabs(a - b) < tol; +} + +namespace { + +static constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +static Bar mk_bar(int64_t ts, double o, double h, double l, double c, double v) { + Bar b; + b.open = o; b.high = h; b.low = l; b.close = c; b.volume = v; b.timestamp = ts; + return b; +} + +class MCEngine : public BacktestEngine { +public: + std::string exit_comment(int i) const { return closed_trade_exit_comment(i); } + double exit_price(int i) const { return closed_trade_exit_price(i); } + double entry_price(int i) const { return closed_trade_entry_price(i); } + double trade_size(int i) const { return closed_trade_size(i); } + int exit_bar(int i) const { return closed_trade_exit_bar_index(i); } + double position_size() const { return signed_position_size(); } +}; + +// 100%-equity short at 1x margin (TV default margin_short=100), market entry +// filling at bar0 close = 100 -> qty 10, liquidation price 100. +class ShortProbe : public MCEngine { +public: + explicit ShortProbe(double qty_step = 0.0, bool disable_mc = false) { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.0; + margin_short_ = 100.0; + process_orders_on_close_ = true; + qty_step_ = qty_step; + syminfo_mintick_ = 0.01; + if (disable_mc) set_margin_call_enabled(false); + } + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) strategy_entry("S", false, kNaN, kNaN, kNaN); + } +}; + +// 5x leveraged long (margin_long = 20), 100% of equity: qty 10 @ 100, +// liquidation price = (100 - 100) / (0.2 - 1) ... = 100 - 100/(10*... ) see +// compute_liquidation_price: (equity/(qty*pv) - entry) / (m - 1) +// = (1000/10 - 100) / (0.2 - 1) = 0 / -0.8 = 100 -> wait: at 100% of equity +// the long's margin requirement 10*100*0.2 = 200 <= 1000, so liq is where +// equity(P) = 0.2*10*P: 1000 + (P-100)*10 = 2P -> 8P = 0 ... use the engine's +// formula directly in the assertions below instead of restating it. +class LevLongProbe : public MCEngine { +public: + LevLongProbe() { + initial_capital_ = 1000.0; + default_qty_type_ = QtyType::PERCENT_OF_EQUITY; + default_qty_value_ = 100.0; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.0; + margin_long_ = 20.0; + process_orders_on_close_ = true; + syminfo_mintick_ = 0.01; + } + void on_bar(const Bar& /*bar*/) override { + if (bar_index_ == 0) strategy_entry("L", true, kNaN, kNaN, kNaN); + } +}; + +static int count_margin_calls(const MCEngine& e) { + int n = 0; + for (int i = 0; i < e.trade_count(); ++i) + if (e.exit_comment(i) == std::string("Margin call")) ++n; + return n; +} + +// ---- A: gap-open breach, extreme does not breach the survivor ------------- +static void test_gap_open_single_slice_at_open() { + std::printf("test_gap_open_single_slice_at_open\n"); + // bar1 opens at 104 (> liq 100). At the open: equity = 1000 - 4*10 = 960, + // required = 10*104 = 1040 -> q_min = 10 - 960/104 = 0.769231, 4x = 3.076923. + // Survivor 6.923077 @ high 106: equity = 1000 - 4*3.076923 - 6*6.923077 + // = 945.85, required = 6.923077*106 = 733.85 -> no second slice. + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), + mk_bar(2000, 104.0, 106.0, 103.0, 105.0, 1.0), + mk_bar(3000, 105.0, 105.5, 104.0, 105.0, 1.0), + }; + ShortProbe eng; + eng.run(bars.data(), (int)bars.size()); + CHECK(count_margin_calls(eng) == 1); + CHECK(eng.trade_count() >= 1); + if (eng.trade_count() >= 1) { + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(eng.exit_bar(0) == 1); + CHECK(near(eng.exit_price(0), 104.0)); // AT THE OPEN, not the high + CHECK(near(eng.trade_size(0), 3.0769230769, 1e-6)); // open-priced 4x shortfall + } + CHECK(near(eng.position_size(), -(10.0 - 3.0769230769), 1e-6)); +} + +// ---- B: gap-open breach + deeper extreme breach: two slices on one bar ---- +static void test_gap_open_then_extreme_second_slice() { + std::printf("test_gap_open_then_extreme_second_slice\n"); + // bar1: open 104 -> open slice 3.076923 (as above), survivor 6.923077. + // high 130: equity = 1000 - 12.307692 - 30*6.923077 = 780.0, + // required = 6.923077*130 = 900.0 -> q_min = 6.923077 - 780/130 = 0.923077, + // 4x = 3.692308 -> second "Margin call" row @130 on the same bar. + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), + mk_bar(2000, 104.0, 130.0, 103.0, 128.0, 1.0), + mk_bar(3000, 128.0, 128.5, 127.0, 128.0, 1.0), + }; + ShortProbe eng; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() >= 2); + if (eng.trade_count() >= 2) { + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(eng.exit_comment(1) == std::string("Margin call")); + CHECK(eng.exit_bar(0) == 1); + CHECK(eng.exit_bar(1) == 1); + CHECK(near(eng.exit_price(0), 104.0)); + CHECK(near(eng.trade_size(0), 3.0769230769, 1e-6)); + CHECK(near(eng.exit_price(1), 130.0)); + CHECK(near(eng.trade_size(1), 3.6923076923, 1e-6)); + } +} + +// ---- C: no open breach -> the established single extreme slice ----------- +static void test_no_open_breach_keeps_extreme_only() { + std::printf("test_no_open_breach_keeps_extreme_only\n"); + // bar1 opens at 99.5 (< liq 100): no open slice. high 105 -> the ordinary + // extreme slice: equity@105 = 950, required 1050, q_min 0.952381, 4x + // 3.809524 @105 (the test_margin_call.cpp reference values). + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), + mk_bar(2000, 99.5, 105.0, 99.0, 104.0, 1.0), + mk_bar(3000, 104.0, 104.5, 103.0, 104.0, 1.0), + }; + ShortProbe eng; + eng.run(bars.data(), (int)bars.size()); + CHECK(count_margin_calls(eng) == 1); + if (eng.trade_count() >= 1) { + CHECK(near(eng.exit_price(0), 105.0)); + CHECK(near(eng.trade_size(0), 3.80952381, 1e-4)); + CHECK(eng.exit_bar(0) == 1); + } +} + +// ---- D: whole-share lot grid --------------------------------------------- +static void test_gap_open_whole_share_grid() { + std::printf("test_gap_open_whole_share_grid\n"); + // qty_step = 1: bar0 short 10 @100. bar1 open 104: raw q_min 0.769231 + // floors to 0 -> the one-contract fallback closes exactly 1 share @104. + { + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), + mk_bar(2000, 104.0, 104.5, 103.0, 104.0, 1.0), + mk_bar(3000, 104.0, 104.5, 103.0, 104.0, 1.0), + }; + ShortProbe eng(/*qty_step=*/1.0); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() >= 1); + if (eng.trade_count() >= 1) { + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(near(eng.exit_price(0), 104.0)); + CHECK(near(eng.trade_size(0), 1.0)); + CHECK(eng.exit_bar(0) == 1); + } + } + // Larger gap: open 112 -> equity 880, required 1120, q_min = 10 - 880/112 + // = 2.142857 -> floor 2 -> 4x = 8 shares @112 (floor-before-4x at the + // OPEN price; the high-priced rule would give a different lot). + { + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), + mk_bar(2000, 112.0, 112.5, 111.0, 112.0, 1.0), + mk_bar(3000, 112.0, 112.5, 111.0, 112.0, 1.0), + }; + ShortProbe eng(/*qty_step=*/1.0); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() >= 1); + if (eng.trade_count() >= 1) { + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(near(eng.exit_price(0), 112.0)); + CHECK(near(eng.trade_size(0), 8.0)); + } + // Survivor 2 @112.5: equity = 1000 - 12*8 - 12.5*2 = 879, required + // 225 -> no second slice on this bar. + CHECK(count_margin_calls(eng) == 1); + CHECK(near(eng.position_size(), -2.0)); + } +} + +// ---- E: leveraged long gapping down --------------------------------------- +static void test_leveraged_long_gap_down() { + std::printf("test_leveraged_long_gap_down\n"); + // 5x long 10 @100 (required margin 200 of equity 1000). Liquidation where + // 1000 + (P-100)*10 = 0.2*10*P -> 8P = 0 ... i.e. P = 0? No: equity(P) = + // 1000 + 10*(P-100) = 10P; required = 2P; 10P >= 2P always -> at 100% of + // equity a 5x long is never in deficit. Use an 400%-of-equity long instead: + // qty 40 @100 (required 800 <= 1000). equity(P) = 1000 + 40*(P-100) + // = 40P - 3000; required = 8P -> deficit when 32P < 3000 -> P < 93.75. + class BigLevLong : public LevLongProbe { + public: + BigLevLong() { default_qty_value_ = 400.0; } + }; + // bar1 gaps down to 90: equity = 40*90 - 3000 = 600, required = 720, + // q_min = 40 - 600/18 = 6.666667, 4x = 26.666667 @90 at the open. + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), + mk_bar(2000, 90.0, 91.0, 89.5, 90.5, 1.0), + mk_bar(3000, 90.5, 91.0, 90.0, 90.5, 1.0), + }; + BigLevLong eng; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() >= 1); + if (eng.trade_count() >= 1) { + CHECK(eng.exit_comment(0) == std::string("Margin call")); + CHECK(eng.exit_bar(0) == 1); + CHECK(near(eng.exit_price(0), 90.0)); + CHECK(near(eng.trade_size(0), 26.6666666667, 1e-6)); + } +} + +// ---- F: emulator switch --------------------------------------------------- +static void test_gap_open_disabled() { + std::printf("test_gap_open_disabled\n"); + std::vector bars = { + mk_bar(1000, 100.0, 100.0, 99.0, 100.0, 1.0), + mk_bar(2000, 104.0, 130.0, 103.0, 128.0, 1.0), + mk_bar(3000, 128.0, 128.5, 127.0, 128.0, 1.0), + }; + ShortProbe eng(/*qty_step=*/0.0, /*disable_mc=*/true); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.trade_count() == 0); +} + +} // namespace + +int main() { + test_gap_open_single_slice_at_open(); + test_gap_open_then_extreme_second_slice(); + test_no_open_breach_keeps_extreme_only(); + test_gap_open_whole_share_grid(); + test_leveraged_long_gap_down(); + test_gap_open_disabled(); + std::printf("%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +} diff --git a/tests/test_session_day_anchors.cpp b/tests/test_session_day_anchors.cpp new file mode 100644 index 0000000..4d1d242 --- /dev/null +++ b/tests/test_session_day_anchors.cpp @@ -0,0 +1,360 @@ +// Symbol-clock D/W/M anchors shared by ta.vwap's default anchor, +// timeframe.change, time()/time_close() and request.security period keys. +// +// TradingView's daily bar is the SESSION day: OANDA:EURUSD +// (America/New_York, 1700-1700) opens at 17:00 ET and its week on Sunday +// 17:00 ET; NASDAQ:AAPL (0930-1600) opens 09:30 ET Monday..Friday; a 24x7 +// UTC symbol opens at 00:00 UTC. The tz="UTC" + empty/"24x7" session forms +// must stay bit-identical to the pre-existing UTC integer math. +#include +#include +#include +#include + +#include +#include +#include +#include + +using namespace pineforge; + +static int tests_passed = 0; +static int tests_failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +#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) + +// 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"; +static const std::string RTH = "0930-1600"; +static const std::string UTC = "UTC"; +static const std::string NONE; + +// ─── Forex: America/New_York 1700-1700 ──────────────────────────────────────── + +static void test_forex_daily_open_is_17et() { + std::printf("test_forex_daily_open_is_17et\n"); + // Tue 2025-06-10 03:00 EDT belongs to the session that opened Mon 17:00 EDT. + const int64_t bar = utc_ms(2025, 6, 10, 7, 0); + CHECK_EQ_MS(session_period_open_ms(bar, NY, FX, CalendarPeriod::DAY), + utc_ms(2025, 6, 9, 21, 0)); + // Mon 16:30 EDT is still SUNDAY's session (opened Sun 17:00 EDT). + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 6, 9, 20, 30), NY, FX, CalendarPeriod::DAY), + utc_ms(2025, 6, 8, 21, 0)); + // The first bar at 17:00 EDT opens the new session. + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 6, 9, 21, 0), NY, FX, CalendarPeriod::DAY), + utc_ms(2025, 6, 9, 21, 0)); + // UTC midnight is NOT a boundary on this symbol. + CHECK(session_day_index(utc_ms(2025, 6, 9, 23, 45), NY, FX) + == session_day_index(utc_ms(2025, 6, 10, 0, 0), NY, FX)); + CHECK(session_day_index(utc_ms(2025, 6, 9, 20, 45), NY, FX) + != session_day_index(utc_ms(2025, 6, 9, 21, 0), NY, FX)); + // Winter (EST): 17:00 ET == 22:00Z. + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 1, 15, 12, 0), NY, FX, CalendarPeriod::DAY), + utc_ms(2025, 1, 14, 22, 0)); + // Daily close == next session open (exclusive); time_close reports the last ms. + CHECK_EQ_MS(session_period_close_ms(bar, NY, FX, CalendarPeriod::DAY), + utc_ms(2025, 6, 10, 21, 0)); + CHECK_EQ_MS(pine_time_close(bar, "D", "", "", "15", NY, FX), + utc_ms(2025, 6, 10, 21, 0) - 1); +} + +static void test_forex_dst_step() { + std::printf("test_forex_dst_step\n"); + // US DST began Sun 2025-03-09 07:00Z. Friday before: 17:00 EST == 22:00Z. + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 3, 7, 12, 0), NY, FX, CalendarPeriod::DAY), + utc_ms(2025, 3, 6, 22, 0)); + // Monday after: the week/day opened Sun 17:00 EDT == 21:00Z. + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 3, 10, 8, 0), NY, FX, CalendarPeriod::DAY), + utc_ms(2025, 3, 9, 21, 0)); + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 3, 10, 8, 0), NY, FX, CalendarPeriod::WEEK), + utc_ms(2025, 3, 9, 21, 0)); + // Fall back: DST ended Sun 2025-11-02 06:00Z. Monday 2025-11-03 bar -> + // Sun 17:00 EST == 22:00Z. + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 11, 3, 10, 0), NY, FX, CalendarPeriod::DAY), + utc_ms(2025, 11, 2, 22, 0)); + // Friday before fall-back: Thu 17:00 EDT == 21:00Z. + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 10, 31, 10, 0), NY, FX, CalendarPeriod::DAY), + utc_ms(2025, 10, 30, 21, 0)); +} + +static void test_forex_week_opens_sunday_17et() { + std::printf("test_forex_week_opens_sunday_17et\n"); + const int64_t sun_open = utc_ms(2025, 6, 8, 21, 0); + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 6, 10, 7, 0), NY, FX, CalendarPeriod::WEEK), sun_open); + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 6, 8, 22, 0), NY, FX, CalendarPeriod::WEEK), sun_open); + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 6, 13, 20, 45), NY, FX, CalendarPeriod::WEEK), sun_open); + // Friday 16:45 EDT of the previous week. + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 6, 6, 20, 45), NY, FX, CalendarPeriod::WEEK), + utc_ms(2025, 6, 1, 21, 0)); + // Weekly close == next Sunday open. + CHECK_EQ_MS(session_period_close_ms(utc_ms(2025, 6, 10, 7, 0), NY, FX, CalendarPeriod::WEEK), + utc_ms(2025, 6, 15, 21, 0)); + // tf_change("W") fires on the Sunday open, not at Monday 00:00Z. + CHECK(tf_change(utc_ms(2025, 6, 6, 20, 45), utc_ms(2025, 6, 8, 21, 0), "W", NY, FX)); + CHECK(!tf_change(utc_ms(2025, 6, 8, 21, 0), utc_ms(2025, 6, 9, 0, 0), "W", NY, FX)); + CHECK(!tf_change(utc_ms(2025, 6, 8, 21, 0), utc_ms(2025, 6, 9, 21, 15), "W", NY, FX)); +} + +static void test_forex_month_opens_on_trading_date() { + std::printf("test_forex_month_opens_on_trading_date\n"); + // July 1 2025 is a Tuesday: its daily bar opens Mon Jun 30 17:00 EDT. + const int64_t jul_open = utc_ms(2025, 6, 30, 21, 0); + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 7, 10, 7, 0), NY, FX, CalendarPeriod::MONTH), jul_open); + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 6, 30, 21, 0), NY, FX, CalendarPeriod::MONTH), jul_open); + // Mon Jun 30 16:45 EDT is still June. + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 6, 30, 20, 45), NY, FX, CalendarPeriod::MONTH), + utc_ms(2025, 5, 31, 21, 0)); // June 1 is a Sunday: nominal Sat-open session + // Aug 1 2025 is a Friday -> Thu Jul 31 17:00 EDT. + CHECK_EQ_MS(session_period_close_ms(utc_ms(2025, 7, 10, 7, 0), NY, FX, CalendarPeriod::MONTH), + utc_ms(2025, 7, 31, 21, 0)); + // timeframe.change("M") / security M keys agree with the open. + CHECK(tf_change(utc_ms(2025, 6, 30, 20, 45), utc_ms(2025, 6, 30, 21, 0), "M", NY, FX)); + CHECK(!tf_change(utc_ms(2025, 6, 30, 21, 0), utc_ms(2025, 7, 1, 0, 0), "M", NY, FX)); + CHECK(!tf_change(utc_ms(2025, 7, 1, 20, 45), utc_ms(2025, 7, 1, 21, 15), "M", NY, FX)); + CHECK(crosses_boundary(utc_ms(2025, 6, 30, 20, 45), utc_ms(2025, 6, 30, 21, 0), + CalendarPeriod::MONTH, NY, FX)); + // Year roll: Jan 1 2026 is a Thursday -> Wed Dec 31 17:00 EST == 22:00Z. + CHECK_EQ_MS(session_period_open_ms(utc_ms(2026, 1, 8, 12, 0), NY, FX, CalendarPeriod::MONTH), + utc_ms(2025, 12, 31, 22, 0)); +} + +static void test_forex_tf_change_daily() { + std::printf("test_forex_tf_change_daily\n"); + CHECK(tf_change(utc_ms(2025, 6, 9, 20, 45), utc_ms(2025, 6, 9, 21, 0), "D", NY, FX)); + CHECK(tf_change(utc_ms(2025, 6, 9, 20, 45), utc_ms(2025, 6, 9, 21, 0), "1D", NY, FX)); + CHECK(!tf_change(utc_ms(2025, 6, 9, 23, 45), utc_ms(2025, 6, 10, 0, 0), "D", NY, FX)); + // The tz-less form is the UTC-midnight rule (corpus regime). + CHECK(tf_change(utc_ms(2025, 6, 9, 23, 45), utc_ms(2025, 6, 10, 0, 0), "D")); + CHECK(!tf_change(utc_ms(2025, 6, 9, 20, 45), utc_ms(2025, 6, 9, 21, 0), "D")); +} + +static void test_forex_pine_time_symbol_clock() { + std::printf("test_forex_pine_time_symbol_clock\n"); + const int64_t bar = utc_ms(2025, 6, 10, 7, 0); // Tue 03:00 EDT + CHECK_EQ_MS(pine_time(bar, "D", "", "", "15", NY, FX), utc_ms(2025, 6, 9, 21, 0)); + CHECK_EQ_MS(pine_time(bar, "1D", "", "", "15", NY, FX), utc_ms(2025, 6, 9, 21, 0)); + CHECK_EQ_MS(pine_time(bar, "W", "", "", "15", NY, FX), utc_ms(2025, 6, 8, 21, 0)); + // Two-arg time("D", "") — a timezone in the session slot is not a + // session: filter dropped, open still the symbol's daily bar. + CHECK_EQ_MS(pine_time(bar, "D", "Europe/Prague", "", "15", NY, FX), utc_ms(2025, 6, 9, 21, 0)); + // A VALID session argument defines the day in ITS timezone (TV keys + // `time("D", "0000-2359", "America/New_York")` on New York midnight — + // measured on lukeborgerding-orb-avwap-retest, 100% vs 18%): the + // tz-only calendar floor of the 5-arg forms, na outside the window. + CHECK_EQ_MS(pine_time(utc_ms(2025, 6, 10, 14, 0), "D", RTH, NY, "15", NY, FX), + pine_time(utc_ms(2025, 6, 10, 14, 0), "D", RTH, NY, "15")); + CHECK_EQ_MS(pine_time(utc_ms(2025, 6, 10, 14, 0), "D", RTH, NY, "15", NY, FX), + utc_ms(2025, 6, 10, 4, 0)); + CHECK(is_na(pine_time(bar, "D", RTH, NY, "15", NY, FX))); + // Same on a UTC/24x7 symbol: the session's tz rolls the day, not UTC. + CHECK_EQ_MS(pine_time(utc_ms(2025, 6, 10, 1, 0), "D", "0000-2359", NY, "15", UTC, "24x7"), + utc_ms(2025, 6, 9, 4, 0)); + CHECK_EQ_MS(pine_time_close(utc_ms(2025, 6, 10, 1, 0), "D", "0000-2359", NY, "15", UTC, "24x7"), + pine_time_close(utc_ms(2025, 6, 10, 1, 0), "D", "0000-2359", NY, "15")); + // Intraday tfs keep the epoch grid. + CHECK_EQ_MS(pine_time(bar, "60", "", "", "15", NY, FX), pine_time(bar, "60", "", "", "15")); + // Empty tf falls back to the chart tf. + CHECK_EQ_MS(pine_time(bar, "", "", "", "D", NY, FX), utc_ms(2025, 6, 9, 21, 0)); +} + +// ─── Equities: America/New_York 0930-1600 ───────────────────────────────────── + +static void test_equity_daily_open_is_0930et() { + std::printf("test_equity_daily_open_is_0930et\n"); + const int64_t bar = utc_ms(2025, 6, 10, 14, 0); // Tue 10:00 EDT + CHECK_EQ_MS(session_period_open_ms(bar, NY, RTH, CalendarPeriod::DAY), utc_ms(2025, 6, 10, 13, 30)); + CHECK_EQ_MS(pine_time(bar, "D", "", "", "15", NY, RTH), utc_ms(2025, 6, 10, 13, 30)); + // Close == 16:00 ET, not the next session open. + CHECK_EQ_MS(session_period_close_ms(bar, NY, RTH, CalendarPeriod::DAY), utc_ms(2025, 6, 10, 20, 0)); + CHECK_EQ_MS(pine_time_close(bar, "D", "", "", "15", NY, RTH), utc_ms(2025, 6, 10, 20, 0) - 1); + // Winter: 09:30 EST == 14:30Z. + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 1, 15, 15, 0), NY, RTH, CalendarPeriod::DAY), + utc_ms(2025, 1, 15, 14, 30)); + // Session-day advances between Friday's last bar and Monday's first. + CHECK(session_day_index(utc_ms(2025, 6, 6, 19, 45), NY, RTH) + != session_day_index(utc_ms(2025, 6, 9, 13, 30), NY, RTH)); + CHECK(session_day_index(utc_ms(2025, 6, 9, 13, 30), NY, RTH) + == session_day_index(utc_ms(2025, 6, 9, 19, 45), NY, RTH)); + CHECK(tf_change(utc_ms(2025, 6, 9, 19, 45), utc_ms(2025, 6, 10, 13, 30), "D", NY, RTH)); + CHECK(!tf_change(utc_ms(2025, 6, 10, 13, 30), utc_ms(2025, 6, 10, 13, 45), "D", NY, RTH)); +} + +static void test_equity_week_and_month_open_monday_first() { + std::printf("test_equity_week_and_month_open_monday_first\n"); + const int64_t bar = utc_ms(2025, 6, 11, 14, 0); // Wed + CHECK_EQ_MS(session_period_open_ms(bar, NY, RTH, CalendarPeriod::WEEK), utc_ms(2025, 6, 9, 13, 30)); + CHECK_EQ_MS(pine_time(bar, "W", "", "", "15", NY, RTH), utc_ms(2025, 6, 9, 13, 30)); + CHECK_EQ_MS(session_period_close_ms(bar, NY, RTH, CalendarPeriod::WEEK), utc_ms(2025, 6, 16, 13, 30)); + // July 1 2025 (Tuesday) 09:30 EDT. + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 7, 10, 14, 0), NY, RTH, CalendarPeriod::MONTH), + utc_ms(2025, 7, 1, 13, 30)); + CHECK(tf_change(utc_ms(2025, 6, 30, 19, 45), utc_ms(2025, 7, 1, 13, 30), "M", NY, RTH)); + CHECK(!tf_change(utc_ms(2025, 6, 27, 19, 45), utc_ms(2025, 6, 30, 13, 30), "M", NY, RTH)); + CHECK(tf_change(utc_ms(2025, 6, 6, 19, 45), utc_ms(2025, 6, 9, 13, 30), "W", NY, RTH)); + CHECK(!tf_change(utc_ms(2025, 6, 9, 19, 45), utc_ms(2025, 6, 10, 13, 30), "W", NY, RTH)); +} + +// ─── East-of-UTC exchange (Asia/Tokyo 0900-1530) ────────────────────────────── + +static void test_tokyo_open_on_previous_utc_date() { + std::printf("test_tokyo_open_on_previous_utc_date\n"); + const std::string TK = "Asia/Tokyo", JP = "0900-1530"; + const int64_t bar = utc_ms(2025, 6, 10, 1, 0); // Tue 10:00 JST + CHECK_EQ_MS(session_period_open_ms(bar, TK, JP, CalendarPeriod::DAY), utc_ms(2025, 6, 10, 0, 0)); + CHECK_EQ_MS(session_period_open_ms(bar, TK, JP, CalendarPeriod::WEEK), utc_ms(2025, 6, 9, 0, 0)); + // Tue Jul 1 09:00 JST == Mon Jun 30 24:00Z: the month open sits on the + // previous UTC date and must still resolve to July. + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 7, 10, 1, 0), TK, JP, CalendarPeriod::MONTH), + utc_ms(2025, 7, 1, 0, 0)); + CHECK_EQ_MS(session_period_close_ms(bar, TK, JP, CalendarPeriod::DAY), utc_ms(2025, 6, 10, 6, 30)); +} + +// ─── 24x7 / UTC identity ────────────────────────────────────────────────────── + +static void test_utc_identity_with_tz_less_forms() { + std::printf("test_utc_identity_with_tz_less_forms\n"); + const char* tfs[] = {"D", "1D", "W", "1W", "M", "1M", "60", "240", "15"}; + const std::string sessions[] = {NONE, "24x7"}; + int64_t ms = utc_ms(2024, 12, 20, 0, 0); + const int64_t end = utc_ms(2025, 3, 20, 0, 0); + int checked = 0; + for (; ms < end; ms += 7 * 3600000LL + 37 * 60000LL) { + for (const auto& sess : sessions) { + CHECK_EQ_MS(session_period_open_ms(ms, UTC, sess, CalendarPeriod::DAY), + pine_time(ms, "D", "", "", "15")); + CHECK_EQ_MS(session_period_open_ms(ms, UTC, sess, CalendarPeriod::WEEK), + pine_time(ms, "W", "", "", "15")); + CHECK_EQ_MS(session_period_open_ms(ms, UTC, sess, CalendarPeriod::MONTH), + pine_time(ms, "M", "", "", "15")); + CHECK_EQ_MS(session_day_index(ms, UTC, sess), ms / kMsPerDay); + CHECK_EQ_MS(session_day_index(ms, NONE, sess), ms / kMsPerDay); + for (const char* tf : tfs) { + CHECK_EQ_MS(pine_time(ms, tf, "", "", "15", UTC, sess), + pine_time(ms, tf, "", "", "15")); + CHECK_EQ_MS(pine_time_close(ms, tf, "", "", "15", UTC, sess), + pine_time_close(ms, tf, "", "", "15")); + CHECK(tf_change(ms, ms + 900000, tf, UTC, sess) == tf_change(ms, ms + 900000, tf)); + CHECK(tf_change(ms, ms + 86400000LL, tf, UTC, sess) == tf_change(ms, ms + 86400000LL, tf)); + } + ++checked; + } + } + CHECK(checked > 200); +} + +static void test_vwap_utc_identity_and_forex_reset() { + std::printf("test_vwap_utc_identity_and_forex_reset\n"); + // Identity: the tz/session overload with UTC + 24x7 reproduces the tz-less + // sequence bit-for-bit (including the recompute path). + ta::VWAP a, b; + int64_t ms = utc_ms(2025, 6, 8, 12, 0); + double px = 1.1000, vol = 100.0; + for (int i = 0; i < 400; ++i, ms += 900000) { + px += ((i * 7919) % 13 - 6) * 0.0001; + vol = 50.0 + (i * 31) % 97; + double va = a.compute(px, vol, ms); + double vb = b.compute(px, vol, ms, UTC, "24x7"); + CHECK(std::memcmp(&va, &vb, sizeof(double)) == 0); + if (i % 5 == 0) { + double ra = a.recompute(px + 0.0002, vol, ms); + double rb = b.recompute(px + 0.0002, vol, ms, UTC, "24x7"); + CHECK(std::memcmp(&ra, &rb, sizeof(double)) == 0); + } + } + // Forex: the cumulator resets at 17:00 ET (21:00Z in June), not 00:00Z. + ta::VWAP fx; + fx.compute(1.10, 100.0, utc_ms(2025, 6, 9, 20, 30), NY, FX); + fx.compute(1.20, 100.0, utc_ms(2025, 6, 9, 20, 45), NY, FX); + double v = fx.compute(1.30, 100.0, utc_ms(2025, 6, 9, 21, 0), NY, FX); + CHECK(v == 1.30); // fresh session: vwap == src + v = fx.compute(1.50, 100.0, utc_ms(2025, 6, 9, 23, 45), NY, FX); + CHECK(v == 1.40); + v = fx.compute(1.70, 100.0, utc_ms(2025, 6, 10, 0, 0), NY, FX); + CHECK(v == 1.50); // no reset at UTC midnight + // Bands form resets on the same key. + ta::VWAP fb; + fb.compute_bands(1.10, 100.0, utc_ms(2025, 6, 9, 20, 45), 1.0, NY, FX); + ta::VWAPBandsResult r = fb.compute_bands(1.30, 100.0, utc_ms(2025, 6, 9, 21, 0), 1.0, NY, FX); + CHECK(r.vwap == 1.30); + CHECK(r.upper - 1.30 < 1e-6 && 1.30 - r.lower < 1e-6); // single-bar stdev ~ 0 (fp) + // Equities: reset at 09:30 ET between Friday's last bar and Monday's first. + ta::VWAP eq; + eq.compute(200.0, 100.0, utc_ms(2025, 6, 6, 19, 45), NY, RTH); + v = eq.compute(210.0, 100.0, utc_ms(2025, 6, 9, 13, 30), NY, RTH); + CHECK(v == 210.0); + v = eq.compute(230.0, 100.0, utc_ms(2025, 6, 9, 13, 45), NY, RTH); + CHECK(v == 220.0); +} + +static void test_session_length_edge_cases() { + std::printf("test_session_length_edge_cases\n"); + // "0000-0000" is a full day whose trading date is its open date. + const std::string ALLDAY = "0000-0000"; + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 6, 10, 7, 0), UTC, ALLDAY, CalendarPeriod::DAY), + utc_ms(2025, 6, 10, 0, 0)); + CHECK_EQ_MS(session_period_close_ms(utc_ms(2025, 6, 10, 7, 0), UTC, ALLDAY, CalendarPeriod::DAY), + utc_ms(2025, 6, 11, 0, 0)); + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 6, 10, 7, 0), UTC, ALLDAY, CalendarPeriod::WEEK), + utc_ms(2025, 6, 9, 0, 0)); + // CME-style 1800-1700 wraps midnight like forex: trading date == close date. + const std::string CME = "1800-1700"; + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 6, 10, 7, 0), NY, CME, CalendarPeriod::DAY), + utc_ms(2025, 6, 9, 22, 0)); + CHECK_EQ_MS(session_period_close_ms(utc_ms(2025, 6, 10, 7, 0), NY, CME, CalendarPeriod::DAY), + utc_ms(2025, 6, 10, 21, 0)); + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 6, 10, 7, 0), NY, CME, CalendarPeriod::WEEK), + utc_ms(2025, 6, 8, 22, 0)); + // Day-of-week suffix does not disturb parsing. + CHECK_EQ_MS(session_period_open_ms(utc_ms(2025, 6, 10, 14, 0), NY, "0930-1600:23456", CalendarPeriod::DAY), + utc_ms(2025, 6, 10, 13, 30)); +} + +int main() { + std::printf("=== Session-day anchor tests ===\n\n"); + test_forex_daily_open_is_17et(); + test_forex_dst_step(); + test_forex_week_opens_sunday_17et(); + test_forex_month_opens_on_trading_date(); + test_forex_tf_change_daily(); + test_forex_pine_time_symbol_clock(); + test_equity_daily_open_is_0930et(); + test_equity_week_and_month_open_monday_first(); + test_tokyo_open_on_previous_utc_date(); + test_utc_identity_with_tz_less_forms(); + test_vwap_utc_identity_and_forex_reset(); + test_session_length_edge_cases(); + std::printf("\n=== Results: %d passed, %d failed ===\n", tests_passed, tests_failed); + return tests_failed > 0 ? 1 : 0; +} diff --git a/tests/test_session_time.cpp b/tests/test_session_time.cpp index f64179c..777dc01 100644 --- a/tests/test_session_time.cpp +++ b/tests/test_session_time.cpp @@ -113,6 +113,75 @@ static void test_time_gmt_in_session_slot() { CHECK(!is_na(pine_time(bar, "D", "UTC", "", "15"))); } +// --- syminfo_tz: default zone for a tz-less session (Pine: exchange tz) --- + +static void test_time_session_default_tz_is_syminfo() { + std::printf("test_time_session_default_tz_is_syminfo\n"); + // time(tf, "0930-1600") with no tz argument: Pine reads the session in + // syminfo.timezone. 12:00Z is 08:00 EDT (outside NY RTH) but inside + // 0930-1600 read as UTC; 19:30Z is 15:30 EDT (inside) but outside in UTC. + int64_t bar_1200z = 1775563200000LL; // 2026-04-07 12:00 UTC = 08:00 EDT + int64_t bar_1930z = 1775590200000LL; // 2026-04-07 19:30 UTC = 15:30 EDT + // Historical default (empty syminfo_tz) == UTC, byte-identical. + CHECK(!is_na(pine_time(bar_1200z, "15", "0930-1600", "", "15"))); + CHECK( is_na(pine_time(bar_1930z, "15", "0930-1600", "", "15"))); + CHECK(!is_na(pine_time(bar_1200z, "15", "0930-1600", "", "15", ""))); + CHECK(!is_na(pine_time(bar_1200z, "15", "0930-1600", "", "15", "UTC"))); + // syminfo.timezone = America/New_York flips both bars. + CHECK( is_na(pine_time(bar_1200z, "15", "0930-1600", "", "15", "America/New_York"))); + CHECK(!is_na(pine_time(bar_1930z, "15", "0930-1600", "", "15", "America/New_York"))); + // time_close follows the same rule. + CHECK( is_na(pine_time_close(bar_1200z, "15", "0930-1600", "", "15", "America/New_York"))); + CHECK(!is_na(pine_time_close(bar_1930z, "15", "0930-1600", "", "15", "America/New_York"))); + // The intraday open value itself is unaffected (UTC bucket). + CHECK(pine_time(bar_1930z, "15", "0930-1600", "", "15", "America/New_York") == bar_1930z); +} + +static void test_time_session_explicit_tz_beats_syminfo() { + std::printf("test_time_session_explicit_tz_beats_syminfo\n"); + int64_t bar_1200z = 1775563200000LL; // 08:00 EDT / 12:00 UTC / 21:00 Tokyo + // Explicit "UTC" wins over syminfo NY: 12:00 is inside 0930-1600. + CHECK(!is_na(pine_time(bar_1200z, "15", "0930-1600", "UTC", "15", "America/New_York"))); + // Explicit NY wins over syminfo UTC: 08:00 EDT is outside. + CHECK( is_na(pine_time(bar_1200z, "15", "0930-1600", "America/New_York", "15", "UTC"))); +} + +static void test_time_session_syminfo_tz_dst_aware() { + std::printf("test_time_session_syminfo_tz_dst_aware\n"); + // Session "0930-1600" in America/New_York across the 2025-11-02 fall-back: + // the UTC start of the window steps from 13:30Z (EDT) to 14:30Z (EST). + int64_t fri_1345z = 1761918300000LL; // 2025-10-31 13:45 UTC = 09:45 EDT → inside + int64_t mon_1345z = 1762177500000LL; // 2025-11-03 13:45 UTC = 08:45 EST → outside + int64_t mon_1445z = 1762181100000LL; // 2025-11-03 14:45 UTC = 09:45 EST → inside + CHECK(!is_na(pine_time(fri_1345z, "15", "0930-1600", "", "15", "America/New_York"))); + CHECK( is_na(pine_time(mon_1345z, "15", "0930-1600", "", "15", "America/New_York"))); + CHECK(!is_na(pine_time(mon_1445z, "15", "0930-1600", "", "15", "America/New_York"))); + // Under the old UTC default all three were "inside" — DST-invariant in UTC. + CHECK(!is_na(pine_time(fri_1345z, "15", "0930-1600", "", "15"))); + CHECK(!is_na(pine_time(mon_1345z, "15", "0930-1600", "", "15"))); +} + +static void test_time_syminfo_tz_does_not_move_calendar_open() { + std::printf("test_time_syminfo_tz_does_not_move_calendar_open\n"); + // syminfo_tz is a SESSION default only. The D open of a tz-less call keeps + // rolling exactly where it did before (UTC) — the calendar path is owned + // by a separate fix. An explicit tz still moves it (pre-existing). + int64_t bar = 1775572200000LL; // 2026-04-07 14:30 UTC = 10:30 EDT + int64_t plain = pine_time(bar, "D", "", "", "15"); + int64_t with_sym = pine_time(bar, "D", "", "", "15", "America/New_York"); + int64_t sess_sym = pine_time(bar, "D", "0000-2359", "", "15", "America/New_York"); + int64_t explicit_ny = pine_time(bar, "D", "0000-2359", "America/New_York", "15"); + CHECK(plain == 1775520000000LL); // 2026-04-07 00:00 UTC + CHECK(with_sym == plain); + CHECK(sess_sym == plain); + CHECK(explicit_ny != plain); + CHECK(pine_time_close(bar, "D", "", "", "15", "America/New_York") + == pine_time_close(bar, "D", "", "", "15")); + // tz-looking string in the session slot is still dropped (invalid session), + // not adopted, regardless of syminfo_tz. + CHECK(pine_time(bar, "D", "America/New_York", "", "15", "America/New_York") == plain); +} + int main() { test_time_hourly_bucket_utc(); test_time_session_ny_inside(); @@ -123,6 +192,10 @@ int main() { test_time_tz_in_session_daily_change(); test_time_real_session_2arg_still_filters(); test_time_gmt_in_session_slot(); + test_time_session_default_tz_is_syminfo(); + test_time_session_explicit_tz_beats_syminfo(); + test_time_session_syminfo_tz_dst_aware(); + test_time_syminfo_tz_does_not_move_calendar_open(); std::printf("session_time: %d passed, %d failed\n", tests_passed, tests_failed); return tests_failed > 0 ? 1 : 0; diff --git a/tests/test_zero_lot_entry_decline.cpp b/tests/test_zero_lot_entry_decline.cpp new file mode 100644 index 0000000..99514db --- /dev/null +++ b/tests/test_zero_lot_entry_decline.cpp @@ -0,0 +1,349 @@ +/* + * test_zero_lot_entry_decline.cpp — an entry whose lot-floored opening + * quantity is ZERO is declined cleanly (no fill, no trade row, no open trade) + * instead of opening a phantom zero-quantity position. + * + * Ground truth: 3commas-3commas-bch-heikin-ashi-rsi-fade-short-strategy on + * NASDAQ:AAPL 15m (qty_step 1 share, process_orders_on_close=true, + * qty = 280 / close). TV tape: 2025-12-02 15:45 UTC close 286.96 -> + * 280/286.96 = 0.9757 -> 0 shares -> NO row; the next TV entry is + * 2025-12-09 18:15 UTC @ 278.35 qty 1 (280/278.38 = 1.0058 -> 1), and the + * strategy keeps trading (26 TV entries through 2026-04-28). Pre-fix the + * engine handed the floored 0 to open_fresh_position: strategy.position_size + * read 0 (script believed it was flat, never placed its exit) while + * strategy.opentrades read 1 and pyramiding=1 was saturated -> every later + * entry dropped (2 engine trades vs 26). + * + * RED-1 explicit qty 0.9757 @ step 1, POOC -> declined; NEXT >=1-share + * signal fills qty 1 (position SHORT 1, opentrades 1). + * RED-2 CASH default sizing (qty=na, 280 cash) @ step 1 -> same. + * RED-3 explicit qty 0.9757 @ step 1, next-bar-open fill (POOC=false). + * RED-4 pyramiding DCA MARKET add usdt/close -> 0 @ step 1 -> declined, NO + * pyramiding slot consumed: the next >=1-share add still fills. + * RED-5 same with a LIMIT add placed while in position (tv_carry_qty > 0 is + * snapshotted for it but is NOT a deferred-flip carry) -> declined. + * GREEN-A same explicit qty @ step 0.0001 -> fills 0.9757 (unchanged path). + * GREEN-B explicit qty 2.47 @ step 1 -> fills 2 (existing floor unchanged). + * GREEN-C step 0 (corpus default), explicit qty 1 -> fills 1 (unchanged). + */ + +#include +#include +#include +#include +#include + +#include +#include + +using namespace pineforge; + +static int tests_passed = 0; +static int tests_failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +#define CHECK_NEAR(a, b, tol) \ + do { \ + double _a = (a), _b = (b); \ + if (!(std::fabs(_a - _b) <= (tol))) { \ + std::printf(" FAIL %s:%d %s == %.10f, expected %.10f\n", \ + __FILE__, __LINE__, #a, _a, _b); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +static constexpr double kNaN = std::numeric_limits::quiet_NaN(); + +static Bar mk_bar(int64_t ts, double o, double h, double l, double c) { + Bar b; + b.open = o; b.high = h; b.low = l; b.close = c; + b.volume = 1.0; b.timestamp = ts; + return b; +} + +namespace { + +// Script chars (indexed by bar_index_): +// 'S' explicit SHORT market entry, qty = cash_ / close (the 3commas shape) +// 'D' default-sized SHORT market entry (qty = na) +// 'F' explicit SHORT market entry, qty = fixed_qty_ +// 'A' explicit SHORT market ADD, qty = add_cash_ / close (DCA safety order) +// 'L' explicit SHORT LIMIT add, qty = add_cash_ / close, limit = limit_ +// 'M' explicit SHORT LIMIT add, qty = fixed_qty_, limit = limit_ +// '.' nothing +class Probe : public BacktestEngine { +public: + Probe(double qty_step, bool pooc, QtyType default_type, double default_value, + int pyramiding = 1) { + initial_capital_ = 10000.0; + default_qty_type_ = default_type; + default_qty_value_ = default_value; + commission_type_ = CommissionType::PERCENT; + commission_value_ = 0.055; + margin_long_ = 100.0; + margin_short_ = 100.0; + slippage_ = 3; + syminfo_mintick_ = 0.01; + qty_step_ = qty_step; + pyramiding_ = pyramiding; + process_orders_on_close_ = pooc; + } + std::string script; + double cash_ = 280.0; + double fixed_qty_ = 1.0; + double add_cash_ = 280.0; + double limit_ = kNaN; + + void on_bar(const Bar& bar) override { + if (bar_index_ < 0 || bar_index_ >= (int)script.size()) return; + switch (script[bar_index_]) { + case 'S': strategy_entry("E", false, kNaN, kNaN, cash_ / bar.close); break; + case 'D': strategy_entry("E", false, kNaN, kNaN, kNaN); break; + case 'F': strategy_entry("E", false, kNaN, kNaN, fixed_qty_); break; + case 'A': strategy_entry("E", false, kNaN, kNaN, add_cash_ / bar.close); break; + case 'L': strategy_entry("E", false, limit_, kNaN, add_cash_ / bar.close); break; + case 'M': strategy_entry("E", false, limit_, kNaN, fixed_qty_); break; + default: break; + } + } + using BacktestEngine::position_qty_; + using BacktestEngine::position_side_; + using BacktestEngine::pyramid_entries_; + double position_size() const { return signed_position_size(); } + int opentrades() const { return (int)pyramid_entries_.size(); } + using BacktestEngine::position_entry_count_; + bool all_lots_positive() const { + for (const auto& e : pyramid_entries_) if (!(e.qty > 0.0)) return false; + return true; + } +}; + +// The AAPL divergence bar (close 286.96, 280/close = 0.9757) followed by the +// bar TV actually entered on (close 278.38, 280/close = 1.0058). +static std::vector aapl_bars() { + return { + mk_bar(1000, 285.00, 287.00, 284.50, 286.96), // 'S' -> 0.9757 -> 0 + mk_bar(2000, 286.00, 286.50, 285.00, 286.04), // '.' still flat in TV + mk_bar(3000, 278.00, 279.00, 277.50, 278.38), // 'S' -> 1.0058 -> 1 + mk_bar(4000, 278.00, 278.50, 277.00, 278.00), + }; +} + +// RED-1. POOC explicit-qty short: 0.9757 -> 0 -> declined; next signal fills 1. +// Pre-fix: bar 0 opens a phantom SHORT with qty 0 (opentrades 1, size 0) and +// bar 2's entry is dropped by the pyramiding cap. +void test_red1_pooc_explicit_zero_lot_declined_then_next_fills() { + std::printf("-- RED-1: POOC explicit zero-lot declined, next signal fills --\n"); + Probe eng(/*qty_step=*/1.0, /*pooc=*/true, QtyType::FIXED, 1.0); + eng.script = "S.S."; + std::vector bars = aapl_bars(); + // Run all four bars and inspect end state + ledger: pre-fix the phantom + // shows up as opentrades 1 with size 0 and a dropped second entry. + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::SHORT); + CHECK_NEAR(eng.position_size(), -1.0, 1e-9); // pre-fix: -0.0 + CHECK(eng.opentrades() == 1); + CHECK_NEAR(eng.pyramid_entries_.back().qty, 1.0, 1e-9); // pre-fix: 0 + // Fill at bar 2's close minus 3 ticks of slippage (short). + CHECK_NEAR(eng.pyramid_entries_.back().price, 278.35, 1e-9); + CHECK(eng.trade_count() == 0); // still open +} + +// RED-2. CASH default sizing (qty = na, default 280 cash): frozen quantity +// floors to 0 at 286.96 -> declined (KI-72 covers only percent_of_equity); +// next signal fills 1. +void test_red2_cash_default_zero_lot_declined() { + std::printf("-- RED-2: CASH default zero-lot declined --\n"); + Probe eng(/*qty_step=*/1.0, /*pooc=*/true, QtyType::CASH, 280.0); + eng.script = "D.D."; + std::vector bars = aapl_bars(); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::SHORT); + CHECK_NEAR(eng.position_size(), -1.0, 1e-9); + CHECK(eng.opentrades() == 1); + CHECK_NEAR(eng.pyramid_entries_.back().qty, 1.0, 1e-9); + CHECK(eng.trade_count() == 0); +} + +// RED-3. Next-bar-open fill (POOC=false): the zero-lot order placed on bar 0 +// is declined at bar 1's open; bar 2's order fills at bar 3's open. +void test_red3_next_bar_open_zero_lot_declined() { + std::printf("-- RED-3: next-bar-open explicit zero-lot declined --\n"); + Probe eng(/*qty_step=*/1.0, /*pooc=*/false, QtyType::FIXED, 1.0); + eng.script = "S.S."; + std::vector bars = aapl_bars(); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::SHORT); + CHECK_NEAR(eng.position_size(), -1.0, 1e-9); + CHECK(eng.opentrades() == 1); + CHECK_NEAR(eng.pyramid_entries_.back().qty, 1.0, 1e-9); + CHECK_NEAR(eng.pyramid_entries_.back().price, 278.00 - 0.03, 1e-9); +} + +// GREEN-A. Same explicit qty at step 0.0001 (ETH lane): fills 0.9757 on bar 0 +// exactly as before; bar 2's entry is then capped by pyramiding=1. +void test_greenA_fine_step_unchanged() { + std::printf("-- GREEN-A: step 0.0001 fills 0.9757 (unchanged) --\n"); + Probe eng(/*qty_step=*/0.0001, /*pooc=*/true, QtyType::FIXED, 1.0); + eng.script = "S.S."; + std::vector bars = aapl_bars(); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::SHORT); + CHECK_NEAR(eng.position_size(), -0.9757, 1e-9); + CHECK(eng.opentrades() == 1); + CHECK_NEAR(eng.pyramid_entries_.back().price, 286.93, 1e-9); +} + +// GREEN-B. Explicit 2.47 at step 1 floors to 2 (existing behavior). +void test_greenB_floor_above_one_unchanged() { + std::printf("-- GREEN-B: 2.47 @ step 1 -> 2 (unchanged) --\n"); + Probe eng(/*qty_step=*/1.0, /*pooc=*/true, QtyType::FIXED, 1.0); + eng.fixed_qty_ = 2.47; + eng.script = "F..."; + std::vector bars = aapl_bars(); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::SHORT); + CHECK_NEAR(eng.position_size(), -2.0, 1e-9); +} + +// GREEN-C. qty_step 0 (corpus default), explicit qty 1 -> fills 1. +void test_greenC_step_zero_unchanged() { + std::printf("-- GREEN-C: step 0 explicit qty 1 (unchanged) --\n"); + Probe eng(/*qty_step=*/0.0, /*pooc=*/true, QtyType::FIXED, 1.0); + eng.fixed_qty_ = 1.0; + eng.script = "F..."; + std::vector bars = aapl_bars(); + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::SHORT); + CHECK_NEAR(eng.position_size(), -1.0, 1e-9); +} + +// RED-4. 3commas DCA shape (pyramiding=2, POOC, same id "E"): base 2 shares, +// then a safety-order MARKET add sized 280/close at close 290 -> 0.9655 -> 0. +// TV does not place it: no lot, no trade row, NO pyramiding slot spent, so the +// next add (qty 1) still fills -> position -3, two positive lots. +// Pre-fix: the zero add opens a qty-0 lot (count 2 == cap) and the 1-share add +// is dropped by the cap -> position -2 with a qty-0 phantom lot. +void test_red4_market_add_zero_lot_declined_no_slot() { + std::printf("-- RED-4: DCA MARKET zero-lot add declined, slot preserved --\n"); + Probe eng(/*qty_step=*/1.0, /*pooc=*/true, QtyType::FIXED, 1.0, /*pyramiding=*/2); + eng.fixed_qty_ = 2.0; + eng.script = "FAF."; + std::vector bars = { + mk_bar(1000, 250.0, 251.0, 249.0, 250.0), // F: base 2 @ 249.97 + mk_bar(2000, 289.0, 291.0, 288.0, 290.0), // A: 280/290 = 0.9655 -> 0 -> declined + mk_bar(3000, 292.0, 293.0, 291.0, 292.0), // F: add 2 -> fills (slot free) + mk_bar(4000, 292.0, 292.5, 291.5, 292.0), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::SHORT); + CHECK_NEAR(eng.position_size(), -4.0, 1e-9); // pre-fix: -2.0 + CHECK(eng.opentrades() == 2); + CHECK(eng.position_entry_count_ == 2); + CHECK(eng.all_lots_positive()); // pre-fix: qty-0 lot present + CHECK_NEAR(eng.pyramid_entries_.back().qty, 2.0, 1e-9); + CHECK_NEAR(eng.pyramid_entries_.back().price, 292.0 - 0.03, 1e-9); + CHECK(eng.trade_count() == 0); +} + +// RED-5. Same DCA shape but the safety order is a LIMIT add placed while +// already short (POOC=false). strategy_entry snapshots the live position into +// tv_carry_qty for every priced entry, yet the add kernel never applies that +// carry (it is a deferred-flip rule for entries firing from FLAT on the +// opposite side) — so it must not exempt the zero-lot add. Pre-fix +// (1f2681d): carry 2 + own 0 > eps -> admitted -> qty-0 lot, slot burned, the +// later 1-share limit add is rejected at placement by the pyramiding cap. +void test_red5_limit_add_zero_lot_declined_no_slot() { + std::printf("-- RED-5: DCA LIMIT zero-lot add declined, slot preserved --\n"); + std::vector bars = { + mk_bar(1000, 250.0, 251.0, 249.0, 250.0), // F: base 2, fills bar1 open + mk_bar(2000, 250.0, 251.0, 249.0, 250.0), // L: limit 290, qty 280/290 -> 0 + mk_bar(3000, 290.0, 291.0, 289.0, 290.0), // limit touched: zero-lot add -> declined + mk_bar(4000, 290.0, 291.0, 289.0, 290.0), // bar 3: limit 292 qty 1 (placement OK: count 1) + mk_bar(5000, 292.0, 293.0, 291.0, 292.0), // fills 1 @ 292 -> position -3 + mk_bar(6000, 292.0, 292.5, 291.5, 292.0), + }; + // Bar 3 places the 1-share limit add directly (no script char for it). + struct Driver : Probe { + using Probe::Probe; + void on_bar(const Bar& bar) override { + if (bar_index_ == 3) { + strategy_entry("E", false, /*limit=*/292.0, kNaN, /*qty=*/1.0); + return; + } + Probe::on_bar(bar); + } + }; + Driver d(/*qty_step=*/1.0, /*pooc=*/false, QtyType::FIXED, 1.0, /*pyramiding=*/2); + // Size the limit add from the LIMIT price the way a DCA script does + // (usdt / trigger price): 280 / 290 = 0.9655 -> 0 shares. 'L' divides + // add_cash_ by the bar close (250), so scale it to land on 280/290. + d.add_cash_ = 280.0 * 250.0 / 290.0; + d.limit_ = 290.0; + d.fixed_qty_ = 2.0; + d.script = "FL...."; + d.run(bars.data(), (int)bars.size()); + CHECK(d.position_side_ == PositionSide::SHORT); + CHECK_NEAR(d.position_size(), -3.0, 1e-9); // pre-fix: -2.0 + CHECK(d.opentrades() == 2); // base + 1-share add + CHECK(d.position_entry_count_ == 2); + CHECK(d.all_lots_positive()); // pre-fix: qty-0 lot + CHECK_NEAR(d.pyramid_entries_.back().qty, 1.0, 1e-9); + CHECK_NEAR(d.pyramid_entries_.back().price, 292.0, 1e-9); // limit fills unslipped + CHECK(d.trade_count() == 0); +} + +// GREEN-D. A LIMIT add that survives the floor (qty 1.12 -> 1) fills as before +// and spends its slot normally. +void test_greenD_limit_add_one_share_unchanged() { + std::printf("-- GREEN-D: LIMIT add 1.12 -> 1 fills (unchanged) --\n"); + Probe eng(/*qty_step=*/1.0, /*pooc=*/false, QtyType::FIXED, 1.0, /*pyramiding=*/2); + eng.fixed_qty_ = 2.0; + eng.add_cash_ = 280.0; // 280/250 = 1.12 -> 1 + eng.limit_ = 290.0; + eng.script = "FL...."; + std::vector bars = { + mk_bar(1000, 250.0, 251.0, 249.0, 250.0), + mk_bar(2000, 250.0, 251.0, 249.0, 250.0), + mk_bar(3000, 290.0, 291.0, 289.0, 290.0), + mk_bar(4000, 290.0, 291.0, 289.0, 290.0), + mk_bar(5000, 292.0, 293.0, 291.0, 292.0), + mk_bar(6000, 292.0, 292.5, 291.5, 292.0), + }; + eng.run(bars.data(), (int)bars.size()); + CHECK(eng.position_side_ == PositionSide::SHORT); + CHECK_NEAR(eng.position_size(), -3.0, 1e-9); + CHECK(eng.opentrades() == 2); + CHECK(eng.position_entry_count_ == 2); + CHECK_NEAR(eng.pyramid_entries_.back().qty, 1.0, 1e-9); + CHECK_NEAR(eng.pyramid_entries_.back().price, 290.0, 1e-9); +} + +} // namespace + +int main() { + std::printf("--- zero_lot_entry_decline ---\n"); + test_red1_pooc_explicit_zero_lot_declined_then_next_fills(); + test_red2_cash_default_zero_lot_declined(); + test_red3_next_bar_open_zero_lot_declined(); + test_red4_market_add_zero_lot_declined_no_slot(); + test_red5_limit_add_zero_lot_declined_no_slot(); + test_greenD_limit_add_one_share_unchanged(); + test_greenA_fine_step_unchanged(); + test_greenB_floor_above_one_unchanged(); + test_greenC_step_zero_unchanged(); + std::printf("\n=== Results: %d passed, %d failed ===\n", + tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +}