<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://savabs.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://savabs.github.io/" rel="alternate" type="text/html" /><updated>2026-09-02T07:36:05+00:00</updated><id>https://savabs.github.io/feed.xml</id><title type="html">Saurabh Patel</title><subtitle>Measurements, mostly negative, on ML systems and markets.</subtitle><author><name>Saurabh Patel</name></author><entry><title type="html">An agent harness that lost to a single API call</title><link href="https://savabs.github.io/2026/08/31/agent-harness-lost-to-one-api-call.html" rel="alternate" type="text/html" title="An agent harness that lost to a single API call" /><published>2026-08-31T00:00:00+00:00</published><updated>2026-08-31T00:00:00+00:00</updated><id>https://savabs.github.io/2026/08/31/agent-harness-lost-to-one-api-call</id><content type="html" xml:base="https://savabs.github.io/2026/08/31/agent-harness-lost-to-one-api-call.html"><![CDATA[<p>For four months (2026-04-30 to 2026-08-31, 75 commits) I built an autonomous coding agent — a harness around a
language model with a planner, a worker loop, a verification gate, a model
router and a cost dashboard. The thesis was written down in the repo:</p>

<blockquote>
  <p><em>models are replaceable; the harness is the asset.</em></p>
</blockquote>

<p>On 2026-08-31 I ran a pre-registered test of that claim against the obvious
control: one raw API call to the same model, given the same task. The result:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Harness</th>
      <th>One API call</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>tests unblocked</td>
      <td><strong>0</strong></td>
      <td><strong>287</strong></td>
    </tr>
    <tr>
      <td>real failures fixed (of 23)</td>
      <td>0</td>
      <td>0</td>
    </tr>
    <tr>
      <td>wall time</td>
      <td>248.8 s</td>
      <td>4.5 s</td>
    </tr>
    <tr>
      <td>cost</td>
      <td>$0.527</td>
      <td>$0.046</td>
    </tr>
  </tbody>
</table>

<p><strong>11x the cost, 55x the wall time, strictly less accomplished.</strong> Two
pre-registered stop conditions fired. I killed the project the same day.</p>

<p>This is the write-up, including the parts that make me look bad.</p>

<hr />

<h2 id="the-benchmark-that-said-it-was-working">The benchmark that said it was working</h2>

<p>Two months earlier, in June, I had run a head-to-head on the same question and
gotten the opposite answer:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Raw API</th>
      <th>Harness</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>All 35 tests pass?</td>
      <td><strong>no</strong></td>
      <td><strong>yes</strong></td>
    </tr>
    <tr>
      <td>Steps completed</td>
      <td>18/18</td>
      <td>18/18</td>
    </tr>
    <tr>
      <td>Cost</td>
      <td>$0.0027</td>
      <td>$0.0056</td>
    </tr>
    <tr>
      <td>Wall time</td>
      <td>49 s</td>
      <td>81 s</td>
    </tr>
  </tbody>
</table>

<p>I wrote this underneath it at the time:</p>

<blockquote>
  <p><em>It cost more, but all 35 tests passed — the work was actually completed.</em>
<em>The harness advantage is that the system can spend a little more when
necessary to finish the job correctly.</em></p>
</blockquote>

<p>That benchmark ran on <code>ci_rescue_sprint</code> — <strong>a broken practice repo I had
written myself.</strong> I chose the breakages, and I chose them from the set of things
I had built the harness to handle. The benchmark measured my ability to write a
benchmark my system passes.</p>

<p>If you take one thing from this piece, take that. A benchmark you author is a
mirror. It will keep telling you the thing is working right up until the moment
something you did not author disagrees.</p>

<h2 id="the-pre-registration">The pre-registration</h2>

<p>Everything below the “Target” heading was written <strong>before any run</strong>, with this
clause at the top:</p>

<blockquote>
  <p><strong>Thresholds fixed here are not to be edited after results are seen.</strong> If a
criterion turns out to be wrong, add a dated note saying so; do not quietly
change the number.</p>
</blockquote>

<h3 id="target">Target</h3>

<table>
  <thead>
    <tr>
      <th> </th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Repo</td>
      <td><code>bottlepy/bottle</code> @ tag <code>0.12.18</code>, commit <code>1d531f7</code></td>
    </tr>
    <tr>
      <td>Runtime</td>
      <td>CPython 3.12.14</td>
    </tr>
    <tr>
      <td>Provenance</td>
      <td>Released third-party code. Nothing authored by me. Old code on a new interpreter is the real “we upgraded and CI went red” scenario.</td>
    </tr>
    <tr>
      <td>Disclosure</td>
      <td>I chose the checkout point. I did not choose the failures.</td>
    </tr>
  </tbody>
</table>

<p>Verified pristine baseline: <strong>22 collection errors, 0 tests execute.</strong></p>

<p>I probed a throwaway copy to confirm the depth, then deleted it so no fix could
leak into the run. Three distinct layers, not one edit cascading:</p>

<ol>
  <li><code>inspect.getargspec</code>, removed in 3.11 → 21 modules fail to import</li>
  <li><code>imp</code>, removed in 3.12 → <code>test/test_importhook.py</code> fails to import</li>
  <li>Underneath: <strong>23 real failures across 7 modules</strong> — <code>test_stpl</code> (7),
<code>test_sendfile</code> (5), <code>test_environ</code> (4), <code>test_wsgi</code> (2),
<code>test_outputfilter</code> (2), <code>test_config</code> (2), <code>test_plugins</code> (1) — with
diverse causes (AttributeError, KeyError, AssertionError, TemplateError,
FileNotFoundError)</li>
</ol>

<p>Full green requires real work.</p>

<h3 id="arms">Arms</h3>

<p>Identical goal, identical repo copy, identical model.</p>

<ul>
  <li><strong>Arm A</strong> — the harness, unattended.</li>
  <li><strong>Arm B</strong> — raw single-shot call, same prompt.</li>
  <li><strong>Model</strong> — <code>claude-haiku-4-5</code> for both. Deliberately the <em>weaker</em> model: if
the harness substitutes for model strength, that is the sharpest possible
evidence for “the harness is the asset.”</li>
</ul>

<h3 id="grading-independent-of-the-harness">Grading, independent of the harness</h3>

<p>The harness’s own success report is <strong>not</strong> evidence. Grading was:</p>

<ol>
  <li>Run <code>pytest</code> in the resulting workspace with the target venv directly.</li>
  <li>Count passed / failed / errors.</li>
  <li><code>git diff --stat</code> on the workspace.</li>
  <li><strong>Cheating check:</strong> assert <code>test/</code> is unmodified. Any change there is an
automatic fail regardless of the suite result.</li>
</ol>

<h3 id="decision-rule">Decision rule</h3>

<p><code>lift = fixed_A - fixed_B</code>.</p>

<ul>
  <li><strong>STOP — thesis dead:</strong> <code>lift &lt;= 0</code>.</li>
  <li><strong>STOP — cannot do the work:</strong> <code>fixed_A == 0</code>.</li>
  <li><strong>STOP — integrity failure:</strong> any file under <code>test/</code> modified in Arm A.</li>
  <li><strong>CONTINUE:</strong> <code>lift &gt; 0</code> <strong>and</strong> <code>fixed_A &gt;= 10</code> <strong>and</strong> <code>test/</code> untouched.</li>
</ul>

<h3 id="power-caveat-also-written-in-advance">Power caveat, also written in advance</h3>

<p>N=1 repository. <strong>This can decisively show failure. It cannot show success.</strong> A
positive result buys a larger run, never a pitch.</p>

<hr />

<h2 id="amendment-disclosed">Amendment, disclosed</h2>

<p><strong>Arm B had already run when I wrote this amendment. Arm A had not run at all.</strong>
I am recording it rather than silently editing the section above, which is what
the “add a dated note” clause was for.</p>

<p><strong>The artifact:</strong> a single collection error aborts the entire pytest run. Arm B
repaired the <code>getargspec</code> breakage — unblocking 287 tests — but left the <code>imp</code>
breakage, so the suite still aborted and the pre-registered metric “tests made
to pass” read <strong>0</strong>. The same run graded with <code>--continue-on-collection-errors</code>
reads <strong>287 passed, 23 failed, 1 error</strong>.</p>

<p>A metric that reports 287 as 0 is not measuring capability. Worse, it is
near-binary: any arm that fails to fix <em>every</em> import error scores zero, so both
arms could score 0 and trigger “thesis dead” for a pytest behaviour rather than
a harness property.</p>

<p><strong>Grader change:</strong> <code>--continue-on-collection-errors</code>, applied identically to
both arms.</p>

<p><strong>Metric change:</strong> “tests made to pass” is also a bad scale metric in the
opposite direction — from a baseline where nothing collects, one import fix
scores 287. Three numbers reported separately instead:</p>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>unblocked</code></td>
      <td>tests that now execute at all — dominated by the 2 import fixes, low information</td>
    </tr>
    <tr>
      <td><code>real_fixed</code></td>
      <td>of the 23 genuine failures underneath, how many now pass</td>
    </tr>
    <tr>
      <td><code>green</code></td>
      <td>suite fully passing: 0 failed, 0 errors</td>
    </tr>
  </tbody>
</table>

<p>The scale bar is assessed against <code>real_fixed</code>, not <code>unblocked</code>.</p>

<hr />

<h2 id="results">Results</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Arm A (harness)</th>
      <th>Arm B (one call)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>unblocked</code></td>
      <td><strong>0</strong></td>
      <td><strong>287</strong></td>
    </tr>
    <tr>
      <td><code>real_fixed</code> (of 23)</td>
      <td><strong>0</strong></td>
      <td><strong>0</strong></td>
    </tr>
    <tr>
      <td><code>green</code></td>
      <td>no</td>
      <td>no</td>
    </tr>
    <tr>
      <td>suite at end</td>
      <td>22 errors, 0 tests run</td>
      <td>287 passed, 23 failed, 1 error</td>
    </tr>
    <tr>
      <td><code>test/</code> modified</td>
      <td>none</td>
      <td>none</td>
    </tr>
    <tr>
      <td>files changed</td>
      <td>none (only its own state dir)</td>
      <td><code>bottle.py</code></td>
    </tr>
    <tr>
      <td>wall time</td>
      <td>248.8 s</td>
      <td>4.5 s</td>
    </tr>
    <tr>
      <td>cost</td>
      <td>$0.527</td>
      <td>$0.046</td>
    </tr>
  </tbody>
</table>

<p><code>lift = 0</code>. Two stop conditions fired independently. Integrity was clean in both
arms — no test file was touched.</p>

<p>Single-shot repaired the one breakage visible at the top of the traceback and
nothing beneath it. That is the expected shape for a single call, and it is a
fair baseline. It is also 287 more tests than the harness managed.</p>

<h2 id="why-it-failed-the-plan-was-never-grounded-in-the-failure-output">Why it failed: the plan was never grounded in the failure output</h2>

<p>The harness planned six tasks:</p>

<pre><code>fix_collections_imports        fix_itertools_filter
fix_functools_wraps            fix_pkg_resources_or_importlib
fix_httpserver_imports         fix_string_types
</code></pre>

<p>The two actual causes are <code>inspect.getargspec</code> and <code>imp</code>. <strong>Not one of the six
targets either.</strong> The plan was generated from the <em>wording of the goal</em>, not
from running the suite. Every task was a guess about what “fails on Python 3.12”
usually means. A perfectly-executing worker would still have fixed the wrong
things.</p>

<p>This is architectural, not a bad-luck run. The system had a component that
could enumerate the real failures. The worker never consulted it. The one
command that did consult it exited early, because its parser only matched
<code>FAILED</code> lines and all 22 failures were collection <code>ERROR</code>s.</p>

<blockquote>
  <p><strong>The path that knows the real failures cannot parse them; the path that runs
does not ask.</strong></p>
</blockquote>

<p>Secondary: 5 of 6 tasks ended <code>Exceeded max turns (12) without finish</code>. The
worker made real edits and reasoned correctly about them, but never converged,
so every edit was rolled back.</p>

<h2 id="the-portability-finding">The portability finding</h2>

<p>The thesis was <em>models are replaceable</em>. Moving one provider took five code
fixes before a single task could execute:</p>

<ol>
  <li><code>rich</code> missing from <code>requirements.txt</code> — the worker crashed on import</li>
  <li>three components hardcoded the previous provider’s env var and sent that key to the new endpoint</li>
  <li><code>response_format={"type": "json_object"}</code> rejected — needs <code>json_schema</code></li>
  <li>planner <code>max_tokens=4096</code> truncated the execution graph into unparseable JSON</li>
  <li>the ReAct loop treats any non-<code>{</code> reply as <code>finish</code>, so it terminated on turn 1 having taken no action</li>
</ol>

<p>Number 5 matters most. That fallback is only safe while the <em>provider</em> forces
JSON via <code>response_format</code>. The loop’s control flow depended on a provider-side
guarantee. On a provider without it, the agent stopped after one turn on every
task.</p>

<p>The harness was not provider-agnostic. It was load-bearing on one vendor’s API
surface — which is the precise opposite of the claim it was built on.</p>

<h2 id="the-dashboard-was-lying">The dashboard was lying</h2>

<p>On an earlier run that failed outright — planning error, zero tasks, $0 spent —
the summary printed:</p>

<pre><code>Manual dev time:  ₹1,680  (2 billable hours)
You saved:        ₹1,680.00  (100%)
</code></pre>

<p>(₹1,680 is roughly US$20.)</p>

<p>It also reported <code>Model: DeepSeek V4</code> when configured for Haiku, and
<code>Routing providers: 5</code> when the configured ladder held two, both unreachable.</p>

<p>A savings figure asserted on a run that did nothing is its own category of bug:
the surface claiming value the measurement does not support. Nothing in the
system was checking that the dashboard’s claims were downstream of anything real.</p>

<h2 id="what-this-does-not-show">What this does not show</h2>

<ul>
  <li><strong>N=1.</strong> As pre-registered, this shows failure, not the absence of capability.</li>
  <li><strong>Haiku is a weak model.</strong> A stronger one might do better — but Arm B used the
same model and still beat the harness, so this does not rescue the thesis.</li>
  <li><strong>Five fixes preceded the run.</strong> Some fragility is provider-swap artifact. The
primary cause — planning without reading the failures — is not.</li>
</ul>

<h2 id="honest-note-on-process">Honest note on process</h2>

<p>Six Arm A runs were needed. Five failed on defects listed above; one failed
because of a misconfiguration of mine, which silently routed to a cheaper
planner and would have been reported as a harness failure had I not caught it.</p>

<p>Each fix was made <strong>before looking at any Arm A number</strong>, and tuning stopped at
the first complete run rather than continuing until the result improved. That
rule is the only thing separating six debugging runs from six attempts to get a
number I liked.</p>

<hr />

<h2 id="what-id-tell-you-to-take-from-this">What I’d tell you to take from this</h2>

<ol>
  <li><strong>A benchmark you authored measures your ability to author benchmarks.</strong> Mine
said 35/35 in June. It was a repo I broke myself, in ways I had built for.</li>
  <li><strong>Grade from outside the system.</strong> The harness reported success on runs that
changed no files. If the thing being measured also reports the measurement,
you have no measurement.</li>
  <li><strong>Pre-register the stop condition, then honour it.</strong> The value of writing
<code>lift &lt;= 0 → STOP</code> in advance is entirely in what it costs you later.</li>
  <li><strong>Check what your planner reads.</strong> Mine planned from the task description
instead of the error output, and no amount of worker quality could recover
that.</li>
  <li><strong>“Provider-agnostic” is a claim, and it is testable in an afternoon.</strong> Swap
the provider. Mine failed five ways before it ran once.</li>
</ol>

<p>The measurement cost me a day. The thing it killed had four months in it. That
trade was worth making, and the only reason it was available to make is that
the numbers were written down before the run.</p>]]></content><author><name>Saurabh Patel</name></author><category term="Other" /><summary type="html"><![CDATA[For four months (2026-04-30 to 2026-08-31, 75 commits) I built an autonomous coding agent — a harness around a language model with a planner, a worker loop, a verification gate, a model router and a cost dashboard. The thesis was written down in the repo:]]></summary></entry><entry><title type="html">No detectable forward-return edge in CFTC Commitments-of-Traders positioning anomalies</title><link href="https://savabs.github.io/2026/08/29/a-null-result-with-its-power-calculation.html" rel="alternate" type="text/html" title="No detectable forward-return edge in CFTC Commitments-of-Traders positioning anomalies" /><published>2026-08-29T00:00:00+00:00</published><updated>2026-08-29T00:00:00+00:00</updated><id>https://savabs.github.io/2026/08/29/a-null-result-with-its-power-calculation</id><content type="html" xml:base="https://savabs.github.io/2026/08/29/a-null-result-with-its-power-calculation.html"><![CDATA[<p><strong>A null result, with the power calculation that tells you how little it proves.</strong></p>

<hr />

<h2 id="summary">Summary</h2>

<p>We ran a forward-return event study on extreme weekly positioning readings in the
CFTC Commitments of Traders (COT) disaggregated report, across 19 futures
contracts joined to tradeable price series, 2023-04 to 2026-06.</p>

<p><strong>Result: 0 of 51 testable hypotheses survive Benjamini-Hochberg correction at
alpha = 0.05.</strong> The best uncorrected p-value is 0.002; its BH-adjusted value is
0.102. Under a week-clustered resample that respects the calendar correlation
between contracts, the best uncorrected p rises to 0.010 and the best adjusted p
to 0.294. Nothing is significant under either.</p>

<p><strong>What this does not show.</strong> Our specification pools <em>two-sided</em> |z| on
<em>level extremity</em> of <em>managed-money</em> positions at a <em>20-day</em> horizon. The
published literature that finds an effect finds it in <em>signed changes</em> in
<em>commercial</em> positions at a <em>one-week</em> horizon. Those are different hypotheses,
and ours cancels a signed effect by construction. Our test also has roughly
<strong>10% power</strong> against a plausibly-sized true effect. The honest claim is:</p>

<blockquote>
  <p><em>No detectable effect, at approximately 10% power, on a specification the
literature does not endorse.</em></p>
</blockquote>

<p>It is <strong>not</strong> “COT positioning does not predict returns.” Overclaiming a null is
the same error as overclaiming an edge, and this one is underpowered enough that
the null was close to guaranteed before we started. Section 7 is the part that
matters most; read it before you cite this.</p>

<p>Everything below is reproducible from a single script against free, keyless,
public data. The reproduction command is in Section 9.</p>

<hr />

<h2 id="1-question">1. Question</h2>

<p>TirraMind shipped a paid product that flagged <code>|z| &gt;= 2.0</code> anomalies on CFTC
futures-positioning fields. The question we needed answered before continuing to
sell it was the plain one:</p>

<blockquote>
  <p>Does an extreme weekly reading in a CFTC positioning field precede an abnormal
forward return in the underlying futures contract, relative to what that
contract was doing anyway?</p>
</blockquote>

<p>The answer determined whether the product had a defensible claim. It did not, and
the product was discontinued. This document is the study, not the postmortem.</p>

<hr />

<h2 id="2-the-four-design-choices-that-make-this-checkable">2. The four design choices that make this checkable</h2>

<p>Most of the value in a COT event study is in the parts that are easy to get
wrong in the direction of a false positive. We name ours up front so a reviewer
can attack them directly.</p>

<h3 id="21-publication-lag-is-honoured-this-is-the-big-one">2.1 Publication lag is honoured (this is the big one)</h3>

<p>Every COT observation carries a <strong>Tuesday “as-of” date</strong>. The report is not
public until <strong>Friday, approximately 3:30 PM ET</strong> — three days later.</p>

<p>Measuring forward returns from the as-of date grants roughly three days of
lookahead. It is, in our view, the single most likely way to manufacture a false
positive in this literature, because commodity futures autocorrelate at that
horizon and the as-of date is the timestamp the data ships with.</p>

<p>The study computes a publication timestamp <code>pub_ts = observed_at + 3 days</code> and
takes the entry price at the <strong>first instrument close at or after <code>pub_ts</code></strong>,
with a 7-day tolerance (beyond which the event is dropped, not silently shifted).
A verbatim sample from the script’s own leakage audit:</p>

<pre><code>002602 ZC=F  obs=2023-04-11  pub=2023-04-14  entry_close=2023-04-19  (gap from obs: 8.0d)
</code></pre>

<p>Every entry used is strictly after the data existed publicly. Section 7.4
quantifies the one place where this guard is imperfect.</p>

<h3 id="22-the-null-is-the-unconditional-return-not-zero">2.2 The null is the unconditional return, not zero</h3>

<p>The baseline for each (field, horizon) cell is the mean forward log-return over
<strong>every</strong> week in which a z-score was computable at all — 1,578 to 1,581
observations per horizon, pooled across all 19 tickers — regardless of whether
<code>|z|</code> crossed any threshold.</p>

<p>This matters because 2023-2026 was a positive-drift period for this basket
(gold, silver and copper all trended up). Against a zero null, the drift alone
would print an “edge” of about +0.41% at 20 days. Our reported edge is excess
over the drift.</p>

<h3 id="23-benjamini-hochberg-across-all-51-hypotheses">2.3 Benjamini-Hochberg across all 51 hypotheses</h3>

<p>We tested 9 fields x 3 horizons x 2 thresholds = 54 cells, of which 51 have
<code>n_events &gt;= 3</code> and are testable. All 51 p-values go into one BH family. We do
not report a “best” cell as if it were the only test we ran, because it was not.
See Harvey, Liu and Zhu (2016) for why this is not optional in this field.</p>

<p>The correction is arithmetic anyone can check:</p>

<pre><code>smallest p = 0.002, rank 1 of m = 51  -&gt;  0.002 x 51 / 1 = 0.102
</code></pre>

<h3 id="24-pseudo-replication-is-measured-not-waved-at">2.4 Pseudo-replication is measured, not waved at</h3>

<p><code>n_events</code> is not <code>n_independent_observations</code>. When five metals contracts all
print <code>|z| &gt;= 2</code> on the same Tuesday because of one macro or margin event, that
is closer to one observation than to five.</p>

<p>For the best cell (<code>mm_net_pct_oi</code>, <code>|z| &gt;= 2</code>, 20 days), the 123 events fall on
only <strong>67 distinct as-of weeks</strong> — mean 1.84 contracts per firing week, with four
weeks at the maximum of 5. Section 6 re-runs every cell resampling <em>weeks</em>
instead of events, which is the correct unit.</p>

<hr />

<h2 id="3-data">3. Data</h2>

<h3 id="31-sources--free-keyless-public-domain">3.1 Sources — free, keyless, public domain</h3>

<table>
  <thead>
    <tr>
      <th>Layer</th>
      <th>Source</th>
      <th>Access</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Positioning</td>
      <td>CFTC Disaggregated Futures-Only COT, weekly flat file <code>https://www.cftc.gov/dea/newcot/f_disagg.txt</code> and yearly history ZIPs <code>https://www.cftc.gov/files/dea/history/fut_disagg_txt_{year}.zip</code></td>
      <td>Public domain, no key, no token</td>
    </tr>
    <tr>
      <td>Prices</td>
      <td>Daily closes for the 19 linked front-month futures tickers</td>
      <td>Free public endpoints</td>
    </tr>
  </tbody>
</table>

<p>No exchange redistribution licence is involved. No credential of any kind is
required to obtain the positioning data, and none appears anywhere in the code.</p>

<h3 id="32-panel">3.2 Panel</h3>

<p>19 contract-to-ticker links, all of which pass the join test (&gt;= 20 CFTC weekly
points, &gt;= 40 instrument daily closes, overlapping date ranges). All 19 are used
unconditionally — none was selected or dropped on the basis of how it performed.</p>

<table>
  <thead>
    <tr>
      <th>CFTC code</th>
      <th>Ticker</th>
      <th>CFTC weekly points</th>
      <th>Instrument daily closes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>002602</td>
      <td>ZC=F</td>
      <td>160</td>
      <td>753</td>
    </tr>
    <tr>
      <td>023651</td>
      <td>NG=F</td>
      <td>160</td>
      <td>791</td>
    </tr>
    <tr>
      <td>005602</td>
      <td>ZS=F</td>
      <td>160</td>
      <td>753</td>
    </tr>
    <tr>
      <td>080732</td>
      <td>SB=F</td>
      <td>160</td>
      <td>755</td>
    </tr>
    <tr>
      <td>001602</td>
      <td>ZW=F</td>
      <td>88</td>
      <td>753</td>
    </tr>
    <tr>
      <td>088691</td>
      <td>GC=F</td>
      <td>159</td>
      <td>754</td>
    </tr>
    <tr>
      <td>111659</td>
      <td>RB=F</td>
      <td>30</td>
      <td>755</td>
    </tr>
    <tr>
      <td>057642</td>
      <td>LE=F</td>
      <td>111</td>
      <td>753</td>
    </tr>
    <tr>
      <td>073732</td>
      <td>CC=F</td>
      <td>109</td>
      <td>754</td>
    </tr>
    <tr>
      <td>054642</td>
      <td>HE=F</td>
      <td>159</td>
      <td>753</td>
    </tr>
    <tr>
      <td>083731</td>
      <td>KC=F</td>
      <td>159</td>
      <td>755</td>
    </tr>
    <tr>
      <td>033661</td>
      <td>CT=F</td>
      <td>159</td>
      <td>755</td>
    </tr>
    <tr>
      <td>06765T</td>
      <td>BZ=F</td>
      <td>159</td>
      <td>791</td>
    </tr>
    <tr>
      <td>085692</td>
      <td>HG=F</td>
      <td>159</td>
      <td>755</td>
    </tr>
    <tr>
      <td>06765A</td>
      <td>CL=F</td>
      <td>159</td>
      <td>790</td>
    </tr>
    <tr>
      <td>084691</td>
      <td>SI=F</td>
      <td>159</td>
      <td>754</td>
    </tr>
    <tr>
      <td>076651</td>
      <td>PL=F</td>
      <td>159</td>
      <td>754</td>
    </tr>
    <tr>
      <td>040701</td>
      <td>OJ=F</td>
      <td>159</td>
      <td>755</td>
    </tr>
    <tr>
      <td>075651</td>
      <td>PA=F</td>
      <td>159</td>
      <td>753</td>
    </tr>
  </tbody>
</table>

<p>Coverage: CFTC as-of dates for the linked contracts run <strong>2022-01-04 to
2026-08-18</strong> (160 distinct weeks). Daily closes run <strong>2023-04-18 to 2026-06-09</strong>.</p>

<h3 id="33-fields-tested">3.3 Fields tested</h3>

<p>Nine fields, derived from the standard disaggregated columns so that anyone can
rebuild them from the raw CFTC file:</p>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Definition from raw COT columns</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>mm_net</code></td>
      <td><code>M_Money_Positions_Long_All</code> - <code>M_Money_Positions_Short_All</code></td>
    </tr>
    <tr>
      <td><code>pm_net</code></td>
      <td><code>Prod_Merc_Positions_Long_All</code> - <code>Prod_Merc_Positions_Short_All</code></td>
    </tr>
    <tr>
      <td><code>swap_net</code></td>
      <td><code>Swap_Positions_Long_All</code> - <code>Swap__Positions_Short_All</code></td>
    </tr>
    <tr>
      <td><code>mm_net_pct_oi</code></td>
      <td><code>mm_net</code> / <code>Open_Interest_All</code> x 100, rounded to 2 decimal places</td>
    </tr>
    <tr>
      <td><code>mm_weekly_flow</code></td>
      <td><code>Change_in_M_Money_Long_All</code> - <code>Change_in_M_Money_Short_All</code></td>
    </tr>
    <tr>
      <td><code>open_interest</code></td>
      <td><code>Open_Interest_All</code></td>
    </tr>
    <tr>
      <td><code>oi_change</code></td>
      <td><code>Change_in_Open_Interest_All</code></td>
    </tr>
    <tr>
      <td><code>conc_top4_long</code></td>
      <td><code>Conc_Net_LE_4_TDR_Long_All</code></td>
    </tr>
    <tr>
      <td><code>conc_top4_short</code></td>
      <td><code>Conc_Net_LE_4_TDR_Short_All</code></td>
    </tr>
  </tbody>
</table>

<h3 id="34-attrition--one-in-three-eligible-events-cannot-be-graded">3.4 Attrition — one in three eligible events cannot be graded</h3>

<p>Of the <strong>21,294</strong> (contract, field, week) combinations where the CFTC series has
enough history to compute a causal z-score, <strong>7,029 (33.0%)</strong> are dropped because
no instrument close exists within 7 days of the publication timestamp.</p>

<p>Two causes, both pure data coverage, neither dependent on outcomes:</p>

<ol>
  <li><strong>Head gap.</strong> CFTC positioning history for these contracts starts 2022-01-04;
daily closes start 2023-04-18. Roughly 15 months of positioning anomalies have
no tradeable price series to score against.</li>
  <li><strong>Tail gap.</strong> Daily closes stop between 2026-04-17 and 2026-06-09 depending on
ticker, while COT continues to 2026-08-18 — a further 10 to 18 weeks per
contract with no forward label.</li>
</ol>

<p>A further 36 (1-day), 63 (5-day) and 63 (20-day) events are dropped for lacking a
close far enough ahead. State this plainly: <strong>one in three otherwise-eligible
anomalies in the product’s own history could not be graded at all.</strong></p>

<hr />

<h2 id="4-method">4. Method</h2>

<ol>
  <li><strong>Deduplicate.</strong> Observations are deduplicated on <code>(entity_id, observed_at)</code>,
keeping the highest <code>rowid</code>. Duplicate ingestion was a live failure mode in
this codebase and inflates apparent event counts if left alone.</li>
  <li><strong>Causal z-score.</strong> For each (contract, field), an expanding-window z-score
using only prior history:
<code>z_t = (x_t - mean(x_{1..t-1})) / std(x_{1..t-1})</code>, requiring at least 20
prior points and a non-degenerate history standard deviation. This is a direct
reuse of the production scoring function, not a reimplementation, so the study
grades exactly what the product shipped.</li>
  <li><strong>Entry.</strong> <code>pub_ts = observed_at + 3 days</code>; entry at the first close at or
after <code>pub_ts</code>, within a 7-day tolerance.</li>
  <li><strong>Forward return.</strong> <code>log(close[i + h] / close[i])</code> for h in {1, 5, 20} trading
days, where <code>i</code> is the entry index.</li>
  <li><strong>Event set.</strong> All entries with <code>|z| &gt;= threshold</code>, threshold in {2.0, 3.0}.</li>
  <li><strong>Population (null).</strong> All entries with a computable z, unconditionally.</li>
  <li><strong>Test.</strong> Percentile bootstrap on the event mean (B = 2,000), two-sided
against the population mean:
<code>p = 2 x min(P(mean* &lt;= base), P(mean* &gt;= base))</code>. Confidence intervals use a
circular block bootstrap (block length T^(1/3)); see Lahiri (1999).</li>
  <li><strong>Correction.</strong> Benjamini-Hochberg (1995) FDR at alpha = 0.05 across all 51
testable cells.</li>
</ol>

<p>Environment: Python 3.12.14, numpy 2.5.2, scipy 1.18.1, statsmodels 0.14.6.
Repository commit <code>2597e8b</code>; <code>scripts/cftc_event_study.py</code>,
<code>agent/quant/scoring.py</code> and <code>agent/tools/cftc.py</code> are unmodified at that commit.</p>

<hr />

<h2 id="5-results">5. Results</h2>

<p>All 54 cells, sorted by uncorrected p-value. <code>mean_ev</code> and <code>mean_base</code> are mean
forward log-returns in percent; <code>edge</code> is their difference; <code>hit</code> rates are the
fraction of positive returns.</p>

<table>
  <thead>
    <tr>
      <th>field</th>
      <th>|z|&gt;=</th>
      <th>h(d)</th>
      <th>n_ev</th>
      <th>n_pop</th>
      <th>mean_ev%</th>
      <th>mean_base%</th>
      <th>edge%</th>
      <th>hit_ev</th>
      <th>hit_base</th>
      <th>p</th>
      <th>p_BH</th>
      <th>sig</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>mm_net_pct_oi</td>
      <td>2.0</td>
      <td>20</td>
      <td>123</td>
      <td>1578</td>
      <td>2.584</td>
      <td>0.406</td>
      <td>2.178</td>
      <td>65.04%</td>
      <td>50.44%</td>
      <td>0.002</td>
      <td>0.102</td>
      <td>No</td>
    </tr>
    <tr>
      <td>swap_net</td>
      <td>3.0</td>
      <td>20</td>
      <td>9</td>
      <td>1578</td>
      <td>6.630</td>
      <td>0.406</td>
      <td>6.225</td>
      <td>88.89%</td>
      <td>50.44%</td>
      <td>0.010</td>
      <td>0.240</td>
      <td>No</td>
    </tr>
    <tr>
      <td>mm_net</td>
      <td>2.0</td>
      <td>20</td>
      <td>190</td>
      <td>1578</td>
      <td>1.809</td>
      <td>0.406</td>
      <td>1.403</td>
      <td>57.37%</td>
      <td>50.44%</td>
      <td>0.016</td>
      <td>0.240</td>
      <td>No</td>
    </tr>
    <tr>
      <td>conc_top4_short</td>
      <td>2.0</td>
      <td>20</td>
      <td>174</td>
      <td>1578</td>
      <td>1.965</td>
      <td>0.406</td>
      <td>1.559</td>
      <td>58.05%</td>
      <td>50.44%</td>
      <td>0.019</td>
      <td>0.240</td>
      <td>No</td>
    </tr>
    <tr>
      <td>open_interest</td>
      <td>3.0</td>
      <td>1</td>
      <td>41</td>
      <td>1581</td>
      <td>0.680</td>
      <td>0.002</td>
      <td>0.678</td>
      <td>70.73%</td>
      <td>51.36%</td>
      <td>0.024</td>
      <td>0.240</td>
      <td>No</td>
    </tr>
    <tr>
      <td>swap_net</td>
      <td>2.0</td>
      <td>20</td>
      <td>148</td>
      <td>1578</td>
      <td>2.024</td>
      <td>0.406</td>
      <td>1.619</td>
      <td>58.78%</td>
      <td>50.44%</td>
      <td>0.029</td>
      <td>0.240</td>
      <td>No</td>
    </tr>
    <tr>
      <td>oi_change</td>
      <td>2.0</td>
      <td>20</td>
      <td>136</td>
      <td>1578</td>
      <td>2.010</td>
      <td>0.406</td>
      <td>1.604</td>
      <td>56.62%</td>
      <td>50.44%</td>
      <td>0.033</td>
      <td>0.240</td>
      <td>No</td>
    </tr>
    <tr>
      <td>oi_change</td>
      <td>3.0</td>
      <td>1</td>
      <td>30</td>
      <td>1581</td>
      <td>0.935</td>
      <td>0.002</td>
      <td>0.933</td>
      <td>66.67%</td>
      <td>51.36%</td>
      <td>0.040</td>
      <td>0.244</td>
      <td>No</td>
    </tr>
    <tr>
      <td>swap_net</td>
      <td>2.0</td>
      <td>1</td>
      <td>148</td>
      <td>1581</td>
      <td>0.347</td>
      <td>0.002</td>
      <td>0.344</td>
      <td>53.38%</td>
      <td>51.36%</td>
      <td>0.043</td>
      <td>0.244</td>
      <td>No</td>
    </tr>
    <tr>
      <td>conc_top4_short</td>
      <td>3.0</td>
      <td>1</td>
      <td>19</td>
      <td>1581</td>
      <td>0.674</td>
      <td>0.002</td>
      <td>0.672</td>
      <td>73.68%</td>
      <td>51.36%</td>
      <td>0.075</td>
      <td>0.366</td>
      <td>No</td>
    </tr>
    <tr>
      <td>open_interest</td>
      <td>3.0</td>
      <td>20</td>
      <td>39</td>
      <td>1578</td>
      <td>2.027</td>
      <td>0.406</td>
      <td>1.622</td>
      <td>61.54%</td>
      <td>50.44%</td>
      <td>0.079</td>
      <td>0.366</td>
      <td>No</td>
    </tr>
    <tr>
      <td>swap_net</td>
      <td>3.0</td>
      <td>1</td>
      <td>9</td>
      <td>1581</td>
      <td>1.353</td>
      <td>0.002</td>
      <td>1.351</td>
      <td>55.56%</td>
      <td>51.36%</td>
      <td>0.094</td>
      <td>0.388</td>
      <td>No</td>
    </tr>
    <tr>
      <td>swap_net</td>
      <td>3.0</td>
      <td>5</td>
      <td>9</td>
      <td>1578</td>
      <td>2.611</td>
      <td>0.094</td>
      <td>2.517</td>
      <td>66.67%</td>
      <td>51.33%</td>
      <td>0.099</td>
      <td>0.388</td>
      <td>No</td>
    </tr>
    <tr>
      <td>conc_top4_long</td>
      <td>2.0</td>
      <td>5</td>
      <td>135</td>
      <td>1578</td>
      <td>0.645</td>
      <td>0.094</td>
      <td>0.551</td>
      <td>58.52%</td>
      <td>51.33%</td>
      <td>0.171</td>
      <td>0.582</td>
      <td>No</td>
    </tr>
    <tr>
      <td>pm_net</td>
      <td>2.0</td>
      <td>20</td>
      <td>142</td>
      <td>1578</td>
      <td>1.755</td>
      <td>0.406</td>
      <td>1.350</td>
      <td>57.04%</td>
      <td>50.44%</td>
      <td>0.179</td>
      <td>0.582</td>
      <td>No</td>
    </tr>
    <tr>
      <td>conc_top4_short</td>
      <td>3.0</td>
      <td>5</td>
      <td>19</td>
      <td>1578</td>
      <td>1.321</td>
      <td>0.094</td>
      <td>1.227</td>
      <td>63.16%</td>
      <td>51.33%</td>
      <td>0.187</td>
      <td>0.582</td>
      <td>No</td>
    </tr>
    <tr>
      <td>mm_net</td>
      <td>2.0</td>
      <td>5</td>
      <td>190</td>
      <td>1578</td>
      <td>0.466</td>
      <td>0.094</td>
      <td>0.372</td>
      <td>54.74%</td>
      <td>51.33%</td>
      <td>0.204</td>
      <td>0.582</td>
      <td>No</td>
    </tr>
    <tr>
      <td>conc_top4_short</td>
      <td>3.0</td>
      <td>20</td>
      <td>19</td>
      <td>1578</td>
      <td>-1.900</td>
      <td>0.406</td>
      <td>-2.306</td>
      <td>42.11%</td>
      <td>50.44%</td>
      <td>0.214</td>
      <td>0.582</td>
      <td>No</td>
    </tr>
    <tr>
      <td>mm_net</td>
      <td>3.0</td>
      <td>1</td>
      <td>24</td>
      <td>1581</td>
      <td>0.358</td>
      <td>0.002</td>
      <td>0.355</td>
      <td>58.33%</td>
      <td>51.36%</td>
      <td>0.217</td>
      <td>0.582</td>
      <td>No</td>
    </tr>
    <tr>
      <td>conc_top4_long</td>
      <td>2.0</td>
      <td>1</td>
      <td>136</td>
      <td>1581</td>
      <td>0.231</td>
      <td>0.002</td>
      <td>0.229</td>
      <td>59.56%</td>
      <td>51.36%</td>
      <td>0.234</td>
      <td>0.597</td>
      <td>No</td>
    </tr>
    <tr>
      <td>swap_net</td>
      <td>2.0</td>
      <td>5</td>
      <td>148</td>
      <td>1578</td>
      <td>0.534</td>
      <td>0.094</td>
      <td>0.440</td>
      <td>52.03%</td>
      <td>51.33%</td>
      <td>0.254</td>
      <td>0.617</td>
      <td>No</td>
    </tr>
    <tr>
      <td>conc_top4_short</td>
      <td>2.0</td>
      <td>1</td>
      <td>174</td>
      <td>1581</td>
      <td>0.202</td>
      <td>0.002</td>
      <td>0.200</td>
      <td>58.05%</td>
      <td>51.36%</td>
      <td>0.312</td>
      <td>0.659</td>
      <td>No</td>
    </tr>
    <tr>
      <td>open_interest</td>
      <td>3.0</td>
      <td>5</td>
      <td>39</td>
      <td>1578</td>
      <td>0.671</td>
      <td>0.094</td>
      <td>0.577</td>
      <td>66.67%</td>
      <td>51.33%</td>
      <td>0.318</td>
      <td>0.659</td>
      <td>No</td>
    </tr>
    <tr>
      <td>mm_weekly_flow</td>
      <td>3.0</td>
      <td>5</td>
      <td>27</td>
      <td>1578</td>
      <td>-0.914</td>
      <td>0.094</td>
      <td>-1.008</td>
      <td>51.85%</td>
      <td>51.33%</td>
      <td>0.332</td>
      <td>0.659</td>
      <td>No</td>
    </tr>
    <tr>
      <td>oi_change</td>
      <td>3.0</td>
      <td>20</td>
      <td>30</td>
      <td>1578</td>
      <td>2.109</td>
      <td>0.406</td>
      <td>1.703</td>
      <td>60.00%</td>
      <td>50.44%</td>
      <td>0.341</td>
      <td>0.659</td>
      <td>No</td>
    </tr>
    <tr>
      <td>conc_top4_long</td>
      <td>2.0</td>
      <td>20</td>
      <td>135</td>
      <td>1578</td>
      <td>-0.182</td>
      <td>0.406</td>
      <td>-0.588</td>
      <td>48.15%</td>
      <td>50.44%</td>
      <td>0.344</td>
      <td>0.659</td>
      <td>No</td>
    </tr>
    <tr>
      <td>oi_change</td>
      <td>2.0</td>
      <td>1</td>
      <td>138</td>
      <td>1581</td>
      <td>0.205</td>
      <td>0.002</td>
      <td>0.202</td>
      <td>58.70%</td>
      <td>51.36%</td>
      <td>0.349</td>
      <td>0.659</td>
      <td>No</td>
    </tr>
    <tr>
      <td>mm_net</td>
      <td>2.0</td>
      <td>1</td>
      <td>190</td>
      <td>1581</td>
      <td>0.145</td>
      <td>0.002</td>
      <td>0.143</td>
      <td>53.16%</td>
      <td>51.36%</td>
      <td>0.374</td>
      <td>0.661</td>
      <td>No</td>
    </tr>
    <tr>
      <td>open_interest</td>
      <td>2.0</td>
      <td>1</td>
      <td>218</td>
      <td>1581</td>
      <td>0.179</td>
      <td>0.002</td>
      <td>0.176</td>
      <td>60.09%</td>
      <td>51.36%</td>
      <td>0.376</td>
      <td>0.661</td>
      <td>No</td>
    </tr>
    <tr>
      <td>mm_weekly_flow</td>
      <td>3.0</td>
      <td>20</td>
      <td>27</td>
      <td>1578</td>
      <td>-1.021</td>
      <td>0.406</td>
      <td>-1.426</td>
      <td>44.44%</td>
      <td>50.44%</td>
      <td>0.452</td>
      <td>0.765</td>
      <td>No</td>
    </tr>
    <tr>
      <td>conc_top4_long</td>
      <td>3.0</td>
      <td>20</td>
      <td>20</td>
      <td>1578</td>
      <td>-0.319</td>
      <td>0.406</td>
      <td>-0.725</td>
      <td>40.00%</td>
      <td>50.44%</td>
      <td>0.472</td>
      <td>0.765</td>
      <td>No</td>
    </tr>
    <tr>
      <td>mm_net_pct_oi</td>
      <td>3.0</td>
      <td>20</td>
      <td>25</td>
      <td>1578</td>
      <td>1.348</td>
      <td>0.406</td>
      <td>0.942</td>
      <td>52.00%</td>
      <td>50.44%</td>
      <td>0.480</td>
      <td>0.765</td>
      <td>No</td>
    </tr>
    <tr>
      <td>conc_top4_long</td>
      <td>3.0</td>
      <td>5</td>
      <td>20</td>
      <td>1578</td>
      <td>-0.444</td>
      <td>0.094</td>
      <td>-0.538</td>
      <td>55.00%</td>
      <td>51.33%</td>
      <td>0.509</td>
      <td>0.787</td>
      <td>No</td>
    </tr>
    <tr>
      <td>open_interest</td>
      <td>2.0</td>
      <td>20</td>
      <td>216</td>
      <td>1578</td>
      <td>0.787</td>
      <td>0.406</td>
      <td>0.381</td>
      <td>52.31%</td>
      <td>50.44%</td>
      <td>0.561</td>
      <td>0.798</td>
      <td>No</td>
    </tr>
    <tr>
      <td>mm_net_pct_oi</td>
      <td>3.0</td>
      <td>1</td>
      <td>25</td>
      <td>1581</td>
      <td>0.225</td>
      <td>0.002</td>
      <td>0.223</td>
      <td>60.00%</td>
      <td>51.36%</td>
      <td>0.573</td>
      <td>0.798</td>
      <td>No</td>
    </tr>
    <tr>
      <td>mm_net_pct_oi</td>
      <td>3.0</td>
      <td>5</td>
      <td>25</td>
      <td>1578</td>
      <td>0.638</td>
      <td>0.094</td>
      <td>0.544</td>
      <td>48.00%</td>
      <td>51.33%</td>
      <td>0.584</td>
      <td>0.798</td>
      <td>No</td>
    </tr>
    <tr>
      <td>oi_change</td>
      <td>2.0</td>
      <td>5</td>
      <td>136</td>
      <td>1578</td>
      <td>0.315</td>
      <td>0.094</td>
      <td>0.221</td>
      <td>57.35%</td>
      <td>51.33%</td>
      <td>0.597</td>
      <td>0.798</td>
      <td>No</td>
    </tr>
    <tr>
      <td>mm_net</td>
      <td>3.0</td>
      <td>5</td>
      <td>24</td>
      <td>1578</td>
      <td>0.506</td>
      <td>0.094</td>
      <td>0.412</td>
      <td>54.17%</td>
      <td>51.33%</td>
      <td>0.606</td>
      <td>0.798</td>
      <td>No</td>
    </tr>
    <tr>
      <td>mm_weekly_flow</td>
      <td>2.0</td>
      <td>20</td>
      <td>114</td>
      <td>1578</td>
      <td>-0.085</td>
      <td>0.406</td>
      <td>-0.491</td>
      <td>50.00%</td>
      <td>50.44%</td>
      <td>0.610</td>
      <td>0.798</td>
      <td>No</td>
    </tr>
    <tr>
      <td>oi_change</td>
      <td>3.0</td>
      <td>5</td>
      <td>30</td>
      <td>1578</td>
      <td>0.534</td>
      <td>0.094</td>
      <td>0.440</td>
      <td>60.00%</td>
      <td>51.33%</td>
      <td>0.631</td>
      <td>0.805</td>
      <td>No</td>
    </tr>
    <tr>
      <td>mm_weekly_flow</td>
      <td>2.0</td>
      <td>1</td>
      <td>114</td>
      <td>1581</td>
      <td>-0.107</td>
      <td>0.002</td>
      <td>-0.110</td>
      <td>50.88%</td>
      <td>51.36%</td>
      <td>0.678</td>
      <td>0.819</td>
      <td>No</td>
    </tr>
    <tr>
      <td>mm_net_pct_oi</td>
      <td>2.0</td>
      <td>5</td>
      <td>123</td>
      <td>1578</td>
      <td>0.229</td>
      <td>0.094</td>
      <td>0.135</td>
      <td>55.28%</td>
      <td>51.33%</td>
      <td>0.685</td>
      <td>0.819</td>
      <td>No</td>
    </tr>
    <tr>
      <td>mm_weekly_flow</td>
      <td>2.0</td>
      <td>5</td>
      <td>114</td>
      <td>1578</td>
      <td>-0.091</td>
      <td>0.094</td>
      <td>-0.185</td>
      <td>56.14%</td>
      <td>51.33%</td>
      <td>0.699</td>
      <td>0.819</td>
      <td>No</td>
    </tr>
    <tr>
      <td>mm_net_pct_oi</td>
      <td>2.0</td>
      <td>1</td>
      <td>123</td>
      <td>1581</td>
      <td>0.064</td>
      <td>0.002</td>
      <td>0.062</td>
      <td>52.03%</td>
      <td>51.36%</td>
      <td>0.707</td>
      <td>0.819</td>
      <td>No</td>
    </tr>
    <tr>
      <td>conc_top4_long</td>
      <td>3.0</td>
      <td>1</td>
      <td>21</td>
      <td>1581</td>
      <td>0.148</td>
      <td>0.002</td>
      <td>0.145</td>
      <td>52.38%</td>
      <td>51.36%</td>
      <td>0.760</td>
      <td>0.861</td>
      <td>No</td>
    </tr>
    <tr>
      <td>pm_net</td>
      <td>2.0</td>
      <td>1</td>
      <td>143</td>
      <td>1581</td>
      <td>0.058</td>
      <td>0.002</td>
      <td>0.055</td>
      <td>52.45%</td>
      <td>51.36%</td>
      <td>0.812</td>
      <td>0.899</td>
      <td>No</td>
    </tr>
    <tr>
      <td>pm_net</td>
      <td>2.0</td>
      <td>5</td>
      <td>142</td>
      <td>1578</td>
      <td>0.171</td>
      <td>0.094</td>
      <td>0.077</td>
      <td>58.45%</td>
      <td>51.33%</td>
      <td>0.854</td>
      <td>0.899</td>
      <td>No</td>
    </tr>
    <tr>
      <td>conc_top4_short</td>
      <td>2.0</td>
      <td>5</td>
      <td>174</td>
      <td>1578</td>
      <td>0.142</td>
      <td>0.094</td>
      <td>0.048</td>
      <td>53.45%</td>
      <td>51.33%</td>
      <td>0.868</td>
      <td>0.899</td>
      <td>No</td>
    </tr>
    <tr>
      <td>mm_weekly_flow</td>
      <td>3.0</td>
      <td>1</td>
      <td>27</td>
      <td>1581</td>
      <td>0.119</td>
      <td>0.002</td>
      <td>0.117</td>
      <td>51.85%</td>
      <td>51.36%</td>
      <td>0.871</td>
      <td>0.899</td>
      <td>No</td>
    </tr>
    <tr>
      <td>mm_net</td>
      <td>3.0</td>
      <td>20</td>
      <td>24</td>
      <td>1578</td>
      <td>0.224</td>
      <td>0.406</td>
      <td>-0.182</td>
      <td>45.83%</td>
      <td>50.44%</td>
      <td>0.888</td>
      <td>0.899</td>
      <td>No</td>
    </tr>
    <tr>
      <td>open_interest</td>
      <td>2.0</td>
      <td>5</td>
      <td>216</td>
      <td>1578</td>
      <td>0.031</td>
      <td>0.094</td>
      <td>-0.063</td>
      <td>54.63%</td>
      <td>51.33%</td>
      <td>0.899</td>
      <td>0.899</td>
      <td>No</td>
    </tr>
    <tr>
      <td>pm_net</td>
      <td>3.0</td>
      <td>1</td>
      <td>2</td>
      <td>1581</td>
      <td>-0.125</td>
      <td>0.002</td>
      <td>-0.128</td>
      <td>50.00%</td>
      <td>51.36%</td>
      <td>—</td>
      <td>—</td>
      <td>not testable</td>
    </tr>
    <tr>
      <td>pm_net</td>
      <td>3.0</td>
      <td>5</td>
      <td>2</td>
      <td>1578</td>
      <td>9.834</td>
      <td>0.094</td>
      <td>9.740</td>
      <td>100.00%</td>
      <td>51.33%</td>
      <td>—</td>
      <td>—</td>
      <td>not testable</td>
    </tr>
    <tr>
      <td>pm_net</td>
      <td>3.0</td>
      <td>20</td>
      <td>2</td>
      <td>1578</td>
      <td>-20.634</td>
      <td>0.406</td>
      <td>-21.039</td>
      <td>50.00%</td>
      <td>50.44%</td>
      <td>—</td>
      <td>—</td>
      <td>not testable</td>
    </tr>
  </tbody>
</table>

<p><strong>0 of 51 tested cells survive BH at alpha = 0.05. Smallest adjusted p = 0.102.</strong></p>

<p>Two things to notice before reading anything into the top rows:</p>

<ul>
  <li><strong>21 of 54 cells have <code>n_events &lt; 30</code>.</strong> The <code>swap_net</code> <code>|z| &gt;= 3</code> row with a
6.2% “edge” has <strong>n = 9</strong>. The <code>pm_net</code> <code>|z| &gt;= 3</code> rows have <strong>n = 2</strong> and are
reported only so that the table is complete; they are not testable and the
+9.83% and -20.63% figures are single-observation noise.</li>
  <li><strong>The largest apparent edges all sit at the 20-day horizon</strong>, which is exactly
where calendar clustering across correlated contracts does the most damage to
the effective sample size. Section 6 takes that seriously.</li>
</ul>

<hr />

<h2 id="6-robustness-resampling-weeks-instead-of-events">6. Robustness: resampling weeks instead of events</h2>

<p>The bootstrap in Section 4 resamples <em>events</em> i.i.d., which assumes the 123
events in the best cell are 123 independent draws. They are not: they fall on 67
distinct as-of weeks, and on four of those weeks five contracts fire together.</p>

<p>We therefore re-ran every cell with a <strong>cluster bootstrap over as-of weeks</strong>
(resample the 67 weeks with replacement, take all events in each drawn week,
B = 10,000), then re-applied BH across the same 51 cells.</p>

<table>
  <thead>
    <tr>
      <th>field</th>
      <th>|z|&gt;=</th>
      <th>h(d)</th>
      <th>n_ev</th>
      <th>n_weeks</th>
      <th>edge%</th>
      <th>p (week-clustered)</th>
      <th>p_BH</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>swap_net</td>
      <td>3.0</td>
      <td>20</td>
      <td>9</td>
      <td>9</td>
      <td>6.225</td>
      <td>0.0098</td>
      <td>0.294</td>
    </tr>
    <tr>
      <td>mm_net_pct_oi</td>
      <td>2.0</td>
      <td>20</td>
      <td>123</td>
      <td>67</td>
      <td>2.178</td>
      <td>0.0142</td>
      <td>0.294</td>
    </tr>
    <tr>
      <td>oi_change</td>
      <td>3.0</td>
      <td>1</td>
      <td>30</td>
      <td>24</td>
      <td>0.933</td>
      <td>0.0196</td>
      <td>0.294</td>
    </tr>
    <tr>
      <td>conc_top4_short</td>
      <td>2.0</td>
      <td>20</td>
      <td>174</td>
      <td>75</td>
      <td>1.559</td>
      <td>0.0244</td>
      <td>0.294</td>
    </tr>
    <tr>
      <td>open_interest</td>
      <td>3.0</td>
      <td>1</td>
      <td>41</td>
      <td>32</td>
      <td>0.678</td>
      <td>0.0322</td>
      <td>0.294</td>
    </tr>
    <tr>
      <td>mm_net</td>
      <td>2.0</td>
      <td>20</td>
      <td>190</td>
      <td>89</td>
      <td>1.403</td>
      <td>0.0346</td>
      <td>0.294</td>
    </tr>
    <tr>
      <td>pm_net</td>
      <td>2.0</td>
      <td>20</td>
      <td>142</td>
      <td>78</td>
      <td>1.350</td>
      <td>0.0474</td>
      <td>0.345</td>
    </tr>
    <tr>
      <td>oi_change</td>
      <td>2.0</td>
      <td>20</td>
      <td>136</td>
      <td>69</td>
      <td>1.604</td>
      <td>0.0548</td>
      <td>0.349</td>
    </tr>
    <tr>
      <td>swap_net</td>
      <td>2.0</td>
      <td>20</td>
      <td>148</td>
      <td>73</td>
      <td>1.619</td>
      <td>0.0678</td>
      <td>0.352</td>
    </tr>
    <tr>
      <td>swap_net</td>
      <td>2.0</td>
      <td>1</td>
      <td>148</td>
      <td>73</td>
      <td>0.344</td>
      <td>0.0752</td>
      <td>0.352</td>
    </tr>
  </tbody>
</table>

<p><strong>Still 0 of 51 survive.</strong> The best uncorrected p moves from 0.002 to 0.010; the
best adjusted p moves from 0.102 to <strong>0.294</strong>. Clustering costs roughly a factor
of five in the headline cell’s p-value, which is the honest measure of how much
of its <code>n</code> was real.</p>

<p>Event-count-to-week-count ratios for every <code>|z| &gt;= 2</code> cell, for anyone who wants
to check the clustering claim directly:</p>

<table>
  <thead>
    <tr>
      <th>field</th>
      <th>events (20d labelled)</th>
      <th>distinct weeks</th>
      <th>max contracts in one week</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>mm_net</td>
      <td>190</td>
      <td>89</td>
      <td>6</td>
    </tr>
    <tr>
      <td>open_interest</td>
      <td>216</td>
      <td>80</td>
      <td>6</td>
    </tr>
    <tr>
      <td>swap_net</td>
      <td>148</td>
      <td>73</td>
      <td>5</td>
    </tr>
    <tr>
      <td>pm_net</td>
      <td>142</td>
      <td>78</td>
      <td>6</td>
    </tr>
    <tr>
      <td>conc_top4_long</td>
      <td>135</td>
      <td>76</td>
      <td>4</td>
    </tr>
    <tr>
      <td>conc_top4_short</td>
      <td>174</td>
      <td>75</td>
      <td>6</td>
    </tr>
    <tr>
      <td>mm_net_pct_oi</td>
      <td>123</td>
      <td>67</td>
      <td>5</td>
    </tr>
    <tr>
      <td>mm_weekly_flow</td>
      <td>114</td>
      <td>67</td>
      <td>5</td>
    </tr>
    <tr>
      <td>oi_change</td>
      <td>136</td>
      <td>69</td>
      <td>7</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="7-limitations">7. Limitations</h2>

<p>This section is longer than the results section. That is the correct ratio for a
null this underpowered.</p>

<h3 id="71-the-test-has-about-10-power-this-is-the-dominant-limitation">7.1 The test has about 10% power. This is the dominant limitation.</h3>

<p>A t-statistic scales as the square root of sample size. Take a reference weekly
positioning effect of <strong>t = 2.14 estimated on 1,451 weekly cross-sections</strong> —
roughly the magnitude of the stronger published weekly-horizon results — and
scale it to our sample:</p>

<pre><code>t_expected = 2.14 x sqrt(n / 1451)
  n = 116  -&gt;  t = 0.61
  n = 160  -&gt;  t = 0.71
</code></pre>

<p>Against a two-sided alpha = 0.05 critical value of 1.96, that gives power of
<strong>9.3% to 11.0%</strong>. In other words: <strong>if an effect of that size were really there,
this study would have failed to detect it roughly nine times out of ten.</strong></p>

<p>Sensitivity across reference effect sizes, because the 2.14 figure is a stipulated
benchmark rather than a value we re-estimated ourselves — substitute your own:</p>

<table>
  <thead>
    <tr>
      <th>reference t (at n = 1,451)</th>
      <th>expected t at n = 123</th>
      <th>power</th>
      <th>expected t at n = 160</th>
      <th>power</th>
      <th>n needed for 80% power</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1.50</td>
      <td>0.44</td>
      <td>7.2%</td>
      <td>0.50</td>
      <td>7.9%</td>
      <td>5,062</td>
    </tr>
    <tr>
      <td>2.00</td>
      <td>0.58</td>
      <td>9.0%</td>
      <td>0.66</td>
      <td>10.2%</td>
      <td>2,847</td>
    </tr>
    <tr>
      <td>2.14</td>
      <td>0.62</td>
      <td>9.6%</td>
      <td>0.71</td>
      <td>11.0%</td>
      <td>2,487</td>
    </tr>
    <tr>
      <td>2.50</td>
      <td>0.73</td>
      <td>11.3%</td>
      <td>0.83</td>
      <td>13.2%</td>
      <td>1,822</td>
    </tr>
    <tr>
      <td>3.00</td>
      <td>0.87</td>
      <td>14.1%</td>
      <td>1.00</td>
      <td>16.9%</td>
      <td>1,265</td>
    </tr>
  </tbody>
</table>

<p>Under every plausible assumption we would need on the order of <strong>1,300 to 5,000
weekly observations</strong> for a well-powered test. We have 123 events on 67
independent weeks. A null result at this power is close to preordained, and
should update your beliefs very little.</p>

<h3 id="72-the-specification-cancels-a-signed-effect-by-construction">7.2 The specification cancels a signed effect by construction</h3>

<p>This is the limitation a domain expert will raise first, and they are right to.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>This study</th>
      <th>The literature that finds an effect</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Signal</td>
      <td><code>|z|</code> — <strong>two-sided</strong>, absolute</td>
      <td><strong>signed</strong> position change</td>
    </tr>
    <tr>
      <td>Variable</td>
      <td><strong>level</strong> extremity of the position</td>
      <td><strong>change</strong> in the position</td>
    </tr>
    <tr>
      <td>Trader class</td>
      <td>primarily <strong>managed money</strong> / swap / concentration</td>
      <td>primarily <strong>commercial</strong> hedgers</td>
    </tr>
    <tr>
      <td>Horizon</td>
      <td>1, 5, <strong>20</strong> trading days</td>
      <td><strong>one week</strong></td>
    </tr>
  </tbody>
</table>

<p>Pooling <code>|z|</code> means a week where managed money is extremely <em>long</em> and a week
where it is extremely <em>short</em> land in the same event bucket with the same sign of
expected return. If the true effect is signed — and the hedging-pressure
literature says it is — this specification averages it to approximately zero
<strong>by construction</strong>, regardless of whether the effect exists.</p>

<p>Kang, Rouwenhorst and Tang (2020) make this concrete: they decompose position
variation into a short-horizon component driven by non-commercial liquidity
provision and a long-horizon component driven by commercial hedging demand, and
find these two components have <strong>contrasting directional effects</strong> on expected
returns. A two-sided, level-based, class-pooled specification is close to a
worst case for detecting that structure.</p>

<p>We tested what the product shipped. We did not test what the literature endorses.</p>

<h3 id="73-sample-and-coverage">7.3 Sample and coverage</h3>

<ul>
  <li><strong>19 contracts</strong>, all commodity futures. No financials, no currencies, no
equity index. Nothing here generalises beyond commodities.</li>
  <li><strong>Approximately three years</strong> of overlapping positioning and price coverage
(2023-04 to 2026-06) — one macro regime, with a strong metals uptrend inside it.</li>
  <li><strong>33.0% of computable events dropped</strong> for want of a price series. The dropped
window is systematic (2022-01 to 2023-04, plus a 2026 tail), not random, so
this is a coverage restriction rather than a random-missingness assumption.</li>
  <li><strong>Front-month price series</strong> are used without an explicit roll-return
adjustment. At a 20-day horizon this is a real source of noise in the return
measurement.</li>
</ul>

<h3 id="74-two-residual-leakage-exposures-we-could-not-fully-eliminate">7.4 Two residual leakage exposures we could not fully eliminate</h3>

<p>Named because a reviewer would find them.</p>

<ol>
  <li><strong>Non-Tuesday as-of dates.</strong> 3 of the 334 distinct as-of dates in the raw
table are Mondays (2020-12-21, 2023-07-03, 2025-11-10) rather than Tuesdays.
For those, <code>observed_at + 3 days</code> lands on a Thursday, one day before a normal
Friday release, so up to one day of lookahead is possible. This touches <strong>10
event-observations across the whole study and 0 in the headline cell</strong> — but
note that our internal note previously asserted <em>every</em> as-of date was a
Tuesday, and that was wrong.</li>
  <li><strong>The three-day lag is a constant, not a per-report release timestamp.</strong> We do
not consume the CFTC’s actual release-time metadata. Where a release was
delayed past Friday (US federal holidays, the 2018-2019 shutdown backlog), a
constant lag would understate the true delay. Both exposures push toward false
<em>positives</em>, and we found none — so they cannot explain the null. They would
matter to anyone reusing this harness to chase a positive.</li>
</ol>

<h3 id="75-statistical-fine-print">7.5 Statistical fine print</h3>

<ul>
  <li>The p-value is a percentile bootstrap of the event mean against the population
mean <strong>treated as known without error</strong>. With <code>n_pop</code> ~ 1,578 the baseline’s own
sampling error is small relative to the event mean’s, but it is not zero, so
the reported p-values are mildly anti-conservative.</li>
  <li><strong>The event set is a subset of the population.</strong> In the best cell, events are
7.79% of the population, so the baseline is contaminated with the very
observations being tested. Correcting for this would <em>raise</em> the measured edge
from 2.178% to 2.362% — the contamination is conservative, but it is there.</li>
  <li><strong>The confidence intervals use a circular block bootstrap; the p-values do
not.</strong> The p-value resamples i.i.d. That mismatch is precisely why Section 6
exists, and Section 6 is the number we would defend.</li>
  <li><code>B = 2,000</code> bootstrap draws makes p-values granular near zero. Re-running the
best cell at <code>B = 20,000</code> gives p = 0.0031 rather than 0.002; the BH conclusion
is unchanged.</li>
  <li>The z-score uses an expanding window with a 20-point minimum. Early-sample
z-scores are estimated on as few as 20 prior observations and are noisy.</li>
</ul>

<hr />

<h2 id="8-relation-to-the-literature">8. Relation to the literature</h2>

<h3 id="81-our-null-replicates-the-published-null">8.1 Our null replicates the published null</h3>

<p>Sanders, Irwin and Merrin (2009) run bivariate Granger-causality tests on COT
trader positions across <strong>ten agricultural futures markets</strong> and conclude there is
“very little evidence that traders’ positions are useful in forecasting (leading)
returns,” while finding substantial evidence that traders <em>respond</em> to prices —
non-commercials in particular behaving as trend followers.</p>

<p>Their paper reports individual test p-values without any multiple-testing
correction. Applying Benjamini-Hochberg to the 30 published p-values from their
positions-to-returns direction (10 markets x 3 trader categories) leaves
<strong>0 of 30 significant at alpha = 0.05, with a minimum adjusted p of 0.105</strong>.</p>

<p>Set that beside our own minimum adjusted p of <strong>0.102</strong> and the picture is
consistent: on this class of specification, at this kind of sample size, neither
their data nor ours produces a finding that survives correction. Our contribution
is not overturning a positive result — it is that the published null holds up
under a stricter procedure, on different markets, in a different decade, with
publication lag explicitly handled.</p>

<p><em>Note for replicators:</em> the paper is open access
(DOI <a href="https://doi.org/10.22004/ag.econ.54547">10.22004/ag.econ.54547</a>, full text
at <code>ageconsearch.umn.edu/record/54547</code>). The BH re-run is a one-liner —
<code>multipletests(published_p_values, alpha=0.05, method="fdr_bh")</code> — so the 0-of-30
figure takes about a minute to verify against their tables. We reproduce their
p-values here by reference rather than by transcription; check them yourself
before citing this claim.</p>

<h3 id="82-where-the-effect-actually-lives-if-it-lives-anywhere">8.2 Where the effect actually lives, if it lives anywhere</h3>

<p>Kang, Rouwenhorst and Tang (2020, <em>Journal of Finance</em>) is the strongest modern
evidence that positioning carries information. Their finding is <em>structural</em>:
short-horizon position variation reflects non-commercial liquidity provision,
long-horizon variation reflects commercial hedging demand, the two have opposite
directional effects on expected returns, and the liquidity-provision income
largely offsets the insurance premium commercials pay.</p>

<p>Nothing in our study contradicts that, because our specification cannot see it.
A study designed to test it would use <strong>signed</strong> changes in <strong>commercial</strong> net
positions at a <strong>one-week</strong> horizon, decomposed into short- and long-horizon
components. That is a different paper, and one worth running.</p>

<h3 id="83-multiple-testing-in-this-field-is-not-optional">8.3 Multiple testing in this field is not optional</h3>

<p>Harvey, Liu and Zhu (2016) argue that most claimed cross-sectional return
findings are likely false because of the sheer number of specifications tested,
and propose a t-statistic threshold above 3.0 for a new factor. Our best
uncorrected t-equivalent, under week clustering, corresponds to p = 0.010 — well
short of that bar, before any correction. Any COT study that reports a single
“best” specification without disclosing the search that produced it should be
read with this in mind. We tested 54 cells and are telling you all 54.</p>

<hr />

<h2 id="9-replication">9. Replication</h2>

<h3 id="91-reproducing-our-exact-numbers">9.1 Reproducing our exact numbers</h3>

<p>The study is one read-only script. It queries a local SQLite pipeline database
built by this repository’s collectors; the database is not distributed (it is
150 MB and contains 34 unrelated sources), but every input to it is free and
keyless.</p>

<pre><code>git clone &lt;this repository&gt;
cd tirramind
git checkout 2597e8b
.venv/bin/python scripts/cftc_event_study.py
</code></pre>

<p>The script prints, in order: the 19-pair join table with per-pair point counts,
the attrition counts, the leakage audit with sample entry timestamps, and the
full 54-row results table with BH-adjusted p-values. Every number in Sections 3
and 5 of this document appears verbatim in that output.</p>

<h3 id="92-reproducing-the-study-from-scratch-without-our-database">9.2 Reproducing the study from scratch, without our database</h3>

<p>You do not need our infrastructure. You need:</p>

<ol>
  <li><strong>Positioning.</strong> Download <code>fut_disagg_txt_{year}.zip</code> from
<code>https://www.cftc.gov/files/dea/history/</code> for 2022-2026 (public domain, no
key). Filter to the 19 <code>CFTC_Contract_Market_Code</code> values in Section 3.2, and
derive the nine fields with the formulas in Section 3.3.</li>
  <li><strong>Prices.</strong> Daily closes for the 19 tickers in Section 3.2 over the same
window, from any free source.</li>
  <li><strong>Method.</strong> Implement Section 4 exactly: expanding-window z with a 20-point
floor and <code>x[:-1]</code> as history; entry at the first close at or after
as-of + 3 days with a 7-day tolerance; forward log returns at 1, 5, 20
trading days; unconditional-return baseline; bootstrap p; BH across all
testable cells.</li>
</ol>

<p>Your event counts will differ from ours to the extent your price coverage
differs — recall that a third of our events were dropped for price-coverage
reasons, and that attrition is entirely a property of the price series you pick.
If you have price history back to 2022 or earlier, you will have a <strong>larger and
better-powered</strong> sample than we did, and we would genuinely like to know what
you find.</p>

<h3 id="93-the-specification-we-would-run-next">9.3 The specification we would run next</h3>

<p>If you want to spend the effort well, do not re-run ours. Run the one the
literature endorses and we did not test:</p>

<ul>
  <li><strong>signed</strong> (not absolute) z on <strong>changes</strong> (not levels)</li>
  <li><strong>commercial / producer-merchant</strong> net positions</li>
  <li><strong>one-week</strong> forward horizon</li>
  <li>across as many contracts and years as you can join</li>
  <li>with publication lag handled as in Section 2.1, BH across every cell you test,
and week-clustered resampling as in Section 6</li>
</ul>

<p>That test would be informative whichever way it came out. Ours was not.</p>

<hr />

<h2 id="10-conclusion">10. Conclusion</h2>

<p>Across 19 commodity futures, 9 CFTC positioning fields, 3 horizons and 2 z-score
thresholds — 51 testable hypotheses in one Benjamini-Hochberg family — <strong>no
specification survives correction</strong>, with or without clustering for the fact that
correlated contracts fire in the same week. The smallest adjusted p-value is
0.102 under i.i.d. resampling and 0.294 under week clustering.</p>

<p>We used this to stop selling a product whose central claim was that these
anomalies precede price moves. For that decision the result was sufficient: an
underpowered null is not a licence to make a positive claim, and we could not
find the effect we were charging for.</p>

<p>For the broader question, the result is close to uninformative, and we will not
pretend otherwise. The test had roughly 10% power and used a two-sided,
level-based specification that cancels the signed, change-based effect the
literature actually reports. <strong>The correct reading of this paper is “we could not
detect it, and we would not have expected to” — not “it is not there.”</strong></p>

<p>The finding we will defend is narrower and more useful: <strong>the publication-lag
handling, the unconditional-return null, the family-wide correction and the
week-clustered resampling are each individually capable of turning an apparent
COT edge into nothing.</strong> If a COT study reports an edge and does not describe all
four, that is where to look first.</p>

<h3 id="reproduce-with">Reproduce with</h3>

<pre><code>.venv/bin/python scripts/cftc_event_study.py
</code></pre>

<hr />

<h2 id="references">References</h2>

<ul>
  <li>Benjamini, Y., and Y. Hochberg (1995). “Controlling the False Discovery Rate: A
Practical and Powerful Approach to Multiple Testing.” <em>Journal of the Royal
Statistical Society, Series B</em> 57(1): 289-300.
DOI <a href="https://doi.org/10.1111/j.2517-6161.1995.tb02031.x">10.1111/j.2517-6161.1995.tb02031.x</a></li>
  <li>Harvey, C. R., Y. Liu, and C. Zhu (2016). “… and the Cross-Section of Expected
Returns.” <em>Review of Financial Studies</em> 29(1): 5-68.
DOI <a href="https://doi.org/10.1093/rfs/hhv059">10.1093/rfs/hhv059</a></li>
  <li>Kang, W., K. G. Rouwenhorst, and K. Tang (2020). “A Tale of Two Premiums: The
Role of Hedgers and Speculators in Commodity Futures Markets.” <em>Journal of
Finance</em> 75(1): 377-417.
DOI <a href="https://doi.org/10.1111/jofi.12845">10.1111/jofi.12845</a></li>
  <li>Lahiri, S. N. (1999). “Theoretical Comparisons of Block Bootstrap Methods.”
<em>Annals of Statistics</em> 27(1).
DOI <a href="https://doi.org/10.1214/aos/1018031117">10.1214/aos/1018031117</a></li>
  <li>Sanders, D. R., S. H. Irwin, and R. P. Merrin (2009). “Smart Money: The
Forecasting Ability of CFTC Large Traders in Agricultural Futures Markets.”
<em>Journal of Agricultural and Resource Economics</em> 34(2): 276-296.
DOI <a href="https://doi.org/10.22004/ag.econ.54547">10.22004/ag.econ.54547</a> (open
access)</li>
</ul>

<hr />

<h2 id="appendix-a--corrections-to-the-internal-research-note">Appendix A — Corrections to the internal research note</h2>

<p><code>docs/research/cftc_forward_return_event_study.md</code> is the working note this paper
is built from. Two of its statements did not survive re-verification and are
corrected here. Both corrections make the result <em>weaker</em>, not stronger.</p>

<ol>
  <li><strong>Distinct-week count for the best cell.</strong> The note states that the 123 events
in the <code>mm_net_pct_oi</code>, <code>|z| &gt;= 2</code>, 20-day cell “fall on only 116 distinct
weeks.” That figure belongs to the <strong>251 pre-filter</strong> events. The <strong>123
labelled</strong> events fall on <strong>67</strong> distinct as-of weeks. Clustering is therefore
materially worse than the note claimed, which is why Section 6 re-runs every
cell at the week level.</li>
  <li><strong>“Every as-of date is a Tuesday.”</strong> 331 of 334 distinct as-of dates are
Tuesdays; 3 are Mondays. See Section 7.4 for the bounded exposure this creates.</li>
</ol>

<p>Both were found by re-deriving the note’s numbers from the database rather than
copying them forward. Everything else in the note reproduced exactly against
commit <code>2597e8b</code>.</p>]]></content><author><name>Saurabh Patel</name></author><category term="Other" /><summary type="html"><![CDATA[A null result, with the power calculation that tells you how little it proves.]]></summary></entry><entry><title type="html">Thirteen Ways a Pipeline Lies</title><link href="https://savabs.github.io/2026/08/29/thirteen-ways-a-pipeline-lies.html" rel="alternate" type="text/html" title="Thirteen Ways a Pipeline Lies" /><published>2026-08-29T00:00:00+00:00</published><updated>2026-08-29T00:00:00+00:00</updated><id>https://savabs.github.io/2026/08/29/thirteen-ways-a-pipeline-lies</id><content type="html" xml:base="https://savabs.github.io/2026/08/29/thirteen-ways-a-pipeline-lies.html"><![CDATA[<p>I spent about five months building a data pipeline that collects free public
data — CFTC futures positioning, DeFi TVL, SEC insider filings, GDELT events,
bankruptcy dockets, AIS vessel tracks, government contracts — scores it for
anomalies, and sold a weekly digest off the result.</p>

<p>I am shutting the product down. Four independent measurements say the edge
isn’t there, the data isn’t proprietary, the price point is occupied, and the
cross-source graph I built the whole thesis on has three genuinely cross-domain
joinable source pairs out of 561, covering four entities. That’s a separate write-up.</p>

<p>This one is about something more portable, and in my opinion more useful:
<strong>the codebase evolved from “healthy-looking and empty” to “healthy-looking
and full of constants.”</strong></p>

<p>The first state is easy to catch. Every monitoring check you’d naturally write
— did the job run, did it exit zero, did it write rows — catches an empty
table. The second state passes all of those checks. The job runs, exits zero,
writes rows, and the rows are garbage that is <em>shaped</em> like a result. Sorting
by a column that’s secretly a Unix timestamp looks exactly like sorting by
anomaly score, right up until you print the column.</p>

<p>Here are thirteen concrete ways this system lied to me. All of them shipped.
None of them shipped to a paying customer, because there never was one —
which is its own finding, and the reason I could afford to be this candid. For each I’ve given the symptom, how
long it hid, the root cause, the fix, and the general rule — the rule is the
part worth your time, the war story is just evidence that I paid for it.</p>

<p>The title is a nod to the repo’s own failure log, which runs F-01 through
F-13. The numbering below is its own; some of these are in that log, some were
found while writing this.</p>

<p>Two snapshots frame everything that follows. This is the same database three
days apart:</p>

<table>
  <thead>
    <tr>
      <th>table</th>
      <th style="text-align: right">2026-08-26</th>
      <th style="text-align: right">2026-08-29</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>entity_observations</code></td>
      <td style="text-align: right">365,739</td>
      <td style="text-align: right">375,657</td>
    </tr>
    <tr>
      <td><code>entity_alerts</code></td>
      <td style="text-align: right"><strong>0</strong></td>
      <td style="text-align: right">4,852</td>
    </tr>
    <tr>
      <td><code>convergence_clusters</code></td>
      <td style="text-align: right"><strong>0</strong></td>
      <td style="text-align: right">42</td>
    </tr>
    <tr>
      <td><code>beliefs</code></td>
      <td style="text-align: right"><strong>0</strong></td>
      <td style="text-align: right">115</td>
    </tr>
    <tr>
      <td><code>signals</code></td>
      <td style="text-align: right"><strong>0</strong></td>
      <td style="text-align: right">92</td>
    </tr>
  </tbody>
</table>

<p>On the 26th I could see the problem from across the room. On the 29th it looked
fixed. Ways 7 through 10 are what those 4,852 rows and 42 rows actually contain.</p>

<hr />

<h2 id="1-the-dag-reported-completed-when-the-write-threw">1. The DAG reported “completed” when the write threw</h2>

<p><strong>Symptom.</strong> <code>daily_collection</code> surfaced as healthy. Its run history, once I
looked directly at it, was: one successful run ever, on 2026-04-19, and
<code>pipeline_data</code> holding 194 rows total for the life of the project.</p>

<p><strong>How long it hid.</strong> From April to late August — about four months. Two runs
were additionally stuck in status <code>running</code> forever, which is the one signal
that <em>did</em> look wrong, and which I misdiagnosed (see Way 3).</p>

<p><strong>Root cause.</strong> In the executor’s <code>_execute_layer</code>, the storage-exception path
emitted a <code>log.warning</code> and left the node result’s status as <code>"completed"</code>.
A run whose writes all threw was byte-for-byte indistinguishable, in
<code>dag_runs</code>, from a run that succeeded.</p>

<p><strong>Fix</strong> (<code>b7a126b</code>). Storage exceptions set status to <code>failed</code>. A stale-run
reaper plus a heartbeat so a process that dies mid-flight stops claiming to be
<code>running</code>. And a distinct classification for missing-credential skips, so an
unconfigured source degrades loudly instead of returning an empty list that
looks like “nothing happened today.”</p>

<p>Here is the run history now. Note that it got <em>worse</em>-looking, which is the
point:</p>

<pre><code>2026-04-19  completed
2026-04-19  failed
2026-04-21  failed
2026-04-21  failed
2026-08-25  failed  x4
2026-08-27  failed  x4
</code></pre>

<p>One success, eleven failures. Before the fix, those eleven were reporting as
<code>completed</code>, except two that were stuck in <code>running</code> and had been since April.</p>

<p>Eight nodes now skip explicitly on missing FRED/EIA/NASA credentials; two fail
on real upstream outages (a 403 from the lobbying source, a dead PatentsView
endpoint). That matches ground truth instead of hiding it.</p>

<p>There is a second thing visible in that list: between 2026-04-21 and
2026-08-25 there are <strong>no runs at all</strong>. The cron schedules were declared in
the DAG definitions and never actually installed anywhere. So for four months
the pipeline was simultaneously not running, and reporting success when it did.</p>

<blockquote>
  <p><strong>Rule.</strong> An exception handler that logs and continues has converted a
failure into a silence. Every <code>except: log.warning(...)</code> on a write path is
a decision that the caller should not be told. Make that decision
deliberately, and write down why, or don’t make it.</p>
</blockquote>

<hr />

<h2 id="2-rows-written-was-a-count-of-nodes-not-a-count-of-rows">2. “Rows written” was a count of nodes, not a count of rows</h2>

<p>This one I found while writing this post. It is unfixed as of today, and I’m
including it because a list of failures I already solved is a less honest
document than one that includes the one I found on the way to publishing.</p>

<p><strong>Symptom.</strong> The guard I added in Way 1 to catch silent success — a run that
completes, has store-eligible nodes, and writes nothing gets downgraded to
<code>failed</code> — does not fire for the scoring DAGs. <code>rl_training</code> has six
<code>completed</code> runs and <code>rl_transitions</code> has zero rows. <code>adversarial_scan</code> has
six <code>completed</code> runs and <code>adversarial_flags</code> has zero rows.</p>

<p><strong>Root cause.</strong> <code>agent/pipeline/executor.py:215</code>:</p>

<pre><code class="language-python">run.rows_written = sum(1 for nr in run.node_results.values() if nr.stored)
</code></pre>

<p><code>nr.stored</code> is a <code>bool</code> — “this node’s result envelope landed in the store.”
So <code>rows_written</code> is a count of <em>nodes</em>, capped at the number of nodes in the
DAG. A DAG whose every operator dutifully persisted one summary envelope into
the generic <code>pipeline_data</code> table, while writing nothing to the domain table it
exists to populate, scores <code>rows_written = 5</code> and sails through. The guard’s
other escape hatch is <code>eligible_store_nodes == 0</code>, and the two DAGs above take
one or the other — I have not pinned down which, and it doesn’t much matter,
because both are the same mistake: the guard reasons about the executor’s
bookkeeping rather than about the database.</p>

<p>I wrote a guard against fake success, named its variable after the thing I
wanted to measure, and populated it with a proxy that cannot go to zero in the
failure mode I was guarding against.</p>

<p><strong>Fix.</strong> Not yet applied. The correct measure is per-destination-table row
deltas taken before and after the run, which is what an auditor would ask for
and what I should have written the first time.</p>

<blockquote>
  <p><strong>Rule.</strong> When you build a health check, ask what value the field would take
during the exact incident you are defending against. If it can’t reach the
alerting value during that incident, you have built a check that is
guaranteed to pass. And a field name is documentation: <code>rows_written</code> that
holds a node count will be trusted by the next reader, who will be me.</p>
</blockquote>

<hr />

<h2 id="3-a-timeout-that-could-not-stop-the-work">3. A timeout that could not stop the work</h2>

<p><strong>Symptom.</strong> The nightly chain ran all nine DAGs, then refused to exit for 40+
minutes. The box — 1 vCPU, 1.9 GB — reached <strong>20 MB available with swap at
2038 / 2047 MB</strong>. Two nodes, <code>train_gnn</code> and <code>generate_features</code>, were both
marked <code>failed</code>, and memory kept climbing after they had “finished.”</p>

<p><strong>How long it hid.</strong> Months. It produced the same evidence as Way 1 — a run
stuck in <code>running</code> with no error recorded — so I fixed the reporting bug,
saw the signature persist, and had no second hypothesis.</p>

<p><strong>Root cause.</strong> Two faults compounding.</p>

<p>The executor’s own source said the thing out loud: a timeout marks the node
failed and moves on, <em>“since the operator keeps running in the background
because a thread cannot be forcibly stopped.”</em> <code>future.result(timeout=...)</code>
times out the <strong>wait</strong>, not the <strong>work</strong>. A timed-out operator is still
running and still allocating. The timeout was bookkeeping, not cancellation.</p>

<p>And <code>TIRRA_PIPELINE_WORKERS</code> defaulted to 4, on one core. Four workers on a
single core buy no parallelism — only context-switching and roughly 4x peak
RSS. Nothing compared that default against <code>nproc</code>.</p>

<p>Net: a 30-minute <code>train_gnn</code> timeout leaves an orphaned thread eating RAM for
as long as the parent lives, and the parent cannot exit while it runs.</p>

<p><strong>Fix</strong> (<code>6d656ba</code>, <code>dec920b</code>). Workers set to 1 on single-core hosts, with the
measurement recorded next to the setting. The executor now logs an explicit
<code>ORPHAN THREAD</code> line naming the node and how far past its timeout it still is,
and refuses to start new work in a pool a timeout has already degraded. The
nightly chain was narrowed to the light DAGs that actually feed the product.
And <code>MemoryMax=1200M</code> / <code>MemorySwapMax=1500M</code> on the systemd unit, so the OOM
killer takes the batch job rather than choosing the customer-facing API
service for me.</p>

<blockquote>
  <p><strong>Rule.</strong> A timeout that cannot cancel its work is not a timeout, it is a
leak with a stopwatch attached. In any runtime without forcible thread
cancellation — Python very much included — either give the operator a
cooperative cancellation token it checks, or run it in a process you can
actually kill. And never let a background job share an OOM domain with
something a customer is waiting on.</p>

  <p>Corollary, which cost me more than the bug did: when a fix doesn’t clear the
symptom, the fix was not wrong — you had two causes. I had assumed one.</p>
</blockquote>

<hr />

<h2 id="4-duplicate-ingestion-manufactured-structural-break-this-week">4. Duplicate ingestion manufactured “structural break this week”</h2>

<p><strong>Symptom.</strong> The weekly digest told paying subscribers there was a structural
break <em>this week</em> in five separate CFTC futures contracts. There were no
structural breaks. There was one collector running eight times in a day.</p>

<p><strong>Root cause.</strong> Collection re-ingests the same CFTC report on every run with no
upsert. So <code>entity_observations</code> accumulates duplicate rows. Measured on the
live database, and you can re-run this yourself:</p>

<pre><code class="language-sql">SELECT COUNT(*)                                        -- 5488
FROM entity_observations WHERE observation_type='futures_positioning';

SELECT COUNT(*) FROM (SELECT DISTINCT entity_id, observed_at
FROM entity_observations WHERE observation_type='futures_positioning');
                                                       -- 5046
</code></pre>

<p>442 duplicates of 5,488 rows, 8.1%. And they are not spread evenly — a single
day, 2026-08-18, carries 272 rows against a typical day’s ~35, because
collection ran about eight times that day.</p>

<p>The consequence is the interesting part. The last ten rows for every
high-duplication contract, by insertion order:</p>

<pre><code>2026-08-18, 2026-08-18, 2026-08-18, 2026-08-18, 2026-08-18,
2026-08-18, 2026-08-18, 2026-08-18, 2026-06-02, 2026-06-02
</code></pre>

<p>Eight consecutive “weekly” observations carrying the <strong>same</strong> timestamp. The
changepoint detector (BOCPD) sees a discontinuity where that block begins —
which is real, the series genuinely changes shape there — and the elapsed time
from that index to now, measured in the series’ own index space, is 0.0 weeks.
“A structural break, zero weeks ago.” Rendered to the customer as <em>this week</em>.</p>

<p><strong>Fix</strong> (<code>1e86f0f</code>). The read path now dedupes on <code>(entity_id, observed_at)</code>,
keeping <code>max(rowid)</code> so the most recently written value for a timestamp wins.
Cotton went from 169 points to 159 and its “this week” changepoint became
<code>None</code>. Ondo TVL went from 3,570 points to 1,190 — roughly 3x duplication in
DeFi too. Every surviving changepoint is now 15 to 186 weeks old, which is the
honest answer: there are no fresh structural breaks in this data right now.</p>

<p>That is a read-side guard, and I’m labelling it as one. The ingestion path
still needs the upsert; without it the duplicate rows keep accumulating and
every consumer other than the digest still sees them.</p>

<blockquote>
  <p><strong>Rule.</strong> Idempotency is not a nice-to-have on an ingestion path, it is the
difference between a time series and a log of your own cron schedule. And
notice the shape of the failure: the <em>data</em> was fine, the <em>analysis</em> was
fine, and the artefact of re-running the collector was laundered through a
correct changepoint detector into a confident false claim. Anywhere you
measure elapsed time by counting array positions, you have assumed the
array’s spacing is real.</p>
</blockquote>

<hr />

<h2 id="5-a-50-point-baseline-where-1096-points-existed">5. A 50-point baseline where 1,096 points existed</h2>

<p>This is the most expensive one, and it is the one I’d want a stranger to take
away.</p>

<p><strong>Symptom.</strong> The digest was shipping 29 volatility “anomalies” per run. After
the fix it shipped 4. Twenty-five of twenty-nine were false positives, going
out to subscribers, every week.</p>

<p><strong>Root cause.</strong> The scorer computed z-scores against
<code>instrument_volatility</code>, which holds roughly 4 months of history. The exact
same 89 instruments also had <code>instrument_daily</code> going back to April 2023,
under a superseded observation-type name that nobody had reconciled. Live
numbers:</p>

<pre><code>instrument_volatility  89 entities   46-61 points    2026-04-18 .. 2026-08-26
instrument_daily       89 entities  752-1096 points  2023-04-18 .. 2026-06-09
</code></pre>

<p>A ~50-point baseline makes ordinary seasonal variance look extreme. Nothing
was broken. No exception was raised, no row was missing, no test failed. The
statistics were computed correctly on the wrong denominator, and 86% of the
output was noise.</p>

<p><strong>Fix</strong> (<code>2782d21</code>). The scorable-source table now maps a <em>tuple</em> of
<code>(observation_type, fields)</code> pairs per source and unions across type names,
with the later entry winning on the overlap period. <code>realized_vol_20d</code> findings
went 29 → 4.</p>

<p>The same commit killed three other things for related reasons, which I mention
because “make the product smaller” was the correct move each time:
<code>polymarket</code> was removed (its per-entity series tops out at 15 points, a hard
structural ceiling below the 20-point minimum — it was reported as a working
source every single run, forever); <code>instrument_return.close</code> was cut because 28
of its 33 findings had a z-sign matching the trailing return direction, i.e.
“this asset has been trending,” sold as anomaly detection. <code>sovereign_debt</code> was
<em>kept</em> despite contributing almost nothing, because 5 of 13 entities clear the
floor with real variance — it is genuinely quiet rather than structurally
incapable, and that distinction is the whole job.</p>

<blockquote>
  <p><strong>Rule.</strong> A statistical result has three inputs — the method, the data, and
the <em>amount</em> of data — and only the first two get reviewed. Print <code>n</code>
alongside every derived statistic, in the output the customer sees, and set
a floor below which you emit nothing rather than something. Then go looking
for whether a longer version of your series already exists in your own
store under a different name. Mine did: three years of it, for the identical
89 instruments, in the identical table, under a superseded type name.</p>
</blockquote>

<hr />

<h2 id="6-field-names-that-did-not-exist-discarding-175275-observations-per-run">6. Field names that did not exist, discarding 175,275 observations per run</h2>

<p><strong>Symptom.</strong> Every edition of the digest was 100% CFTC content, despite five
sources being configured. I had assumed the other four were quiet.</p>

<p><strong>Root cause.</strong> The scorable-source config declared field names that do not
appear in the stored JSON payloads, and the series builder silently skips any
field it cannot find. Three of five sources therefore built <em>zero</em> series,
with no error logged anywhere:</p>

<table>
  <thead>
    <tr>
      <th>source</th>
      <th>declared fields</th>
      <th>actual fields</th>
      <th style="text-align: right">rows used</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>instrument_volatility</code></td>
      <td><code>volatility</code>, <code>value</code></td>
      <td><code>realized_vol_20d</code>, <code>intraday_range</code></td>
      <td style="text-align: right">0 of 4,294</td>
    </tr>
    <tr>
      <td><code>tvl_change</code></td>
      <td><code>tvl_change</code>, <code>value</code></td>
      <td><code>tvl_usd</code></td>
      <td style="text-align: right">0 of 162,251</td>
    </tr>
    <tr>
      <td><code>market_probability</code></td>
      <td><code>probability</code>, <code>value</code></td>
      <td><code>yes_price</code>, <code>no_price</code>, <code>volume_24h</code></td>
      <td style="text-align: right">0 of 8,730</td>
    </tr>
  </tbody>
</table>

<p>175,275 observations per run, read from disk, matched against nothing,
discarded in silence.</p>

<p><strong>Fix</strong> (<code>2957703</code>). Corrected field names. Findings went from 8 (100% CFTC) to
26 across three sources. The DeFi TVL series turned out to carry 3,123- and
3,570-point baselines with genuine changepoints — far deeper history than
CFTC’s 169 weekly points. The best data in the system had been invisible
because of a string literal.</p>

<blockquote>
  <p><strong>Rule.</strong> A lookup that misses is an event, not a <code>None</code>. Any config that
names a field in a schemaless payload must be validated against a live
sample at startup and fail loudly on a miss — because “this source is quiet”
and “this source is misconfigured” produce identical output, and only one of
them is something you’d act on.</p>
</blockquote>

<hr />

<h2 id="7-the-anomaly-ranking-sorted-by-unix-timestamp">7. The anomaly ranking sorted by Unix timestamp</h2>

<p>Now we are in “healthy-looking and full of constants” territory. The
<code>entity_alerts</code> table has 4,852 rows. It is the input to the ranking that
decides which entities are interesting. It is fully populated, non-null,
plausibly distributed, and completely determined by the clock.</p>

<p><strong>Symptom.</strong> Nothing. That’s the point. The table looks fine.</p>

<p><strong>Root cause.</strong> <code>agent/fusion/surprise.py</code>, the temporal component:</p>

<pre><code class="language-python">dt_actual = obs.get("observed_at", 0.0)
# We use the raw absolute error, z-scored later
temporal_s = abs(dt_pred_val - dt_actual)
</code></pre>

<p><code>observed_at</code> is a raw Unix epoch, around 1.787e9. The model’s predicted delta
is a small number near zero. So <code>temporal_surprise</code> is, to four significant
figures, <strong>the observation’s timestamp</strong>. The comment promises z-scoring
“later.” Later never arrives.</p>

<p>The composite is a fixed weighted sum of five components. Decomposing it over
all 4,852 rows against the code’s own normalised weights:</p>

<table>
  <thead>
    <tr>
      <th>component</th>
      <th style="text-align: right">mean contribution</th>
      <th style="text-align: right">share of composite</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>temporal</code></td>
      <td style="text-align: right">265,258,327.59</td>
      <td style="text-align: right"><strong>90.066%</strong></td>
    </tr>
    <tr>
      <td><code>neighborhood</code></td>
      <td style="text-align: right">29,252,180.70</td>
      <td style="text-align: right"><strong>9.932%</strong></td>
    </tr>
    <tr>
      <td><code>value</code></td>
      <td style="text-align: right">4,542.36</td>
      <td style="text-align: right">0.002%</td>
    </tr>
    <tr>
      <td><code>obs_type</code></td>
      <td style="text-align: right">6.85</td>
      <td style="text-align: right">0.000%</td>
    </tr>
    <tr>
      <td><code>memory</code></td>
      <td style="text-align: right">0.00</td>
      <td style="text-align: right">0.000%</td>
    </tr>
  </tbody>
</table>

<p>The reconstruction matches the stored <code>composite_surprise</code> mean to fifteen
significant figures, so this is arithmetic, not inference. And
<code>neighborhood_surprise</code> is itself a weighted mean of neighbours’ composites —
so it, too, is 90% timestamp. The Spearman correlation between
<code>composite_surprise</code> and the two epoch-derived terms alone is <strong>0.99993</strong>.</p>

<p>Everything the score was supposed to measure — how surprising the observation’s
<em>type</em> was, how far its <em>value</em> deviated, how much the entity’s memory <em>drifted</em>
— together accounts for 0.002% of the ranking.</p>

<p>Three further constants in the same table, all across all 4,852 rows:</p>

<ul>
  <li><code>memory_drift</code>, <code>cusum_statistic</code>, <code>event_study_score</code> are <strong>exactly 0.0</strong> in
every row. Three of nine columns are dead and nothing said so.</li>
  <li><code>obs_type_surprise</code> sits at <code>23.025850929940457</code> in 4,808 of 4,852 rows —
that is <code>-log(1e-10)</code>, the clamp floor. 99.1% of the time the model assigns
the observed type a probability below the clamp, and the “surprise” is the
clamp constant, not a measurement.</li>
  <li><code>alert_time</code> has exactly <strong>one</strong> distinct value across all 4,852 rows.</li>
</ul>

<p><strong>Fix.</strong> None applied — this table is now excluded from everything customer-
facing, and the tier that would have consumed it is being retired.</p>

<blockquote>
  <p><strong>Rule.</strong> Any quantity that enters a weighted sum must be dimensionless
before it gets there, and the place to enforce that is a unit test that
asserts each component’s magnitude range, not a comment saying it’ll be
normalised later. Then: for every composite score you ship, log the
per-component contribution share on a real sample. If one term is 90% of the
score, either that’s intended and you should say so, or you have just found
a bug that no amount of staring at the formula would have shown you — this
formula is <em>correct</em>. It’s the inputs that aren’t.</p>
</blockquote>

<hr />

<h2 id="8-cosine-similarity-that-could-not-return-anything-but-10">8. Cosine similarity that could not return anything but 1.0</h2>

<p><strong>Symptom.</strong> <code>convergence_clusters</code> — the table representing “these entities
are surprising together, which is the actual product thesis” — has 42 rows.
Their <code>correlated_surprise_score</code> values run from <strong>0.9976510</strong> to
<strong>0.9998583</strong>. All 42. The score’s own docstring says “1.0 for perfectly
aligned surprise patterns, 0.0 for orthogonal.”</p>

<p><strong>Root cause.</strong> The score is the mean pairwise cosine similarity of the
five-element surprise vectors from Way 7 — used raw, unnormalised. Every such
vector has the shape <code>[~23, ~1.7e9, ~small, ~1.4e8, 0.0]</code>. Its largest
component is between one and eight orders of magnitude bigger than every other
component, and it is a timestamp, so it agrees closely between any two rows
collected in the same era. Two such vectors point in almost the same direction
<em>no matter which entities they describe</em>.</p>

<p>Measured on 300 real alert vectors, 44,850 pairs:</p>

<pre><code>pairwise cosine     min 0.7306   p1 0.9093   median 0.9890   max 1.0000
                    49.1% of all pairs exceed 0.99
</code></pre>

<p>Averaging over a cluster’s pairs pulls it tighter still, which is how 42 out of
42 land in a band 0.0022 wide. The number is not measuring convergence. It is
measuring that these are all timestamps.</p>

<p><strong>Fix.</strong> None; the table is excluded from the product and the tier is retiring.</p>

<blockquote>
  <p><strong>Rule.</strong> Cosine similarity on unnormalised, mixed-unit vectors is not a
similarity measure, it is a measure of which component has the biggest units.
Standardise per dimension before any geometric operation. And a diagnostic
for free: if a score that is <em>defined</em> on [0, 1] never leaves a 0.002-wide
band across your entire dataset, it is not a score. Assert the spread, not
just the range — <code>0 &lt;= x &lt;= 1</code> passes on a constant.</p>
</blockquote>

<hr />

<h2 id="9-placeholders-that-outlived-the-promise-to-fill-them">9. Placeholders that outlived the promise to fill them</h2>

<p>Same 42 rows, different mechanism, so it gets its own entry.</p>

<p><strong>Symptom.</strong> <code>contributing_tools_json</code> is <code>[]</code> in all 42 rows.
<code>temporal_span_hours</code> is <code>0.0</code> in all 42 rows. Both are columns a reader would
use to sanity-check a cluster: which data sources produced it, and over what
window.</p>

<p><strong>Root cause.</strong> <code>agent/fusion/convergence.py</code>:</p>

<pre><code class="language-python">tools: tuple[str, ...] = ()          # filled by scorer
...
temporal_span_hours=0.0,             # same moment
</code></pre>

<p>The scorer does not fill it. <code>grep -n contributing_tools agent/fusion/entity_scorer.py</code>
returns nothing. The DAG faithfully persists the empty tuple.</p>

<p><code>temporal_span_hours</code> is worse, because the class docstring twelve lines above
defines it: <em>“Hours between earliest and latest member alert_time.”</em> That is a
subtraction over data the function already has in hand. It is a hardcoded
literal instead, with a comment asserting a fact — “same moment” — that nothing
measured.</p>

<p>Both survived from the initial implementation to production because a column
that is always empty raises no error and fails no test, and because writing the
comment felt like writing the ticket.</p>

<p><strong>Fix.</strong> None; excluded with the rest of the table.</p>

<blockquote>
  <p><strong>Rule.</strong> <code># filled in later</code> is an unassigned ticket living in a place with
no ticket queue. If a field cannot be computed yet, make it nullable and
leave it <code>NULL</code> — a <code>NULL</code> is a question, an empty list is an answer. Then
add the cheap test that catches this entire family: for every column you
persist, assert it takes more than one distinct value across a real run.
That single test would have caught Ways 7, 8, and 9 on the day each landed.</p>
</blockquote>

<hr />

<h2 id="10-a-threshold-that-filtered-nothing">10. A threshold that filtered nothing</h2>

<p><strong>Symptom.</strong> The convergence detector’s first step is “filter to entities with
<code>composite_surprise</code> above a threshold.” The default threshold is <code>2.0</code>.</p>

<p>The minimum <code>composite_surprise</code> in the table is <strong>64,241,535</strong>.</p>

<p><strong>Root cause.</strong> The threshold was chosen when the composite was a z-score-like
quantity in single digits, which is what the design intended. Way 7’s units bug
moved the composite eight orders of magnitude, and the threshold — a plain
default argument in a signature, correct at the time it was written — was never
revisited, because nothing connects the two. Every entity passes. The
“elevated entities” set is the set of all entities.</p>

<p>So the clustering that produced Way 8’s numbers ran over the entire graph, not
over an anomalous subset. There is no selection step. There never was.</p>

<p><strong>Fix.</strong> None; excluded.</p>

<blockquote>
  <p><strong>Rule.</strong> A constant threshold on a derived quantity is a silent coupling to
that quantity’s scale, and scale changes don’t produce merge conflicts. Log
the pass rate of every filter, every run — <code>filtered 4852 -&gt; 4852 (100.0%)</code>
is a line that would have ended this in one night. A filter that never
rejects and a filter that always rejects are both bugs, and neither one
throws.</p>
</blockquote>

<hr />

<h2 id="11-six-tests-that-passed-by-never-running-the-code">11. Six tests that passed by never running the code</h2>

<p><strong>Root cause.</strong> <code>tests/test_entity_linking.py</code> has a fixture builder for GDELT
events, defaulting to <code>goldstein: float = 1.0</code>. In May, a Goldstein tension
gate was added to <code>GDELTTool._persist_entities</code>:</p>

<pre><code class="language-python">if gs is None or gs &gt;= _GOLDSTEIN_TENSION_THRESHOLD:   # -5.0
    continue
</code></pre>

<p>Goldstein scores run roughly −10 (conflict) to +10 (cooperation), and the gate
drops anything at or above −5.0 as routine diplomatic noise — by the source
file’s own measured distribution, that is 89.9% of GDELT. Every fixture in the
class defaulted to <code>1.0</code>, squarely inside the discarded 89.9%. So every event
was dropped before reaching the link-creation code the class exists to test.</p>

<p><code>TestGDELTEventInvolves</code> has ten tests, and the gate split them cleanly:</p>

<ul>
  <li><strong>Four</strong> assert a link <em>is</em> created (<code>test_normal_link_created</code>,
<code>test_link_metadata_contains_event_info</code>, <code>test_dedup_same_country_pair</code>,
<code>test_multiple_country_pairs</code>). These went <strong>red</strong> the day the gate landed.</li>
  <li><strong>Six</strong> assert that <em>no</em> link is created — same country, missing country,
whitespace-only country, no store attached. These stayed <strong>green</strong>, and were
worthless. <code>assert len(links) == 0</code> is trivially true when nothing runs.
Not one of them could have failed for any reason, and all six were serving
as evidence that the gate logic and the country-comparison logic worked.</li>
</ul>

<p><strong>How long it hid.</strong> The gate landed 2026-05-12; this was found 2026-08-27.
107 days.</p>

<p>And here is the part I’m least proud of, because it’s the part that made the
other 107 days possible: <strong>the four red tests were red the whole time.</strong> The
suite had 19 failures when I finally sat down with it. A suite with a standing
failure count is a suite nobody reads, and inside that unread failure count
were six tests quietly reporting success for a reason that had nothing to do
with the code under test.</p>

<p><strong>Fix</strong> (<code>74017b1</code>). Fixture default changed to −7.0 so the code actually runs;
the class went green <em>and</em> meaningful. Suite went 19 failures to 10, and the
remaining 10 were real bugs fixed separately. The fix carries a seven-line
comment naming the commit that introduced the gate, because the next person to
see <code>goldstein=-7.0</code> will wonder why it’s not zero.</p>

<blockquote>
  <p><strong>Rule.</strong> A test that cannot fail is worse than no test, because it consumes
