Correction (October 7, revised the same evening). A reader, arhancanli, pointed out that the two holdouts below are not the same trades. I replayed the 3x/3x bracket on only the 70 holdout trades both bar sets cover: public bars −$75.54, her own bars −$75.54, identical on every one of the 70 trades, and the bracket is ahead in 26% of day-resamples on both. The whole sign flip comes from 18 trades that are in her own bars' holdout and not in the public one. My first version of this note said the public bars "never had" those trades. That was wrong, and the same reader's follow-up question made me check: 17 of the 18 are in the public bars, in the training 70%. The public feed lacks 60 older trades (50 of them before the feed starts on September 3), so its 70/30 cut lands a day later, and her own bars' holdout starts on September 23 instead of September 24. Those 18 trades sit on three days, 15 of them on September 23, where the bracket made +$338.62 against her +$40.12; the 18th is one GC trade on September 29 with no public file at all. Two trades carry $322 of the $425 gap; without them the other 16 sum to +$103.50 with a bootstrap 95% interval of −$260 to +$479 and 7 of 16 positive, which is nothing. And the sign depends on where the cut falls: a holdout starting Sept 22 gives the bracket +$430 over her exits, the 70/30 cut (Sept 23, 01:18 KST) +$174, Sept 23 at midnight −$64, Sept 24 −$125, Sept 25 +$85. (An earlier version of this note quoted a 93% trade-level resample on the 18; the same reader pointed out that 15 of them share one day, so that number treated three days as eighteen observations. Withdrawn.) So the public bars were right where they existed. What moved the answer was where the holdout cut fell, and mostly one day. The headline overstates it; the rest of the post stands, with that reading. What settles it is a forward window fixed before it starts, so one is now written down and committed: PREREGISTRATION.md, October 8 to 31, days as the unit, a sign test with the thresholds stated, reported either way.
Last week I published a result about my partner's futures trading: her hand exits beat every fixed take-profit/stop bracket I could fit. It was out of sample, it had a sanity check, and it was wrong. Not because of a bug. Because of the bars.
This is what the check looked like, what it couldn't see, and the version of the table I now trust.
The check that passed
Her NinjaTrader export has one row per trade with entry time and fill price, on the PC's clock. The 1-minute bars I had at first were public (Yahoo Finance), in UTC. To replay her entries on bars you need two things: the clock offset between the two, and some confidence that each fill actually happened inside the bar you're about to use.
The tool searches offsets in 30-minute steps across a day in each direction, and for every offset counts how many entries have a fill price between that bar's low and high. The offset with the highest share wins:
def align(trades, bars):
best = (pd.Timedelta(0), -1.0)
for half_hours in range(-28, 29):
off = pd.Timedelta(minutes=30 * half_hours)
m = _fill_bars(trades, bars, off) # one row per entry: its bar at this offset
inside = ((m["entry_price"] >= m["low"]) & (m["entry_price"] <= m["high"])).sum() / len(m)
if inside > best[1]:
best = (off, float(inside))
return best
On her September export the answer was an offset of 9 hours (Korea), and every entry that had a bar sat inside it. The output said:
233 of 293 entries have bars that match their fills (clock offset 0 days 09:00:00).
Every bar that existed was the right bar. I read that line as "the data is good" and moved on.
What it couldn't see
The 60 missing entries were not random. The public feed started on September 3, so her first days were gone, and it only carried the front-month contract, so anything she traded on another expiry had no bars at all. The check only scores entries that have a bar. It says nothing about the ones that don't, and it can't, because there is nothing to compare them against.
Then she exported her own 1-minute bars from NinjaTrader. All 293 entries had a bar, and the same alignment code found the same offset.
The same test, both ways
The test: fit a bracket (target and stop as multiples of her usual move) on her older 70% of trades, score it on the newest 30% it never saw, and compare with what her hand exits made on those same trades.
| Bars | Entries with bars | Holdout trades | Best in-sample bracket | Bracket, out of sample | Her exits, same trades |
|---|---|---|---|---|---|
| Public (Yahoo) | 233 of 293 | 70 | target 3x / stop 3x | −$75.54 | +$176.46 |
| Her own (NinjaTrader) | 293 of 293 | 88 | target 3x / stop 3x | +$444.86 | +$271.36 |
Same code, same bracket, opposite sign. On public bars her exits looked like the strongest part of her record, and I wrote that up. On her own bars a wide symmetric bracket would have made about $170 more than her hands this month.
How much of that is noise? Her trades cluster by day, so I resampled the 88 holdout trades by day 1,000 times: the bracket came out ahead in 66% of resamples. Better than a coin flip, not by much. The honest summary is "open question, next month's export decides", and that is what the report says now.
It wasn't only the bracket
The rules table moved too. She trades with a rule about the 15-minute 200 EMA. Checked at the moment of each click, no look-ahead:
| Bars | Followed | Trades | Win rate | Net |
|---|---|---|---|---|
| Public | yes | 60 | 60.0% | +$26.54 |
| Public | no | 96 | 52.1% | −$83.95 |
| Her own | yes | 82 | 67.1% | +$450.08 |
| Her own | no | 161 | 57.1% | +$520.31 |
The direction of the gap survived (with the trend wins more). The size of everything else didn't. On public bars breaking the rule looked like a losing habit. On her own bars it was 161 trades that made money, with a ten-point lower win rate. Those lead to different conversations.
What I do differently now
- Coverage is a result, not a precondition. The replay prints "N of M entries have bars" before any table, and the next change is to list which entries are missing and why (date range, contract month). A check that only scores what exists needs a second line about what doesn't.
- The trader's own bars, or nothing. Public minute data for futures is fine for charts and bad for replaying someone's fills: wrong start date, front month only, and no way to tell from the inside.
- Report the sign flip. I put a correction on the first post rather than quietly updating the number. If a reader made a decision on the old table, they deserve to know it moved.
- Fill-inside-bar is necessary, not sufficient. It catches the wrong offset and the wrong contract. It cannot catch absence.
The code is open source and runs on your machine; the replay prints the coverage line and the bracket table from a plain NinjaTrader export plus your own bars:
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.
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 (10)
One thing in the bracket table is worth splitting before putting the whole sign flip on the bars: the two holdouts aren't the same trades. The newest 30% of 233 entries and the newest 30% of 293 are different sets, 70 and 88 trades cut at different points, so the move from -$75.54 to +$444.86 mixes two causes: different bars and different trades. Replaying the 3x/3x bracket on only the trades both holdouts share, once on each set of bars, separates them. If those trades flip sign too, the public bars were wrong even where they existed; if they don't, the flip came from which trades got in. With the bracket ahead in 66% of day-resamples, knowing which it was decides what next month's export can actually settle.
You were right, and the split settles it. The two holdouts share 70 trades; the other 18 are newest-30% trades only her NinjaTrader bars cover. Replaying 3x/3x on the 70 shared trades: public bars −$75.54, her own bars −$75.54. Identical, 0 of 70 trades differ, and the bracket is ahead in 26% of day-resamples on both. The whole sign flip is the 18 trades the public bars never had: bracket +$520.40 vs her exits +$94.90 on those. So the public bars weren't wrong where they existed. They were missing, and the missing slice wasn't neutral: it carried the entire difference. That's a different headline from the one I wrote, so I'm adding this as a correction at the top of the post. Thank you for not letting it slide.
Good to see the correction go at the top. Before the new headline stands, note it rests on 18 trades: +$520.40 against +$94.90 is a $425 gap. Resample those 18 trades the way you resampled days and report the share where the bracket comes out ahead; with 18 trades, one or two large winners can carry the whole difference.
It's also worth asking why the missing slice is exactly the newest 30%. A gap at the recent end may share a cause with something that differs about those trades (a contract roll, session hours, a different volatility regime), and that cause could matter more to the result than the exit rule.
Ran both, and the second question changed the story more than the first. The 18 trades sit on three days (15 on Sept 23, 2 on Sept 24, 1 on Sept 29), so a day-level resample isn't possible; the function refuses under five days, correctly. Trade-level: the bracket is ahead in 93% of 1,000 draws, but you're right about concentration: the two largest diffs (one GC trade on Sept 29, +$220 against her exit; one MGC on Sept 23, +$102) carry $322 of the $425, leaving +$103.50 across the other 16.
Then the part I got wrong. The 18 are not a gap at the recent end, and 17 of them aren't missing from the public bars at all: they're in the public set, on the training side of its cut. The public feed lacks 60 older trades (50 before it starts on Sept 3), so its 70/30 split lands a day later, and her own bars' holdout starts on Sept 23 instead of Sept 24. Sept 23 alone: 15 trades, bracket +$338.62 vs her +$40.12. The 18th is a full-size GC trade with no public file. So "the public bars never had them" was wrong; what moved the sign was where the cut fell, and mostly one day. I've rewritten the correction at the top to say exactly that, and fixed the README. Thank you for pushing twice.
Thanks for rerunning it and writing down exactly what moved. One caution on the 93%: resampling the 18 trades treats them as independent, but 15 sit on Sept 23, so they share one day's market moves and the effective sample is closer to three days than eighteen. The 93% describes those trades under that assumption, not days.
Cheaper checks that fit this data: (1) slide the train/holdout cut across Sept 22, 23 and 24 and show the sign of the bracket-minus-hers difference at each; if it flips with a one-day move, that is the finding. (2) Redo the trade-level draw without the two large trades (the GC and MGC ones), so the +$103.50 on the other 16 comes with an interval, plus a sign test on those 16. (3) Show Sept 23 alone against the rest, even at n=3 days, so nobody reads the result as eighteen observations.
Fixing the README to say the cut moved by a day is the right call; I'd put the concentration numbers in its headline too.
Agreed on the 93%, and I've withdrawn it from the note rather than caveated it: three days dressed up as eighteen observations is exactly what it was. Your three checks, on her own bars, 3x/3x fixed:
(1) Sliding the holdout start: from Sept 22 the bracket is +$430 over her exits (102 trades); the 70/30 cut, which lands at Sept 23 01:18 KST, +$174 (88); from Sept 23 at midnight −$64 (99); Sept 24 −$125 (73); Sept 25 +$85 (65). The sign flips with a one-day move, and once with an hour's move. That is the finding.
(2) Without the GC and MGC trades, the other 16 sum to +$103.50 with a 10,000-draw bootstrap 95% interval of −$260 to +$479; sign test 7 of 16 positive, p = 0.80. Nothing there.
(3) Sept 23 alone: 15 trades, bracket +$338.62 vs her +$40.12. The other six holdout days, 73 trades: bracket +$106.24 vs her +$231.24. One day against the rest, in opposite directions.
The note at the top now says that, with the withdrawal spelled out, and the README headline carries the concentration numbers and the slide. The honest summary of the whole post is now "the sign of this comparison depends on which day the holdout starts, so it isn't a result yet". Thank you for not accepting the second version either.
The sign flipping with a one-day move, and once with an hour's move, is the strongest line in the update, and it also tells you what the unit of evidence is: days, not trades. Fifteen trades on Sept 23 are one draw from one regime. With seven holdout days, even a perfect 7 of 7 day-level sign test only reaches p = 0.016 two-sided, and a more realistic 5 of 7 gives p = 0.45, so this comparison can't be settled on the data that exists.
The useful next step is probably a forward window fixed before it starts: the bracket and her exits both frozen at 3x/3x, a start date and an end date (or a minimum number of trading days), the metric as the per-day difference in P&L, and the decision rule written down in the note. Then the forward result can contradict the in-sample one, which the Sept 22-25 slide can no longer do.
Done, and committed before the window opens: github.com/ssap-pa/tilt-check/blob/main/PREREGISTRATION.md. Frozen: the 3x/3x bracket with the September point sizes per instrument (not re-fitted), her own 1-minute bars, 120-minute horizon, both-levels-in-one-bar counts as the stop. Window: entries from Oct 8 00:00 KST to Oct 31, extended until there are 15 trading days with entries if October is short. Metric: per trading day, bracket net minus her net on the same entries. Decision: two-sided sign test on the days; 13 of 15 (p = 0.007) or 12 of 15 (p = 0.035) either way is a result, anything else is "not settled" and gets written as that, no second cut, no second bracket. Days dropped for missing bars are listed. Reported either way, with the per-day table, in the same post and README.
Your p = 0.45 for 5 of 7 is also why I didn't try to squeeze a conclusion out of September's seven days. If the October export contradicts the in-sample +$444.86, that's the headline, and the earlier number becomes what it should have been from the start: the reason to run the test.
The thresholds are right (13/15 two-sided p = 0.0074, 12/15 = 0.035), and the rule is deliberately hard to pass. Worth knowing what that costs: if the bracket really beats her on 70% of days, the chance of reaching 12 of 15 is only about 30%; at 60% it is about 9%, and it takes a true 80% day-rate to get to roughly 65%. So "not settled" is the most likely outcome unless the effect is large, and that is fine as long as it is read as "no evidence either way", not as "no effect".
Two small things to write down before Oct 8 so they can't be chosen afterwards: how a day with an exactly zero difference is handled (drop it, or count it as half), and that the per-day table carries the dollar sums next to the signs. A sign test ignores size, so 12 wins can still sit beside one loss day that wipes out the total, and the reader should see that without it changing the decision.
Both written in, as an amendment stamped 01:45 KST on Oct 8, 1h45m after the window opened and before any October export exists, so it's dated honestly rather than back-dated. Zero-difference days are dropped and listed as zero days, with a table of the wins needed for each remaining n (13: 11, 14: 12, 15: 12, 16: 13, 17: 13 ...). The per-day table carries bracket net, her net, the difference and the sign side by side with totals, and the file says in so many words that twelve small wins beside one loss day that erases the total is a thing the reader will see without it moving the decision.
I checked your power numbers (binomial, same thresholds): 70% day-rate gives 30% to reach 12 of 15, 60% gives 9%, 80% gives 65%. They're in the file too, with the sentence that "not settled" means no evidence either way, not no effect. So the most likely November headline is "not settled", and that will be written exactly that way. Thanks for the five rounds; the test is better than the post was.