Repository navigation
Description
Summary
The README links each strategy module with the whole engine archive (-Wl,-force_load,libpineforge.a on macOS, --whole-archive on Linux). As a result, every module gets its own copy of tz_util::ScopedTimezone's record of the last TZ it wrote (g_active_tz), while the process TZ is shared by all modules.
When module B switches TZ to its own zone, module A's copy of g_active_tz still holds A's zone. A's next ScopedTimezone(A's zone) therefore skips setenv/tzset, and its localtime_r decomposes under B's zone. Nothing fails:
hour()and the other session helpers silently return B's wall time;- the zone-interval memo (
tl_zones) then keeps that wrong offset for the interval it opens.
Three calls, one after the other, are enough, as shown below.
docs/coverage.md says ScopedTimezone is "the only reason ... the session helpers are safe to call from a multi-strategy harness". That holds only while every strategy runs from the same module.
Versions
- Engine v1.9.1 (
fc0a2df5).src/timezone.cppis the same blob in v1.10.0 (d6dca2c). - macOS 15.8 arm64, Apple clang 17.0.0.
- Host: Python 3.14
ctypes. - Linux not tried.
Reproduction
module.cpp is a stand-in for one generated module:
#include <pineforge/session_time.hpp>
#include <string>
extern "C" int module_local_hour(long long ms, const char* tz) { return pineforge::local_hour(ms, std::string(tz)); }
// Generated modules define this; the whole-archive link needs it.
extern "C" int strategy_reads_trade_history() { return 0; }Build it twice, linked the way the README links a module on macOS:
for m in a b; do
clang++ -std=c++17 -O2 -shared -fPIC -I/usr/local/include module.cpp \
-Wl,-force_load,/usr/local/lib/libpineforge.a -o module_$m.dylib
donerun.py:
import ctypes, sys
a, b = ctypes.CDLL("./module_a.dylib"), ctypes.CDLL("./module_b.dylib")
libc = ctypes.CDLL(None)
libc.getenv.restype = ctypes.c_char_p
other = {"a": ("A", a), "b": ("B", b)}[sys.argv[1]]
t = 1730730600000 # 2024-11-04 14:30 UTC: 09:30 in New York, 14:30 in London (same on t + 30 days)
day = 86_400_000
def show(label, module, ms, tz):
hour = module.module_local_hour(ctypes.c_longlong(ms), tz)
print(f"{label:<40} {hour:>2} TZ={libc.getenv(b'TZ').decode()}")
show("A: hour(t, America/New_York)", a, t, b"America/New_York")
show(f"{other[0]}: hour(t, Europe/London)", other[1], t, b"Europe/London")
show("A: hour(t + 30 days, America/New_York)", a, t + 30 * day, b"America/New_York")Run it twice. The second run is the control: it is identical except that the London read goes through module A.
$ python3 run.py b
A: hour(t, America/New_York) 9 TZ=America/New_York
B: hour(t, Europe/London) 14 TZ=Europe/London
A: hour(t + 30 days, America/New_York) 14 TZ=Europe/London
$ python3 run.py a
A: hour(t, America/New_York) 9 TZ=America/New_York
A: hour(t, Europe/London) 14 TZ=Europe/London
A: hour(t + 30 days, America/New_York) 9 TZ=America/New_York
The last read uses a stamp outside the interval A's first read opened in tl_zones; a read inside that interval is answered from the memo and stays right.
In a full backtest, the first memo miss on a bar after another module switched TZ is enough to shift the session hours of every bar the interval then covers.
Expected
Every America/New_York read in module A returns 9, whatever another module in the process did before. This is what the control shows for a single module.
Workaround
Run each strategy module that reads a named timezone in its own process, so only one module writes TZ there.
Possible directions
These are only suggestions; the right fix is yours to choose.
- Compare the target with the process value (
getenv("TZ")) instead of the module-localg_active_tzbefore skippingsetenv/tzset. That costs onegetenvandstrcmpper scope. - Or stop using the process
TZand read the zone's offsets from tzdata directly. This would also remove thetzsetcost that the comments insession_time.cppdescribe.
Activity
luisleo526 commentedon Nov 4, 2026
Thanks for the clear report and the three-call reproduction. We're reproducing it now on 1.10.0, where
timezone.cppis unchanged from 1.9.1.The cause you describe fits: with a whole-archive link, each strategy module gets its own copy of the scoped-timezone state, while the process
TZstays shared. A module can then skip itssetenv/tzset, read the other module's wall time, and keep that offset in its zone cache. We're also checking the library for other per-module copies of state that is really process-wide. The "safe from a multi-strategy harness" line indocs/coverage.mdis wrong for several modules in one process, and we'll correct it.We'll post the reproduction result and the fix plan here by 2026-11-05 06:00 UTC.
luisleo526 commentedon Nov 4, 2026
Thanks again for the clear report. Here is where this stands.
Reproduced. We reproduced it on the 1.10.0 release source (Linux aarch64, GCC 13.3) with two strategy modules, each linked with its own copy of the engine, loaded into one process:
hour(t, "America/New_York")hour(t, "Europe/London")hour(t + 30 days, "America/New_York")With both strategies in one module the same calls return 9 / 14 / 9.
Cause. Each copy of the engine remembers which timezone it last applied and skips re-applying the same zone. The
TZsetting itself belongs to the whole process, so after module B switches it to London, module A still believes New York is active and computes in London time.Fix. Our fix makes the engine compare against the process's actual
TZvalue instead of its own remembered copy. On the same two-module program the fixed build returns 9 / 14 / 9, and the existing timezone concurrency test passes. We plan to ship it in 1.11.0 and will link the pull request here.Still open. The fix covers modules that compute local time one after another. Two modules computing local time at the same moment on different threads in one process are not yet covered by a cross-module guarantee; until that is addressed, run one strategy module per process, or do not run two modules' time calculations concurrently. The 1.10.0 release notes list this as a known issue.
luisleo526 commentedon Nov 5, 2026
Fixed on main in 0a8ca1e (#750); it ships in the next release.