<?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>Gold Is Not Illiquid — You Picked the Wrong Symbol</title>
      <dc:creator>王伟</dc:creator>
      <pubDate>Sat, 07 Nov 2026 01:00:03 +0000</pubDate>
      <link>https://dev.to/wei-backtest/gold-is-not-illiquid-you-picked-the-wrong-symbol-236h</link>
      <guid>https://dev.to/wei-backtest/gold-is-not-illiquid-you-picked-the-wrong-symbol-236h</guid>
      <description>&lt;p&gt;&lt;em&gt;"This instrument is too illiquid to backtest" is a conclusion you should be suspicious of.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I was building a 1-minute breakout study across commodities and FX. My first pass produced a median of &lt;strong&gt;11 one-minute bars per trading day&lt;/strong&gt; on gold futures.&lt;/p&gt;

&lt;p&gt;A full futures session is about &lt;strong&gt;1,380&lt;/strong&gt; one-minute bars. Eleven is 0.8% coverage. I wrote it down as "gold is too thin at 1-minute resolution, drop it" and moved on.&lt;/p&gt;

&lt;p&gt;Gold is the most liquid commodity future in the world. It was not thin. &lt;strong&gt;My symbol was wrong.&lt;/strong&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%2F9exwnepwk2an2vnfnzea.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%2F9exwnepwk2an2vnfnzea.png" alt="Bars per day, before and after the symbol change" width="800" height="351"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What a continuous contract actually is
&lt;/h2&gt;

&lt;p&gt;Historical futures data vendors give you a synthetic series stitched across contract months, so you can chart more than three months of history. Two common variants:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;suffix&lt;/th&gt;
&lt;th&gt;what it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;*.c.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;continuation&lt;/strong&gt; — just rolls from one front month to the next on the roll date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;*.v.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;volume-weighted / ratio-adjusted&lt;/strong&gt; — adjusts the historical prices so the series is continuous without gaps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The continuation series is fine for a daily chart. At 1-minute resolution it has a specific pathology: &lt;strong&gt;on most days, the &lt;code&gt;c.0&lt;/code&gt; series is quoting a contract that is not the one people were trading.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I measured it as "bars per day" — the count of distinct 1-minute bars the series produced per session. A real, actively-traded contract produces ~1,380. A series that spends most of its time pointing at a nearly-dead back month produces almost nothing, because that contract barely trades.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;Same source, same period, same one-minute frequency. Only the symbol suffix changed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;contract&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;*.c.0&lt;/code&gt; bars/day&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;*.v.0&lt;/code&gt; bars/day&lt;/th&gt;
&lt;th&gt;multiplier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GC gold&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;1,372&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;125×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MGC micro gold&lt;/td&gt;
&lt;td&gt;357&lt;/td&gt;
&lt;td&gt;1,380&lt;/td&gt;
&lt;td&gt;3.9×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SI silver&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;1,292&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;129×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HG copper&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;1,255&lt;/td&gt;
&lt;td&gt;90×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6E euro FX&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;1,327&lt;/td&gt;
&lt;td&gt;15×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6B pound FX&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;1,197&lt;/td&gt;
&lt;td&gt;45×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note that &lt;strong&gt;NQ, ES, CL, NG, M6E and ZW were fine&lt;/strong&gt; — their &lt;code&gt;c.0&lt;/code&gt; series produced ~1,380 bars/day all along, because those contracts roll in a way that keeps the continuation series pointed at liquid months. So this is not "&lt;code&gt;c.0&lt;/code&gt; is always broken". It is "&lt;code&gt;c.0&lt;/code&gt; is broken for &lt;em&gt;some&lt;/em&gt; contracts and you have to check per contract".&lt;/p&gt;

&lt;p&gt;That distinction matters, because the cheap version of this lesson ("always use &lt;code&gt;v.0&lt;/code&gt;") is not what the data says. What the data says is: &lt;strong&gt;check the bar count per instrument before you believe anything about liquidity.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnostic
&lt;/h2&gt;

