Skip to content

max_intraday_filled_orders cap-day boundary uses chart_timezone instead of exchange timezone #16

Closed

Description

@luisleo526

Engine Bug — max_intraday_filled_orders cap-day boundary uses chart_timezone instead of exchange timezone

Filed: 2026-05-17
Resolved (workaround): 2026-05-17 — validator-level override; engine fix deferred to pineforge-data integration
Severity: Moderate (was: 1 corpus probe weak; now: probe excellent via validator override)
Introduced by: engine commit 6b062fc (2026-05-15) — "feat(engine): 5 bug fixes + chart-TZ + harness overrides + 5 ctests"
Pre-existing relative to Pine v6 sprint: YES (not introduced this sprint)

Workaround shipped (2026-05-17)

Validator gained per-probe engine_chart_timezone key in inputs.json schema
(commit 8a7cabd in pineforge-utils). When set, bypasses the
_engine_chart_tz_for_csv_tz(csv_tz) auto-derivation and passes the value
directly to strategy_set_chart_timezone. Empty string forces UTC fast path.

Probe cap-max-intraday-filled-orders-isolate-01 now sets
engine_chart_timezone: "" in its inputs.json (corpus commit 5449e35):
weak (51.2% match) → excellent (100% bit-exact, 1958/1958).

Long-term fix (deferred to pineforge-data)

Per Pine v6 docs, "intraday day" boundary for max_intraday_filled_orders /
max_intraday_loss should follow the symbol's exchange timezone
(syminfo.timezone), NOT the chart display timezone.

Currently the engine has syminfo_.timezone field but no harness populates
it (defaults to "UTC"). Once pineforge-data provides per-symbol metadata
(see docs/pine_v6_dispatch_scope.html Rule 2), the proper fix is:

  1. Engine: change all four call sites that currently use
    _decompose_bar_time_chart_tz() for intraday-counter logic (see
    "Related code paths" below) to use a new _decompose_bar_time_intraday_tz()
    helper that reads syminfo_.timezone instead of chart_timezone_.
  2. Harness: query pineforge-data for the symbol's exchange tz, plumb into
    syminfo_.timezone via a new C ABI setter.
  3. Validator: deprecate the engine_chart_timezone override key (no longer
    needed once syminfo.timezone is populated correctly per symbol).

Symptom

When validator runs probe cap-max-intraday-filled-orders-isolate-01 against
TV's tv_trades.csv (1958 trades, Asia/Taipei chart):

  • Engine produces 1971 trades (count delta +0.66%, but in compare window 4%)
  • Trade-by-trade match_pct ≈ 51.2%
  • entry price drift p90 ≈ 0%
  • exit price drift p90 ≈ 0.79%
  • pnl rel diff p90 ≈ 100% (a handful of trades have wildly wrong exit prices)
  • Validator scores degree=2 (weak)

Bisect (see linked-doodling-beacon.md audit phase) confirms:

  • Pre-6b062fc: degree=3 (moderate), match_pct 82.2%
  • Post-6b062fc (current main): degree=2 (weak), match_pct 51.2%

Root cause (hypothesis)

6b062fc introduced _decompose_bar_time_chart_tz() (see
include/pineforge/engine.hpp:355-365, _intraday_cap_currently_latched)
to make max_intraday_filled_orders use chart-tz-aware day rollover instead
of UTC-day rollover. When the validator passes chart_timezone="Asia/Taipei"
(the default for this probe's TV csv interpretation, since
tv_trades_csv_tz=utc_plus_8 → engine chart_tz="Asia/Taipei"), the cap-day
boundary fires at Asia/Taipei midnight (= 16:00 UTC).

The 4% extra trades and 49% mismatched-trade rate suggest the engine
either:

  1. Computes Asia/Taipei midnight slightly wrong (e.g., off-by-one bar at
    the boundary), or
  2. Triggers cap-close at a different bar than TV does (TV's broker
    emulator fires cap-close on the cap-triggering fill's bar; engine
    may fire on next bar or carry into the new day).

The stored engine_trades.csv (committed at probe creation time) shows
1958 trades 100% aligned to TV — but that csv was generated by
run_strategy.py with --chart-tz "" (UTC fast path), bypassing the
6b062fc chart-tz-aware code path. Hence the stored csv proves the
non-chart-tz path is correct.

Reproduction

cd /Users/haoliangwen/code/pineforge-utils/validator
python validate.py \
    /Users/haoliangwen/code/pineforge-engine/corpus/validation/cap-max-intraday-filled-orders-isolate-01 \
    --engine-repo /Users/haoliangwen/code/pineforge-engine \
    --codegen-repo /Users/haoliangwen/code/pineforge-codegen \
    --inline --workers 1 --out-json /tmp/cap-repro.json
python3 -c "
import json
s = json.load(open('/tmp/cap-repro.json'))['strategies'][0]
print(f'degree={s[\"match_degree\"]} label={s[\"match_label\"]}')
print(f'match_pct={s[\"interior_match_pct\"]:.1f}%')
print(f'n_tv={s[\"n_tv_in_compare_window\"]} n_engine={s[\"n_engine_trades\"]}')
"
# Expected: degree=2 weak, match_pct ≈ 51.2%, n_engine=1971 (TV 1958)

Failed remediation attempts

  • Setting tv_trades_csv_tz=utc in probe inputs.json: repaired count
    delta (1957 vs 1955, 0.05%) but introduced systematic 8-hour timestamp
    display offset → match_pct dropped to 1.3% (net worse). Reverted at
    corpus commit 30fb66c.

Additional findings (2026-05-17 deep dive)

Trade-by-trade diff between tv_trades.csv (1958 trades) and validator-mode
fresh engine output (1971 trades in compare window):

  • First 15 trades match bit-exact (timestamps + prices). Cap hasn't
    triggered yet in this window. Proves engine signal generation + entry
    logic + price emission are correct.
  • 1003 matched trades total — all with time_diff_ms == 0 and
    entry_diff == 0. Cap-close pairs within matched set show
    exit_diff outliers (~80-135) where one side fires synthetic cap-close
    with P&L != 0 and other side fires regular ma-cross exit with P&L == 0
    (or vice versa). Same instant, different exit interpretation.
  • 968 engine_extra + 955 unmatched_tv = cascade divergence after
    cap fires on different bars. Once cap fires on a bar TV doesn't (or
    vice versa), the latch silences subsequent trades for the rest of the
    chart-day, producing entirely different trade lists going forward.

The engine_trades.csv committed in the corpus (mtime May 15, generated
by run_strategy.py with 15m bars + --chart-tz "" UTC) matches TV 1958
bit-exactly. Validator does NOT rewrite engine_trades.csv — it's a
fossil. The validator runs the engine in-memory and compares the fresh
in-memory trade list (1971 trades).

Failed remediation attempts (cont'd)

  • Setting ohlcv_csv to 15m non-warmup OHLCV in probe inputs.json:
    match_pct stayed at 51.3% (n_engine=1847 vs n_tv=1835). Bar-source
    granularity is NOT the bug; aggregation produces correct 15m bars.
    Reverted.

Investigation directions

  1. Diff cap-close trade-by-trade: dump TV's 1958 trades and engine's
    1971 trades side-by-side (sorted by entry timestamp); identify which 13
    are extras and where the 49% mismatched ones diverge. Likely cluster
    around chart-day boundaries.
  2. Inspect _decompose_bar_time_chart_tz() boundary edge case: does
    dayofmonth * 100 + month key match TV's chart-day partitioning at
    month-rollover days? E.g., 2025-04-30 23:55 Asia/Taipei vs 2025-05-01
    00:00 — does engine emit cap-close on the right side?
  3. Compare against pre-6b062fc UTC-only path: add a probe-level
    override to force chart_tz="" regardless of csv_tz, run validator,
    confirm 100% match. This would prove _decompose_bar_time_chart_tz()
    is the sole bug locus.

Related code paths

  • include/pineforge/engine.hpp:337-365 — intraday_fill_count_,
    intraday_day_, intraday_cap_hit_, _intraday_cap_currently_latched()
  • include/pineforge/engine.hpp:646 — _decompose_bar_time_chart_tz()
    declaration
  • src/engine_fills.cpp — apply_filled_order_to_state cap-close emission
  • src/engine_risk.cpp — cap-related risk logic
  • tests/test_intraday_cap_auto_close.cpp — existing ctest (passes; covers
    UTC fast path only)
  • tests/test_intraday_rollover_chart_tz.cpp — existing ctest (passes; may
    not cover the exact failure mode)

Not affected

  • All other corpus probes pass (224/228 baseline-excellent unchanged).
  • The cap-close latch logic itself is correct (was added in 6b062fc
    alongside this regression).
  • UTC fast path (chart_tz="") appears to produce correct trades (stored
    engine_trades.csv proves this).

Activity

  1. luisleo526 commented on May 24, 2026

    @luisleo526
    CollaboratorAuthor

    Closing for a fresh structured start.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions