<?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: FirmTape</title>
    <description>The latest articles on DEV Community by FirmTape (@firmtape).</description>
    <link>https://dev.to/firmtape</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%2F4084601%2Feafabb21-81de-4d43-8585-24b5a580b611.png</url>
      <title>DEV Community: FirmTape</title>
      <link>https://dev.to/firmtape</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/firmtape"/>
    <language>en</language>
    <item>
      <title>How much does the volatility model move a GEX terminal's numbers? We swapped it and measured</title>
      <dc:creator>FirmTape</dc:creator>
      <pubDate>Thu, 27 Aug 2026 21:31:32 +0000</pubDate>
      <link>https://dev.to/firmtape/how-much-does-the-volatility-model-move-a-gex-terminals-numbers-we-swapped-it-and-measured-27e7</link>
      <guid>https://dev.to/firmtape/how-much-does-the-volatility-model-move-a-gex-terminals-numbers-we-swapped-it-and-measured-27e7</guid>
      <description>&lt;p&gt;Every published gamma-exposure construction — including the one Cboe's own research uses — prices gamma the same way: Black-Scholes, one implied vol per contract, inverted from the quote mid. The literature offers upgrades: fit an arbitrage-free smile (SVI), or replace the lognormal with a Gram-Charlier expansion whose skew and kurtosis supposedly matter most at 0DTE. &lt;strong&gt;No paper measures what switching the model does to the number a terminal actually ships.&lt;/strong&gt; So we did: the production machinery runs verbatim — same book, same dealer weights, same root finder — with only the vol input swapped, over 14 sessions including the two worst flip days in our archive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What moves:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;swap&lt;/th&gt;
&lt;th&gt;zero-gamma flip (median)&lt;/th&gt;
&lt;th&gt;net gamma at spot&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SVI slice fit&lt;/td&gt;
&lt;td&gt;2.5 pts&lt;/td&gt;
&lt;td&gt;4.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gram-Charlier&lt;/td&gt;
&lt;td&gt;8.3 pts&lt;/td&gt;
&lt;td&gt;13%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;higher moments alone&lt;/td&gt;
&lt;td&gt;3.9 pts&lt;/td&gt;
&lt;td&gt;~10%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;one-tick mid ambiguity (worst case)&lt;/td&gt;
&lt;td&gt;0.27 pts&lt;/td&gt;
&lt;td&gt;0.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three readings. &lt;strong&gt;First, the model systematic is real&lt;/strong&gt;: a defensible-but-different vol model moves the flip 2–3 points and aggregate gamma 5–13%. Nobody quotes that error bar; ours is now in the terminal tooltip. &lt;strong&gt;Second, the upgrades are not upgrades&lt;/strong&gt;: the SVI refit makes frame-to-frame flip stability &lt;em&gt;worse&lt;/em&gt; on 10 of 14 sessions (on 2024-08-05 the p95 frame jump goes 69 → 380 points — fit-parameter chatter is its own noise source), and half its displacement is just collapsing call and put IVs into one smile. &lt;strong&gt;Third, the Gram-Charlier story inverts at the aggregate&lt;/strong&gt;: its corrections are smallest in the last 30 minutes — exactly where the theory says Black-Scholes is most wrong — because near expiry the book's gamma concentrates at the money, where every model that prices the straddle agrees.&lt;/p&gt;

&lt;p&gt;There is no minute-level exchange truth for gamma, so none of this says which model is &lt;em&gt;right&lt;/em&gt; — it says how much the answer depends on the choice. Per-contract Black-Scholes stays.&lt;/p&gt;

&lt;p&gt;What changed instead: the flip's real defect was never the vol model but a discontinuous root selector, so as of this week the terminal's flip is a smoothed, tracked estimator (p95 frame jump 17.6 → 4.5 points) and ships with a crossing standard error drawn as a band. A root deserves an error bar, not a different model.&lt;/p&gt;

&lt;p&gt;Full study: &lt;a href="https://firmtape.com/research/how-much-does-the-vol-model-move-the-book" rel="noopener noreferrer"&gt;How much does the vol model move GEX?&lt;/a&gt;&lt;/p&gt;

</description>
      <category>analysis</category>
      <category>data</category>
      <category>science</category>
    </item>
    <item>
      <title>How accurately can complex option trades be signed? First grading against exchange truth</title>
      <dc:creator>FirmTape</dc:creator>
      <pubDate>Thu, 27 Aug 2026 21:30:33 +0000</pubDate>
      <link>https://dev.to/firmtape/how-accurately-can-complex-option-trades-be-signed-first-grading-against-exchange-truth-1067</link>
      <guid>https://dev.to/firmtape/how-accurately-can-complex-option-trades-be-signed-first-grading-against-exchange-truth-1067</guid>
      <description>&lt;p&gt;To build a dealer book from the options tape you must decide, print by print, who bought. For simple orders the quote rule does that. But on the day we measured, &lt;strong&gt;40.2% of all SPX option prints were legs of multi-leg packages&lt;/strong&gt; — and on those legs the quote rule is invalid by construction: an exchange fills a spread at a net price and allocates it across legs by convention, so a leg can print anywhere inside (or outside) its own market regardless of who initiated.&lt;/p&gt;

&lt;p&gt;Our terminal signs the &lt;strong&gt;package&lt;/strong&gt;: legs printing on the same millisecond are reassembled into their parent order, the package's net price is compared to a net bid/ask derived from the leg quotes, and the quote rule is applied at that level. The idea is not unique to us. What has never existed — for ours or anyone's scheme — is an accuracy number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The truth set.&lt;/strong&gt; Cboe publishes a free trade-by-trade sample: 3% of executions, each carrying the side, the capacity (customer / market maker / firm), the trade type, and an id linking legs of one complex execution. That is per-leg ground truth. Joined to our tape: 12,422 complex legs carry a verdict.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The number:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;rule, graded on customer legs&lt;/th&gt;
&lt;th&gt;per print&lt;/th&gt;
&lt;th&gt;volume-weighted&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;package signer, as shipped&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;80.4%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;79.1%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;excluding legged-in contras (unrecoverable in principle)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;87.9%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;84.7%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;single-leg quote rule on the same legs&lt;/td&gt;
&lt;td&gt;75.1%&lt;/td&gt;
&lt;td&gt;75.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;coin flip&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Iron condors grade at 88%, strangles at 91%. Even the legged-in category — which Cboe itself declines to attribute — comes out at 69% rather than the coin flip we budgeted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mirror check.&lt;/strong&gt; A rule that claims to find the customer should be reliably wrong about the market maker on the other side. It is: on legs where the sampled participant is a market maker, the rule agrees with that side only 10.9% of the time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The semantics finding.&lt;/strong&gt; Against time-priority truth the rule fails (16.9% vs true liquidity removers) while scoring 90.4% against auction initiators. Both make sense at once only one way: the package quote rule identifies &lt;strong&gt;the side that paid the spread against the derived net mid&lt;/strong&gt;, not the side that arrived second. For a customer-versus-dealer book, that is not a bug — it is the reason the rule works.&lt;/p&gt;

&lt;p&gt;One truth day, binomial SE ~0.7pp; the signer abstains on ~45% of complex volume rather than guessing. Full study: &lt;a href="https://firmtape.com/research/how-accurate-is-complex-trade-signing" rel="noopener noreferrer"&gt;How accurate is complex-trade signing?&lt;/a&gt;&lt;/p&gt;

</description>
      <category>finance</category>
      <category>trading</category>
      <category>statistics</category>
      <category>python</category>
    </item>
    <item>
      <title>What is SPX option flow actually made of? A fifth still trades on a physical floor</title>
      <dc:creator>FirmTape</dc:creator>
      <pubDate>Thu, 27 Aug 2026 21:29:33 +0000</pubDate>
      <link>https://dev.to/firmtape/what-is-spx-option-flow-actually-made-of-a-fifth-still-trades-on-a-physical-floor-2npb</link>
      <guid>https://dev.to/firmtape/what-is-spx-option-flow-actually-made-of-a-fifth-still-trades-on-a-physical-floor-2npb</guid>
      <description>&lt;p&gt;Every print on the SPX options tape carries an OPRA condition code naming the pipe it came through: electronic book, complex order book, price-improvement auction, a multi-leg order legging into the simple book, or a human being on the trading floor in Chicago. Nobody appears to have published the composition for SPX — the one product where the answer is unusual. So we tabulated five years of it, every code mapped, cancels excluded, zero unmapped residual.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The near book, by year (share of volume):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;channel&lt;/th&gt;
&lt;th&gt;2022&lt;/th&gt;
&lt;th&gt;2024&lt;/th&gt;
&lt;th&gt;2026&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;electronic simple book&lt;/td&gt;
&lt;td&gt;34.2%&lt;/td&gt;
&lt;td&gt;34.8%&lt;/td&gt;
&lt;td&gt;26.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;complex book + complex auctions&lt;/td&gt;
&lt;td&gt;36.1%&lt;/td&gt;
&lt;td&gt;39.1%&lt;/td&gt;
&lt;td&gt;44.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;physical floor&lt;/td&gt;
&lt;td&gt;20.8%&lt;/td&gt;
&lt;td&gt;19.8%&lt;/td&gt;
&lt;td&gt;20.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AIM price-improvement auctions&lt;/td&gt;
&lt;td&gt;2.9%&lt;/td&gt;
&lt;td&gt;2.9%&lt;/td&gt;
&lt;td&gt;6.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three readings:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The floor is not dying.&lt;/strong&gt; A steady fifth of the near book trades by open outcry, five years running — independently matching the one recent academic estimate (Galati, Hendershott, Khan, Riordan: over 20% of U.S. options floor volume, "almost exclusively" S&amp;amp;P 500 index options). The dominant floor code is the proprietary-product multi-leg print: big structures negotiated by humans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The complex book keeps eating the simple book.&lt;/strong&gt; 36% → 45%, while simple electronic fell to 26%. If your dealer book signs legs individually, nearly half the tape is mis-handled by construction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The auction slice is retail, and it doubled.&lt;/strong&gt; Cboe's free trade-by-trade sample carries each participant's capacity. Joined to our tape: the initiator of an AIM auction is a Customer 97.8% of the time, the contra is a market maker 95.6% of the time, and 100% of initiating volume is ≤10 contracts — the mechanism's size cap, visible in the data. Small-lot auction internalization is the closest thing SPX has to a printed retail label — and on the 0DTE expiry it reached almost 10% of volume this year.&lt;/p&gt;

&lt;p&gt;One more composition fact from the signed book: at the close, dealers end up net LONG gamma on 83% of sessions — the crowd's structural habit is selling same-day optionality into the close.&lt;/p&gt;

&lt;p&gt;Full series, channel map and honesty section: &lt;a href="https://firmtape.com/research/what-is-spx-flow-made-of" rel="noopener noreferrer"&gt;What is SPX flow made of?&lt;/a&gt;&lt;/p&gt;

</description>
      <category>analysis</category>
      <category>data</category>
      <category>fintech</category>
    </item>
    <item>
      <title>How fast do dealers hedge SPX 0DTE flow? We measured it: 2-3 minutes</title>
      <dc:creator>FirmTape</dc:creator>
      <pubDate>Thu, 27 Aug 2026 21:28:29 +0000</pubDate>
      <link>https://dev.to/firmtape/how-fast-do-dealers-hedge-spx-0dte-flow-we-measured-it-2-3-minutes-2oni</link>
      <guid>https://dev.to/firmtape/how-fast-do-dealers-hedge-spx-0dte-flow-we-measured-it-2-3-minutes-2oni</guid>
      <description>&lt;p&gt;Every dealer-positioning story assumes the same mechanism: customers force option delta onto dealers, dealers hedge it in the underlying, the hedge moves the index. The premise is stated everywhere and measured almost nowhere — the canonical footprint study (Hu 2014) is equities, pre-0DTE, daily grain. So we measured it on SPX 0DTE directly: &lt;strong&gt;when a customer imbalance lands this minute, how many minutes until the index has absorbed the hedge?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setup.&lt;/strong&gt; 1,088 SPX sessions (2022–2026) at one-minute grain. The dealer's option-delta inventory is a published series on our terminal; its minute change, negated, is the delta dealers must hedge that minute. Regress forward index returns at 1–30 minute horizons on that imbalance, with day and minute-of-day fixed effects, reversal controls, and the imbalance's absolute size as a magnitude control. Day-clustered errors; last 40 sessions held out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Answer: two to three minutes.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;horizon&lt;/th&gt;
&lt;th&gt;coef, bp per 1σ&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;1 min&lt;/td&gt;
&lt;td&gt;+0.038&lt;/td&gt;
&lt;td&gt;+4.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 min&lt;/td&gt;
&lt;td&gt;+0.050&lt;/td&gt;
&lt;td&gt;+4.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 min&lt;/td&gt;
&lt;td&gt;+0.048&lt;/td&gt;
&lt;td&gt;+3.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;td&gt;+0.021&lt;/td&gt;
&lt;td&gt;+1.2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The footprint is front-loaded exactly as a hedging story requires — strongest in the first two minutes, statistically gone by the fifth. On the held-out 40 sessions the sign is right at all seven horizons tested.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The placebo is the point.&lt;/strong&gt; Rerun everything with one change: every minute's imbalance keeps its exact magnitude and gets a coin-flip sign. The placebo predicts nothing, at any horizon, in either sample. The information is not in how much traded — it is in &lt;em&gt;which way the signing engine says it traded&lt;/em&gt;. Random signs kill the result; the tape-signed book carries it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this is not:&lt;/strong&gt; 0.04bp per standard deviation is not a trading edge after any spread. It is a measurement of the hedging machine's latency — and, as far as we can find, the first published figure of its kind for SPX.&lt;/p&gt;

&lt;p&gt;Full study with the reproduce recipe (it runs entirely off our free public session files): &lt;a href="https://firmtape.com/research/how-fast-do-dealers-hedge" rel="noopener noreferrer"&gt;How fast do dealers hedge?&lt;/a&gt;&lt;/p&gt;

</description>
      <category>analysis</category>
      <category>data</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Does open interest pin the index or widen its range? A decomposition over 1,084 SPX sessions</title>
      <dc:creator>FirmTape</dc:creator>
      <pubDate>Thu, 27 Aug 2026 09:02:57 +0000</pubDate>
      <link>https://dev.to/firmtape/options-pinning-vs-amplification-both-are-true-concentration-pins-size-amplifies-4k35</link>
      <guid>https://dev.to/firmtape/options-pinning-vs-amplification-both-are-true-concentration-pins-size-amplifies-4k35</guid>
      <description>&lt;p&gt;Two published claims about what option open interest does to the underlying point in opposite directions. Golez and Jackwerth (JFE 2012): the index is pulled toward strikes on expiration days - pinning. Elms (2025, SSRN 6564078): in the 0DTE era, high ATM open interest comes with ~16% wider daily ranges - amplification. Our earlier replication on SPX open interest (not a SPY proxy) found Elms's significant cell reverses sign: high ATM 0DTE OI, narrower ranges.&lt;/p&gt;