&lt;p&gt;One number tells you whether you are looking at a liquidity problem or a data problem:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;median one-minute bars per trading day, as a fraction of a full session.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If it is above ~90%, the instrument is fine and any weirdness is downstream. If it is far below — 1%, 10%, 26% — do not conclude the market is thin. Conclude that &lt;strong&gt;your series is not pointing at the traded contract&lt;/strong&gt; and go check the symbol.&lt;/p&gt;

&lt;p&gt;Concretely:&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;bars_per_day&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bars_per_day&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;     &lt;span class="c1"&gt;# ~1380 for a liquid futures contract
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that before you run any strategy. It takes seconds and it separates "this market has no liquidity" from "I downloaded the wrong thing". Those two conclusions have completely different follow-ups, and the second one is much more common.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong twice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First error: attributing to the market what was caused by the symbol.&lt;/strong&gt; I had a plausible story — some commodities really are thin — and the story made the measurement easy to accept. The 11 bars looked like evidence for the story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second error: applying the fix too broadly.&lt;/strong&gt; After finding this, I briefly believed no &lt;code&gt;c.0&lt;/code&gt; series was usable. That is false: six of my contracts were fine. Over-correcting would have meant re-downloading data I already had, which is not free.&lt;/p&gt;

&lt;p&gt;The corrective habit is narrow and cheap: &lt;strong&gt;check the bar count per instrument&lt;/strong&gt;. Not per asset class, not per vendor. Per instrument, once, before you trust it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A related trap in the same family
&lt;/h2&gt;

&lt;p&gt;While fixing this I found the neighbouring problem, which is worse because it does not show up as missing data:&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;c.0&lt;/code&gt; series' &lt;strong&gt;price level&lt;/strong&gt; can differ from the actual traded contract's price by a non-trivial amount — I measured about 0.6% between the continuous gold series and the active month. That does not reduce your bar count. It silently shifts your levels, so support and resistance levels computed on the synthetic series do not correspond to prices anyone could have traded.&lt;/p&gt;

&lt;p&gt;Missing data announces itself. &lt;strong&gt;Displaced prices do not.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So the check list for any vendor-supplied continuous series is two items, not one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bars per day&lt;/strong&gt; — is the series actually populated?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price level versus the front month&lt;/strong&gt; — is the series quoting prices that existed?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both are cheap. Neither is in the vendor's documentation.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Bar-count-per-day is the first thing I check on any new dataset, and it is why the demo data in the open-source linter ships with a gap check (&lt;a href="https://github.com/wei498766-ai/backtest-lie-detector" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, MIT, no dependencies): a series that is 1% populated will produce a backtest full of technical artefacts that look like opportunities.&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>futures</category>
      <category>data</category>
      <category>python</category>
    </item>
    <item>
      <title>Three Answers to "How Many Coins Should I Test?"</title>
      <dc:creator>王伟</dc:creator>
      <pubDate>Fri, 06 Nov 2026 01:00:04 +0000</pubDate>
      <link>https://dev.to/wei-backtest/three-answers-to-how-many-coins-should-i-test-hk1</link>
      <guid>https://dev.to/wei-backtest/three-answers-to-how-many-coins-should-i-test-hk1</guid>
      <description>&lt;p&gt;&lt;em&gt;Same exchange, same period, same question. Three answers, and only the last one is about the market.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I wanted to know how many perpetual contracts my backtest universe should contain. It is a mundane question with a mundane answer, and it took me three attempts.&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%2Fe4phrbolzpxslkmfw5sn.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%2Fe4phrbolzpxslkmfw5sn.png" alt="Three answers to the same question" width="799" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;98, then 101, then 157.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  v0: ask the exchange — 98
&lt;/h2&gt;

&lt;p&gt;The obvious method. Call the exchange's listings endpoint, take every contract currently trading, done.&lt;/p&gt;

&lt;p&gt;This gives you &lt;strong&gt;exactly the set of things that survived to today.&lt;/strong&gt; Everything delisted is simply absent — not filed under "delisted", just not there. The API is not lying; it is answering "what is listed now", which is a different question from "what existed during my sample".&lt;/p&gt;

&lt;p&gt;A universe of 98 survivors, tested over a period when 59 more contracts came and went, will show you a result that the market never offered. You could only have traded the ones that made it.&lt;/p&gt;

&lt;p&gt;That is textbook survivorship bias. It is also easy to &lt;strong&gt;believe you are immune&lt;/strong&gt;, because you did not filter anything — you just used the exchange's list. The bias was upstream, in the question I asked.&lt;/p&gt;

&lt;h2&gt;
  
  
  v1: add delisted contracts — 101
&lt;/h2&gt;

&lt;p&gt;So I pulled the full universe including delisted contracts: 157 total. Then I filtered to those with meaningful trading volume, so I would not simulate coins nobody traded.&lt;/p&gt;

&lt;p&gt;The filter was: &lt;strong&gt;"has at least X volume over the last 90 days of available data."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Result: &lt;strong&gt;101 contracts.&lt;/strong&gt; Barely different from 98. Almost every delisted contract got filtered out.&lt;/p&gt;

&lt;p&gt;Why? Because a delisted contract's "last 90 days of available data" is the 90 days &lt;em&gt;ending at its delisting&lt;/em&gt; — when trading volume collapses to near zero. My filter was asking "was this liquid recently", and for a dead contract the answer is always no. &lt;strong&gt;My own quality filter was re-introducing the survivorship bias I had just fixed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the part worth internalising. I did the right thing (include delisted contracts) and then undid it with a reasonable-looking filter. The bug was not in the data, it was in the &lt;strong&gt;window I measured over&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  v2: measure before the end — 157
&lt;/h2&gt;

&lt;p&gt;The fix is a single change: for delisted contracts, measure volume over the 90 days &lt;strong&gt;before delisting&lt;/strong&gt;, not the 90 days ending at the last available row.&lt;/p&gt;

&lt;p&gt;That is the window in which the contract was actually being traded by people. And with that window, the answer became &lt;strong&gt;157&lt;/strong&gt; — the full universe.&lt;/p&gt;

&lt;p&gt;Three numbers, one question:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;version&lt;/th&gt;
&lt;th&gt;what it did&lt;/th&gt;
&lt;th&gt;contracts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;v0&lt;/td&gt;
&lt;td&gt;exchange's current listing&lt;/td&gt;
&lt;td&gt;98&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v1&lt;/td&gt;
&lt;td&gt;full universe, filtered by volume in the last 90 days&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v2&lt;/td&gt;
&lt;td&gt;full universe, filtered by volume 90 days before delisting&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;157&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why 59 contracts is a big deal
&lt;/h2&gt;

&lt;p&gt;The missing 59 are, by construction, &lt;strong&gt;the ones that failed&lt;/strong&gt;. A survivorship-biased backtest over this universe is not slightly optimistic — it is testing a market in which you could only have picked winners.&lt;/p&gt;

&lt;p&gt;The size of the effect depends on your strategy. A cross-sectional momentum strategy that picks the strongest contract each period will be dramatically distorted, because the losers were never candidates. A single-instrument timing strategy is less affected, though not unaffected: the instrument you chose &lt;em&gt;is&lt;/em&gt; a survivor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The transferable lesson
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"I did not filter anything, I just used the exchange's list" is still a filter.&lt;/strong&gt; It is the filter "must still be alive today", applied by someone else, and it is invisible because you never wrote it.&lt;/p&gt;

&lt;p&gt;And the second-order lesson is the one that bit me: &lt;strong&gt;a reasonable quality filter can undo a correct universe correction&lt;/strong&gt;, silently. If you include delisted instruments and then require recent liquidity, you have re-imposed survival without noticing.&lt;/p&gt;

&lt;p&gt;Two rules I use now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;State the universe as a sentence with a date range&lt;/strong&gt; — "all perpetual contracts that traded at any point between 2020-01 and 2024-12, including those delisted before 2024-12". If you cannot write that sentence, you do not know what you tested.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure every per-instrument filter over each instrument's own active window&lt;/strong&gt;, not a fixed calendar window ending at the end of your data. The end of the data is a date that only survivors share.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A footnote on the volume number itself
&lt;/h2&gt;

&lt;p&gt;Even with the right universe, I had to accept that the "volume" field I was filtering on was not what it appeared to be — in one of my data sources it is the &lt;strong&gt;number of ticks&lt;/strong&gt;, not traded size.&lt;/p&gt;

&lt;p&gt;That is a separate article. But the pattern is the same one: &lt;strong&gt;the column name told me what the field was &lt;em&gt;called&lt;/em&gt;, not what it &lt;em&gt;measured&lt;/em&gt;.&lt;/strong&gt; Both this and the survivorship story are about the difference between a dataset's interface and its contents.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Universe construction is outside what a trade-log linter can check — it only sees the trades you gave it. It can tell you the sample size you need for a given edge, which is usually the moment you realise how much a missing 59 contracts matters.&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>data</category>
      <category>python</category>
    </item>
    <item>
      <title>I Costed Futures Like Crypto, and Half the Loss Was Made Up</title>
      <dc:creator>王伟</dc:creator>
      <pubDate>Sat, 31 Oct 2026 01:00:04 +0000</pubDate>
      <link>https://dev.to/wei-backtest/i-costed-futures-like-crypto-and-half-the-loss-was-made-up-368k</link>
      <guid>https://dev.to/wei-backtest/i-costed-futures-like-crypto-and-half-the-loss-was-made-up-368k</guid>
      <description>&lt;p&gt;&lt;em&gt;A cost model is not a detail. It can be larger than the effect you are trying to measure.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I ran a 1-minute futures strategy and it lost 94% of its capital. That looked like a decisive answer: the strategy does not work.&lt;/p&gt;

&lt;p&gt;It was not decisive. &lt;strong&gt;Half of that loss was invented by my cost model.&lt;/strong&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%2Fqr7mkmrqxf4pou2ljlcr.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%2Fqr7mkmrqxf4pou2ljlcr.png" alt="Cost assumption vs measurement" width="799" height="329"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I assumed
&lt;/h2&gt;

&lt;p&gt;I used &lt;strong&gt;5 basis points per side&lt;/strong&gt;, which is 10 bp round trip. That number came from crypto, where I had been working. It felt conservative: &lt;em&gt;surely paying more cost than reality is the safe direction.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That intuition is wrong, and not mildly wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually costs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Instrument&lt;/th&gt;
&lt;th&gt;measured round trip&lt;/th&gt;
&lt;th&gt;assumption was&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;NQ (Nasdaq futures)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.30 bp&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;33× too high&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MGC (micro gold)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.55 bp&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;18× too high&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BTC perpetual&lt;/td&gt;
&lt;td&gt;10.2 bp&lt;/td&gt;
&lt;td&gt;about right&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note the last row. The number I used was correct — &lt;strong&gt;for the asset class I took it from&lt;/strong&gt;. That is the trap: a cost assumption is a fact about a specific market, and it does not travel.&lt;/p&gt;

&lt;p&gt;Futures on a liquid index are extraordinarily cheap to trade. Crypto perpetuals are not. They differ by more than an order of magnitude, and I applied the expensive one to the cheap market.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it mattered more than usual
&lt;/h2&gt;

&lt;p&gt;Here is the part that makes this a systemic problem rather than a slip.&lt;/p&gt;

