Repository navigation
Description
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:
- 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 readssyminfo_.timezoneinstead ofchart_timezone_. - Harness: query pineforge-data for the symbol's exchange tz, plumb into
syminfo_.timezonevia a new C ABI setter. - Validator: deprecate the
engine_chart_timezoneoverride 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:
- Computes Asia/Taipei midnight slightly wrong (e.g., off-by-one bar at
the boundary), or - 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=utcin 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 commit30fb66c.
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 == 0and
entry_diff == 0. Cap-close pairs within matched set show
exit_diffoutliers (~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_csvto 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
- 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. - Inspect
_decompose_bar_time_chart_tz()boundary edge case: does
dayofmonth * 100 + monthkey 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? - Compare against pre-
6b062fcUTC-only path: add a probe-level
override to forcechart_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()
declarationsrc/engine_fills.cpp— apply_filled_order_to_state cap-close emissionsrc/engine_risk.cpp— cap-related risk logictests/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
docs: remove docs/issues/ — bug tickets moved to GitHub Issues
docs(coverage): update pine_v6_coverage_detail.md for post-sprint state
luisleo526 commentedon May 24, 2026
Closing for a fresh structured start.