the attention a missing test would have attracted. Two cheap defences:
mutation-test your critical paths (break the code on purpose, confirm the
suite goes red), and treat coverage on <em>guard clauses</em> as a first-class
metric — the gate that skipped everything was covered; the branch behind it
was not. Whenever you add an early-<code>continue</code> to production code, grep the
fixtures that feed it.</p>

  <p>And the meta-rule, which is the expensive one: <strong>a non-zero standing failure
count destroys the signal value of the entire suite.</strong> Not just for the
broken tests — for every test in it, including the six that were lying.</p>
</blockquote>

<hr />

<h2 id="12-a-test-that-asserted-the-bug">12. A test that asserted the bug</h2>

<p><strong>Symptom.</strong> Three DAGs failed or silently produced nothing.
<code>entity_scoring</code> crashed with <code>index 69 is out of bounds for dimension 1 with
size 69</code>. The other two threw <code>mat1 and mat2 shapes cannot be multiplied
(93x49 and 23x64)</code>. Six downstream tables held zero rows against 365,000
healthy observations.</p>

<p><strong>Root cause.</strong> Three registries had drifted apart with nothing comparing them:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th style="text-align: right">live DB</th>
      <th style="text-align: right">code constants</th>
      <th style="text-align: right">trained weights</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>entity types</td>
      <td style="text-align: right">12</td>
      <td style="text-align: right">11</td>
      <td style="text-align: right">12</td>
    </tr>
    <tr>
      <td>observation types</td>
      <td style="text-align: right">38 present, 4 unknown to code</td>
      <td style="text-align: right">48</td>
      <td style="text-align: right">48</td>
    </tr>
    <tr>
      <td>instrument feature dim</td>
      <td style="text-align: right">49</td>
      <td style="text-align: right">49</td>
      <td style="text-align: right"><strong>23</strong></td>
    </tr>
  </tbody>
</table>

<p>Three failures fell out of that. <code>ENRICHMENT_DIM</code> was hardcoded to <code>55</code> —
correct only while the observation-type list had 46 entries; the writer indexes
<code>offset + 9 + ot_idx</code> over the <em>live</em> list, so at 48 types the block overflowed.
For one node type that overflow hit the end of the tensor and crashed. For
instrument nodes it ran into the price-feature block that follows: <strong>silent
corruption instead of a crash, depending only on node type.</strong></p>

<p>Separately, <code>maritime_area</code> existed in the database but not in the code’s
<code>ENTITY_TYPES</code>, so the feature builder fell back to <code>type_idx = 0</code> and one-hot
encoded every maritime area as a CFTC contract. A <code>log.warning</code> fired and the
run continued. It trained and scored as the wrong kind of entity for months.</p>

<p>And the part that belongs in this list: <strong>a test asserted the buggy behaviour.</strong>
<code>assert features[0, 0] == 1.0</code> — which is precisely what the wrong fallback
produces. The suite was green <em>over</em> the corruption, and had been used as
evidence that the corruption wasn’t there.</p>

<p><strong>Fix</strong> (<code>7c79c02</code>). <code>ENRICHMENT_DIM</code> is now derived
(<code>9 + len(OBSERVATION_TYPES)</code>), never written as a literal. An unknown entity
type gets an <strong>all-zero</strong> one-hot — claiming no identity rather than the wrong
one. A new <code>validate_schema_against_store()</code> raises before anything trains or
scores, listing every live type the code cannot encode. And checkpoint loading
now names the drift explicitly — <code>instrument: trained_weights=23
expected_by_model=49</code> — instead of logging “skipped N keys,” which is how a
randomly-initialised layer had been passing for a harmless omission.</p>

<blockquote>
  <p><strong>Rule.</strong> Three rules, and I’d take all three anywhere:</p>
  <ol>
    <li>Any dimension derived from a registry must be <strong>computed</strong>, never
hardcoded. A literal that equals <code>len(SOME_LIST)</code> today is a time bomb
with one registry edit left on its timer.</li>
    <li>Never degrade an unknown categorical to index 0. Claiming no identity is
honest; claiming the <em>wrong</em> identity is corruption that trains cleanly
and evaluates cleanly and is invisible.</li>
    <li>If a test asserts a fallback or default, verify the fallback is correct
before treating the green suite as evidence of anything. A test freezes
behaviour; it does not bless it.</li>
  </ol>
</blockquote>

<hr />

<h2 id="13-fifty-one-hypotheses-zero-survivors-and-one-that-looked-significant">13. Fifty-one hypotheses, zero survivors, and one that looked significant</h2>

<p>The last lie is the one I’d been most at risk of telling other people.</p>

<p><strong>Symptom.</strong> A forward-return event study on the product’s own CFTC anomalies
produced a headline result: <code>mm_net_pct_oi</code> at <code>|z| &gt;= 2</code>, 20 trading days,
n=123 events, <strong>+2.18% edge over baseline</strong>, 65.0% hit rate against a 50.4%
base rate, <strong>p = 0.002</strong>.</p>

<p>That is a marketing page. It is also noise.</p>

<p><strong>Root cause.</strong> The study tested 9 fields x 3 horizons x 2 z-thresholds = 54
hypotheses, 51 of them with enough events to be testable. Under the null, the
expected number of uncorrected p &lt; 0.05 results from 51 independent tests is
about 2.6. I got 7. Applying Benjamini-Hochberg at alpha = 0.05: <strong>0 of 51
survive.</strong> The best adjusted p-value is 0.102.</p>

<p>A single p = 0.002 pulled from a 51-cell grid is not evidence. It is the grid.</p>

<p>Two further problems, both of which cut against the positive:</p>

<p><strong>Pseudo-replication.</strong> Even the best cell is not 123 independent
observations. Those 123 events fall on only <strong>116 distinct weeks</strong>, and the
busiest weeks have 5 of 5 eligible contracts firing simultaneously — the same
macro positioning shift hitting correlated commodities in the same week. The
effective n is meaningfully below the counted n, so even the uncorrected 0.002
overstates confidence before BH is applied at all.</p>

<p><strong>Attrition.</strong> Of 21,294 (contract, field, week) combinations where a causal
z-score was computable, <strong>7,029 (33.0%)</strong> had to be dropped because no
instrument price existed within 7 days of the CFTC report’s publication date.
One in three anomalies in the product’s own history cannot be graded at all.</p>

<p>I built the method to be hostile to a positive result, which is the only way to
believe one:</p>

<ul>
  <li>The null is the <strong>unconditional</strong> forward return over the same horizon pooled
across all 19 tickers (n ≈ 1,578 per horizon), not zero. The 2023–2026
commodity tape drifted up; measuring against zero would have handed me an
“edge” that was just the drift.</li>
  <li><strong>Publication lag is honoured.</strong> CFTC <code>observed_at</code> is the Tuesday <em>as-of</em>
date (verified: every distinct timestamp in the DB falls on a Tuesday); the
report goes public the following Friday. The study adds a 3-day lag and
enters at the first close on or after publication. The naive version would
have traded three days before the data existed. That constraint is where most
of the 33% attrition comes from.</li>
  <li>The z-score function is a <strong>direct copy</strong> of the production one — expanding
window, <code>hist = x[:-1]</code> — so the test scores what the product shipped, not an
idealised version of it.</li>
  <li>No contract was selected or dropped on performance. All 19 price-linked
contracts are used unconditionally.</li>
</ul>

<p><strong>And the null replicates the literature.</strong> Re-running BH on the published
p-values in Sanders, Irwin &amp; Merrin (2009) — a positive result in the COT
literature — gives <strong>0 of 30 survivors</strong>, minimum adjusted p = 0.105, against
my 0.102. My negative result agrees with the published data. It disagrees with
the published <em>conclusion</em>, and the difference is entirely that the published
work does not correct for multiple testing. Neither does most of the rest of
this literature.</p>

<p><strong>The caveat I have to state, because a hostile reader would find it.</strong> My
specification pools two-sided <code>|z|</code> and appends the forward return unsigned:</p>

<pre><code class="language-python">if abs(z) &gt;= zt:
    events[(field, zt, h)].append(log_ret)
</code></pre>

<p>A genuinely signed effect — high positioning predicts down, low predicts up —
cancels itself in that bucket by construction. Combined with roughly 10% power
at these sample sizes, the honest claim is <strong>“no detectable effect at low power
on this specification,”</strong> not “no effect exists.” I am not going to overstate
my negative result to make the shutdown look more decisive than it is.</p>

<blockquote>
  <p><strong>Rule.</strong> Decide your hypothesis count <em>before</em> you look, and correct for it.
If you tested a grid, report the grid — a result presented without its
denominator is not a result. Then check whether your n is really n: events
clustered in calendar time or across correlated instruments are not
independent trials, and nothing in the p-value knows that. And when a
negative result would be convenient for you, audit it as hard as you’d audit
a positive one; mine has a specification flaw that I would very much have
preferred not to find.</p>
</blockquote>

<hr />

<h2 id="what-id-actually-keep">What I’d actually keep</h2>

<p>Reading these back, they sort into four families, and only one of them is
about being careless.</p>

<p><strong>It lied about whether it ran</strong> (1, 2, 3). Failures converted to silence by
exception handlers; a health check measuring a proxy that cannot reach its own
alerting value during the incident it guards; a timeout that stops the waiting
rather than the working. Ordinary, and every system has them. These are the
cheap ones to find, because someone eventually notices the table is empty.</p>

<p><strong>It lied about what it had</strong> (4, 5, 6, 12). Duplicates laundered through a
correct changepoint detector into a false market claim; a correct statistic on
a 22x-too-short baseline; 175,275 rows a run discarded on a string literal; a
registry drift that crashed for one node type and silently corrupted for
another. Shared signature: <strong>nothing threw</strong>. The data was wrong in a way that
has no exception type, so there was nothing to catch and nowhere to catch it.</p>

<p><strong>It lied about what it meant</strong> (7, 8, 9, 10, 13). A ranking sorted by
wall-clock; a similarity that could not return anything but 1.0; placeholders
that outlived the comment promising to fill them; a filter with a 100% pass
rate; a p-value detached from its denominator. Every one of these tables is
populated, non-null, correctly typed, plausibly distributed, and would pass any
schema check you or I would write. The formulas are right. The units aren’t.</p>

<p><strong>And the tests agreed with all of it</strong> (11, 12, and Way 1’s guard). Six tests
green because a gate skipped the code they existed to test. One test asserting
the corrupt fallback value, so the suite was green <em>over</em> the corruption and
was being cited as evidence against it. And a silent-success guard whose own
measure was a node count. Every one of those is a defence I built, that
reported the all-clear, on the specific failure it existed to catch.</p>

<p>The through-line: <strong>every check I had asked “is there output?” and none asked
“does the output vary?”</strong> A constant is the most convincing possible fake — it
has a type, a range, a mean, a plausible distribution once you have 4,852 of
them, and it is stable across runs, which reads as reliability.</p>

<p>If you take one thing from this, take the cheapest test in the list, from
Way 9. For every column you persist, on every real run, assert it takes more
than one distinct value, and log its distinct count and its spread. That single
check catches Ways 7, 8, 9, and 10 — four of the five worst things in this
post — on the day each of them lands, before any of it reaches a customer.</p>

<p>I did not have that check. That is why this is a post-mortem and not a
changelog.</p>

<hr />

<h2 id="reproducing-any-of-this">Reproducing any of this</h2>

<p>Everything above is a query, a file and line, or a commit in the repository.
Nothing is anonymised and nothing is rounded in my favour.</p>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>Evidence</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td><code>b7a126b</code>; <code>SELECT dag_name, status, COUNT(*) FROM dag_runs GROUP BY 1,2</code></td>
    </tr>
    <tr>
      <td>2</td>
      <td><code>agent/pipeline/executor.py:215</code> and <code>:250</code>; unfixed</td>
    </tr>
    <tr>
      <td>3</td>
      <td>LESSONS.md F-13; <code>6d656ba</code>, <code>dec920b</code>; <code>agent/pipeline/executor.py:328-354</code>, <code>:471</code></td>
    </tr>
    <tr>
      <td>4</td>
      <td><code>1e86f0f</code>; duplicate-count query in the text above</td>
    </tr>
    <tr>
      <td>5</td>
      <td><code>2782d21</code>; point counts per <code>observation_type</code> in <code>entity_observations</code></td>
    </tr>
    <tr>
      <td>6</td>
      <td><code>2957703</code>; <code>4294 + 162251 + 8730 = 175275</code></td>
    </tr>
    <tr>
      <td>7</td>
      <td><code>agent/fusion/surprise.py:190-196</code>, weights at <code>:74-100</code>; decomposition over <code>entity_alerts</code></td>
    </tr>
    <tr>
      <td>8</td>
      <td><code>agent/fusion/convergence.py:184-199</code>; <code>SELECT MIN/MAX(correlated_surprise_score) FROM convergence_clusters</code></td>
    </tr>
    <tr>
      <td>9</td>
      <td><code>agent/fusion/convergence.py:167</code> and <code>:175</code> (docstring at <code>:46</code>); <code>grep -n contributing_tools agent/fusion/entity_scorer.py</code> returns nothing</td>
    </tr>
    <tr>
      <td>10</td>
      <td><code>agent/fusion/convergence.py:97</code>, default <code>surprise_threshold=2.0</code>; <code>MIN(composite_surprise) = 64241535</code></td>
    </tr>
    <tr>
      <td>11</td>
      <td><code>74017b1</code>; <code>tests/test_entity_linking.py::TestGDELTEventInvolves</code> (line 611); <code>agent/tools/gdelt.py:50-53,716</code></td>
    </tr>
    <tr>
      <td>12</td>
      <td>LESSONS.md F-12; <code>7c79c02</code></td>
    </tr>
    <tr>
      <td>13</td>
      <td><code>docs/research/cftc_forward_return_event_study.md</code>; <code>scripts/cftc_event_study.py</code></td>
    </tr>
  </tbody>
</table>

<p>The failure log this is drawn from — F-01 through F-13, each with symptom, root
cause, fix, and prevention rule — is <code>LESSONS.md</code> in the repository. It was
maintained continuously, not reconstructed for this post, which is the only
reason I can date how long each of these hid.</p>]]></content><author><name>Saurabh Patel</name></author><category term="Other" /><summary type="html"><![CDATA[I spent about five months building a data pipeline that collects free public data — CFTC futures positioning, DeFi TVL, SEC insider filings, GDELT events, bankruptcy dockets, AIS vessel tracks, government contracts — scores it for anomalies, and sold a weekly digest off the result.]]></summary></entry><entry><title type="html">Welcome to “How Things Work”!</title><link href="https://savabs.github.io/2025/02/12/sv%C4%81gata.html" rel="alternate" type="text/html" title="Welcome to “How Things Work”!" /><published>2025-02-12T00:00:00+00:00</published><updated>2025-02-12T00:00:00+00:00</updated><id>https://savabs.github.io/2025/02/12/sv%C4%81gata</id><content type="html" xml:base="https://savabs.github.io/2025/02/12/sv%C4%81gata.html"><![CDATA[<p>Hello and welcome! 🌟</p>

<h2 id="heres-what-you-can-expect">Here’s what you can expect:</h2>
<ul>
  <li><strong>Simple</strong> - Learning and Building stuff</li>
</ul>

<p>Thank you for being here, and happy exploring! 🚀</p>]]></content><author><name>Saurabh Patel</name></author><category term="Other" /><summary type="html"><![CDATA[Hello and welcome! 🌟]]></summary></entry></feed>