Skip to content

Two strategy modules in one process: a module reads local time in the other module's timezone #735

Closed

Description

@letiep97

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.cpp is 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
done

run.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-local g_active_tz before skipping setenv/tzset. That costs one getenv and strcmp per scope.
  • Or stop using the process TZ and read the zone's offsets from tzdata directly. This would also remove the tzset cost that the comments in session_time.cpp describe.

Activity

  1. luisleo526 commented on Nov 4, 2026

    @luisleo526
    Collaborator

    Thanks for the clear report and the three-call reproduction. We're reproducing it now on 1.10.0, where timezone.cpp is 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 TZ stays shared. A module can then skip its setenv / 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 in docs/coverage.md is 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.

  2. luisleo526 commented on Nov 4, 2026

    @luisleo526
    Collaborator

    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:

    call expected 1.10.0
    module A: hour(t, "America/New_York") 9 9
    module B: hour(t, "Europe/London") 14 14
    module A: hour(t + 30 days, "America/New_York") 9 14

    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 TZ setting 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 TZ value 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.

  3. luisleo526 commented on Nov 5, 2026

    @luisleo526
    Collaborator

    Fixed on main in 0a8ca1e (#750); it ships in the next release.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions