<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: 王伟</title>
    <description>The latest articles on DEV Community by 王伟 (@wei-backtest).</description>
    <link>https://dev.to/wei-backtest</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4198694%2F866d0ccd-ecfb-4592-b4e0-7fe050f96373.png</url>
      <title>DEV Community: 王伟</title>
      <link>https://dev.to/wei-backtest</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wei-backtest"/>
    <language>en</language>
    <item>
      <title>After 455 Parameter Sets, a t-Stat of 2.18 Means Nothing</title>
      <dc:creator>王伟</dc:creator>
      <pubDate>Wed, 21 Oct 2026 16:44:56 +0000</pubDate>
      <link>https://dev.to/wei-backtest/after-455-parameter-sets-a-t-stat-of-218-means-nothing-k3n</link>
      <guid>https://dev.to/wei-backtest/after-455-parameter-sets-a-t-stat-of-218-means-nothing-k3n</guid>
      <description>&lt;p&gt;&lt;em&gt;The significance test you learned assumes you ran one test. You ran hundreds.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I had a strategy with a t-statistic of &lt;strong&gt;2.18&lt;/strong&gt; on 400 trades.&lt;/p&gt;

&lt;p&gt;Under the textbook reading, that is significant. The conventional threshold for 5% two-sided is about 1.96, and 2.18 clears it. I had spent weeks on the parameter grid before that number appeared, evaluating roughly &lt;strong&gt;455 parameter sets&lt;/strong&gt; along the way.&lt;/p&gt;

&lt;p&gt;So I asked the question that killed it: &lt;em&gt;if all 455 of those sets had zero edge, how big would the best of them look?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The test nobody runs
&lt;/h2&gt;

&lt;p&gt;The standard t-test answers: "if the true mean were zero, how often would a sample like mine show a t of 2.18 or larger?" That question is conditional on &lt;strong&gt;one&lt;/strong&gt; sample.&lt;/p&gt;

&lt;p&gt;I did not take one sample. I took 455, threw away 454 of them, and kept the largest. The quantity I am actually looking at is not a t-statistic from one draw — it is the &lt;strong&gt;maximum of 455 t-statistics&lt;/strong&gt;. The distribution of a maximum is nothing like the distribution of a single value.&lt;/p&gt;

&lt;p&gt;The arithmetic rule of thumb people quote is: expected false positives = &lt;code&gt;N × α&lt;/code&gt;. With 455 sets at 5%, that would predict about 22.8 sets "significant" by luck. That number is right about the &lt;em&gt;count&lt;/em&gt; and useless about the &lt;em&gt;threshold&lt;/em&gt;, because it does not tell you how big the biggest one gets.&lt;/p&gt;

&lt;p&gt;For that, simulate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The simulation
&lt;/h2&gt;

&lt;p&gt;I generated 455 strategies, each with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;exactly zero edge (mean 0)&lt;/li&gt;
&lt;li&gt;400 trades, same volatility as mine&lt;/li&gt;
&lt;li&gt;the same t-statistic formula&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;then recorded the &lt;strong&gt;maximum&lt;/strong&gt; t across the 455, and repeated that 2,000 times. Everything below is real output, not a sketch.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftoyl3pp48mznt4zatn8r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftoyl3pp48mznt4zatn8r.png" alt="Best-of-455 null distribution" width="800" height="417"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Best-of-455 t under the null:
  median   2.99
  p90      3.51
  p95      3.71
  p99      ~4.0

P(best-of-455 t &amp;gt;= your 2.18) = 0.999
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The median best-of-455 t under &lt;strong&gt;pure zero edge&lt;/strong&gt; is &lt;strong&gt;2.99&lt;/strong&gt;. My 2.18 did not clear the median. It was below what search produces on a normal day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the bar moves so much
&lt;/h2&gt;

&lt;p&gt;The maximum of N draws grows, but slowly — roughly like &lt;code&gt;sqrt(2 log N)&lt;/code&gt; for well-behaved tails. Two consequences worth internalising:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The usual t = 3 bar is not conservative enough when N is in the hundreds.&lt;/strong&gt; People treat t = 3 as "strict". Under a 455-wide search at my sample size, the &lt;em&gt;median&lt;/em&gt; best is 2.99. You would need to clear something like the 95th percentile — 3.71 — to say anything at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bar depends on your sample size.&lt;/strong&gt; More trades per strategy tightens the single-test distribution but does not shrink the selection effect; with 400 trades the null max still sits near 3. The two effects do not cancel, and it is easy to add trades while also adding grid points, netting out to nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest accounting
&lt;/h2&gt;

&lt;p&gt;Here is what I do now, and it is mechanical:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write down N before searching.&lt;/strong&gt; Not after. If I planned 20 sets and ended up evaluating 455, the number I report is 455.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute the selection-adjusted p-value&lt;/strong&gt;, not the naive one. Simulate the null (or use a formula) and ask what the best of N looks like at my sample size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report N alongside the t-statistic, every time.&lt;/strong&gt; A t-statistic without N is not interpretable once you have searched.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third one is the cheapest and it is the one nobody does. "t = 2.18" sounds like a result. "t = 2.18 after 455 evaluations, where the null median is 2.99" is a different sentence entirely, and both are one line.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not mean
&lt;/h2&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; mean the strategy is bad. It means the evidence I had was insufficient to distinguish it from noise produced by searching.&lt;/p&gt;

&lt;p&gt;Those are different conclusions with different next actions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Conclusion&lt;/th&gt;
&lt;th&gt;Next action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"The strategy has no edge"&lt;/td&gt;
&lt;td&gt;Drop it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"The evidence is insufficient"&lt;/td&gt;
&lt;td&gt;Collect more data, or search less&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"The edge is real but unproven"&lt;/td&gt;
&lt;td&gt;Paper-trade with small size while accumulating&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Mine was the second. The honest move was to stop adding filters (each filter is a new search, and it would need its own out-of-sample) and instead increase trades per strategy while holding the grid fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable arithmetic
&lt;/h2&gt;

&lt;p&gt;Every additional parameter set you evaluate buys you a slightly higher chance of a good-looking result, and charges you a slightly higher bar to clear. Early on, the first effect dominates: going from 1 to 10 sets is clearly worth it, you learn something. Later, the second dominates: going from 400 to 455 mostly just raises the bar.&lt;/p&gt;

&lt;p&gt;There is a crossover, and it is usually much earlier than people think. Mine was somewhere in the low tens.&lt;/p&gt;

&lt;p&gt;The number of things you tried is not a detail of your process. It is &lt;strong&gt;a parameter of your test&lt;/strong&gt;, and leaving it out is how a search gets reported as a discovery.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The simulation is part of a small open-source kit (&lt;a href="https://github.com/wei498766-ai/backtest-lie-detector" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, MIT, no dependencies): &lt;code&gt;backtest_kit.py selection --tried N&lt;/code&gt; runs the null for your own N and sample size. It reports a probability, not a verdict — the verdict is yours.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you would rather have this as a download than a repo: the same tool, plus the&lt;br&gt;
demo trade logs (one with five bugs planted, one clean), is *&lt;/em&gt;$0** on Gumroad —&lt;br&gt;
pay what you want, and $0 is a valid answer: &lt;a href="https://weiwave23.gumroad.com/l/backtest-lie-detector" rel="noopener noreferrer"&gt;https://weiwave23.gumroad.com/l/backtest-lie-detector&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>statistics</category>
      <category>trading</category>
      <category>backtesting</category>
      <category>python</category>
    </item>
    <item>
      <title>My Walk-Forward Said +29.2 Per Trade. Honest Out-of-Sample Said -4.6.</title>
      <dc:creator>王伟</dc:creator>
      <pubDate>Wed, 21 Oct 2026 16:44:29 +0000</pubDate>
      <link>https://dev.to/wei-backtest/my-walk-forward-said-292-per-trade-honest-out-of-sample-said-46-km2</link>
      <guid>https://dev.to/wei-backtest/my-walk-forward-said-292-per-trade-honest-out-of-sample-said-46-km2</guid>
      <description>&lt;p&gt;&lt;em&gt;A parameter set that won on folds 1-3 lost on folds 4-5. Same strategy, same data.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I ran the standard walk-forward check. Three parameter sets, five sequential folds, about 400 trades. For each fold I picked the set that was best on the &lt;strong&gt;previous&lt;/strong&gt; folds only, then scored it on the fold it had never seen.&lt;/p&gt;

&lt;p&gt;Here is the whole output:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3pvd3k0yemvpmmk519k3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3pvd3k0yemvpmmk519k3.png" alt="Out-of-sample result per fold" width="800" height="426"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fold   picked      oos net    oos t
2      k0.75        436.5     0.88
3      k0.75        827.9     1.56
4      k0.75       -941.2    -2.51
5      k1.00       -813.2    -1.96

Stitched out-of-sample: n=107  net=-489.99  mean=-4.58  t=-0.51
In-sample best mean   :  +29.17 per trade
Optimism gap          :   33.75 per trade
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parameter set &lt;code&gt;k0.75&lt;/code&gt; looked best for three consecutive folds. It was the &lt;em&gt;worst&lt;/em&gt; set for the next two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;+29.17 per trade in sample. −4.58 out of sample.&lt;/strong&gt; The gap is 33.75 per trade, which is larger than the entire in-sample edge — the sign flipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "out-of-sample" actually means
&lt;/h2&gt;

&lt;p&gt;Most people treat out-of-sample as a split. You cut the data at some date, fit on the left, evaluate on the right, and call the right side honest.&lt;/p&gt;

&lt;p&gt;That is not what makes it honest. &lt;strong&gt;Out-of-sample is a procedure, not a file.&lt;/strong&gt; What makes it honest is that no choice in the evaluation was informed by the data being evaluated.&lt;/p&gt;

&lt;p&gt;Here is the distinction, concretely:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you did&lt;/th&gt;
&lt;th&gt;Is the score honest?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Optimised on 2015-2020, tested on 2021-2023, and you never looked at 2021-2023 while designing&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optimised on 2015-2020, tested on 2021-2023, then went back and tweaked because 2021-2023 looked bad&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;no&lt;/strong&gt; — that file is now in-sample&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Picked the parameter that maximised the &lt;em&gt;whole&lt;/em&gt; period, then reported the second half&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;no&lt;/strong&gt; — the second half informed the pick&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Picked on folds 1..i-1, scored on fold i, never revisited&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third row is the one that catches people. If you choose a parameter using any statistic computed over the test period — total return, Sharpe, whether the equity curve "looked right" — the test period has leaked into the choice, and the score is in-sample no matter which file it lives in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap is not a haircut
&lt;/h2&gt;

&lt;p&gt;The natural instinct is to treat the optimism gap as a discount: knock 20% off the in-sample number and move on.&lt;/p&gt;

&lt;p&gt;That does not work, because the gap is not proportional to the edge. It is roughly proportional to &lt;strong&gt;how much selection freedom you had&lt;/strong&gt;. In this example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;in-sample best mean: &lt;strong&gt;+29.17&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;honest out-of-sample: &lt;strong&gt;−4.58&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The gap (33.75) was &lt;em&gt;bigger than the edge&lt;/em&gt; (29.17). There was no discount that turns this back into a positive number, because the honest number is negative.&lt;/p&gt;

&lt;p&gt;When the gap exceeds the edge, what you measured was not a weak strategy. You measured the selection process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;Three mechanisms, all mundane:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Fold-to-fold variance is large.&lt;/strong&gt; A fold with 20-25 trades has a standard error of roughly &lt;code&gt;sd / sqrt(25)&lt;/code&gt;. If per-trade sd is around 100, the standard error of the mean is about 20 per trade. So a "best mean" of 29 is roughly one to two standard errors above zero — entirely reachable by luck. With three candidate sets, picking the top one at each step reliably picks the noisiest winner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Regime change is not announced.&lt;/strong&gt; The demo I used has the edge decaying after ~60% of the sample on purpose, which is a clean version of what real markets do more subtly. A parameter that suits the first regime is not obliged to suit the second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The selection is repeated, not once.&lt;/strong&gt; You do not choose a parameter once. You choose at fold 2, again at fold 3, again at fold 4. Each choice is a fresh chance to pick the lucky one, and the errors compound across the stitched result.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to run this yourself
&lt;/h2&gt;

&lt;p&gt;You need one thing beyond a trade log: &lt;strong&gt;a column saying which parameter set produced each trade.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;entry&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;side&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;entry&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;param&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;set&lt;/span&gt;
&lt;span class="ld"&gt;2024-01-02T09:30:00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;BUY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;100.12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;101.40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;2.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;k&lt;/span&gt;&lt;span class="mf"&gt;0.75&lt;/span&gt;
&lt;span class="ld"&gt;2024-01-03T14:05:00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;SELL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;101.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;100.90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;2.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;k&lt;/span&gt;&lt;span class="mf"&gt;1.00&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that column, the procedure is mechanical:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Split trades into K sequential folds by time.&lt;/li&gt;
&lt;li&gt;For fold &lt;em&gt;i&lt;/em&gt; (starting at &lt;em&gt;i&lt;/em&gt;=2), compute each parameter set's mean on folds 1..&lt;em&gt;i&lt;/em&gt;−1.&lt;/li&gt;
&lt;li&gt;Pick the best one.&lt;/li&gt;
&lt;li&gt;Score it on fold &lt;em&gt;i&lt;/em&gt; only.&lt;/li&gt;
&lt;li&gt;Stitch the fold-&lt;em&gt;i&lt;/em&gt; results. That stitched series is your honest number.&lt;/li&gt;
&lt;li&gt;Compare it to the best in-sample mean. The difference is the gap.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python backtest_kit.py folds trades.csv &lt;span class="nt"&gt;--k&lt;/span&gt; 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the column, all you can do is split validation, which is weaker — it tells you about stability over time, not about selection. The tool says so rather than pretending otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that stings
&lt;/h2&gt;

&lt;p&gt;I did not set out to fool myself. I had a parameter grid, I picked the winner on the development data, and I reported the number. That is the standard workflow, and it is exactly the workflow that produces this gap.&lt;/p&gt;

&lt;p&gt;The gap is not a sign of carelessness. It is the &lt;strong&gt;expected output&lt;/strong&gt; of selecting on noise. Which means the fix is not "be more careful" — it is to make the selection procedure mechanical and to report the stitched number, including when it is negative.&lt;/p&gt;

&lt;p&gt;The uncomfortable question is not "was my gap large". It is: &lt;strong&gt;how many strategies do I still believe in because I never computed this number for them?&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The fold walk and the gap calculation are in a small open-source kit (&lt;a href="https://github.com/wei498766-ai/backtest-lie-detector" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, MIT, no dependencies). If you have run this on your own grid and the gap came out smaller than the edge, I would like to see the setup — that is the interesting case.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;There is also a *&lt;/em&gt;$0** download on Gumroad that bundles the tool with the demo&lt;br&gt;
logs, if you want something to test against before pointing it at your own data —&lt;br&gt;
name your price, and $0 is a valid answer: &lt;a href="https://weiwave23.gumroad.com/l/backtest-lie-detector" rel="noopener noreferrer"&gt;https://weiwave23.gumroad.com/l/backtest-lie-detector&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>trading</category>
      <category>backtesting</category>
      <category>statistics</category>
      <category>python</category>
    </item>
    <item>
      <title>The One-Line Fix That Took My t-Stat From 6.22 to 1.04</title>
      <dc:creator>王伟</dc:creator>
      <pubDate>Wed, 21 Oct 2026 16:42:36 +0000</pubDate>
      <link>https://dev.to/wei-backtest/the-one-line-fix-that-took-my-t-stat-from-622-to-104-5ccl</link>
      <guid>https://dev.to/wei-backtest/the-one-line-fix-that-took-my-t-stat-from-622-to-104-5ccl</guid>
      <description>&lt;p&gt;&lt;em&gt;A breakout strategy passed all six parameter cells. It was a fill assumption, not an edge.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I had a breakout strategy on 1-minute data come in at &lt;strong&gt;t = 6.22&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Six parameter cells, all six passing. That is the kind of number that makes you start thinking about position size and funding an account. It is also the kind of number that should make you suspicious, because in ten years of doing this I have almost never seen a real edge survive a clean audit with a t-stat that high.&lt;/p&gt;

&lt;p&gt;It was not real. The strategy was fine. The &lt;strong&gt;fill assumption&lt;/strong&gt; was wrong, and it was wrong in exactly the direction that flatters you.&lt;/p&gt;

&lt;p&gt;Here is the whole story, because the same bug is probably sitting in a lot of backtesters, including yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;My breakout entry looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;high&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;buy_level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;buy_level&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;buy_level&lt;/code&gt; came from the &lt;strong&gt;previous&lt;/strong&gt; session's high. The logic reads fine: price trades up through my level, I get filled at my level. That is what a limit order does.&lt;/p&gt;

&lt;p&gt;The problem is the gap. When the new session opens with a gap straight &lt;strong&gt;through&lt;/strong&gt; &lt;code&gt;buy_level&lt;/code&gt;, bar &lt;code&gt;j&lt;/code&gt; satisfies &lt;code&gt;high[j] &amp;gt;= buy_level&lt;/code&gt; on its very first tick — at the open. The open is already above my trigger, and I record a fill at &lt;code&gt;buy_level&lt;/code&gt;, a price that never traded in that session.&lt;/p&gt;

