From 35944d4fd1cdb3320a381f2d698a14afa1e6fde8 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 3 Jun 2026 04:06:11 +0800 Subject: [PATCH 1/8] fix(engine): weekly HTF year-boundary aggregation + accept monthly request.security MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weekly: crosses_boundary forced a spurious week boundary at every Jan 1 (tm_year change), splitting the ISO week that straddles Dec/Jan into two partial weeks. A weekly request.security SMA then updated a bar early — visible as ~30 flipped close-vs-weekly-SMA crosses in a chop-at-level month (2026-01). Fix: compare the absolute Monday date (continuous across years). Surfaced by a TV-parity weekly probe (weak->excellent). Monthly: validate_security_timeframes rejected 'M' because tf_to_seconds returns -1 (calendar marker) tripping the <=0 guard, though the CALENDAR month aggregator is wired. Admit the month marker as a coarser HTF (security_lower_tf('M') stays invalid). test_calendar_aggregation_wm gains a year-boundary regression (the Dec29-Jan4 week is one 7-day week). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/engine_security.cpp | 11 +++++++++-- src/timeframe.cpp | 27 ++++++++++++++++++-------- tests/test_calendar_aggregation_wm.cpp | 13 +++++++++++++ 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/engine_security.cpp b/src/engine_security.cpp index cc4c176..b7d19b1 100644 --- a/src/engine_security.cpp +++ b/src/engine_security.cpp @@ -137,7 +137,14 @@ void BacktestEngine::validate_security_timeframes(const std::string& input_tf) { // "abc" produces a precise diagnostic instead of an opaque // std::invalid_argument from stoi. int requested_seconds = safe_tf_to_seconds(state.tf); - if (requested_seconds <= 0) { + // Calendar month ("M" / "NM") has no fixed second count; tf_to_seconds + // returns -1 as a calendar marker (a genuine parse failure returns 0). + // Month is always a COARSER HTF, so admit it for request.security and + // let the CALENDAR TimeframeAggregator (tf_ratio == -1) aggregate it. + // request.security_lower_tf("M") stays invalid — month is never an + // intrabar TF. (Weekly/daily already pass: they return positive seconds.) + bool is_calendar_month = (requested_seconds == -1 && !state.lower_tf_array_requested); + if (requested_seconds <= 0 && !is_calendar_month) { const char* api = state.lower_tf_array_requested ? "request.security_lower_tf" : "request.security"; throw std::runtime_error( @@ -145,7 +152,7 @@ void BacktestEngine::validate_security_timeframes(const std::string& input_tf) { ); } - if (requested_seconds < input_seconds) { + if (!is_calendar_month && requested_seconds < input_seconds) { // Finer than input — only valid for security_lower_tf with // an integer divisor ratio. if (!state.lower_tf_array_requested) { diff --git a/src/timeframe.cpp b/src/timeframe.cpp index e1acc34..0fd8c1d 100644 --- a/src/timeframe.cpp +++ b/src/timeframe.cpp @@ -147,15 +147,26 @@ bool crosses_boundary(int64_t prev_ms, int64_t curr_ms, CalendarPeriod period) { return prev_tm.tm_yday != curr_tm.tm_yday || prev_tm.tm_year != curr_tm.tm_year; case CalendarPeriod::WEEK: { - // ISO week boundary: Monday is start of week. - // Compare (year, week-of-year) using Monday-based week number. - auto monday_week = [](const struct tm& t) -> int { - // tm_wday: 0=Sunday..6=Saturday. Convert to Mon=0..Sun=6. - int dow = (t.tm_wday + 6) % 7; - return (t.tm_yday - dow + 7) / 7; + // Monday-start weeks, CONTINUOUS across the year boundary: the ISO + // week that straddles Dec/Jan (e.g. Mon 2025-12-29 .. Sun 2026-01-04) + // is ONE week, not two. The previous (year || week-of-year) test + // forced a spurious boundary at every Jan 1, splitting that week and + // updating weekly request.security a bar early — visible as flipped + // crosses in a chop-at-level month (proposed-probes weekly probe). + // Fix: compare the absolute calendar date of each timestamp's Monday. + auto monday_epoch_day = [](const struct tm& t) -> long { + // days since 1970-01-01 (proleptic Gregorian), then back up to Monday. + int y = t.tm_year + 1900, m = t.tm_mon + 1, d = t.tm_mday; + 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; + int dow = (t.tm_wday + 6) % 7; // Mon=0..Sun=6 + return day - dow; // the Monday that starts this week }; - return prev_tm.tm_year != curr_tm.tm_year || - monday_week(prev_tm) != monday_week(curr_tm); + return monday_epoch_day(prev_tm) != monday_epoch_day(curr_tm); } case CalendarPeriod::MONTH: return prev_tm.tm_mon != curr_tm.tm_mon || diff --git a/tests/test_calendar_aggregation_wm.cpp b/tests/test_calendar_aggregation_wm.cpp index 8acce68..d13d1e8 100644 --- a/tests/test_calendar_aggregation_wm.cpp +++ b/tests/test_calendar_aggregation_wm.cpp @@ -122,9 +122,22 @@ static void test_weekly_full_weeks_are_seven() { CHECK(sevens >= 6); // most interior weeks are full } +// The ISO week straddling the year boundary (Mon 2025-12-29 .. Sun 2026-01-04) +// must aggregate as ONE 7-day week, not split at Jan 1. Regression for the +// weekly-HTF year-rollover bug found via the proposed-probes weekly probe. +static void test_weekly_year_boundary_not_split() { + std::printf("test_weekly_year_boundary_not_split\n"); + TimeframeAggregator agg("W", "D"); + // 2025-12-22 (Mon) .. 2026-01-19 (Mon): interior completed weeks must all be 7. + std::vector got = completed_subcounts(agg, 2025, 12, 22, 2026, 1, 19); + CHECK(got.size() >= 3); + for (int c : got) CHECK(c == 7); // pre-fix: the Dec29-Jan4 week split into 3+4 +} + int main() { test_monthly_subcounts_match_day_count(); test_weekly_full_weeks_are_seven(); + test_weekly_year_boundary_not_split(); std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); return tests_failed == 0 ? 0 : 1; } From 395b94b151ad098d8dbde75332b253202dda16ad Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 3 Jun 2026 04:06:11 +0800 Subject: [PATCH 2/8] feat(abi): runtime syminfo mintick/pointvalue setters + wire pointvalue into PnL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds strategy_set_syminfo_mintick / strategy_set_syminfo_pointvalue C-ABI setters (append-only; MINOR bump due), mirroring the existing runtime syminfo timezone/session/metadata setters — the engine stores no instrument metadata of its own; the harness injects it per run, by design. pointvalue ($/point/contract) now multiplies realized PnL + MFE/MAE in emit_close_trade (crypto/equity pointvalue=1 -> unchanged; futures e.g. ES=50 -> correct). Kills the dead-field finding. ABI whitelist updated. Validated: ES futures TV-parity excellent (550/550) once injected. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/pineforge/engine.hpp | 8 ++++++++ include/pineforge/pineforge.h | 12 ++++++++++++ scripts/check_c_abi_runtime.py | 2 ++ src/c_abi.cpp | 17 +++++++++++++++++ src/engine_orders.cpp | 16 +++++++++++----- 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 6d2f457..3c9c345 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -1312,6 +1312,14 @@ class BacktestEngine { // would regress the crypto-on-shifted-chart case). void set_syminfo_timezone(const std::string& tz) { syminfo_.timezone = tz; } void set_syminfo_session(const std::string& s) { syminfo_.session = s; } + // Runtime syminfo injection (by design — the engine stores no instrument + // metadata of its own; the harness supplies it per run). mintick drives the + // directional fill snap + slippage*tick economics; pointvalue is the + // futures $-per-point multiplier applied to realized PnL and excursions. + // Both default to crypto/equity values (0.01 / 1.0) and only matter when the + // harness sets a non-default instrument. + void set_syminfo_mintick(double m) { if (m > 0.0) { syminfo_.mintick = m; syminfo_mintick_ = m; } } + void set_syminfo_pointvalue(double pv) { if (pv > 0.0) { syminfo_.pointvalue = pv; } } void set_syminfo_metadata(const std::string& key, double value) { syminfo_metadata_[key] = value; } diff --git a/include/pineforge/pineforge.h b/include/pineforge/pineforge.h index 92f5582..9328faa 100644 --- a/include/pineforge/pineforge.h +++ b/include/pineforge/pineforge.h @@ -358,6 +358,18 @@ PF_API void strategy_set_syminfo_timezone(pf_strategy_t s, const char* tz); * ignored. Call before #run_backtest*. */ PF_API void strategy_set_syminfo_session(pf_strategy_t s, const char* session); +/** Set the instrument tick size (``syminfo.mintick``, default 0.01). Drives the + * directional stop-entry snap and ``slippage = N*mintick`` economics. Set + * per-instrument (e.g. 0.25 for ES, 0.00001 for FX). Non-positive ignored. + * Call before #run_backtest*. */ +PF_API void strategy_set_syminfo_mintick(pf_strategy_t s, double mintick); + +/** Set the instrument point value (``syminfo.pointvalue``, default 1.0) — the + * $-per-point-per-contract multiplier applied to realized PnL and MFE/MAE. Set + * per-instrument (e.g. 50 for ES). Non-positive ignored. Call before + * #run_backtest*. */ +PF_API void strategy_set_syminfo_pointvalue(pf_strategy_t s, double pointvalue); + /** Inject a fundamental/exchange metadata value by Pine member name * (e.g. "shares_outstanding_total", "target_price_average"). These have * no OHLCV source; reads of un-injected members return na. Call before diff --git a/scripts/check_c_abi_runtime.py b/scripts/check_c_abi_runtime.py index c649f7b..4da3b28 100644 --- a/scripts/check_c_abi_runtime.py +++ b/scripts/check_c_abi_runtime.py @@ -21,6 +21,8 @@ "strategy_set_chart_timezone", "strategy_set_syminfo_timezone", "strategy_set_syminfo_session", + "strategy_set_syminfo_mintick", + "strategy_set_syminfo_pointvalue", "strategy_set_syminfo_metadata", "strategy_get_last_error", "pf_version_get", diff --git a/src/c_abi.cpp b/src/c_abi.cpp index 521b4d5..857e4ae 100644 --- a/src/c_abi.cpp +++ b/src/c_abi.cpp @@ -163,6 +163,23 @@ PF_API void strategy_set_syminfo_session(pf_strategy_t s, const char* session) { static_cast(s)->set_syminfo_session(std::string(session)); } +/* Inject the instrument tick size (syminfo.mintick). Drives the directional + * stop-entry snap (long ceil / short floor) and slippage = N*mintick economics. + * Defaults to 0.01 (crypto/equity); set per-instrument (e.g. 0.25 for ES, + * 0.00001 for FX). Non-positive values are ignored. */ +PF_API void strategy_set_syminfo_mintick(pf_strategy_t s, double mintick) { + if (!s) return; + static_cast(s)->set_syminfo_mintick(mintick); +} + +/* Inject the instrument point value (syminfo.pointvalue) — the $ per point per + * contract multiplier applied to realized PnL and MFE/MAE. Defaults to 1.0 + * (crypto/equity); set per-instrument (e.g. 50 for ES). Non-positive ignored. */ +PF_API void strategy_set_syminfo_pointvalue(pf_strategy_t s, double pointvalue) { + if (!s) return; + static_cast(s)->set_syminfo_pointvalue(pointvalue); +} + /* Inject a fundamental/exchange metadata value (shares_outstanding_total, * recommendations_*, target_price_*, …) by Pine member name. Without an * injection the corresponding syminfo.* read returns na. NULL key ignored. */ diff --git a/src/engine_orders.cpp b/src/engine_orders.cpp index 213ead7..d9bea3f 100644 --- a/src/engine_orders.cpp +++ b/src/engine_orders.cpp @@ -280,9 +280,15 @@ void BacktestEngine::purge_exit_orders() { // execute_market_entry. Mirrors TradingView's per-pyramid trade reporting. void BacktestEngine::emit_close_trade(const PyramidEntry& pe, double close_qty, double fill_price, bool was_long) { - double pnl = was_long - ? (fill_price - pe.price) * close_qty - : (pe.price - fill_price) * close_qty; + // Realized PnL scales by the instrument point value ($ per point per + // contract). Crypto/equity (pointvalue=1) is unchanged; futures (e.g. ES=50) + // multiply the price-difference PnL. pnl_pct is a price-return % and is + // point-value-invariant. Commission is in account currency (cash-per-* is + // already absolute; percent-commission notional×pointvalue is a follow-up + // and does not affect cash-per-contract instruments like ES). + const double pv = syminfo_.pointvalue; + double pnl = (was_long ? (fill_price - pe.price) : (pe.price - fill_price)) + * close_qty * pv; double pnl_pct = was_long ? (fill_price / pe.price - 1.0) * 100.0 : (pe.price / fill_price - 1.0) * 100.0; @@ -301,8 +307,8 @@ void BacktestEngine::emit_close_trade(const PyramidEntry& pe, double close_qty, trade.exit_bar_index = bar_index_; trade.entry_id = pe.entry_id; trade.entry_comment = pe.entry_comment; - trade.max_runup = pe.max_runup; - trade.max_drawdown = pe.max_drawdown; + trade.max_runup = pe.max_runup * pv; // $ excursion per unit scales with point value + trade.max_drawdown = pe.max_drawdown * pv; trades_.push_back(trade); net_profit_sum_ += trade.pnl; if (trade.pnl > 0) { gross_profit_sum_ += trade.pnl; win_trades_count_++; } From 7039de224dc2c61a2ade4f59701dd41bb03c7494 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 3 Jun 2026 04:06:11 +0800 Subject: [PATCH 3/8] test(ta): TSI/RCI value bounds + NVI/PVI structural update-rule Engine-only invariant oracles for the remaining TV-oracle-less TA family: TSI/RCI bounded [-100,100]; NVI frozen on non-down-volume bars, PVI frozen on non-up-volume bars (origin-formula-independent structural invariants). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_ta_volume_state_oracle.cpp | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_ta_volume_state_oracle.cpp b/tests/test_ta_volume_state_oracle.cpp index abf178b..e5ab501 100644 --- a/tests/test_ta_volume_state_oracle.cpp +++ b/tests/test_ta_volume_state_oracle.cpp @@ -69,13 +69,41 @@ static void test_bounds_hold() { ta::MFI mfi(14); ta::CMO cmo(14); ta::WPR wpr(14); + ta::TSI tsi(25, 13); + ta::RCI rci(14); for (const auto& b : f) { double m = mfi.compute(b.c, b.v); double cm = cmo.compute(b.c); double w = wpr.compute(b.c, b.h, b.l); + double t = tsi.compute(b.c); + double r = rci.compute(b.c); if (!is_na(m)) CHECK(m >= 0.0 - 1e-9 && m <= 100.0 + 1e-9); if (!is_na(cm)) CHECK(cm >= -100.0 - 1e-9 && cm <= 100.0 + 1e-9); if (!is_na(w)) CHECK(w >= -100.0 - 1e-9 && w <= 0.0 + 1e-9); + if (!is_na(t)) CHECK(t >= -100.0 - 1e-9 && t <= 100.0 + 1e-9); + if (!is_na(r)) CHECK(r >= -100.0 - 1e-9 && r <= 100.0 + 1e-9); + } +} + +// NVI changes ONLY on a volume DECREASE; PVI ONLY on a volume INCREASE. This is +// a structural invariant of the indicators independent of the exact ROC formula +// (so it needs no TV oracle): on an up/equal-volume bar NVI must be unchanged, +// on a down/equal-volume bar PVI must be unchanged. +static void test_nvi_pvi_update_rule() { + std::printf("test_nvi_pvi_update_rule\n"); + auto f = osc_feed(200); + ta::NVI nvi; + ta::PVI pvi; + double prev_nvi = 0.0, prev_pvi = 0.0, prev_vol = 0.0; + bool have_prev = false; + for (const auto& b : f) { + double n = nvi.compute(b.c, b.v); + double p = pvi.compute(b.c, b.v); + if (have_prev) { + if (b.v >= prev_vol) CHECK(n == prev_nvi); // no volume decrease -> NVI frozen + if (b.v <= prev_vol) CHECK(p == prev_pvi); // no volume increase -> PVI frozen + } + prev_nvi = n; prev_pvi = p; prev_vol = b.v; have_prev = true; } } @@ -171,6 +199,7 @@ int main() { test_falling_saturates_low(); test_wpr_endpoints(); test_forward_sum_identities(); + test_nvi_pvi_update_rule(); std::printf("\n%d passed, %d failed\n", tests_passed, tests_failed); return tests_failed == 0 ? 0 : 1; } From e281ede7d67cb61ca5681375ec120d2b3934f92e Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 3 Jun 2026 04:06:11 +0800 Subject: [PATCH 4/8] feat(scripts): DST-aware TV timezone + syminfo mintick/pointvalue injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_corpus.py + run_strategy.py resolved the TV export tz to a fixed integer offset, so an IANA zone with DST (e.g. America/New_York, UTC-4 summer / UTC-5 winter) mis-aligned trades across the Nov DST flip. Both now accept IANA zone names via zoneinfo (DST-aware) alongside the legacy fixed aliases — enables US-equity / FX parity. run_strategy also injects runtime_overrides.mintick / .pointvalue through the new C-ABI setters. Validated: AAPL US-equity (RTH+gaps+DST) TV-parity excellent (82/82). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/run_strategy.py | 45 +++++++++++++++++++++++++++++++++------- scripts/verify_corpus.py | 41 +++++++++++++++++++++++++++++------- 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/scripts/run_strategy.py b/scripts/run_strategy.py index 07896dc..9d082b5 100644 --- a/scripts/run_strategy.py +++ b/scripts/run_strategy.py @@ -217,6 +217,12 @@ def _setup_signatures(self) -> None: L.strategy_set_syminfo_metadata.argtypes = [ ctypes.c_void_p, ctypes.c_char_p, ctypes.c_double] L.strategy_set_syminfo_metadata.restype = None + if hasattr(L, "strategy_set_syminfo_mintick"): + L.strategy_set_syminfo_mintick.argtypes = [ctypes.c_void_p, ctypes.c_double] + L.strategy_set_syminfo_mintick.restype = None + if hasattr(L, "strategy_set_syminfo_pointvalue"): + L.strategy_set_syminfo_pointvalue.argtypes = [ctypes.c_void_p, ctypes.c_double] + L.strategy_set_syminfo_pointvalue.restype = None def run(self, bars_csv: Path, params: dict | None = None, *, trace_enabled: bool = False, trade_start_time_ms: int | None = None, @@ -224,6 +230,8 @@ def run(self, bars_csv: Path, params: dict | None = None, syminfo_timezone: str | None = None, syminfo_session: str | None = None, syminfo_metadata: dict | None = None, + syminfo_mintick: float | None = None, + syminfo_pointvalue: float | None = None, input_tf: str | None = None, script_tf: str | None = None, ohlcv_start_ms: int | None = None, bar_magnifier: bool = False, @@ -274,6 +282,10 @@ def run(self, bars_csv: Path, params: dict | None = None, state, str(mkey).encode(), float(mval)) except (TypeError, ValueError): continue + if syminfo_mintick is not None and hasattr(self.lib, "strategy_set_syminfo_mintick"): + self.lib.strategy_set_syminfo_mintick(state, float(syminfo_mintick)) + if syminfo_pointvalue is not None and hasattr(self.lib, "strategy_set_syminfo_pointvalue"): + self.lib.strategy_set_syminfo_pointvalue(state, float(syminfo_pointvalue)) if hasattr(self.lib, "strategy_set_input"): for key, value in params.items(): if key.startswith("tv_"): @@ -490,14 +502,26 @@ def write_engine_trades_csv(trades: list[dict], path: Path) -> None: -def _tv_timezone_offset(meta: dict) -> int: - tz_name = str(meta.get("tv_trades_csv_tz", "")).lower() - return {"utc_plus_8": 8, "asia_taipei": 8, "utc": 0}.get(tz_name, 8) +def _tv_tzinfo(meta: dict): + """Resolve the TV export tz to a tzinfo. Fixed aliases (utc/utc_plus_8/ + asia_taipei) OR any IANA name (DST-aware, e.g. America/New_York) so the + emit-window for a DST-bearing exchange is computed correctly.""" + from datetime import timedelta + name = str(meta.get("tv_trades_csv_tz", "")).strip() + low = name.lower() + fixed = {"utc_plus_8": 8, "asia_taipei": 8, "utc": 0} + if low in fixed: + return timezone(timedelta(hours=fixed[low])) + if "/" in name: + try: + from zoneinfo import ZoneInfo + return ZoneInfo(name) + except Exception: + pass + return timezone(timedelta(hours=8)) -def _parse_trade_dt(s: str, tz_offset_hours: int) -> int: - from datetime import timedelta - tz = timezone(timedelta(hours=tz_offset_hours)) +def _parse_trade_dt(s: str, tz) -> int: return int(datetime.strptime(s, "%Y-%m-%d %H:%M").replace(tzinfo=tz).timestamp() * 1000) @@ -506,7 +530,7 @@ def _load_tv_entry_window(strategy_dir: Path, meta: dict, bar_interval_ms: int) tv_path = strategy_dir / tv_name if not tv_path.exists(): return None - tz_offset = _tv_timezone_offset(meta) + tz_offset = _tv_tzinfo(meta) entries: list[int] = [] with tv_path.open(encoding="utf-8-sig") as f: reader = csv.DictReader(f) @@ -663,6 +687,11 @@ def main() -> int: syminfo_metadata = runtime_overrides.get("syminfo_metadata") if not isinstance(syminfo_metadata, dict): syminfo_metadata = None + def _num(v): + try: return float(v) + except (TypeError, ValueError): return None + syminfo_mintick = _num(runtime_overrides.get("mintick")) + syminfo_pointvalue = _num(runtime_overrides.get("pointvalue")) report = strat.run(ohlcv_path, params=params, trace_enabled=args.trace_json is not None, trade_start_time_ms=trade_start_ms, @@ -670,6 +699,8 @@ def main() -> int: syminfo_timezone=syminfo_tz, syminfo_session=syminfo_session, syminfo_metadata=syminfo_metadata, + syminfo_mintick=syminfo_mintick, + syminfo_pointvalue=syminfo_pointvalue, input_tf=input_tf_override or None, script_tf=script_tf_override or None, ohlcv_start_ms=ohlcv_start_ms_val, diff --git a/scripts/verify_corpus.py b/scripts/verify_corpus.py index b435c5d..3d4e9d5 100755 --- a/scripts/verify_corpus.py +++ b/scripts/verify_corpus.py @@ -237,13 +237,38 @@ class TradePair: mae: float = 0.0 -def parse_dt(s: str, tz_offset_hours: int) -> int: - """Parse 'YYYY-MM-DD HH:MM' (wall time in tz_offset_hours) as unix seconds (UTC).""" - tz = timezone(timedelta(hours=tz_offset_hours)) +def tv_tzinfo(meta: dict): + """Resolve the TV export timezone to a tzinfo. + + Accepts the legacy fixed-offset aliases (utc / utc_plus_8 / asia_taipei) AND + any IANA zone name (e.g. 'America/New_York', 'Europe/London') so DST-bearing + exchanges parse correctly — a fixed integer offset silently mis-aligns trades + across a DST transition (e.g. US equities flip UTC-4 -> UTC-5 in November). + """ + name = str(meta.get("tv_trades_csv_tz", "")).strip() + low = name.lower() + if low in TV_TZ_BY_NAME: + return timezone(timedelta(hours=TV_TZ_BY_NAME[low])) + if "/" in name: # looks like an IANA zone -> DST-aware + try: + from zoneinfo import ZoneInfo + return ZoneInfo(name) + except Exception: + pass + return timezone(timedelta(hours=TV_CSV_TZ_OFFSET_HOURS)) # default Asia/Taipei + + +def parse_dt(s: str, tz) -> int: + """Parse 'YYYY-MM-DD HH:MM' (wall time in tz) as unix seconds (UTC). + + ``tz`` is any datetime.tzinfo — a fixed timezone(...) or a DST-aware + zoneinfo.ZoneInfo. localizing the naive wall clock then taking .timestamp() + yields the correct POSIX instant including DST. + """ return int(datetime.strptime(s, "%Y-%m-%d %H:%M").replace(tzinfo=tz).timestamp()) -def parse_trades(csv_path: Path, *, tz_offset_hours: int) -> list[TradePair]: +def parse_trades(csv_path: Path, *, tz) -> list[TradePair]: by_num: dict[int, dict] = {} # TradingView exports include a UTF-8 BOM; utf-8-sig strips it. with csv_path.open(encoding="utf-8-sig") as f: @@ -291,10 +316,10 @@ def parse_trades(csv_path: Path, *, tz_offset_hours: int) -> list[TradePair]: r["mfe"] = mfe r["mae"] = mae if kind.startswith("Entry"): - r["entry_time"] = parse_dt(time_field, tz_offset_hours) + r["entry_time"] = parse_dt(time_field, tz) r["entry_price"] = price else: - r["exit_time"] = parse_dt(time_field, tz_offset_hours) + r["exit_time"] = parse_dt(time_field, tz) r["exit_price"] = price pairs: list[TradePair] = [] @@ -412,8 +437,8 @@ def verify_one(strategy_dir: Path, *, verbose: bool = True, show_diffs: int = 0) print(f"{rel}\n MISSING (tv: {tv_path.exists()}, engine: {eng_path.exists()})") return "missing" - tv = parse_trades(tv_path, tz_offset_hours=tv_timezone_offset(meta)) - eng = parse_trades(eng_path, tz_offset_hours=ENGINE_CSV_TZ_OFFSET_HOURS) + tv = parse_trades(tv_path, tz=tv_tzinfo(meta)) + eng = parse_trades(eng_path, tz=timezone.utc) matched = align_by_time(tv, eng) tv_cmp, eng_cmp = trim_to_common_match_window(tv, eng, matched) matched = align_by_time(tv_cmp, eng_cmp) From 906dc61bfdeacad46539fdf865ff8ba6db17846a Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 3 Jun 2026 04:14:49 +0800 Subject: [PATCH 5/8] chore: bump corpus submodule to the new parity probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Points the corpus gitlink at the commit adding the 12 ETH TA/HTF validation probes (headline 233 -> 245 excellent) + the 5 categorized special-validation probes (AAPL/ES/EURUSD/monthly/leverage). The local probe staging area (proposed-probes/) is removed — probes now live in the corpus submodule only, keeping the engine repo clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- corpus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/corpus b/corpus index 1f7f6f9..a3be3e2 160000 --- a/corpus +++ b/corpus @@ -1 +1 @@ -Subproject commit 1f7f6f9a5f7924fc262f1262eab59ddeed5ef4a4 +Subproject commit a3be3e202b8153ec75e6ad4076d9866e768997cc From fe9a9420e3cd39fcdcf05a78d2b2466187d391c5 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 3 Jun 2026 04:18:52 +0800 Subject: [PATCH 6/8] chore: bump corpus submodule (nest SPX probe under special-validation/us-equity) Co-Authored-By: Claude Opus 4.8 (1M context) --- corpus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/corpus b/corpus index a3be3e2..79d964b 160000 --- a/corpus +++ b/corpus @@ -1 +1 @@ -Subproject commit a3be3e202b8153ec75e6ad4076d9866e768997cc +Subproject commit 79d964ba5639db1f459e78a38c17cd580b9c2fc5 From 5a48cd4eb9d011ec74246894e70bf064681b771d Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 3 Jun 2026 04:19:27 +0800 Subject: [PATCH 7/8] chore: bump corpus submodule (SPX index row) --- corpus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/corpus b/corpus index 79d964b..3fcddf8 160000 --- a/corpus +++ b/corpus @@ -1 +1 @@ -Subproject commit 79d964ba5639db1f459e78a38c17cd580b9c2fc5 +Subproject commit 3fcddf87c430047a3afa130edc807510e944b8cb From 60ecfd3294018a4abc56ae8558a7823e1cc08cf3 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 3 Jun 2026 04:23:12 +0800 Subject: [PATCH 8/8] chore: point corpus submodule at merged main (4d46828) corpus PR merged; update the gitlink from the feature-branch commit to the corpus main commit so the submodule reference resolves on a clone of corpus main. Co-Authored-By: Claude Opus 4.8 (1M context) --- corpus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/corpus b/corpus index 3fcddf8..4d46828 160000 --- a/corpus +++ b/corpus @@ -1 +1 @@ -Subproject commit 3fcddf87c430047a3afa130edc807510e944b8cb +Subproject commit 4d468289e61064a44699f4c779e4cf6b19e199d2