&lt;p&gt;With 1-minute entries, the &lt;strong&gt;per-trade risk&lt;/strong&gt; on this strategy was only about &lt;strong&gt;6.2 bp&lt;/strong&gt;. So the cost-to-risk ratio was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10 bp cost ÷ 6.2 bp risk  ≈ 162%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I was charging more than one and a half times the risk of the trade as friction. The result was that every trade lost &lt;strong&gt;3.16R&lt;/strong&gt; on average, of which &lt;strong&gt;2.16R was invented fees&lt;/strong&gt; and only 1.0R was the strategy.&lt;/p&gt;

&lt;p&gt;That is not a strategy being measured. That is a cost model being measured.&lt;/p&gt;

&lt;p&gt;Correcting the number to 0.30 bp moved the same run from &lt;strong&gt;−94.0% to −45.5%&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule of thumb
&lt;/h2&gt;

&lt;p&gt;Before rejecting a strategy on cost grounds, compute one ratio:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;round-trip cost ÷ per-trade risk&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If it exceeds about &lt;strong&gt;5%&lt;/strong&gt;, stop. Do not conclude the strategy is bad — conclude that &lt;strong&gt;you do not know your costs&lt;/strong&gt;, and go measure them. A ratio above 100% (as mine was) means the cost term dominates the result entirely and the backtest is telling you about your assumptions, not about the market.&lt;/p&gt;

&lt;p&gt;The corollary is just as important: a strategy whose edge is small relative to friction is not necessarily broken. It may be &lt;strong&gt;correctly measured and simply too small&lt;/strong&gt; — which is a different conclusion with a different next step (find cheaper execution, trade less often, or move to a higher timeframe) and not "throw it away".&lt;/p&gt;

&lt;h2&gt;
  
  
  How to measure instead of guess
&lt;/h2&gt;

&lt;p&gt;I did not have a perfect cost model afterwards either. What I had was something better than a guess:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use the exchange's own fee schedule&lt;/strong&gt;, not a number from another asset class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the spread separately.&lt;/strong&gt; It is usually larger than commission on liquid futures and it varies by time of day. Sample it, don't assume it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sanity-check against a fill-quality sample.&lt;/strong&gt; Take 50 real fills, compare your simulated price to the actual one. The gap is your slippage estimate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State the number and its source in the results.&lt;/strong&gt; "Costs: 0.30 bp round trip, measured from NQ fill samples over the sample period" is a claim someone can audit. "5 bp" is a habit.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;An optimistic cost assumption makes a bad strategy look good — and you will find out in production, expensively.&lt;/p&gt;

&lt;p&gt;A pessimistic cost assumption makes a &lt;strong&gt;good&lt;/strong&gt; strategy look bad — and you will never find out, because you will have moved on. That failure is silent. It cost me a line of research, and I only noticed because a 94% loss looked too clean to be real.&lt;/p&gt;

&lt;p&gt;So the discipline is symmetric: &lt;strong&gt;do not treat high costs as the safe direction.&lt;/strong&gt; Both directions are errors, and the pessimistic one is the one nobody catches for you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The cost-per-risk check is one of the static checks in a small open-source linter (&lt;a href="https://github.com/wei498766-ai/backtest-lie-detector" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, MIT, no dependencies). It flags a fee column that is missing or zero, and reports cost against per-trade risk so the ratio is visible before you draw conclusions.&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>futures</category>
      <category>python</category>
    </item>
    <item>
      <title>One Missing Call Site Turned -5.7% Into +17.9%</title>
      <dc:creator>王伟</dc:creator>
      <pubDate>Fri, 30 Oct 2026 01:00:03 +0000</pubDate>
      <link>https://dev.to/wei-backtest/one-missing-call-site-turned-57-into-179-jbe</link>
      <guid>https://dev.to/wei-backtest/one-missing-call-site-turned-57-into-179-jbe</guid>
      <description>&lt;p&gt;&lt;em&gt;A one-line structural bug, worth 23.6 points of return and most of a 74% win rate.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I want to start with the number, because the number is the point:&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%2Fpkxwle7ccw0nsimv9ajd.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%2Fpkxwle7ccw0nsimv9ajd.png" alt="The same strategy, before and after" width="800" height="331"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;−5.7% when exits are checked every bar. +17.9% when they are only checked on signal bars.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Same strategy, same data, same period, same costs. The difference is where one function was called from.&lt;/p&gt;

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

&lt;p&gt;My backtester had a loop that looked roughly 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;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bar&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bars&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;signal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# &amp;lt;- entry AND exit logic live in here
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;execute()&lt;/code&gt; contained both the entry and the exit check. Putting the exit check inside a function that only gets called on signal bars means &lt;strong&gt;the exit is only evaluated on bars where a signal fired&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;On every other bar, the position sat there. Stops were not checked. Targets were not checked. Time-based exits were not checked. The strategy effectively had permission to hold through anything, as long as no new signal appeared.&lt;/p&gt;

&lt;p&gt;The fix is not a change to the strategy at all:&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;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bar&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bars&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;signal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;manage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# &amp;lt;- exits, every bar, unconditionally
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire change: move exit management out of the signal branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it flatters you
&lt;/h2&gt;

&lt;p&gt;This one is directionally biased, and the bias is not subtle.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Losers get held longer than they should.&lt;/strong&gt; A position that would have been stopped out keeps living until the next signal happens to fire. Sometimes it recovers. Sometimes it recovers &lt;em&gt;because&lt;/em&gt; you were not checking — which is not a strategy, it is a delay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The win rate inflates.&lt;/strong&gt; My original run reported a &lt;strong&gt;74.1% win rate&lt;/strong&gt;. Fixing the call site moved it by &lt;strong&gt;6 to 8 points&lt;/strong&gt;. That is the difference between "this looks like a professional system" and "this looks like a coin flip with costs".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It shows up as fewer trades reaching their stop.&lt;/strong&gt; If you log exits, you will see stop-loss exits that cluster in time — they fire in bursts on signal bars instead of being spread evenly. That is the fingerprint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mechanism is worth stating plainly: &lt;strong&gt;you were not simulating a strategy, you were simulating a strategy plus a free option to defer every adverse outcome.&lt;/strong&gt; That option is worth a lot, and it is worth it in the direction you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to find this in your own code
&lt;/h2&gt;

&lt;p&gt;Three checks, in increasing order of effort:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Count exits by type over time.&lt;/strong&gt; If stop-outs cluster on the same bars as entries, your exits are gated by the signal path. They should be roughly uniform in time, driven by price, not by signal timing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Search for the phrase you are about to write.&lt;/strong&gt; In almost every backtester, exits end up inside a function the entry path also calls. Grep for &lt;code&gt;execute&lt;/code&gt;, &lt;code&gt;handle_bar&lt;/code&gt;, &lt;code&gt;on_bar&lt;/code&gt;, &lt;code&gt;step&lt;/code&gt; — and ask, for each call site, "does this run on every bar or only sometimes?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Print the bar index of every exit and every entry, then diff.&lt;/strong&gt; If the sets of indices overlap far more than chance, exits are coupled to signals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The structural fix beats the test.&lt;/strong&gt; Make exit management a function that is called &lt;strong&gt;unconditionally, from the main loop, once per bar&lt;/strong&gt;, and make it impossible to call it from the signal branch. If the API shape permits the bug, the bug will come back.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost me
&lt;/h2&gt;

&lt;p&gt;The strategy was negative before the fix. That is the uncomfortable part: this bug &lt;strong&gt;kept a losing strategy alive&lt;/strong&gt; for months. If I had run it honestly I would have dropped it, and I would have been right to.&lt;/p&gt;

&lt;p&gt;Bugs that make results worse get found. Bugs that make results better get &lt;strong&gt;shipped&lt;/strong&gt;. This is the structural asymmetry of backtesting: your attention is drawn to things that look broken, and a false positive looks like progress.&lt;/p&gt;

