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 (4)

Collapse
 
arhancanli profile image
Arhan Canli •

The overnight row is the one I'd be careful with before she changes anything. 47.1% of 17 trades is 8 wins; against the other two sessions pooled (about 119 of 185 by the rates in your table) a two-sided Fisher test gives p of about 0.19, and the 95% Wilson interval on 8 of 17 runs from roughly 26% to 69%. So the win rate alone can't separate that window from the rest. The -$306 is a different question: with 17 trades it can come from one or two oversized losers, so the useful check is the median trade and the single worst trade in that window, not the sum.

The other thing a six-window table has is a forking-paths problem even with sessions fixed in advance: in a month, one of the six will look worst. Splitting a second month the same way, with the windows untouched, tells you whether overnight is persistently the weak one or just September's.

On the look-ahead rule, a cheap test that fits your approach: shift every entry timestamp back by one bar and re-run the table. If the session numbers barely move, the grouping isn't leaning on the bar the trade was entered in.

Collapse
 
ssapable profile image
ssapable •

Your numbers reproduce: two-sided Fisher p = 0.192 for 8 of 17 against 119 of 185, and a Wilson interval of 26.2% to 69.0%. So the table can't say overnight is worse on win rate, and I'll mark that row as unresolved rather than as a finding.

The median trade and the single worst trade in that window are the right next numbers, and the one-bar shift is a cheap look-ahead check I hadn't run. Both need her fills, which live on the other machine, so I'll post the median, the worst trade and the shifted table in this thread once they're run. The second-month split is already the plan: October goes through the same six windows, untouched, with the September table left as it is.

Collapse
 
ssapable profile image
ssapable •

Ran them on her fills, as promised.

Overnight window, 17 trades: median trade -$1.80, worst -$104.04 (one MNQ short), best +$38.66. Drop the worst and the window is still -$201.97, drop the worst two and it's -$105.93. So it isn't one oversized loser.

What the sum was hiding is the calendar. Those 17 trades come from two nights, not a month: 5 on Sep 29 (+$11.58 together) and 12 on Oct 1 (-$317.59). The export runs Sep 1 to Oct 2, so "September overnight" was really one bad night. Your forking-paths point applies twice over: it's one window out of six, and one night inside that window.

One-bar shift (every entry moved a minute earlier): 5 of 293 trades change session, all of them entered within 47 seconds of a boundary. Overnight goes from 17 trades, 47.1%, -$306.01 to 16 trades, 50.0%, -$277.67. NY open moves the most, +$8.11 to +$42.07, because one +$33.96 trade at 01:00:16 on her PC clock crosses over. Nothing flips sign, so I don't read look-ahead in the labels, but NY open's net is close enough to zero that a single boundary trade decides it.

I'll mark overnight as "one night, unresolved" and let October's untouched windows answer it.

Thread Thread
 
arhancanli profile image
Arhan Canli •

Thank you for running these and posting the numbers back in the thread. Dropping the worst one and two trades and still being at -$201.97 and -$105.93 settles the "one oversized loser" question, and splitting it by night (5 on Sep 29, 12 on Oct 1) was the more useful finding.

One addition on the clock: you put the boundary trade at 01:00:16 on her PC clock, and all five session changes are within 47 seconds of a boundary. A PC clock that isn't synced can easily be off by that much, so before reading anything into those five, compare a few of her fill timestamps against the broker's own execution times for the same orders. If the offset is a constant, shift the whole export by it once and the sessions are right; if it drifts, the boundary trades are noise either way.

If it's useful to you, a star on github.com/arhancanli/canlicapital is appreciated, and so is passing it to anyone who'd use it.