&lt;p&gt;The new result is that both stories are true at once, and the univariate framing was hiding it. Put the total 0DTE book into the same regression as the ATM concentration and the two carry opposite signs simultaneously, on 1,084 sessions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;component&lt;/th&gt;
&lt;th&gt;elasticity of forward range&lt;/th&gt;
&lt;th&gt;t&lt;/th&gt;
&lt;th&gt;sign by year 2022-2026&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ATM concentration (+/-0.5% of the open)&lt;/td&gt;
&lt;td&gt;-0.28&lt;/td&gt;
&lt;td&gt;-7.3&lt;/td&gt;
&lt;td&gt;negative 5 of 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;total 0DTE book size&lt;/td&gt;
&lt;td&gt;+0.17&lt;/td&gt;
&lt;td&gt;+4.3&lt;/td&gt;
&lt;td&gt;positive 5 of 5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Where the book sits, the range narrows. How big the book is, the range widens. Univariately the two components nearly cancel (total OI alone: t -1.5) - which is how a blended measure like Elms's SPY proxy can print either sign depending on the mix. His amplification and our reversal stop contradicting each other: they are two halves of one decomposition, and per-strike SPX open interest is what separates them.&lt;/p&gt;

&lt;p&gt;Details that matter: the outcome is the forward range read from minute 30 (open interest is yesterday's EOD book - predetermined), controls include the trailing move, the previous session's range and the overnight gap, and year fixed effects change nothing. This is a cross-sectional association with named controls, not an experiment.&lt;/p&gt;

&lt;p&gt;Full write-up with the original seven pre-specified tests and the free reproduction path: &lt;a href="https://firmtape.com/research/does-open-interest-pin-the-index" rel="noopener noreferrer"&gt;https://firmtape.com/research/does-open-interest-pin-the-index&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All finished sessions replay free at &lt;a href="https://firmtape.com/sessions" rel="noopener noreferrer"&gt;https://firmtape.com/sessions&lt;/a&gt;&lt;/p&gt;

</description>
      <category>finance</category>
      <category>statistics</category>
      <category>datascience</category>
      <category>trading</category>
    </item>
    <item>
      <title>How accurate is the SPX "expected move"? A calibration over 1,088 sessions and 82,000 readings</title>
      <dc:creator>FirmTape</dc:creator>
      <pubDate>Thu, 27 Aug 2026 09:01:29 +0000</pubDate>
      <link>https://dev.to/firmtape/how-accurate-is-the-spx-expected-move-first-calibration-we-know-of-724-vs-the-theoretical-273d</link>
      <guid>https://dev.to/firmtape/how-accurate-is-the-spx-expected-move-first-calibration-we-know-of-724-vs-the-theoretical-273d</guid>
      <description>&lt;p&gt;Every 0DTE dashboard quotes an "expected move" - the one-sigma range to the close implied by ATM volatility. Folklore says the straddle overstates the move most days. As far as we can find, nobody has published the number: when the screen says +/-X points to the close, how often does the close land inside?&lt;/p&gt;

&lt;p&gt;We measured it on 82,378 five-minute readings over 1,088 SPX sessions (2022-04-14 to 2026-08-26). At every frame: ATM IV interpolated at spot, EM = S * sigma * sqrt(tau to 16:00), then the fact - did the close land within EM of that reading's spot? Shares are day-clustered; readings with under 15 minutes left are excluded from the headline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conservative by four points, everywhere
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;threshold&lt;/th&gt;
&lt;th&gt;theory&lt;/th&gt;
&lt;th&gt;measured&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;inside 1.0 x EM&lt;/td&gt;
&lt;td&gt;68.3%&lt;/td&gt;
&lt;td&gt;72.4% +/- 0.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;inside 0.8 x EM (straddle breakeven)&lt;/td&gt;
&lt;td&gt;57.5%&lt;/td&gt;
&lt;td&gt;62.0% +/- 0.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;inside 0.5 x EM&lt;/td&gt;
&lt;td&gt;38.3%&lt;/td&gt;
&lt;td&gt;42.4% +/- 0.8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The folklore is right in sign and modest in size: the quote overstates the delivered close-move by ~4 percentage points of coverage. The four points sit at every threshold - a level of implied vol, not a fat-tail premium. And it is remarkably uniform: coverage runs 71-74% at every hour of the session and in every year 2022-2026. Nothing has drifted as 0DTE volume grew.&lt;/p&gt;

&lt;h2&gt;
  
  
  One observation the naive story does not predict
&lt;/h2&gt;

&lt;p&gt;Cut by the dealer-gamma state at that minute and the gradient runs backwards from intuition: coverage is HIGHER in the short-gamma tail (74.5% at gamma percentile &amp;lt;= 20 vs 71.0% at &amp;gt;= 80). Implied vol prices short-gamma days even more conservatively than the extra movement they deliver to the close. Two to three standard errors - an observation to check, not a result to lean on.&lt;/p&gt;

&lt;p&gt;Caveats named in full (minute closes, close-calibration not range-calibration, overnight excluded) plus a complete reproduce recipe from the public per-session files - per-strike IVs and spot are all you need, no paid data: &lt;a href="https://firmtape.com/research/how-accurate-is-the-expected-move" rel="noopener noreferrer"&gt;https://firmtape.com/research/how-accurate-is-the-expected-move&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All finished sessions replay free at &lt;a href="https://firmtape.com/sessions" rel="noopener noreferrer"&gt;https://firmtape.com/sessions&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update: the variance test (2026-08-28).&lt;/strong&gt; Coverage asks how often the close lands inside; it does not ask whether the quote is the right SIZE. We added the standard checks from the vol-forecasting literature. Result: E[z^2] = 1.034 +/- 0.062 - the expected move is an essentially unbiased VARIANCE forecast, roughly uniform across the day - and under the QLIKE loss it decisively beats a trailing-realized-vol benchmark (1.50 vs 2.05, t -4.2). So "conservative by four points of coverage" is not overpricing: the variance is priced right, and more closes land inside +/-1 EM than the lognormal predicts anyway. Both can be true only if the move distribution is leptokurtic versus the lognormal - more mass in the middle AND fatter far tails at the same variance. The sharper one-liner: &lt;strong&gt;right variance, wrong shape&lt;/strong&gt;. Sizing against the expected move as a one-sigma is honest; treating the 28% outside as lognormal tails is not. Full numbers in the addendum on the study page above.&lt;/p&gt;

</description>
      <category>finance</category>
      <category>statistics</category>
      <category>datascience</category>
      <category>python</category>
    </item>
    <item>
      <title>Does 0DTE options trading raise volatility? We measured a published +9% claim on 1,083 SPX sessions</title>
      <dc:creator>FirmTape</dc:creator>
      <pubDate>Thu, 27 Aug 2026 08:59:55 +0000</pubDate>
      <link>https://dev.to/firmtape/does-0dte-options-trading-raise-volatility-a-published-9-claim-measures-06-on-1083-spx-nni</link>
      <guid>https://dev.to/firmtape/does-0dte-options-trading-raise-volatility-a-published-9-claim-measures-06-on-1083-spx-nni</guid>
      <description>&lt;p&gt;Whether 0DTE options destabilise the index is a live three-way academic dispute. Brogaard, Han and Won (SSRN 4426358): a one-sd increase in the 0DTE volume share raises underlying volatility by ~9% of its mean. Dim, Eraker and Vilkov (SSRN 4692190): 0DTE positioning does not propagate volatility. Adams, Dim, Eraker, Fontaine, Ornthanalai and Vilkov (SSRN 5641974): 0DTE intermediation lowers volatility by 60-90 annualised bps.&lt;/p&gt;

&lt;p&gt;We measured the association on 1,083 SPX sessions of the mature regime (2022-04-14 to 2026-08-20), on the same per-print SPXW tape our terminal runs on. One row per session: realised vol from 390 one-minute returns, and the 0DTE share of the near book's volume (mean 66%, sd 7 points). Controls: previous day's log vol, year and day-of-week fixed effects.&lt;/p&gt;

&lt;h2&gt;
  
  
  The raises-volatility claim does not survive
&lt;/h2&gt;

&lt;p&gt;Same-day: one sd of 0DTE share corresponds to +0.6% of mean volatility (t +0.3) against the published +9%. Yearly signs alternate (+0.1, -0.3, +1.4, -0.9, +1.5 in t-stats) - no year is significant. And the share does not lead volatility either: t -0.9 on next-day vol given today's.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one robust fact runs the other way
&lt;/h2&gt;

&lt;p&gt;Reverse the arrow and the panel produces a stable number: after a volatile session the 0DTE share falls (-0.14 sd per log-unit of vol, t -4.3). Decomposing the ratio: both volume legs mean-revert after a vol shock, but the 0DTE leg retreats roughly twice as hard as the rest of the book (-0.047, t -3.6, vs -0.026, t -2.2). The folklore says volatility spikes attract 0DTE speculation; the tape says 0DTE flow steps away the next day, disproportionately.&lt;/p&gt;

&lt;p&gt;What we cannot test, honestly: the dampening claim rests on the staggered Tue/Thu SPXW listings of April-May 2022 as a natural experiment, and our archive starts 2022-04-14 - days before the first new listing, so there is no pre-period. We refute the plus sign; we neither confirm nor refute the minus.&lt;/p&gt;

&lt;p&gt;Full tables and caveats (the share's denominator is the ~21-expiry near book, not the whole chain): &lt;a href="https://firmtape.com/research/does-0dte-trading-raise-volatility" rel="noopener noreferrer"&gt;https://firmtape.com/research/does-0dte-trading-raise-volatility&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All finished sessions replay free at &lt;a href="https://firmtape.com/sessions" rel="noopener noreferrer"&gt;https://firmtape.com/sessions&lt;/a&gt;&lt;/p&gt;

</description>
      <category>finance</category>
      <category>statistics</category>
      <category>datascience</category>
      <category>trading</category>
    </item>
    <item>
      <title>Is intraday momentum still alive? We measured it on 1,085 SPX sessions of the 0DTE era</title>
      <dc:creator>FirmTape</dc:creator>
      <pubDate>Thu, 27 Aug 2026 08:58:32 +0000</pubDate>
      <link>https://dev.to/firmtape/intraday-momentum-is-dead-in-the-0dte-era-we-measured-it-on-1085-spx-sessions-43g0</link>
      <guid>https://dev.to/firmtape/intraday-momentum-is-dead-in-the-0dte-era-we-measured-it-on-1085-spx-sessions-43g0</guid>
      <description>&lt;p&gt;Two peer-reviewed papers say the last half hour of the US session continues the day's direction. Gao, Han, Li and Zhou ("Market intraday momentum", JFE 2018) found it on SPY 1993-2013; Baltussen, Da, Lammers and Martens (JFE 2021) widened it to sixty futures markets over 1974-2020 and attributed it to gamma-hedging demand. Both samples end before 0DTE options became half the SPX tape.&lt;/p&gt;

&lt;p&gt;We retested on 1,085 SPX sessions, 2022-04-14 to 2026-08-20, one observation per day: the rest-of-day return (open to 30 minutes before the close) against the last-30-minute return.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unconditional effect is gone
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;sample&lt;/th&gt;
&lt;th&gt;days&lt;/th&gt;
&lt;th&gt;slope&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;2022&lt;/td&gt;
&lt;td&gt;175&lt;/td&gt;
&lt;td&gt;+0.009&lt;/td&gt;
&lt;td&gt;+0.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2023&lt;/td&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;td&gt;+0.002&lt;/td&gt;
&lt;td&gt;+0.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2024&lt;/td&gt;
&lt;td&gt;252&lt;/td&gt;
&lt;td&gt;-0.016&lt;/td&gt;
&lt;td&gt;-0.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025&lt;/td&gt;
&lt;td&gt;249&lt;/td&gt;
&lt;td&gt;+0.016&lt;/td&gt;
&lt;td&gt;+1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026&lt;/td&gt;
&lt;td&gt;159&lt;/td&gt;
&lt;td&gt;-0.012&lt;/td&gt;
&lt;td&gt;-0.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;td&gt;1,085&lt;/td&gt;
&lt;td&gt;+0.006 +/- 0.009&lt;/td&gt;
&lt;td&gt;+0.6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Flat overall, flat in every single year, signs alternating. Anyone still trading "the last half hour continues the day" on the index is trading a sample that ended before the market they are in existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one place it still shows
&lt;/h2&gt;

&lt;p&gt;The hedging-demand mechanism makes a sharper prediction than either paper could test: continuation should live where dealers are short gamma. Conditioning on a tape-signed 0DTE dealer book read at 15:30, the slope adds +0.055 (t +3.1) on short-gamma closes, and the public open-interest convention book sees none of it. The interaction is positive in all five years - but short closes are only 15% of sessions, the magnitude swings by an order of magnitude, and the final-40-session holdout rests on four short days. Five-for-five on sign is worth reporting; it is not worth a position.&lt;/p&gt;

&lt;p&gt;Full tables, the named caveats, and a recipe to rebuild everything from the public per-session files (spot minutes, the aggressor-signed volume grid, per-strike IVs) are in the full post: &lt;a href="https://firmtape.com/research/is-intraday-momentum-still-alive" rel="noopener noreferrer"&gt;https://firmtape.com/research/is-intraday-momentum-still-alive&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All 1,000+ finished sessions are free to replay at &lt;a href="https://firmtape.com/sessions" rel="noopener noreferrer"&gt;https://firmtape.com/sessions&lt;/a&gt;&lt;/p&gt;

</description>
      <category>finance</category>
      <category>statistics</category>
      <category>datascience</category>
      <category>trading</category>
    </item>
    <item>
      <title>We replicated a published 0DTE options paper to four decimals. One unit-scale bug reverses its headline result — confirmed by the author</title>
      <dc:creator>FirmTape</dc:creator>
      <pubDate>Wed, 26 Aug 2026 22:05:47 +0000</pubDate>
      <link>https://dev.to/firmtape/we-replicated-a-published-0dte-options-paper-to-four-decimals-one-unit-scale-bug-reverses-its-3ddj</link>
      <guid>https://dev.to/firmtape/we-replicated-a-published-0dte-options-paper-to-four-decimals-one-unit-scale-bug-reverses-its-3ddj</guid>
      <description>&lt;p&gt;&lt;em&gt;This is the short version of the full write-up, which carries every table: &lt;a href="https://firmtape.com/research/vilkov-0dte-rules-replication" rel="noopener noreferrer"&gt;Replicating Vilkov (2026) on 0DTE trading rules&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Grigory Vilkov's "0DTE Trading Rules: Tail Risk, Implementation, and Tactical Timing" (SSRN 4641356) is a rare thing in the trading literature: the author shipped the code &lt;strong&gt;and&lt;/strong&gt; the data panels behind his tables, MIT-licensed. So instead of comparing numbers to a memory, we could re-implement his construction independently and feed his own panel through it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: the replication is exact.&lt;/strong&gt; All seven option structures, both cost tiers, reproduce his published P&amp;amp;L table to four decimal places. No ambiguity about what the code does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: the reproduction exposed a units bug.&lt;/strong&gt; The panel stores the P&amp;amp;L column in &lt;em&gt;percent&lt;/em&gt; of spot (&lt;code&gt;reth_und = (payoff - mid) * 100&lt;/code&gt;) but the bid-ask spread column as a &lt;em&gt;fraction&lt;/em&gt; of spot. The cost code subtracts one from the other directly:&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;pnl_ba&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reth_und&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;half_spread_cost&lt;/span&gt;   &lt;span class="c1"&gt;# percent minus fraction: cost charged at 1/100
&lt;/span&gt;&lt;span class="n"&gt;pnl_ba_fee05&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pnl_ba&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.005&lt;/span&gt;          &lt;span class="c1"&gt;# the flat 0.5bp fee term was correctly scaled
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The half-spread was charged at one hundredth of its true size — about 0.022bp instead of 2.2bp. Rescale it and change nothing else: &lt;strong&gt;every unconditional structure in the paper goes negative.&lt;/strong&gt; The best one (put ratio spread) has a mean gross edge of +0.0251% of spot per day, and the spread you cross to get it costs 0.0343%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: the ML layer makes it worse, not better.&lt;/strong&gt; The paper's conditional layer trains a classifier whose target is &lt;code&gt;y = 1[PNL_net &amp;gt; 0]&lt;/code&gt; — the cost sits &lt;strong&gt;inside the training label&lt;/strong&gt;, not just in the evaluation. Re-pricing the author's shipped predictions at the corrected cost takes the headline conditional Sharpe from +1.55 to +0.33. Rebuilding the labels with the corrected cost and retraining takes it to &lt;strong&gt;−0.70&lt;/strong&gt;. Two-thirds of the published result was cost arithmetic; most of the rest was a model that had learned from mislabelled days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: we emailed the author before publishing.&lt;/strong&gt; His reply, within two days, quoted with permission:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Thank you for this. You have read the units correctly, the finding stands, and it is more consequential than the charitable version you offered me. Please publish it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;He reran the corrected code on the private, longer sample behind the paper — the bug is there too — and pushed the fix upstream (&lt;a href="https://github.com/vilkovgr/0dte-strategies/commit/85a447c4c155b60143a51c0496641e0f0b892321" rel="noopener noreferrer"&gt;commit 85a447c&lt;/a&gt;) with a &lt;code&gt;KNOWN-ISSUES.md&lt;/code&gt; and a unit-scale assert module, so this class of error now fails loudly. The paper's headline conditional result reverses sign: put ratio spread +0.93 published → −0.75 corrected, and no strategy or basket survives net of costs. His framing of what survives is worth quoting too: the &lt;em&gt;gross&lt;/em&gt; directional signal is real; what died is the claim that crossing the quoted spread leaves any of it for the trader.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaways for anyone who ships research code:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mixed scales in one dataframe are a landmine.&lt;/strong&gt; &lt;code&gt;reth_und&lt;/code&gt; in percent next to &lt;code&gt;bas&lt;/code&gt; in fractions, in the same table, with no suffix on either name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert units at boundaries.&lt;/strong&gt; The fix that made this permanent is not the &lt;code&gt;* 100&lt;/code&gt; — it's the guard that checks a cost series is on a plausible scale before it touches P&amp;amp;L.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A metric bug can contaminate your labels.&lt;/strong&gt; If your training target is derived from the quantity your bug distorts, retraining after the fix is part of the correction. Re-evaluating alone understates the damage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship your data.&lt;/strong&gt; This error was findable, and fixable, only because the author published everything. That is the norm this literature needs — his response shows how it should be done.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full post — with the corrected tables, the delta decomposition showing the result is not hidden index beta, the author's complete response, and a 46-session out-of-sample run on our own SPXW tick tape — is at &lt;a href="https://firmtape.com/research/vilkov-0dte-rules-replication" rel="noopener noreferrer"&gt;https://firmtape.com/research/vilkov-0dte-rules-replication&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>quant</category>
      <category>python</category>
      <category>machinelearning</category>
      <category>finance</category>
    </item>
    <item>
      <title>A paywall where the trim is the product</title>
      <dc:creator>FirmTape</dc:creator>
      <pubDate>Tue, 25 Aug 2026 16:13:16 +0000</pubDate>
      <link>https://dev.to/firmtape/a-paywall-where-the-trim-is-the-product-27bo</link>
      <guid>https://dev.to/firmtape/a-paywall-where-the-trim-is-the-product-27bo</guid>
      <description>&lt;p&gt;I sell one thing: a live data stream. Everything that has already finished is free, forever, and that free archive is the evidence the paid stream is measured honestly.&lt;/p&gt;

&lt;p&gt;Last week I added a third thing between them — a public page showing &lt;em&gt;today's&lt;/em&gt; numbers, deliberately late. It sounds like a small feature. It is actually the most dangerous kind of code in the codebase, because it reads the paid file and publishes part of it, and every bug in it is a bug that gives away the product.&lt;/p&gt;

&lt;p&gt;Here is what I got right, what I got wrong, and the two bugs that only showed up in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line: give away timeliness, not a new category
&lt;/h2&gt;

&lt;p&gt;The temptation with a "free tier" is to publish a crippled version of everything. That is hard to explain and harder to defend, because every field becomes a negotiation.&lt;/p&gt;

&lt;p&gt;The rule I landed on: &lt;strong&gt;the free page introduces no new category of number.&lt;/strong&gt; Every field on it is already free elsewhere on the site the next morning. What it adds is &lt;em&gt;when&lt;/em&gt; — and when is exactly what is metered.&lt;/p&gt;

&lt;p&gt;So the paid product keeps the things that are actually different in kind: the per-strike ladder, the second-order series, the internal disagreement between three different ways of computing the same thing, and the path through the day. Not trimmed to a teaser. Absent.&lt;/p&gt;

&lt;p&gt;That framing turned out to be worth more than the trim itself. It fits in one sentence, it survives contact with a pricing page, and it never invites "why is &lt;em&gt;this&lt;/em&gt; field free and &lt;em&gt;that&lt;/em&gt; one not".&lt;/p&gt;

&lt;h2&gt;
  
  
  One function decides, and everything else is presentation
&lt;/h2&gt;

&lt;p&gt;The route that serves this page loads a payload and hands it to exactly one function. That function returns a plain object or &lt;code&gt;null&lt;/code&gt;. Nothing else on the request path sees the payload.&lt;/p&gt;

&lt;p&gt;This is not tidiness. My codebase already has a comment recording what happens when two routes serve one payload under two policies: someone finds the other spelling and walks through it. In my case a request path could be normalised &lt;em&gt;after&lt;/em&gt; it had been checked, so &lt;code&gt;//data/live.json&lt;/code&gt; and &lt;code&gt;/data/LIVE.json&lt;/code&gt; both missed the rule that guarded &lt;code&gt;/data/live.json&lt;/code&gt; and were then resolved into the very file the rule protected. Two parsers of one string will eventually disagree.&lt;/p&gt;

&lt;p&gt;So: one canonicaliser, run once per request, consumed by both the gate and the file read. And one trim, called by one route. The new page is a &lt;em&gt;third&lt;/em&gt; door into that file, and it is the reason I wrote the trim as a pure function with its own test file before wiring anything up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure the delay against the stream, not the clock
&lt;/h2&gt;

&lt;p&gt;The obvious implementation of "fifteen minutes behind" is: take the reading from &lt;code&gt;now() - 15min&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Do not do that.&lt;/p&gt;

&lt;p&gt;Consider what happens when your writer stalls. Wall-clock arithmetic keeps marching, the newest data stops moving, and the effective delay &lt;em&gt;shrinks toward zero&lt;/em&gt; — you begin publishing the freshest thing you have at the exact moment your pipeline is unhealthy and nobody is watching.&lt;/p&gt;

&lt;p&gt;Measure against the data instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the newest reading at least DELAY_MIN older than the newest reading we hold&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toMin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;chosen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newest&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;toMin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;DELAY_MIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;chosen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// the whole session so far is inside the window&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a stall makes the page &lt;em&gt;older&lt;/em&gt;, never fresher. That is the direction a paywall should fail in. As a bonus the function needs no clock at all, which makes it trivially testable — the test suite hands it a synthetic payload and asserts the delay holds, including the stalled case.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that would have given it away
&lt;/h2&gt;

&lt;p&gt;My payload carries the same levels twice, in two shapes. There is a five-minute &lt;em&gt;series&lt;/em&gt;, and there are session-level &lt;em&gt;scalars&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;For a finished day the scalars are the right answer — they are the published level set, the same numbers the archive page prints, and reading them means the two pages can never quote the same day differently.&lt;/p&gt;

&lt;p&gt;For a day in progress the scalars mean &lt;strong&gt;"as of the most recent rebuild"&lt;/strong&gt;. Current. Undelayed. Publishing them would have handed over live values while the page's own timestamp claimed a fifteen-minute delay.&lt;/p&gt;

&lt;p&gt;Two of my seven fields existed &lt;em&gt;only&lt;/em&gt; as scalars. The lazy version of this function would have mixed sources — series where available, scalars where not — and the page would have been simultaneously delayed and not, with no visible symptom.&lt;/p&gt;

&lt;p&gt;The fix was to read a live day entirely from the series, and accept that this means the whole row belongs to one moment rather than being a composite. That is also what makes the timestamp on the page true.&lt;/p&gt;

&lt;p&gt;The test that pins it sets the scalars to absurd sentinel values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;levels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;flip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cr0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ps0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;// never publishable while live&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and then asserts no live reading ever returns 1, 2 or 3. If someone later "simplifies" the function by falling back to the scalars, that test fails loudly instead of the paywall failing quietly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Say "at least", because your delay composes
&lt;/h2&gt;

&lt;p&gt;I shipped the page saying &lt;em&gt;"15 minutes behind the live stream"&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Then I measured it in production, cache-busted, and the reading was twenty-two minutes old.&lt;/p&gt;

&lt;p&gt;Nothing was broken. My function controls the gap to the newest reading &lt;em&gt;in the payload&lt;/em&gt; — and the payload has a lag of its own, because the frames are published behind the real-time tail. The two lags compose, and I had only ever reasoned about mine.&lt;/p&gt;

&lt;p&gt;The timestamp on the page was never wrong. The sentence next to it was: it made a wall-clock promise the code cannot keep. The page now says &lt;strong&gt;"at least 15 minutes"&lt;/strong&gt;, which is true at both ends.&lt;/p&gt;

&lt;p&gt;If you publish a delayed anything, this generalises: the stamp is the claim, the delay is a floor, and stating a floor as an equality is a promise your dependencies have not agreed to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take to the next one
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Write the trim before the route.&lt;/strong&gt; A pure function with a test file, then wire it. The test file becomes the paywall's regression suite rather than coverage-for-its-own-sake, and the two assertions that matter — &lt;em&gt;never fresher than the delay&lt;/em&gt;, &lt;em&gt;never reads the live source&lt;/em&gt; — are the ones you would otherwise verify by staring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pass the gate's own signal in, do not recompute it.&lt;/strong&gt; My trim takes "is this day still being written" as an argument, from the same helper the paywall uses. Two implementations of "is this live" is the same class of bug as two parsers of a path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sentinel values beat mocks&lt;/strong&gt; for "this must never be published". A magic &lt;code&gt;1&lt;/code&gt; that shows up in output is unmistakable; a mock that returns plausible data hides exactly the bug you were testing for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look at it in production before you believe the copy.&lt;/strong&gt; Both of my real bugs — the composed delay, and a page linking the new URL from a footnote nobody read — were invisible from the code and obvious from one &lt;code&gt;curl&lt;/code&gt; and one log line.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is from &lt;a href="https://firmtape.com" rel="noopener noreferrer"&gt;FirmTape&lt;/a&gt;, which rebuilds SPX dealer positioning from the options tape. The page in question is &lt;a href="https://firmtape.com/now" rel="noopener noreferrer"&gt;/now&lt;/a&gt;; the &lt;a href="https://firmtape.com/research" rel="noopener noreferrer"&gt;research section&lt;/a&gt; is where the measurements live, and most of them are null results.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>api</category>
    </item>
    <item>
      <title>Your MCP server is invisible to ChatGPT unless it has these two tools</title>
      <dc:creator>FirmTape</dc:creator>
      <pubDate>Tue, 25 Aug 2026 16:11:29 +0000</pubDate>
      <link>https://dev.to/firmtape/your-mcp-server-is-invisible-to-chatgpt-unless-it-has-these-two-tools-4o57</link>
      <guid>https://dev.to/firmtape/your-mcp-server-is-invisible-to-chatgpt-unless-it-has-these-two-tools-4o57</guid>
      <description>&lt;p&gt;My MCP server had nine working tools. Claude used all of them. ChatGPT could list the server and could not use a single one.&lt;/p&gt;

&lt;p&gt;The reason is short: &lt;strong&gt;ChatGPT reaches an MCP server through exactly two tool names, &lt;code&gt;search&lt;/code&gt; and &lt;code&gt;fetch&lt;/code&gt;, and ignores the rest of the table.&lt;/strong&gt; In deep research that pair is not a convenience layer over your tools — it &lt;em&gt;is&lt;/em&gt; the interface. A server without them is listable and useless.&lt;/p&gt;

&lt;p&gt;Here is what I learned building them for a server that was already in production with a different shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contract
&lt;/h2&gt;

&lt;p&gt;Both tools answer with plain text. Not &lt;code&gt;structuredContent&lt;/code&gt;, not an &lt;code&gt;outputSchema&lt;/code&gt; — the connector reads &lt;code&gt;content[0].text&lt;/code&gt; and expects a documented JSON envelope inside it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;search(query)&lt;/code&gt; returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"snippet"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fetch(id)&lt;/code&gt; returns one document, not wrapped in a list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"the full text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your existing tools declare &lt;code&gt;outputSchema&lt;/code&gt; and answer with &lt;code&gt;structuredContent&lt;/code&gt; — mine did, and it is the better shape for every other client — leave those alone and make these two the exception. They are the only tools in my table that are text-only, and there is a comment in the source saying why so nobody "fixes" it later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule one: every id must round-trip
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;search&lt;/code&gt; hands out ids. &lt;code&gt;fetch&lt;/code&gt; receives them. If a model can be handed an id it cannot resolve, you have built a dead end that costs a round trip to discover.&lt;/p&gt;

&lt;p&gt;The failure mode I avoided by accident and then almost re-introduced: using titles as ids. A model will hand you back something &lt;em&gt;close&lt;/em&gt; to the title, and now you are doing fuzzy matching in your resolver.&lt;/p&gt;

&lt;p&gt;Namespace them instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="s2"&gt;`session:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;day&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;     &lt;span class="c1"&gt;// a dated record&lt;/span&gt;
&lt;span class="s2"&gt;`page:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;       &lt;span class="c1"&gt;// a document on the site&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two prefixes, both trivially parseable, both impossible to produce by paraphrase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule two: tolerant on input, strict on output
&lt;/h2&gt;

&lt;p&gt;Models do not reliably hand back the id they were given. Mine gets handed citation URLs, bare dates, and paths with and without a leading slash — all meaning something perfectly clear. Refusing them is technically correct and practically a wasted turn.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;session:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;page:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// a URL from a citation, with or without the scheme&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;https&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\/\/)?(?:&lt;/span&gt;&lt;span class="sr"&gt;www&lt;/span&gt;&lt;span class="se"&gt;\.)?&lt;/span&gt;&lt;span class="sr"&gt;example&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;com&lt;/span&gt;&lt;span class="se"&gt;(\/[^\s]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)?&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;record&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/(?:&lt;/span&gt;&lt;span class="sr"&gt;record&lt;/span&gt;&lt;span class="se"&gt;)\/(\d{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2})\/?&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;record&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;?#&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;page&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strict on the way out, though. &lt;code&gt;fetch&lt;/code&gt; on a site is an HTTP client you are handing to a language model, and the thing it must never become is a way to reach the routes that execute work. My denylist is my own &lt;code&gt;robots.txt&lt;/code&gt; &lt;code&gt;Disallow&lt;/code&gt; list restated in code — not because robots.txt is a security boundary, but because the two lists answer the same question and having one derive from the other means they cannot drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dates as people type them
&lt;/h2&gt;

&lt;p&gt;If your corpus is time-series, half the queries carry a date, and none of them are ISO.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-08-24
August 24, 2026
24 Aug 2026
August 2026        &amp;lt;- means every record in that month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one non-obvious rule: &lt;strong&gt;a named day must win over the month containing it.&lt;/strong&gt; Parse "24 August 2026" with a month-and-year pattern and you will also match "August 2026" inside it and drag in the whole month. Compute exact days first; only if there are none, fall back to month expansion.&lt;/p&gt;

&lt;p&gt;And return only dates you actually hold. A weekend is silently not a trading day, and the honest answer to "what happened on Saturday" is an empty result, not an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read your catalogue from your own site
&lt;/h2&gt;

&lt;p&gt;The first version of my &lt;code&gt;search&lt;/code&gt; had a hardcoded list of pages. That was wrong within a day: I published a new article and it was unfindable until I redeployed the server.&lt;/p&gt;

&lt;p&gt;Now the catalogue is fetched from the site itself, from two sources, because neither is enough alone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;llms.txt&lt;/code&gt;&lt;/strong&gt; is maintained as one line per page — &lt;code&gt;- [Title](url): description&lt;/code&gt; — so it carries good titles and real blurbs. But it is a &lt;em&gt;curated&lt;/em&gt; file. Mine named five of eleven pages in one section.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sitemap.xml&lt;/code&gt;&lt;/strong&gt; names every public URL and tells you nothing about any of them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So: parse &lt;code&gt;llms.txt&lt;/code&gt; first, then let the sitemap fill the gaps with a title derived from the slug. Edge-cache both. Now anything I publish is searchable without touching the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ranking: "right now" is not "the latest"
&lt;/h2&gt;

&lt;p&gt;This one shipped broken and I only caught it because I tested with a real question.&lt;/p&gt;

&lt;p&gt;The query was &lt;em&gt;"where is the SPX gamma flip right now"&lt;/em&gt;. My scorer put the page that answers exactly that in &lt;strong&gt;fifth&lt;/strong&gt; place, under three dated records. The words that separate the two intents — "right now" versus "latest" — are precisely the ones a keyword scorer treats as noise.&lt;/p&gt;

&lt;p&gt;Keyword scoring cannot fix this. Split by intent instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wantsNow&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;now|currently|at the moment|live&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;today&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wantsLatest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;latest|newest|recent|yesterday|last&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Present tense hoists the live page. Most-recent hoists the newest finished records. &lt;code&gt;yesterday&lt;/code&gt; belongs with the second group deliberately: it is a finished day, and "now" is a moment.&lt;/p&gt;

&lt;p&gt;If your server has one URL that answers the present tense, make sure a present-tense query returns it first. Everything else is a rounding error next to getting that wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not put a long cache on the page whose value is freshness
&lt;/h2&gt;

&lt;p&gt;I gave &lt;code&gt;fetch&lt;/code&gt; a fifteen-minute edge cache for the pages it retrieves. Sensible for documentation that changes on deploy. Wrong for the one page that updates through the day: its own content is already delayed, and a fifteen-minute cache on top of that would hand an assistant a state half an hour old.&lt;/p&gt;

&lt;p&gt;Cache by path, not globally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;cacheTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/live&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cacheEverything&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Keep your real tools
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;search&lt;/code&gt; and &lt;code&gt;fetch&lt;/code&gt; are a document interface. They return prose. My other tools return typed numbers with an &lt;code&gt;outputSchema&lt;/code&gt;, which is strictly better for any client that can call them.&lt;/p&gt;

&lt;p&gt;So both exist, and the descriptions say so — the generic pair carries a line telling a capable client to prefer the specific tools, and the server instructions repeat it. ChatGPT gets an interface it can use. Everything else gets the good one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worth knowing before you start
&lt;/h2&gt;

&lt;p&gt;Adding a custom MCP connector in ChatGPT lives under &lt;strong&gt;Settings → Plugins → Developer mode&lt;/strong&gt;, and that toggle is flagged "elevated risk" because it permits unverified connectors. That is a real decision for whoever owns the account, not a checkbox to breeze past — and it is worth telling your users where it is, because "Connectors" is not where it used to be.&lt;/p&gt;

&lt;p&gt;None of this is hard. It is about a hundred and fifty lines. But no amount of good tool design substitutes for the two names, and I spent a while assuming a well-built server would be discovered on its merits.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://firmtape.com" rel="noopener noreferrer"&gt;FirmTape&lt;/a&gt; — SPX dealer positioning rebuilt from the options tape, with &lt;a href="https://firmtape.com/research" rel="noopener noreferrer"&gt;a public research section&lt;/a&gt; where most of the posts are null results. The MCP server is at &lt;a href="https://firmtape.com/mcp" rel="noopener noreferrer"&gt;https://firmtape.com/mcp&lt;/a&gt; if you want to see the shape described above in something that is actually running.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>node</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I measured which AI crawlers actually fetch my site. The answer changed what I built next.</title>
      <dc:creator>FirmTape</dc:creator>
      <pubDate>Tue, 25 Aug 2026 15:55:27 +0000</pubDate>
      <link>https://dev.to/firmtape/i-measured-which-ai-crawlers-actually-fetch-my-site-the-answer-changed-what-i-built-next-1p86</link>
      <guid>https://dev.to/firmtape/i-measured-which-ai-crawlers-actually-fetch-my-site-the-answer-changed-what-i-built-next-1p86</guid>
      <description>&lt;p&gt;Cloudflare Web Analytics told me &lt;code&gt;chatgpt.com&lt;/code&gt; was one of my largest referrers. That is the sum total of what it could tell me, because Web Analytics is a client-side beacon: it fires from a browser running JavaScript. Every crawler on earth is invisible to it by construction.&lt;/p&gt;

&lt;p&gt;So when I shipped a page specifically for AI assistants to read, I had no way to answer the only question that mattered — &lt;em&gt;did any of them come?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I added about a hundred lines of server-side counting to find out. The result was not what I expected, and it made me throw away part of the work I had just done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three hats, not one
&lt;/h2&gt;

&lt;p&gt;The naive version of this is a counter that increments on "is this a bot". That number is useless, and it took me one iteration to see why: an AI agent hits your site wearing one of three completely different hats.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Index.&lt;/strong&gt; A crawler walking on its own schedule, building a corpus. &lt;code&gt;OAI-SearchBot&lt;/code&gt;, &lt;code&gt;PerplexityBot&lt;/code&gt;, &lt;code&gt;Claude-SearchBot&lt;/code&gt;. This says you are &lt;em&gt;reachable&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On-behalf.&lt;/strong&gt; A person asked a question a second ago, and the assistant is fetching your page &lt;em&gt;right now&lt;/em&gt; to answer it. &lt;code&gt;ChatGPT-User&lt;/code&gt;, &lt;code&gt;Perplexity-User&lt;/code&gt;, &lt;code&gt;Claude-User&lt;/code&gt;. This says you are being &lt;em&gt;cited&lt;/em&gt;. This is the number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Training.&lt;/strong&gt; Corpus collection for model training. &lt;code&gt;GPTBot&lt;/code&gt;, &lt;code&gt;ClaudeBot&lt;/code&gt;, &lt;code&gt;Google-Extended&lt;/code&gt;. Neither of the above.&lt;/p&gt;

&lt;p&gt;All three of OpenAI's agents carry the string "OpenAI" in the wild, and a matcher that collapses them produces a number that looks like data and answers nothing. Order matters when you match, too — test &lt;code&gt;chatgpt-user&lt;/code&gt; before &lt;code&gt;gptbot&lt;/code&gt;, or a naive substring search folds them together.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// UA substring -&amp;gt; [canonical name, hat]. First hit wins, so the more specific&lt;/span&gt;
&lt;span class="c1"&gt;// spelling comes first.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AGENTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;oai-searchbot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;OAI-SearchBot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;index&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chatgpt-user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ChatGPT-User&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;on-behalf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gptbot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GPTBot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;training&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;perplexity-user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Perplexity-User&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;on-behalf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;perplexitybot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PerplexityBot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;index&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Claude-User&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;on-behalf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-searchbot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Claude-SearchBot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;index&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claudebot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ClaudeBot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;training&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;googlebot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Googlebot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;index&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bingbot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bingbot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;index&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ua&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;needle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;AGENTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;needle&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;hat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// a person, or something that does not say&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a fourth number worth having, and it is not a crawler at all. ChatGPT stamps the links it renders with &lt;code&gt;utm_source=chatgpt.com&lt;/code&gt;. Counting those gives you the &lt;strong&gt;click-through&lt;/strong&gt;: a human who saw a citation and decided it was worth opening.&lt;/p&gt;

&lt;p&gt;Read together, the four are a funnel. Index arrives first. On-behalf follows once your page is actually retrievable. Clicks follow once a person decides the citation was worth a tap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it cheap and keeping it private
&lt;/h2&gt;

&lt;p&gt;Two constraints shaped the implementation more than anything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is fed by strangers.&lt;/strong&gt; The traffic this measures is exactly the traffic you do not control. A crawler walking my archive is a thousand page requests. So: aggregate in memory, keyed by day and path, and persist on a debounced flush rather than per request. If you mirror to a paid key-value store, mirror the accumulated day — one write every thirty seconds while anything is arriving, none when it is not. A crawler flood and an idle afternoon then cost exactly the same. Bound the number of distinct paths per agent per day too, or a single deep crawl grows one key per URL forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It should not become surveillance.&lt;/strong&gt; There is no reason for this instrument to hold anything about a person. Mine records which &lt;em&gt;known agent&lt;/em&gt; matched, which path, and the date. No IP, no account, no session, no user-agent string kept verbatim, nothing from the query string except that one attribution parameter. Counts roll off after sixty days. That is enough to answer "did OpenAI fetch this page yet" and not enough to answer anything about anyone.&lt;/p&gt;

&lt;p&gt;If you have a privacy policy that says page views are counted by your analytics provider and stops there — mine did — that sentence stopped being the whole truth the moment this landed. Update it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two bugs worth stealing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The shutdown flush that never ran.&lt;/strong&gt; My server registered signal handlers at module load: &lt;code&gt;process.on(sig, () =&amp;gt; process.exit(0))&lt;/code&gt;. My counter registered its own flush handler later, from inside the &lt;code&gt;listen()&lt;/code&gt; callback. Node runs listeners in registration order, the first one calls &lt;code&gt;process.exit(0)&lt;/code&gt;, and the second never fires. A deploy is a &lt;code&gt;SIGTERM&lt;/code&gt;, and a deploy is precisely when someone is watching the last few hours of data. The fix is not a second handler — it is to flush inside the one that already owns shutdown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Load-or-reset.&lt;/strong&gt; &lt;code&gt;open()&lt;/code&gt; read a file and, if the file was missing, left whatever was already in memory. A second call silently inherited the first one's counts. "Load whatever survived" has to mean &lt;em&gt;empty&lt;/em&gt; when nothing did, or the persisted file stops being the record it claims to be. A test caught this, not a code review.&lt;/p&gt;

&lt;h2&gt;
  
  
  The finding
&lt;/h2&gt;

&lt;p&gt;Half an hour after it shipped, this is what the counter said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  THE FUNNEL
  reachable   (index bots)           9
  cited now   (on-behalf)           15   &amp;lt;- a person was answered from us
  training                           0

  BY AGENT
  ChatGPT-User          on-behalf       15
      /research/level-hold-rates                   1
      /research/what-monthly-expiration...         1
      /learn                                       1
      /about                                       1
      /mcp                                         1
      /                                            1
      /sessions                                    1
      ... five session pages, four research posts

  Googlebot             index            9
      ... eight archive pages and /pricing

  CLICK-THROUGHS
    (nothing yet)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things jumped out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ChatGPT-User&lt;/code&gt; outnumbered Googlebot.&lt;/strong&gt; Fifteen on-behalf fetches to nine index crawls, on a site with a rounding error's worth of search traffic. People are asking assistants questions and the assistants are reading my pages to answer. Not skimming a title — pulling research posts, the explainer index, the docs page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The page I built for them was never fetched.&lt;/strong&gt; I had shipped, that same morning, a URL that answers the single most common question about this subject in plain server-rendered HTML. It appears nowhere in that list. Neither does &lt;code&gt;OAI-SearchBot&lt;/code&gt;, which had not visited at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nobody clicked.&lt;/strong&gt; Fifteen reads, zero click-throughs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that actually means
&lt;/h2&gt;

&lt;p&gt;The middle finding is the one that changed my plans, and it took a minute to understand.&lt;/p&gt;

&lt;p&gt;The assistant fetched &lt;code&gt;/&lt;/code&gt; and &lt;code&gt;/sessions&lt;/code&gt;. My new page was linked from the navigation of both, and named in a sentence on one of them. It did not go.&lt;/p&gt;

&lt;p&gt;In search mode, an assistant opens &lt;strong&gt;what its index returned&lt;/strong&gt;. It does not browse — it does not follow links out of a page it just read. So a page that is hours old and indexed nowhere is unreachable no matter how prominently you link it internally. Every internal link I had added that morning was, for this purpose, worthless.&lt;/p&gt;

&lt;p&gt;And it gets more pointed. &lt;code&gt;/&lt;/code&gt; &lt;em&gt;was&lt;/em&gt; fetched — and &lt;code&gt;/&lt;/code&gt; was a JavaScript application shell whose HTML carried no numbers at all. The most-fetched URL on the site was answering the question with nothing.&lt;/p&gt;

&lt;p&gt;So I moved the answer to where the fetch already lands: the current reading now renders into the shell's HTML as a &lt;code&gt;&amp;lt;noscript&amp;gt;&lt;/code&gt; block. Not a hidden div served only to crawlers — the same bytes for everyone, and a genuine fallback, because without JavaScript that page really is blank. That is the line between a fallback and cloaking, and it is worth staying on the right side of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell you to do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Count the three hats separately.&lt;/strong&gt; One "bot hits" number will not tell you whether you are indexed or whether you are being cited, and those need different responses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check whether your most-fetched URL actually answers anything.&lt;/strong&gt; If it is a JS app shell, an assistant is spending a fetch on your site and leaving with nothing. That is the cheapest fix available and I would not have found it without the data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not assume internal links help here.&lt;/strong&gt; They help humans and they help traditional crawlers. They appear not to help an assistant that only opens search results.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch the click-through row.&lt;/strong&gt; Fifteen reads and zero clicks is one afternoon on one small site and I would not generalise from it. But it is the number that separates "we are in the answer" from "we get the visit", and if you are not counting it you will not notice which one you have.&lt;/p&gt;

&lt;p&gt;The honest caveat on all of this: it is roughly thirty minutes of data from a single site in a niche, and some of those fetches were me testing. It is not a study. It is an instrument that took an afternoon to build and immediately told me something I had got wrong — which is the only real argument for building it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The site is &lt;a href="https://firmtape.com" rel="noopener noreferrer"&gt;FirmTape&lt;/a&gt;, which rebuilds SPX dealer positioning from the options tape. If you want the flavour of what the assistants were pulling: &lt;a href="https://firmtape.com/research/level-hold-rates" rel="noopener noreferrer"&gt;how often dealer-gamma levels hold&lt;/a&gt;, measured across a thousand sessions, and &lt;a href="https://firmtape.com/research/what-monthly-expiration-does-to-the-book" rel="noopener noreferrer"&gt;what monthly expiration does to the book&lt;/a&gt;. Most of what is published there is a null result, which is its own kind of fun.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>seo</category>
      <category>node</category>
    </item>
  </channel>
</rss>