&lt;p&gt;Which is why the check has to be mechanical instead of attentional. I could not have caught this by staring harder at the strategy — the strategy was fine. I caught it by asking a question that had nothing to do with signals: &lt;em&gt;where is this function called from?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape
&lt;/h2&gt;

&lt;p&gt;This belongs to a family I keep running into: &lt;strong&gt;the backtester's control flow is itself a modelling assumption.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You think you are modelling a strategy. You are actually modelling a strategy &lt;em&gt;inside a simulation harness&lt;/em&gt;, and the harness decides when your code gets to see the world. Every asymmetry in that decision — which bars you get called on, whether you see a bar before or after it closes, whether the exit path is privileged — silently becomes part of the strategy.&lt;/p&gt;

&lt;p&gt;So when an audit turns up nothing in the strategy, the next place to look is the harness. Mine was wrong in a way no amount of strategy review could have found.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The call-site check is one of the static checks in a small open-source linter (&lt;a href="https://github.com/wei498766-ai/backtest-lie-detector" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, MIT, no dependencies). It can flag exits that cluster with entries; it cannot read your control flow, which is why the structural fix matters more than the check.&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>python</category>
      <category>debugging</category>
    </item>
    <item>
      <title>"Enough Trades" Is Not a Number, It Is a Ratio</title>
      <dc:creator>王伟</dc:creator>
      <pubDate>Thu, 29 Oct 2026 01:00:04 +0000</pubDate>
      <link>https://dev.to/wei-backtest/enough-trades-is-not-a-number-it-is-a-ratio-40dh</link>
      <guid>https://dev.to/wei-backtest/enough-trades-is-not-a-number-it-is-a-ratio-40dh</guid>
      <description>&lt;p&gt;&lt;em&gt;Two configs, same strategy, same data. One becomes provable in under a year. The other cannot be proven in a working lifetime.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;"Am I overfitting?" gets all the attention. The more common problem is quieter and harder to fix: &lt;strong&gt;you do not have enough data to tell, and no amount of cleverness changes that.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I had two configurations of the same breakout strategy. Same code, same decade, different instrument and parameter set.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;trades&lt;/th&gt;
&lt;th&gt;per-trade vol&lt;/th&gt;
&lt;th&gt;t&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gold, k=0.75, 1:1&lt;/td&gt;
&lt;td&gt;2,352&lt;/td&gt;
&lt;td&gt;0.75%&lt;/td&gt;
&lt;td&gt;2.93&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BTC, k=0.50, 4R&lt;/td&gt;
&lt;td&gt;1,506&lt;/td&gt;
&lt;td&gt;2.16%&lt;/td&gt;
&lt;td&gt;1.10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both looked reasonable. Both had positive expectancy. One was nearly provable; the other was not going to be provable at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The formula
&lt;/h2&gt;

&lt;p&gt;There is a closed form for the sample size you need to reach a given t-statistic, and it has no free parameters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;n = (3 × per-trade volatility ÷ per-trade edge)²
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as: the number of trades you need is the square of three divided by your &lt;strong&gt;signal-to-noise ratio&lt;/strong&gt;. Not "how many trades did I run". Not "how many years of data". The ratio.&lt;/p&gt;

&lt;p&gt;That is why "I have 1,500 trades, that's plenty" is not a statement about evidence. 1,500 trades with noisy returns is worth less than 300 with clean ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying it
&lt;/h2&gt;