&lt;p&gt;I was gifting myself the gap. Systematically, on every gap-through day, in the direction that made the equity curve look better.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz6ji582mrbzd87j0zcx1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz6ji582mrbzd87j0zcx1.png" alt="The fill that never traded" width="800" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;X&lt;/code&gt; is the recorded fill. The dotted line is that bar's low. The fill sits &lt;strong&gt;1.48 below the lowest price of the bar&lt;/strong&gt; — a price that, by construction, no one could have transacted at. The backtester did not complain, because nothing in the code knows what a plausible fill looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;One line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buy_level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;open&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;# the worse of the two, for a long
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the bar opened above my trigger, I get the open — the price I could actually have paid. If it opened below, I get my trigger, as intended. Same for shorts with &lt;code&gt;min(...)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is the entire change. Not a filter, not a parameter, not a new feature.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9t8dpy9be2pb6vbnn8kh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9t8dpy9be2pb6vbnn8kh.png" alt="Before and after" width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same trades, same period, same parameter grid. &lt;strong&gt;6.22 → 1.04.&lt;/strong&gt; Passing cells: &lt;strong&gt;6 of 6 → 0 of 6.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The entire edge lived in the fill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is hard to catch
&lt;/h2&gt;

&lt;p&gt;Three reasons, and they compound.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is plausible.&lt;/strong&gt; &lt;code&gt;entry = buy_level&lt;/code&gt; reads like correct code. It is the natural way to express "I got filled at my level". Nothing about it looks like a mistake, so code review slides right over it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It inflates results in the direction you want.&lt;/strong&gt; There is no error, no crash, no unusual log line. The only symptom is a nicer equity curve, which is precisely the thing you were hoping for. Your brain is not going to flag good news as a bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It hides in the gap.&lt;/strong&gt; If you trade a market that does not gap, you may never see it. It only appears on days where the open jumps past the trigger — and on those days it silently substitutes a favourable price for the one you would have got.&lt;/p&gt;

&lt;h2&gt;
  
  
  How often it actually bites
&lt;/h2&gt;

&lt;p&gt;I measured the share of entries where the recorded fill fell outside the bar's &lt;code&gt;[low, high]&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F86e0nrulupf7ioxtjzxd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F86e0nrulupf7ioxtjzxd.png" alt="Gap fill share by instrument" width="799" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The pattern is exactly what you would expect: the more a market gaps overnight, the worse it gets. Index futures on a session-based breakout are the worst case — &lt;strong&gt;roughly one entry in six&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And note what this does to the interpretation of a backtest. On NQ, 17.4% of entries were priced at a level the market never traded. If your strategy's edge is on the order of a few basis points per trade, and one entry in six is being handed a free gap, then the measured edge is mostly the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I found it
&lt;/h2&gt;

&lt;p&gt;Not by reading the code. I had read that code a dozen times.&lt;/p&gt;

&lt;p&gt;I found it by looking at &lt;strong&gt;when&lt;/strong&gt; the largest 1-minute moves happened. I ran a data-quality pass that printed the twelve biggest 1-minute jumps with their timestamps, and all twelve landed between &lt;strong&gt;18:00 and 18:05 New York time&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That clustering was the tell. Twelve enormous 1-minute moves, all in a five-minute window, every time — those are not market moves. That is the session boundary. The "jumps" were cross-session gaps being recorded inside a session-labelled dataset.&lt;/p&gt;

&lt;p&gt;Once I was looking at gaps specifically, the fill assumption fell apart in about ten minutes.&lt;/p&gt;

&lt;p&gt;The general lesson here is worth more than the bug: &lt;strong&gt;when you audit a backtest, look at the time distribution of anomalies, not just their size.&lt;/strong&gt; A 2% one-minute move at a random time is a market event. A 2% one-minute move that always happens at 18:00 is a data-structure artefact, and it is telling you that your data is not shaped the way your code assumes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this generalises to
&lt;/h2&gt;

&lt;p&gt;Fill assumptions are where backtests lie most often, because they are the one part of the simulation that has no external check. Your signal logic produces a number you can inspect. Your fill price is just... whatever you wrote.&lt;/p&gt;

