DEV Community

ssapable
ssapable

Posted on

A session table in market time, with no look-ahead: the 60 lines that do it

My partner trades micro futures from Korea at night. NinjaTrader stamps every trade with the PC's clock, so her export says things like "entry 13:05". On a Korean screen that reads like lunchtime. In New York it's five past midnight.

The first version of my audit tool grouped her trades by PC hour and printed a table that was accurate and useless: "12:00–14:59" was her worst window, and neither of us could say what that window was. This post is the small amount of code that fixed it, and the rule I now apply to every number the tool prints: nothing may use information from after the moment she clicked.

1. Sessions are defined in New York minutes, not PC hours

Six windows cover the day. Each one is a name plus start and end in minutes after midnight, New York time. The overnight window wraps past midnight, which is the only awkward case.

NY = "America/New_York"

# name, start, end in minutes after midnight New York time (end exclusive)
SESSIONS = [
    ("NY overnight", 23 * 60, 2 * 60),
    ("Europe open", 2 * 60, 5 * 60),
    ("before the NY open", 5 * 60, 9 * 60 + 30),
    ("NY open", 9 * 60 + 30, 12 * 60),
    ("NY afternoon", 12 * 60, 16 * 60),
    ("after the NY close", 16 * 60, 23 * 60),
]
Enter fullscreen mode Exit fullscreen mode

The PC-clock timestamps get localized to the PC's zone and converted. TILTCHECK_TZ exists for the case where you import trades from both sides of a daylight-saving switch on a machine whose own offset has since changed.

def pc_zone():
    return os.environ.get("TILTCHECK_TZ") or datetime.now().astimezone().tzinfo

def ny_minutes(times: pd.Series) -> pd.Series:
    ny = (times.dt.tz_localize(pc_zone(), ambiguous="NaT", nonexistent="shift_forward")
               .dt.tz_convert(NY))
    return ny.dt.hour * 60 + ny.dt.minute

def _inside(m, start, end):
    return (m >= start) & (m < end) if start < end else (m >= start) | (m < end)

def session_of(times: pd.Series) -> pd.Series:
    m = ny_minutes(times)
    out = pd.Series("", index=times.index)
    for name, start, end in SESSIONS:
        out[_inside(m, start, end)] = name
    return out
Enter fullscreen mode Exit fullscreen mode

ambiguous="NaT" and nonexistent="shift_forward" are there for the two hours a year that don't exist or exist twice. Korea has no daylight saving, but the tool shouldn't assume the PC is in Korea.

2. Print both clocks, for the right day

The label the report prints is "NY open 09:30–12:00 New York (22:30–01:00 on this PC)". The PC hours are computed for a specific day, because they move by an hour when New York switches.

def label(name, start, end, day=None):
    day = pd.Timestamp(day or datetime.now()).normalize()
    def hhmm(m): return f"{m // 60:02d}:{m % 60:02d}"
    def pc(m):
        t = (day.tz_localize(NY) + pd.Timedelta(minutes=m)).tz_convert(pc_zone())
        return t.strftime("%H:%M")
    return f"{name} {hhmm(start)}-{hhmm(end)} New York ({pc(start)}-{pc(end)} on this PC)"
Enter fullscreen mode Exit fullscreen mode

The test that caught the daylight-saving bug is three lines, and I'd write it first next time:

monkeypatch.setenv("TILTCHECK_TZ", "Asia/Seoul")
assert session_of(pd.Series([pd.Timestamp("2026-10-02 13:05")])).iloc[0] == "NY overnight"
assert label("NY open", 9 * 60 + 30, 12 * 60, "2026-10-02") == \
    "NY open 09:30-12:00 New York (22:30-01:00 on this PC)"
# after daylight saving ends, the same session sits an hour later on a Korean clock
assert label("NY open", 9 * 60 + 30, 12 * 60, "2026-11-10") == \
    "NY open 09:30-12:00 New York (23:30-02:00 on this PC)"
Enter fullscreen mode Exit fullscreen mode

3. What the table said for her month

Her September export, 293 trades, grouped the new way. These are the rows the report printed:

New York session (her local hours) Trades Win rate Net
NY open, 09:30–12:00 (22:30–01:00) 71 63.4% +$8.11
NY afternoon, 12:00–16:00 (01:00–05:00) 114 64.9% +$1,167.35
NY overnight, 23:00–02:00 (12:00–15:00) 17 47.1% −$306.01

Almost none of the regular-session money came before noon in New York. The overnight window, which is her afternoon, is the only one that lost. She had been reading that window as "the New York session" for a year. Her reaction to the first row: "Right after the open it's so volatile you can't call it. Still, $8 is kind of a shock."

4. The no-look-ahead rule, applied to bars

The same tool later got chart context (EMAs, VWAP bands) from her own 1-minute bars. The rule is that every value attached to an entry at time t must come from bars that had closed by t. A trade must never "know" the bar it was entered in.

Two details do most of the work. Bars are indexed by close time, which is NinjaTrader's convention, and resampling keeps it that way:

def resample(b1: pd.DataFrame, minutes: int) -> pd.DataFrame:
    """1-minute bars -> N-minute bars, still stamped by close time."""
    return b1.resample(f"{minutes}min", label="right", closed="right").agg(
        {"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}).dropna()
Enter fullscreen mode Exit fullscreen mode

With label="right", closed="right", the 15-minute bar stamped 10:15 contains 10:00:01 through 10:15:00. Then a lookup for an entry at 10:14 takes the last bar whose close time is at or before the entry (searchsorted(t, side="right") - 1), so it gets the bar stamped 10:00. The default pandas labels (left) would have handed it the bar that was still forming.

The second detail is session VWAP: it restarts at 18:00 New York, the CME session open, not at midnight on the PC.

ny = b1.index.tz_localize(pc_zone(), ambiguous="NaT", nonexistent="shift_forward").tz_convert(NY)
session = (ny - pd.Timedelta(hours=18)).date
Enter fullscreen mode Exit fullscreen mode

I got the first version of this wrong in a different place, by the way: a win-rate-after-a-win number that used the previous row's result before that trade had closed. It produced the best story in the report (74% after a win, 50% after a loss) and it was false. That's why the rule is now a rule and not a habit.

Run it on your own export

The code is open source and runs on your machine. python -m tiltcheck report --csv trades.csv prints the session table from a plain NinjaTrader trade export; three-numbers prints the sessions, the first trades of the day, and what re-entering within 15 minutes of a loss cost.

GitHub logo ssap-pa / tilt-check

Pre-trade check for futures traders: TabPFN learns from your own NinjaTrader history, Gemma explains it locally. Never places orders.

tilt-check

A pre-trade check for futures traders that learns from your own NinjaTrader history.

TabPFN (open weights) reads your past trades. Gemma (open weights, through Ollama) tells you in plain language what your own numbers say about the trade you're about to take. Everything runs on your machine. It never places an order.

A real check replayed on my partner's history: the groups this trade falls into, an honest model check, and a note from Gemma running locally

I built it for my partner, who trades micro futures (MNQ, MES, MGC) on a prop-firm account and kept asking the same question after a bad session: is this one of my good trades or one of my bad ones?

Want this run on your own export and written up? Your Trading History, Audited: a written report within 48 hours, late means a full refund. The sample is one real account.

What it said about her account

Her numbers, shared with her permission. One account, Sep 1 to Oct 2, 2026:

  • 293 entries over 23…




If you'd rather I run it on your export and write it up, the details are here: https://ssap-pa.github.io/tilt-check/

Top comments (0)