&lt;p&gt;From the table, the observed t and n give the ratio directly: &lt;code&gt;edge/vol = t / sqrt(n)&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;t&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;edge/vol&lt;/th&gt;
&lt;th&gt;trades needed&lt;/th&gt;
&lt;th&gt;still needed&lt;/th&gt;
&lt;th&gt;at this rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gold&lt;/td&gt;
&lt;td&gt;2.93&lt;/td&gt;
&lt;td&gt;2,352&lt;/td&gt;
&lt;td&gt;0.060&lt;/td&gt;
&lt;td&gt;2,466&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;114&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~0.9 yr&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BTC&lt;/td&gt;
&lt;td&gt;1.10&lt;/td&gt;
&lt;td&gt;1,506&lt;/td&gt;
&lt;td&gt;0.028&lt;/td&gt;
&lt;td&gt;11,202&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9,696&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~37 yr&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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%2Fjcrssii8fyav3e6rocr9.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%2Fjcrssii8fyav3e6rocr9.png" alt="Sample size is a ratio" width="799" height="321"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Gold needs about nine more months of trading. BTC needs thirty-seven years — and that assumes the edge and the volatility both stay exactly where they are, which is the optimistic case.&lt;/p&gt;

&lt;p&gt;Both are the same strategy. The difference is that BTC's per-trade volatility is &lt;strong&gt;2.9×&lt;/strong&gt; gold's.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why noise dominates silently
&lt;/h2&gt;

&lt;p&gt;Look at the right panel of that figure: &lt;code&gt;n&lt;/code&gt; grows with the &lt;em&gt;square&lt;/em&gt; of the inverse ratio. Halve your signal-to-noise and you need four times the data. Drop it to a third and you need nine times.&lt;/p&gt;

&lt;p&gt;BTC's ratio is less than half of gold's, so the requirement is roughly &lt;code&gt;(0.060/0.028)² ≈ 4.5×&lt;/code&gt; more trades — and BTC also produces more trades per year, so the year count should improve. It does not, because the ratio entered as a square while the trade frequency is only linear. Frequency cannot outrun a squared penalty.&lt;/p&gt;

&lt;p&gt;This is why a strategy with a great-looking annualised return can be the &lt;strong&gt;worst&lt;/strong&gt; candidate for deployment. BTC had the highest annualised figure of anything I tested and the weakest evidence by a wide margin. The return was real in the sample. The evidence that it would repeat was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do differently now
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Compute the required n before the strategy is finished.&lt;/strong&gt; If the answer is thirty-seven years, I want to know that on day one, not after six months of work. The calculation takes one line and it has changed which projects I start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Treat "needs a lifetime" as a rejection.&lt;/strong&gt; Not "promising but unproven". If the required sample is longer than the instrument's plausible lifespan, the hypothesis is &lt;strong&gt;unfalsifiable in practice&lt;/strong&gt;, and an unfalsifiable strategy is indistinguishable from a random one for as long as I would be trading it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Prefer high signal-to-noise instruments for a first test.&lt;/strong&gt; Same logic as the gold/BTC split: if you must prove something, prove it where the arithmetic permits it. Testing on the noisiest instrument first is the most expensive possible ordering — you spend the most time and learn the least.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Report n and the ratio, not just the t.&lt;/strong&gt; A t-statistic alone hides whether the result is 300 clean trades or 3,000 noisy ones. The ratio makes the difference visible in one line.&lt;/p&gt;

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

&lt;p&gt;Some of the things I believed in earlier were not tested — they were &lt;strong&gt;untestable&lt;/strong&gt;, and I mistook the absence of proof for the presence of evidence.&lt;/p&gt;

&lt;p&gt;That is a worse error than a bug, because a bug can be fixed by editing a line. An unfalsifiable hypothesis can only be abandoned, and abandoning it costs whatever I already invested in it.&lt;/p&gt;

&lt;p&gt;The formula is unforgiving in a useful way. It does not care how much I like the strategy, or how plausible the story is. It just says: with this much noise and this much edge, you need this much data. If you cannot get the data, you cannot get the answer.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The fold, sizing and selection helpers, plus this arithmetic, 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 a config where the required n came out short and the strategy still failed, that is the case I want to hear about — it means the ratio was not the binding constraint.&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>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>