&lt;p&gt;Three specific forms to look for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Gap-through fills&lt;/strong&gt; — the one above. Trigger levels set from prior session data, filled at the trigger regardless of the open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look-ahead on exits&lt;/strong&gt; — if &lt;code&gt;execute()&lt;/code&gt; is only called on bars where a signal fired, then exits are only checked on those bars. I had this one too: fixing it moved a different strategy from &lt;strong&gt;−5.7% to +17.9%&lt;/strong&gt;, and inflated the win rate by 6–8 points. The original reported a 74.1% win rate; most of that was the bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost assumptions borrowed from another asset class&lt;/strong&gt; — two futures strategies of mine were costed at 5bp per side. Actual round-trip cost was closer to 0.30bp (NQ) and 0.55bp (MGC). That is an 18–33× overstatement. Correcting it moved one of them from −94.0% to −45.5%. Rule of thumb: if &lt;code&gt;cost ÷ per-trade risk&lt;/code&gt; exceeds about 5%, suspect the cost model before you reject the strategy.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Three checks you can run on your own log
&lt;/h2&gt;

&lt;p&gt;You do not need my code for these; you need your trade log and your price bars.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Is every fill inside its bar?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For each trade, find the bar containing the entry timestamp and check&lt;br&gt;
&lt;code&gt;low &amp;lt;= entry_price &amp;lt;= high&lt;/code&gt;. Anything outside never traded. This single check catches the gap-fill bug directly, and it is the highest-yield check I know.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Is the fill better than the bar's open?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For breakout entries, if a long fill is below the open, either a limit order filled later in the bar (fine, but then model it that way) or you have gifted yourself the gap (not fine). This one has false positives — limit orders legitimately fill better — so treat it as a prompt to look, not a verdict.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Do your anomalies cluster in time?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Print your largest N moves with timestamps and histogram the hour. If the biggest moves all sit at a fixed time of day, you are looking at session boundaries, and your session handling is probably wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  The tool
&lt;/h2&gt;

&lt;p&gt;I turned the checks I now run into a small script, because doing them by hand every time is how you stop doing them. It is one file, standard library only, no pandas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python backtest_lint.py trades.csv &lt;span class="nt"&gt;--prices&lt;/span&gt; prices.csv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0ynxxc0kjgr9vavu5dl8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0ynxxc0kjgr9vavu5dl8.png" alt="Real output" width="799" height="618"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That image is real output from the tool on a demo log that has five bugs deliberately planted in it. It scores 1/6 and names each one with the row number and the actual numbers. The same repo also ships a clean control that scores 6/6 — a checker that always says "fail" would be worthless, so both directions are testable before you trust it.&lt;/p&gt;

&lt;p&gt;Code: &lt;strong&gt;&lt;a href="https://github.com/wei498766-ai/backtest-lie-detector" rel="noopener noreferrer"&gt;https://github.com/wei498766-ai/backtest-lie-detector&lt;/a&gt;&lt;/strong&gt; — MIT, no dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable part
&lt;/h2&gt;

&lt;p&gt;The uncomfortable part is not that I had a bug. Everyone has bugs.&lt;/p&gt;

&lt;p&gt;The uncomfortable part is that &lt;strong&gt;the bug made the result better, and the result looked like a discovery&lt;/strong&gt;. I did not find this because I was careless. I found it because I got suspicious of a number that was too good, and then went looking for how it could be too good.&lt;/p&gt;

&lt;p&gt;That is not a scalable process. It depends on being suspicious at the right moment, about the right number — and the numbers you most want to believe are exactly the ones you are least likely to question.&lt;/p&gt;

&lt;p&gt;So the process has to be external. Cheap, mechanical checks that run whether or not you are in the mood to doubt yourself.&lt;/p&gt;

&lt;p&gt;That is the whole argument for this kind of tooling. Not that it makes you a better trader, but that it does not care what you want to be true.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you have hit something in this family — particularly anything where the backtester's assumption is invisible in the output — I would like to hear about it. The ones that do not throw errors are the ones worth collecting.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you would rather have this as a download than a repo: the same tool, plus the&lt;br&gt;
demo trade logs (one with five bugs planted, one clean), is *&lt;/em&gt;$0** on Gumroad —&lt;br&gt;
pay what you want, and $0 is a valid answer: &lt;a href="https://weiwave23.gumroad.com/l/backtest-lie-detector" rel="noopener noreferrer"&gt;https://weiwave23.gumroad.com/l/backtest-lie-detector&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>trading</category>
      <category>backtesting</category>
      <category>python</category>
      <category>quant</category>
    </item>
  </channel>
</rss>
