<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://blog.nodrama.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.nodrama.io/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-07-16T15:22:34+00:00</updated><id>https://blog.nodrama.io/feed.xml</id><title type="html">NoDrama</title><subtitle>Engineer | Rust &amp; Python | Distributed Systems | Data Pipelines | ML/AI</subtitle><author><name>Filip Bielejec</name></author><entry><title type="html">A tunnel home</title><link href="https://blog.nodrama.io/tunnel-home-wireguard/" rel="alternate" type="text/html" title="A tunnel home" /><published>2026-07-16T00:00:00+00:00</published><updated>2026-07-16T00:00:00+00:00</updated><id>https://blog.nodrama.io/tunnel-home-wireguard</id><content type="html" xml:base="https://blog.nodrama.io/tunnel-home-wireguard/"><![CDATA[<h1 id="-intro"><a name="intro"></a> Intro</h1>

<p>I want to sit in a cafe, open the laptop, point <code class="language-plaintext highlighter-rouge">OPENAI_BASE_URL</code> at the box at home, and have the coding agent work exactly as it does at my desk. Nothing about the model, the harness, or the evals should notice it moved.</p>

<p>That’s the whole goal. The reason it isn’t trivial is the promise the rest of the setup rests on – <em>nothing leaves the local network</em> (see the <a href="/local-coding-harness/">first post</a>, wher eI put a coding agent on a spare box and the <a href="/household-chat-and-rag-mcp/">second</a> where I gave the household a chat UI).</p>

<p>The honest version of the goal now isn’t “nothing leaves the LAN”.
On a cafe wifi my prompts <em>must</em> cross the public internet.
What I still control is <strong>who is in the path</strong>. So the property to preserve is narrower and more defensible: end-to-end encryption between two machines I own, with no third party able to read it, hold it, or replay it.
Self sovereignty basically.</p>

<!-- That sounds like a small networking chore. It turned out to be a chore wrapped around a stack of wrong assumptions -- starting with mine about my own ISP. -->

<h1 id="-what-the-network-is-doing"><a name="measure"></a> What the network is doing</h1>

<p>Before choosing anything, I ran two commands:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-4</span> <span class="nt">-s</span> ifconfig.me     <span class="c"># -&gt; a real, public IPv4</span>
whois &lt;that address&gt;
</code></pre></div></div>

<p>The address is nowhere near carrier-NAT space (<code class="language-plaintext highlighter-rouge">100.64.0.0/10</code>) – my ISP doesn’t CGNAT residential lines. So port forwarding works and I don’t need NAT traversal at all, which is what makes everything downstream cheap: a hosted mesh service earns its keep by punching through carrier NAT, and I don’t have any.</p>

<p>The RIPE record lists the range as dynamic xDSL. Like anyone who isn’t paying their ISP for a static address, I don’t have one – what I have is a <strong>sticky</strong> address: unchanged for months at a time, contractually dynamic, free to move on any modem reboot or line resync.</p>

<p>There’s also native IPv6 on the line, a whole <code class="language-plaintext highlighter-rouge">/64</code>. Tempting – no NAT at all. But cafe wifi is frequently IPv4-only, and an endpoint you can’t reach from the network you’re actually sitting on is worthless. IPv6 goes in the drawer for now.</p>

<h1 id="-the-failure-mode-that-makes-ddns-non-optional"><a name="lockout"></a> The failure mode that makes DDNS non-optional</h1>

<p>Sticky-not-static sounds like a footnote. It’s the one genuinely bad failure in the design: while abroad, the line resyncs, the home IP changes, client config hardcodes the old endpoint, and fixing it requires knowing the new IP – which requires access to the hone LAN.
Chicken and egg. And because the IP is sticky, this happens rarely enough that you’ll more than likely have completely forgotten it’s possible by the time it bites you.</p>

<p>So: dynamic DNS, via <a href="https://desec.io/">deSEC</a> – a Berlin-based non-profit, open source, DNSSEC by default.
A third party, but a precisely-bounded one: it learns an IP and a hostname, and <strong>no traffic flows through it</strong>.</p>

<p>Two gotchas, both of which only bite when you can’t debug them:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">wg-quick</code> resolves <code class="language-plaintext highlighter-rouge">Endpoint</code> once, at interface start, and never re-resolves.</strong> DDNS alone changes nothing for a live tunnel.</li>
  <li><strong>Delete the AAAA record on purpose.</strong> The box has native IPv6, so the updater would happily publish one, and <code class="language-plaintext highlighter-rouge">wg-quick</code> might then resolve the endpoint to v6 – which works beautifully at home and fails on IPv4-only cafe wifi. <code class="language-plaintext highlighter-rouge">curl -4</code> plus an empty <code class="language-plaintext highlighter-rouge">myipv6=</code> forces v4 and drops the AAAA.</li>
</ul>

<h1 id="-why-wireguard"><a name="choice"></a> Why WireGuard</h1>

<p>With NAT traversal off the table as a requirement, the field narrows to one obvious answer, and every property I wanted falls out of it:</p>

<ul>
  <li><strong>No daemon and no account.</strong> WireGuard is in the kernel, about 4k lines of it. Nothing runs as root on my behalf, nothing phones a control plane, nothing rewrites my iptables behind Docker’s back.</li>
  <li><strong>No third party at all.</strong> Keys are two files I generate. Nobody brokers them and nobody sees the metadata.</li>
  <li><strong>Invisible to scanners.</strong> An unauthenticated packet draws <em>no reply</em> – not a refusal, nothing. The port may as well not exist unless you hold the key.</li>
  <li><strong>Split tunnelling for free.</strong> <code class="language-plaintext highlighter-rouge">AllowedIPs = 10.10.0.1/32</code> routes only the tunnel’s own address, so a commercial VPN can stay on alongside it.</li>
</ul>

<!-- The question I'd been asking -- "is a third-party control plane acceptable?" -- was too abstract to answer honestly. The sharper version: **does a proprietary daemon get root on the box that holds my models, my RAG index, and my spouse's work documents?** Put that way it isn't close. -->

<!-- And the cost of the "hard" option turned out to be two config files, four commands, and a single port-forward rule (`UDP 51820`). -->

<h1 id="-the-design-and-the-invariant-that-makes-it-cheap"><a name="design"></a> The design, and the invariant that makes it cheap</h1>

<p>The thing that keeps this small: <strong><code class="language-plaintext highlighter-rouge">llama-server</code> doesn’t change at all.</strong> It stays bound to <code class="language-plaintext highlighter-rouge">127.0.0.1:8080</code>, never bound to any network interface, mesh or LAN. WireGuard doesn’t expose the model – it makes <code class="language-plaintext highlighter-rouge">weebeastie</code>’s <em>SSH</em> reachable, and the existing SSH tunnel does the rest.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>travel laptop (café)                     weebeastie (home)
                                           wg0 10.10.0.1  ◀── UDP 51820 forwarded
  qwen-code                                    │
  OPENAI_BASE_URL=localhost:8080/v1            ▼
        │                                    sshd  ◀── LAN + wg0 only; :22 NOT forwarded
        ▼                                      │
  ssh -fN -L 8080:127.0.0.1:8080  ═══════▶  127.0.0.1:8080  llama-server
</code></pre></div></div>

<p>The tempting shortcut is to bind <code class="language-plaintext highlighter-rouge">llama-server</code> to the WireGuard interface and skip SSH. That would mean the model needs its own auth – llama.cpp’s <code class="language-plaintext highlighter-rouge">--api-key</code> is a bearer string in a config file, not a credential system – and it puts an unauthenticated inference endpoint one firewall mistake away from the LAN. Going through SSH means the auth is a keypair that already exists, the encryption is doubled, and the loopback-only property from the first post survives untouched.</p>

<p><strong>Port 22 is deliberately not forwarded.</strong> WireGuard is the front door; SSH lives behind it. An unauthenticated WireGuard packet gets <em>no reply at all</em> – the box is invisible to internet-wide scanners.</p>

<p>Most tutorials will also tell you to enable <code class="language-plaintext highlighter-rouge">ip_forward</code> and add a <code class="language-plaintext highlighter-rouge">MASQUERADE</code> rule. Don’t – those are for using the box as a <em>gateway</em> into your LAN. Here the tunnel’s only destination is the box itself, so packets terminate at the interface and the blast radius stays at one host.</p>

<h1 id="-the-trap-never-test-this-from-your-own-lan"><a name="testing"></a> The trap: never test this from your own LAN</h1>

<p>The obvious test is: laptop on the LAN, dial your own public IP, see if it works. This fails – not because your config is wrong, but because most consumer routers don’t implement NAT loopback (hairpinning), so packets from inside addressed to the WAN IP just die. <strong>You get a false negative and go hunting a bug that doesn’t exist.</strong></p>

<!-- I know this because I wrote "never test from the LAN" in my own design doc and then, an hour later, told myself to test from the LAN. -->

<!-- The fix is pleasingly ironic. C -->
<p>In order to test it on LAN connect the <strong>laptop</strong> to a commercial VPN, and bring the tunnel up: your packets exit at its node and re-enter your router from the real internet, so the port-forward gets exercised on the real path with no hairpin involved.
<!-- My NordVPN subscription earns its place after all -- not as the transport, which is what I'd assumed I'd bought it for, but as the **test harness**. It also settles the one fact you cannot establish from inside your own house: whether that public IP is really yours or a carrier NAT wearing a public-range costume. A returning handshake disproves it outright. --></p>

<!-- Two footnotes. NordLynx is itself WireGuard, so you're nesting WireGuard in WireGuard -- if the handshake succeeds but data silently stalls, that's MTU, not auth. And a clean datacenter exit doesn't predict a cafe;  -->
<p>If you want a surefire test use a phone hotspot, since your carrier almost certainly CGNATs you.</p>

<h1 id="-using-it"><a name="use"></a> Using it</h1>

<p>The whole thing collapses into a Makefile target. <code class="language-plaintext highlighter-rouge">away</code> re-points <code class="language-plaintext highlighter-rouge">REMOTE</code> at the WireGuard peer using a target-specific variable.
It’s deliberately explicit rather than a boot-time service: the endpoint is my home public IP, and since my router doesn’t hairpin, a tunnel that came up automatically would be permanently broken at home – which is where the laptop mostly lives. <code class="language-plaintext highlighter-rouge">make away</code> pings the peer and tears itself down in 3 seconds if you’re not actually away.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make tunnels      <span class="c"># at home -- straight over the LAN</span>
make away         <span class="c"># outside -- WireGuard up, then the same tunnels over it</span>
make wg-status    <span class="c"># endpoint / handshake / transfer</span>
make away-stop
</code></pre></div></div>

<p>Everything downstream is untouched. Same <code class="language-plaintext highlighter-rouge">OPENAI_BASE_URL</code>, same env vars, same eval gate, same <code class="language-plaintext highlighter-rouge">qwen -p</code> <em>artichoke</em> smoke test.
From the client’s point of view <code class="language-plaintext highlighter-rouge">localhost:8080</code> is <code class="language-plaintext highlighter-rouge">localhost:8080</code> – the harness genuinely cannot tell it isn’t home.</p>

<h1 id="-where-it-stands"><a name="wrap"></a> Where it stands</h1>

<p>The box now answers from anywhere, and the model is exactly as private as it was on day one: bound to loopback, never on a network interface, reachable only through a keypair I hold. sshd is key-only, root login off, port 22 not exposed to anything. The front door is a UDP port that doesn’t answer strangers.</p>

<p>Configs, the Makefile and the design doc are in the <a href="https://github.com/fbielejec/local-harness">repository</a>.</p>]]></content><author><name>Filip Bielejec</name></author><category term="llm" /><category term="local-inference" /><category term="self-hosting" /><category term="self-sovereign" /><category term="wireguard" /><category term="networking" /><category term="vpn" /><category term="ssh" /><summary type="html"><![CDATA[Reaching the household model from outside the house without giving up the loopback-only posture -- a WireGuard front door, an SSH tunnel behind it, and dynamic DNS to survive a sticky IP.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.nodrama.io/images/logo.png" /><media:content medium="image" url="https://blog.nodrama.io/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">A self-hosted ChatGPT</title><link href="https://blog.nodrama.io/household-chat-and-rag-mcp/" rel="alternate" type="text/html" title="A self-hosted ChatGPT" /><published>2026-07-12T00:00:00+00:00</published><updated>2026-07-12T00:00:00+00:00</updated><id>https://blog.nodrama.io/household-chat-and-rag-mcp</id><content type="html" xml:base="https://blog.nodrama.io/household-chat-and-rag-mcp/"><![CDATA[<h1 id="-intro"><a name="intro"></a> Intro</h1>

<p><a href="/local-coding-harness/">Previously</a> I described how I deployed a coding agent running entirely on the home LAN – a spare box, codenamed <code class="language-plaintext highlighter-rouge">weebeastie</code>, serving mid-range <code class="language-plaintext highlighter-rouge">Qwen3-Coder-30B-A3B</code> model over <code class="language-plaintext highlighter-rouge">llama-server</code>, reached from a laptop by <a href="https://github.com/QwenLM/qwen-code">Qwen-Code</a> through an SSH tunnel.</p>

<p>I wanted to make the same model usable by e.g. spouse, hence these two additions, both guaranteeing the same self-sovereign posture (loopback-bound, nothing leaves my LAN):</p>

<ol>
  <li><strong>A chat UI for everyone.</strong> <a href="https://github.com/open-webui/open-webui">Open WebUI</a> in a container, so any phone, tablet or laptop browser on the LAN gets a ChatGPT-style window onto the local Qwen – with per-user accounts and per-user memory.</li>
  <li><strong>A grounded document assistant.</strong> A retrieval-augmented-generation (RAG) server over European Parliament committee documents, built primarily for my spouse’s work.</li>
</ol>

<p>The interesting design decision is that the RAG isn’t bolted onto the chat, even though Open WebUI comes with some RAG capabilities.
Instead it is a <strong>shared RAG server spoken to over <a href="https://modelcontextprotocol.io/">MCP</a></strong>, so that the <em>same</em> server can be used both by the household chat <em>and</em> my coding agent.</p>

<p>That is because eventually more collections will be indexed in the RAG db - e.g. my notes, household documents, TODO list, vacay plans …</p>

<h1 id="-the-chat"><a name="chat"></a> The chat</h1>

<p>The whole security story from last time was that the inference port never touches the LAN – <code class="language-plaintext highlighter-rouge">llama-server</code> binds <code class="language-plaintext highlighter-rouge">127.0.0.1:8080</code> and is <em>only</em> reachable over an SSH tunnel. Adding a web UI must not break that.</p>

<p>Open WebUI runs in Docker with <code class="language-plaintext highlighter-rouge">network_mode: host</code>.
The UI container reaches the model over loopback and exposes its port <code class="language-plaintext highlighter-rouge">3000</code> to the LAN, making it visible on <code class="language-plaintext highlighter-rouge">192.168.1.22:3000</code>.
The <code class="language-plaintext highlighter-rouge">llama-server</code> port stays as private as before.</p>

<!-- ![The chat in front of the loopback-bound model](/images/2026-07-12-household-chat-and-rag-mcp/chat.svg) -->

<p><img src="/images/2026-07-12-household-chat-and-rag-mcp/answer.png" alt="Open WebUI answering from the local Qwen, with the llama-server journal alongside" /></p>

<p>On the left, the chat at <code class="language-plaintext highlighter-rouge">192.168.1.22:3000</code> answering from <code class="language-plaintext highlighter-rouge">Qwen3-Coder-30B-A3B-Instruct-IQ4_XS</code>; on the right, logs on <code class="language-plaintext highlighter-rouge">weebeastie</code> showing the request stream through the model’s slots – ~100 tokens/s of prompt eval, ~19 tokens/s generated.</p>

<p>A few decisions made along the way:</p>

<ul>
  <li><strong>Reboot survival for free.</strong> Just a docker container with <code class="language-plaintext highlighter-rouge">restart: unless-stopped</code> means the chat auto-returns.</li>
  <li><strong>Fixed accounts, signup locked.</strong> Three accounts (me as admin, spouse, a shared <code class="language-plaintext highlighter-rouge">guest</code>), <code class="language-plaintext highlighter-rouge">ENABLE_SIGNUP=false</code>. Separate accounts give separate, per-user memories automatically.</li>
  <li><strong>Sandbox gap.</strong> The model only turns tokens into tokens; any code it emits runs in the <em>viewer’s</em> browser (WASM/<code class="language-plaintext highlighter-rouge">pyodide</code>), not on <code class="language-plaintext highlighter-rouge">weebeastie</code>; the container isolates filesystem and processes. The deliberate hole is that host networking removes network isolation, so I keep the dangerous switches (server-side code execution, admin-installed tools) <em>off</em> and apply the cheap hardening (<code class="language-plaintext highlighter-rouge">no-new-privileges</code>, <code class="language-plaintext highlighter-rouge">cap_drop: ALL</code>).</li>
  <li><strong>Only one thing leaves the LAN, and only on consent.</strong> Web search via <a href="https://exa.ai/">Exa</a> is on, behind an explicit per-query confirmation. Version checks, telemetry and community sharing are all <em>off</em>.</li>
</ul>

<h1 id="-the-rag-mcp-server"><a name="rag"></a> The RAG MCP server</h1>

<p>The document assistant is a Rust pipeline: fetch EP committee PDFs from the <a href="https://data.europarl.europa.eu/">Open Data Portal</a>, parse, chunk, embed and upsert into a <a href="https://qdrant.tech/">Qdrant</a> vector database, also hosted locally.
Each query is then embedded under the <em>identical</em> recipe as at the insert time, top-k passages are pulled, and the local Qwen is asked to answer <strong>using only those passages, citing each one, or saying “I don’t know.”</strong></p>

<p>In my first version the RAG was packaged as an OpenAI-compatible <em>model</em> (retrieval and generation coupled together) – a second entry in the chat’s model dropdown that owned the whole loop (embed → Qdrant → ground → call the generator → cited answer).
It worked, but it <strong>welded retrieval to this one specific generator</strong>.
So eventually it got re-cast as a <strong>shared MCP retrieval server</strong>, <code class="language-plaintext highlighter-rouge">ep-rag-mcp</code>, attached to the <em>host</em> (the thing running the agent loop), not to the model server.</p>

<p><code class="language-plaintext highlighter-rouge">llama-server</code> is just a text generator with no MCP client.
The decide-to-call → dispatch → feed-back cycle lives <em>above</em> the model, in the host.
So the two hosts – Open WebUI and Qwen-Code – each hold their own MCP client, both point at the <strong>same</strong> retrieval server, and both independently use <code class="language-plaintext highlighter-rouge">llama-server</code> as their generator.
<!-- The MCP server and `llama-server` are **siblings, not stacked**.  -->
The win is loose coupling: retrieval is implemented once and reused; the generator stays a swappable dropdown in the chat.</p>

<p><img src="/images/2026-07-12-household-chat-and-rag-mcp/topology.svg" alt="One shared RAG-over-MCP server, two hosts, one generator" /></p>

<p>Only <code class="language-plaintext highlighter-rouge">:3000</code> faces the LAN. <code class="language-plaintext highlighter-rouge">:8082</code> (MCP), <code class="language-plaintext highlighter-rouge">:6334</code> (Qdrant) and <code class="language-plaintext highlighter-rouge">:8080</code> (the model) all stay loopback. <code class="language-plaintext highlighter-rouge">ep-rag-mcp</code> is deployed exactly like <code class="language-plaintext highlighter-rouge">llama-server</code> – a loopback systemd unit, <code class="language-plaintext highlighter-rouge">Restart=always</code>, enabled at boot.</p>

<h1 id="-tool-call-routing-native-tool-calling-vs-classify-then-answer"><a name="routing"></a> Tool call routing: native tool-calling vs. classify-then-answer</h1>

<p>The household model needs a way to decide whether a message even needs the RAG.
<!-- Retrieving on a question the model already knows is not free -- the grounding prompt then forces "I don't know" onto an answerable question. So the routing decision matters. --></p>

<p>Both paths start from the same artefact: a small <a href="https://github.com/fbielejec/local-harness/blob/88f800016736fe18778404b0ff48fab99913e35e/rag/data/route_tree.json">decision tree expressed as JSON</a>, versioned and auditable, whose node questions are natural language the model evaluates (“Is the user asking a substantive question about the content or positions of EMPL/REGI/IMCO committee documents?”).
Two ways to consume it are:</p>

<ul>
  <li><strong>Mode A – native tool-calling.</strong> Hand the model the tree as policy plus a <code class="language-plaintext highlighter-rouge">search_ep_committee_docs</code> tool, and let it emit a <code class="language-plaintext highlighter-rouge">tool_call</code> when it decides to ground. One turn.</li>
  <li><strong>Mode B – walk the tree, emit a JSON decision.</strong> Ask the model to <em>walk</em> the tree and return a compact <code class="language-plaintext highlighter-rouge">{"reached": …, "tool": …, "reason": …}</code> object. The <em>code</em> then reads that JSON and decides. A separate classify turn <em>before</em> generation.</li>
</ul>

<p>I was not sure if a smaller model like Qwen-30B will be able to reliably answer in Mode B (although prefferable, given one less model turn).</p>

<p>So I just ran both against the same 10 labelled queries – 4 in-domain EP questions, 4 clearly off-topic (including a carbonara recipe), and 2 deliberately hard “generic-EU-fact” borderline cases (“Who is the current President of the Commission?”) that look on-topic but should <em>not</em> hit the document index:</p>

<table>
  <thead>
    <tr>
      <th>Mode</th>
      <th>Routing correct</th>
      <th>Format</th>
      <th>Note</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>B – walk tree → JSON decision</strong></td>
      <td><strong>10/10</strong></td>
      <td>10/10 valid JSON</td>
      <td>correct on <em>both</em> hard borderline cases; ~2.5 s per classify (warm)</td>
    </tr>
    <tr>
      <td>A – native tool-calling</td>
      <td>8/10</td>
      <td>10/10 valid tool args</td>
      <td>0 missed EP questions; the 2 errors were <strong>over-firing</strong> on borderline generic-EU questions</td>
    </tr>
  </tbody>
</table>

<p>The read: Qwen can obey the tree <em>flawlessly</em> when asked to reason through it explicitly (B). Native tool-choice (A) short-circuits that reasoning – it pattern-matches “EU institution → call the EU tool” and skips the walk, over-firing on exactly the borderline questions where restraint matters.
This tracks, as the Open WebUI’s own docs warn that small local models are unreliable at native tool-calling, and Qwen-30B sits right at the border of a small and a medium model.</p>

<p>So the household path pays a <strong>~2.5-second classify turn</strong> to buy a <strong>10/10 vs 8/10</strong> routing score, and does it deterministically – the model never emits a tool_call in the chat; the <em>code</em> decides from parsed JSON, so the routing is as reliable as the classifier and all the grounding logic lives in one place in Rust (“code is law”).
For a spouse asking policy questions, correct-and-a-bit-slower beats fast-and-occasionally-wrong.</p>

<p>Answer and a citation, chunk directly from the Qdrant db:</p>

<p><img src="/images/2026-07-12-household-chat-and-rag-mcp/ragged_answer.png" alt="Factual answer" /></p>

<!-- My coding agent takes the other branch: Qwen-Code attaches to the *same* MCP server and uses native tool-calling, because a coding agent has a human in the loop and the occasional over-fire is cheap to shrug off. Same tree, same retrieval server, two evaluators -- the tree is the structure, the evaluator is pluggable. -->

<h1 id="-where-it-stands"><a name="wrap"></a> Where it stands</h1>

<p>The box that used to just autocomplete my code now has a face the whole household can talk to from any device, a per-user memory, and a grounded, cited answer path over real EP committee documents – all still behind a single LAN-facing port, with the model itself as private as the day it was set up. Setup, the Rust RAG crates, and the routing spike are in the <a href="https://github.com/fbielejec/local-harness">repository</a>.</p>]]></content><author><name>Filip Bielejec</name></author><category term="llm" /><category term="local-inference" /><category term="self-hosting" /><category term="self-sovereign" /><category term="rag" /><category term="mcp" /><category term="qdrant" /><category term="open-webui" /><category term="qwen" /><summary type="html"><![CDATA[I deployed Open WebUI ChatGPT-style chat UI for every device on the LAN, and a shared RAG-over-MCP retrieval server that both the chat and the coding agent can attach to.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.nodrama.io/images/logo.png" /><media:content medium="image" url="https://blog.nodrama.io/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">A household coding agent</title><link href="https://blog.nodrama.io/local-coding-harness/" rel="alternate" type="text/html" title="A household coding agent" /><published>2026-07-07T00:00:00+00:00</published><updated>2026-07-07T00:00:00+00:00</updated><id>https://blog.nodrama.io/local-coding-harness</id><content type="html" xml:base="https://blog.nodrama.io/local-coding-harness/"><![CDATA[<h1 id="-introduction"><a name="intro"></a> Introduction</h1>

<p>Commercial coding assistants ship your keystrokes to someone else’s datacenter.
That is a fine for most people; but if the whole point is <em>self-sovereignty</em> – keeping your code, your prompts, and your family’s chatbot traffic on hardware you physically own there are now alternatives.
<a href="https://vitalik.eth.limo/general/2026/04/02/secure_llms.html">Vitalik’s <em>Secure LLMs</em></a> makes the case for a local-first inference, open weights, and sandboxing everything.
<a href="https://magazine.sebastianraschka.com/p/using-local-coding-agents">Sebastian Raschka’s <em>Using local coding agents</em></a> showed it is now quite <em>practical</em> – a small mixture-of-experts model with only a few billion active parameters is enough to drive a real agent loop.</p>

<p>So the goal for this project: an agentic coding assistant, plus a household chat frontend, that never leave the local network.</p>

<h1 id="-one-strongish-box-many-thin-clients"><a name="topology"></a> One strongish box, many thin clients</h1>

<p>I already have a spare desktop – an i9-10850K with 64 GB of RAM and a modest GTX 1050 Ti codename<code class="language-plaintext highlighter-rouge">weebeastie</code>.
The idea was to make it the household’s inference engine: it holds the model and does the heavy lifting, while laptops, phones, and tablets on the LAN are just thin clients talking to it.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>laptop / phone / tablet                        weebeastie  192.168.1.22
  coding harness   ──ssh tunnel──▶ localhost:8080 ──▶ llama-server (llama.cpp)
  local git repos                                     Qwen3-Coder-30B-A3B  (GGUF)
</code></pre></div></div>

<p>A couple of hardware realities that shaped everything downstream:</p>

<ul>
  <li><strong>The CPU is the real inference engine, not the GPU.</strong> 4 GB of VRAM is by far too little to
hold a 30B model’s experts, so the expert layers live in RAM and run on the 10 cores. The
GPU earns its keep only by accelerating attention.
<!-- and holding the KV cache. --></li>
  <li><strong>Generation is memory-bandwidth-bound.</strong> With ~45 GB/s of dual-channel DDR4, token
throughput is essentially set by <em>how many bytes you read per token</em>.
<!-- That single fact turns out to be the whole story of the tuning that follows. --></li>
</ul>

<p><code class="language-plaintext highlighter-rouge">llama-server</code> from <a href="https://github.com/ggml-org/llama.cpp">llama.cpp</a> does the serving: selected mainly because it has every CPU/GPU knob you might think of.
It stays bound to loopback address on the remote and is reached over an SSH tunnel – the inference port is never actually on the LAN.</p>

<h1 id="-picking-the-harness-qwen-code"><a name="harness"></a> Picking the harness: Qwen-Code</h1>

<p>The <em>harness</em> is the agent loop – the thing that reads files, calls tools, runs commands, and feeds results back to the model.
It matters as much as the model, harnesses differ very sharply in how they treat tool calls and in turn how many tokens they burn (e.g. Raschka notes Codex is frugal, Claude Code is the most token-hungry).</p>

<p>I went with <a href="https://github.com/QwenLM/qwen-code">Qwen-Code</a> for two reasons.
Firstly it plays nicely with <code class="language-plaintext highlighter-rouge">llama-server</code>, talking to it directly with no proxy required.
Second it is tuned for the Qwen model family – which is exactly the model family the hardware pushed me toward.</p>

<h1 id="-choosing-the-model-family-then-sweeping-it"><a name="model"></a> Choosing the model family, then sweeping it</h1>

<p>The harsh truth is my modest hardware at the moment rules out most of the field.
A 355B-class model or a dense 70B simply will not fit in 64 GB
<!-- , and even if it did, reading that many bytes per token over DDR4 would make it unusably slow.  -->
The one path to a responsive agent on this box is a <strong>small-active-parameter MoE</strong> – e.g. <a href="https://huggingface.co/Qwen/Qwen3-Coder-30B-A3B-Instruct">Qwen3-Coder-30B-A3B-Instruct</a> with 30B total parameters but only ~3B active per token, coder-specialized, and well-supported in GGUF.</p>

<p>So that fixes the <em>family</em>. The open question was which quantization and serving config gives the most tokens/second without the model getting measurably worse at coding.
Since generation scales with bytes-read-per-token, a smaller quant should be strictly faster – as long as it still “codes correctly”.</p>

<p>For such tasks, requiring a time-consuming-yet-repetetive sweep over parameters I’ve enjoyed good experience offloading them to the <a href="/autoresearch-gem-strategy/">autoresearch loops</a>.
So I instructed an agent to do an overnight sweep, benchmarked with <code class="language-plaintext highlighter-rouge">llama-bench</code> at a realistic 16k context depth, and measured every candidate with a coding quality eval supplied by Raschka’s <a href="https://github.com/rasbt/local-coding-agent-evals">local-coding-agent-evals</a>.</p>

<p>Results below:</p>

<table>
  <thead>
    <tr>
      <th>Config</th>
      <th>gen @ 16k ctx</th>
      <th>quality gate</th>
      <th>verdict</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Q4_K_M (baseline)</td>
      <td>8.38 tok/s</td>
      <td>5/5 agent-pack</td>
      <td>reference</td>
    </tr>
    <tr>
      <td><strong>IQ4_XS</strong></td>
      <td><strong>8.80 tok/s</strong></td>
      <td><strong>5/5 agent-pack</strong></td>
      <td><strong>winner (+5%)</strong></td>
    </tr>
    <tr>
      <td>Q4_0</td>
      <td>8.77 tok/s</td>
      <td>–</td>
      <td>dominated by IQ4_XS</td>
    </tr>
    <tr>
      <td>KV-cache quant q4_0</td>
      <td>8.40 tok/s</td>
      <td>–</td>
      <td>tie (not the bottleneck)</td>
    </tr>
    <tr>
      <td>partial expert offload to GPU</td>
      <td>~8.85 tok/s</td>
      <td>–</td>
      <td>tie (PCIe ping-pong cancels the gain)</td>
    </tr>
    <tr>
      <td>more threads (20 vs 10)</td>
      <td>8.82 tok/s</td>
      <td>–</td>
      <td>tie (bandwidth-bound, not thread-bound)</td>
    </tr>
    <tr>
      <td>speculative decoding</td>
      <td>8.76 tok/s</td>
      <td>–</td>
      <td>no gain</td>
    </tr>
  </tbody>
</table>

<p>This table is almost monotonously consistent with one hypothesis: <strong>the only lever that ever moves the generation speed is the size of the expert weights you read per token.</strong>
Dropping from Q4_K_M to <code class="language-plaintext highlighter-rouge">IQ4_XS</code> (4.25 bits per weight, 15.25 GiB) buys a clean +5% <em>and</em> uses less VRAM – while scoring identically on the coding eval.
Everything else – KV-cache quantization, GPU expert offload, more threads, even speculative decoding with a vocab-compatible draft model – lands within noise.
<!-- The bottleneck is irreducible: bytes over the memory bus. -->
<!-- `IQ4_XS` is the production config now. -->
Note that this finding is for <em>my</em> harware, and will more than likely differ, so I just present it as the methodology more than actual open-weighted model recommendation.</p>

<h1 id="-setup-instructions"><a name="repo"></a> Setup instructions</h1>

<p>Setup instructions, eval and code published in the <a href="https://github.com/fbielejec/local-harness">repository</a>.</p>

<!-- # <a name="next"/> Next: opening it to the household -->

<!-- The other half  vision is a -->
<!-- ChatGPT-like web UI so the rest of the household can use the same model from any device -- -->
<!-- [Open WebUI](https://github.com/open-webui/open-webui) in a container, LAN-exposed behind a -->
<!-- login wall, talking to the same loopback-bound `llama-server`. The nice security property: -->
<!-- only the UI (which has accounts) is reachable on the LAN; the raw inference port never is. -->]]></content><author><name>Filip Bielejec</name></author><category term="llm" /><category term="local-inference" /><category term="self-hosting" /><category term="llama-cpp" /><category term="qwen" /><category term="agentic-coding" /><summary type="html"><![CDATA[Running an open-weights coding agent entirely on the home LAN -- a spare box serves a Qwen3-Coder MoE over llama.cpp to laptops and phones around the house. Why the harness is Qwen-Code, and how a quantization sweep found the fastest model that still codes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.nodrama.io/images/logo.png" /><media:content medium="image" url="https://blog.nodrama.io/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Bitcoin regime detection with HMMs</title><link href="https://blog.nodrama.io/btc-regime-detection-drawdown-hmm/" rel="alternate" type="text/html" title="Bitcoin regime detection with HMMs" /><published>2026-06-03T00:00:00+00:00</published><updated>2026-06-03T00:00:00+00:00</updated><id>https://blog.nodrama.io/btc-regime-detection-drawdown-hmm</id><content type="html" xml:base="https://blog.nodrama.io/btc-regime-detection-drawdown-hmm/"><![CDATA[<h1 id="-introduction"><a name="intro"></a> Introduction</h1>

<p>Perhaps a truism but markets behave differently in different regimes: a momentum strategy that works in 2021 stops working in mid-2022.
<!-- A short basis trade that's safe through a chop blows up at the first 30% week. -->
If you want a principled label for “what kind of market is this, right now” evaluated systematically then the obvious tool is a <a href="https://en.wikipedia.org/wiki/Hidden_Markov_model">Hidden Markov Model</a>.
<!-- The hard part isn't the HMM. -->
<!-- EM for HMMs has been textbook since [Rabiner (1989)](https://www.cs.ubc.ca/~murphyk/Bayes/rabiner.pdf), and  -->
Textbook since Hamilton applied Markov-switching to the US GNP (<a href="https://www.jstor.org/stable/1912559">Hamilton, 1989</a>).
The hard part is <em>what data to feed it</em>.</p>

<p>In this post I argue that for cycle-aware crypto regime detection on daily BTC candles, the right observation is <strong>drawdown from the running all-time high</strong>:</p>

\[d_t = \log(p_t) - \max_{s \leq t} \log(p_s)\]

<p>It’s strictly causal, bounded above by zero, mean-reverts to zero on new highs, and – importantly – it encodes <em>where in the cycle you are</em> without breaking the stationarity assumption.
We fit a vanilla 3-state Gaussian HMM to this single number and the latent states line up with what a trader would call a bear, a chop, and a bull, with no hand-labelling and no look-ahead.</p>

<p>The model lives at <a href="/regimes/">/regimes/</a> as a live dashboard, updated daily.</p>

<hr />

<p><em>TL;DR</em>
On BTCUSDT daily, 2021-06 → 2023-06, a 3-state Gaussian HMM on <strong>drawdown from rolling max</strong> produces a median bear-state posterior of <strong>1.00</strong> over all of calendar 2022 and exactly <strong>one Viterbi flip</strong> that year.
The bull leg (2021-06 → 2021-10) classifies 100% bull; calendar 2022 is 55% bear / 45% ranging / 0% bull; the 2023-H1 recovery is 59% ranging / 41% bear / 0% bull.
Every window matches historical consensus labels.
Student’s-t emissions collapse to Gaussian at this feature scale ($\nu$ saturates at the upper bound), so the shipped model is the simpler one.</p>

<hr />

<h1 id="-a-short-tour-of-hmm-regime-models"><a name="lit"></a> A short tour of HMM regime models</h1>

<p>HMMs in finance have a fairly settled toolkit.
EM for HMMs has been used since <a href="https://www.cs.ubc.ca/~murphyk/Bayes/rabiner.pdf">Rabiner (1989)</a>, and the interesting axis of variation are <em>the observation</em> and <em>the transition structure</em>, not the inference machinery itself.</p>

<p><a href="https://www.jstor.org/stable/1912559"><strong>Hamilton (1989)</strong></a> introduced the Markov-switching autoregression for U.S. GNP growth: a $K=2$ model where the observation is the growth rate and the latent states are labelled “expansion” and “recession”.
The subsequent literature varies different observations, different K, sometimes time non-homogenous transitions.</p>

<p>For crypto specifically, four papers map the space:</p>

<ul>
  <li><strong><a href="https://arxiv.org/abs/2011.03741">Koki, Leonardos, Piliouras (2020)</a></strong> fit a Bayesian $K=4$ NH-HMM to the daily percentage log-returns of BTC / ETH / XRP (2014–2019), with Pólya-Gamma augmentation on the multinomial-logit transition probabilities.
They report <strong>VIX as the dominant transition predictor</strong> (posterior inclusion probability 1.00 across all three coins), and that $K=4$ beats $K \in {2, 3, 5}$ on out-of-sample CRPS.
The structural finding is the interesting part: the bull regime splits into <strong>two sub-states that share an almost identical mean and differ only in volatility</strong> – a “bull-high-vol” and a “bull-low-vol” – plus a low-occupancy “calm” auxiliary state.
With $K=3$ on log-returns you cannot distinguish bull-vol from bull-quiet; EM either collapses them into one over-flipping bull or lets a heavy-tail emission absorb both via a uniformly fat tail.</li>
  <li><strong>Shih, Huang, Hsu (2024)</strong> apply MS-AR(q) to <strong>weekly</strong> BTC (2018–2022) with $K=2$ and Gaussian shocks. VIX and the USD index enter the <strong>mean equation</strong> (not the transitions matrix). The high-$\sigma$ “bear” state has $\sigma \approx 0.053$ vs $\sigma \approx 0.007$ for the bull – a $7.5\times$ ratio – and weekly aggregation does most of the work that a heavier emission family would have to do at the daily scale.</li>
  <li><strong>Pakštaitė et al. (2025)</strong> do full Bayesian MCMC on daily BTC with a $K=2$ NH-HMM, but the observation is <strong>log-transformed scaled price</strong> (a price level, not a return). They use a Pólya-Gamma logistic transition with MCMC covariate selection over 16 macro / on-chain predictors, fit on a 100-day rolling window chosen explicitly to match a typical regime duration of 30–60 days.</li>
  <li><strong>Malekinezhad &amp; Rafati (2026)</strong> push to <strong>4-hour</strong> BTC (2024–2026) with a <strong>multivariate observation vector</strong> $(r_t, \sigma_t^{\mathrm{RV}}, v_t)$ – log-return, rolling realised volatility, normalised volume – and $K=3$ NH-HMM with locally-varying transitions.</li>
</ul>

<p>Two patterns stand out:</p>

<ol>
  <li>The <strong>default observation is log-return</strong> (or a growth rate, going back to Hamilton on US GNP) – stationary, well-studied, and what most off-the-shelf finance HMM pipelines feed in. A smaller strand of the literature instead models a <em>level</em> – log-price, optionally scaled or detrended (<a href="#refs">Pakštaitė et al., 2025</a>).
<!-- to dodge the secular drift problem ([Pakštaitė et al., 2025](#refs)). --></li>
  <li>The interesting extensions go in <strong>two orthogonal directions</strong>: enrich the observation (Pakštaitė: log-price level; Malekinezhad: multivariate channels), or enrich the transitions (Koki: covariate-driven NH-HMM, VIX dominant).</li>
</ol>

<p>The standard observation has a known structural problem on crypto: it conflates “going down” with “high volatility”, and $K=3$ isn’t enough to encode the bull-vol vs bull-quiet split.
The natural responses are Koki’s $K=4$ (more states) or Malekinezhad’s multivariate emission (more channels per state).</p>

<p>In this post we take another approach: keep $K=3$ (for operational reasons), keep the homogeneous transition matrix, keep univariate Gaussian emissions – and change the <em>observation</em> to something that carries cumulative cycle information without secular drift.</p>

<h1 id="-the-stationarity-problem"><a name="stationarity"></a> The stationarity problem</h1>

<!-- The HMM's emission model assumes that, conditional on the hidden state, observations are drawn _iid_ from the state's emission distribution. -->
<!-- That distribution is fixed once fit. -->
<!-- If the underlying observable drifts secularly, the fitted emission parameters drift with it and "regime" devolves into "early window vs late window". -->
<!-- This is a known and reasonable concern; it's why almost every finance HMM in the literature feeds the model log-returns rather than log-prices. -->

<p>The log-return as the observation, although established in the literature, has its own failure mode: namely <strong>a return is local.</strong>
What we mean by that is that a single -3% day in a bull market and a single -3% day in a bear market are indistinguishable to the emission.</p>

<p>Discrimination has to come from <em>volatility</em> – bear regimes are detected because their conditional return variance is higher.
This works (it’s why the literature settled here) but it conflates “the market is going down” with “the market is volatile”, which is only sometimes the same thing.
A long, slow grind down at low volatility – which is what late 2022 looked like once the FTX shock cleared – doesn’t separate well from a sideways chop on log-returns.</p>

<p>Concretely: In an early experimen fitting a 3-state Student’s-t HMM to BTC daily log-returns over 2021-06 → 2023-06 placed 100% of 2022 into a single “ranging” state, with $\hat\nu$ collapsing to the lower bound of $3$ in <em>every</em> state.
The model was correctly reporting that daily 2022 returns look like a fat-tailed walk near zero – statistically indistinguishable from any other post-2021 window.
<!-- The feature is the problem, not the model. --></p>

<p>The diagnosis pointed to two structural fixes:</p>

<ol>
  <li><strong>More states</strong> – Koki’s $K=4$, which gives EM somewhere to send the calm days and lets the bull split into vol vs quiet.</li>
  <li><strong>A more informative observation</strong> – something the latent state can anchor to cumulatively, not just per-day.</li>
</ol>

<p>Pakštaitė et al. reached for the 2-nd and used the level: scaled log-price.
Their approach validates a broader claim that “regime structure lives at the cumulative-price level, not at the per-day return level”.</p>

<p>But log-price is non-stationary – BTC’s log-price has a positive secular drift <em>and</em> a multi-year cycle on top of it.
An HMM fit on raw log-price tends to detect “year 1 vs year 2 vs year 3” as the latent state; “bear” becomes “early in the window”.</p>

<p>So the candidate observations split:</p>

<table>
  <thead>
    <tr>
      <th>Observation</th>
      <th style="text-align: center">Stationary?</th>
      <th style="text-align: center">Cycle-aware?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>log-return $r_t = \log(p_t) - \log(p_{t-1})$</td>
      <td style="text-align: center">yes</td>
      <td style="text-align: center">no – local only</td>
    </tr>
    <tr>
      <td>log-price $\log(p_t)$</td>
      <td style="text-align: center">no</td>
      <td style="text-align: center">yes – but trend-dominated</td>
    </tr>
    <tr>
      <td>drawdown $d_t = \log(p_t) - \max_{s \leq t} \log(p_s)$</td>
      <td style="text-align: center">bounded, mean-reverting</td>
      <td style="text-align: center">yes</td>
    </tr>
  </tbody>
</table>

<p>Log-return is stationary but cycle-blind. Log-price is cycle-aware but breaks the HMM’s stationarity assumption.</p>

<p>Using drawdownp threads both: it’s bounded above by zero, it reverts to zero whenever a new running ATH is made, and it sits deeply negative throughout sustained drawdowns.
It carries cycle information <em>without</em> secular drift.</p>

<p>It’s not strictly stationary in the textbook sense – consecutive $d_t$ values are serially correlated through the running max, which only ever moves up – but it satisfies the property the HMM actually cares about: the marginal distribution of $d_t$ in a long bear looks the same regardless of which bear.</p>

<h1 id="-the-fit"><a name="fit"></a> The fit</h1>

<p>The recipe:</p>

<ol>
  <li>Compute $d_t = \log(p_t) - \mathrm{cummax}(\log(p_t))$ on BTCUSDT daily closes.</li>
  <li>Fit a $K$-state HMM with Gaussian (and, separately, Student’s-t) emissions via Baum-Welch.</li>
  <li>K-means warm start, 5 random restarts, keep the best log-likelihood.</li>
  <li>Decode with Viterbi for the hard label; keep the forward-backward posterior for the soft one.</li>
  <li>Sort states by ascending $\hat\mu$ and name them bear $\to$ ranging $\to$ bull ($K=3$) or bear $\to$ bull ($K=2$).</li>
</ol>

<!-- Self-contained NumPy implementation, no external HMM dependency. -->
<p>Window: 2021-06-01 → 2023-06-30 (covers the 2021 bull leg, the 2022 bear, and the early 2023 recovery – three regimes you’d want a regime detector to definitely find).</p>

<p>We fit four models: Gaussian and Student’s-t at $K \in {2, 3}$.
The pass criterion was stated upfront, before looking at any fit:</p>

<ul>
  <li>Median bear-state posterior over calendar 2022 $\geq 0.6$.</li>
  <li>Viterbi flip count over calendar 2022 $\leq 6$.</li>
  <li>For Student-t vs Gaussian: median-bear-posterior gap $\geq 0.15$ <em>or</em> flip-count reduction $\geq 30\%$.</li>
</ul>

<h1 id="-does-it-match-what-a-trader-would-label"><a name="results"></a> Does it match what a trader would label?</h1>

<p>All four models pass criteria 1 and 2 trivially.
The $K=3$ Gaussian fit is the cleanest.</p>

<p><strong>Fitted emission parameters</strong> ($K=3$ Gaussian, 2021-06 → 2023-06):</p>

<table>
  <thead>
    <tr>
      <th>Regime</th>
      <th style="text-align: right">$\hat\mu$</th>
      <th style="text-align: right">$\hat\sigma$</th>
      <th>Interpretation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>bear</td>
      <td style="text-align: right">−1.21</td>
      <td style="text-align: right">0.12</td>
      <td>~70% off running ATH, tight cluster</td>
    </tr>
    <tr>
      <td>ranging</td>
      <td style="text-align: right">−0.69</td>
      <td style="text-align: right">0.20</td>
      <td>~50% off ATH, wider – local rallies / declines</td>
    </tr>
    <tr>
      <td>bull</td>
      <td style="text-align: right">−0.13</td>
      <td style="text-align: right">0.11</td>
      <td>~12% off ATH, tight – at or near running highs</td>
    </tr>
  </tbody>
</table>

<p>The means form a near-evenly-spaced grid on the drawdown axis with comparable $\sigma$ across states.
EM doesn’t have to fight a single dominant distribution for variance share – which is what you see in $K=3$ fits on log-return, where the “high-vol” state usually swallows all the interesting structure.</p>

<p><strong>Calendar-2022 metrics</strong> (the pass-criterion window):</p>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th style="text-align: right">Gaussian $K=2$</th>
      <th style="text-align: right">Student-t $K=2$</th>
      <th style="text-align: right">Gaussian $K=3$</th>
      <th style="text-align: right">Student-t $K=3$</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Median bear-state posterior</td>
      <td style="text-align: right">1.00</td>
      <td style="text-align: right">1.00</td>
      <td style="text-align: right">1.00</td>
      <td style="text-align: right">1.00</td>
    </tr>
    <tr>
      <td>Viterbi flips in 2022</td>
      <td style="text-align: right">1</td>
      <td style="text-align: right">1</td>
      <td style="text-align: right">1</td>
      <td style="text-align: right">1</td>
    </tr>
    <tr>
      <td>Fraction of 2022 in bear</td>
      <td style="text-align: right">65%</td>
      <td style="text-align: right">65%</td>
      <td style="text-align: right">55%</td>
      <td style="text-align: right">55%</td>
    </tr>
    <tr>
      <td>Fraction of 2022 in ranging ($K=3$)</td>
      <td style="text-align: right">–</td>
      <td style="text-align: right">–</td>
      <td style="text-align: right">45%</td>
      <td style="text-align: right">45%</td>
    </tr>
    <tr>
      <td>Fraction of 2022 in bull</td>
      <td style="text-align: right">35%</td>
      <td style="text-align: right">35%</td>
      <td style="text-align: right">0%</td>
      <td style="text-align: right">0%</td>
    </tr>
  </tbody>
</table>

<p>Median bear posterior of 1.00 means: on the typical day in 2022, the model is essentially certain we’re in the bear state.
One Viterbi flip across the entire year means it doesn’t oscillate.
The $K=2$ versions absorb the recovery rallies into “bull” because there’s nowhere else to put them; $K=3$ correctly classifies the rallies as ranging and reserves “bull” for actual proximity to ATH.</p>

<p><strong>Per-window Viterbi composition</strong> ($K=3$ Gaussian):</p>

<table>
  <thead>
    <tr>
      <th>Window</th>
      <th style="text-align: right">bear</th>
      <th style="text-align: right">ranging</th>
      <th style="text-align: right">bull</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2021-06 → 2021-10 (bull leg)</td>
      <td style="text-align: right">0%</td>
      <td style="text-align: right">0%</td>
      <td style="text-align: right">100%</td>
    </tr>
    <tr>
      <td>2021-11 → 2022-12 (full bear)</td>
      <td style="text-align: right">47%</td>
      <td style="text-align: right">39%</td>
      <td style="text-align: right">13%</td>
    </tr>
    <tr>
      <td>2022 (calendar year)</td>
      <td style="text-align: right">55%</td>
      <td style="text-align: right">45%</td>
      <td style="text-align: right">0%</td>
    </tr>
    <tr>
      <td>2023-01 → 2023-06 (recovery)</td>
      <td style="text-align: right">41%</td>
      <td style="text-align: right">59%</td>
      <td style="text-align: right">0%</td>
    </tr>
  </tbody>
</table>

<p>Three windows, three textbook labels, and the model – which never sees those labels – reconstructs each one:</p>

<ul>
  <li>The 2021 bull leg is <strong>100% bull</strong>. Price is making new ATHs continuously; $d_t$ around zero.</li>
  <li>The 2022 calendar year is <strong>100% non-bull</strong> (55% bear, 45% ranging), with the ranging mass concentrated in the late-summer recovery rally before the FTX collapse pulled it back to bear.</li>
  <li>The 2023 recovery is <strong>59% ranging, 41% bear, 0% bull</strong>. The bear mass is January-February (still near the cycle bottom); the ranging mass is March onward (rising off the low but still ~60% below the cycle ATH).</li>
</ul>

<p>A level-anchored feature would get the 2023 recovery wrong: full-window z-scored log-price would label rising-but-below-mean days as “bear”, because “bull” for that observation just means “above the window mean”.
Drawdown puts the threshold where a trader puts it – at the running ATH.</p>

<p>One unexpected result: <strong>Student-t collapses to Gaussian.</strong>
The fitted $\hat\nu$ saturates at the upper search bound ($30$) in every state, in every $K$.
At this feature scale – $\sigma \approx 0.1$–$0.2$ log-units, observations bounded above by zero – there are no outliers far enough from any state’s $\mu$ to warrant heavy tails.
<!-- Gaussian is sufficient and Student-t buys nothing. -->
<!-- We ship the simpler one. --></p>

<h1 id="-what-the-model-still-misses"><a name="caveats"></a> What the model still misses</h1>

<ol>
  <li>
    <p><strong>The Gaussian violates the hard upper bound $d_t \leq 0$.</strong>
For the bull state ($\hat\mu = -0.13$, $\hat\sigma = 0.11$), the fitted Gaussian assigns ~12% probability mass above zero, where the true density is exactly zero.
No observations actually fall above zero, so the likelihood is unaffected – but a window dominated by frequent new ATHs would push the model to inflate the bull-state $\sigma$ to absorb mass piled at the boundary.
A principled fix is a truncated Gaussian or a Beta on $-d_t$.
<!-- Not necessary at this window. --></p>
  </li>
  <li>
    <p><strong>Serial correlation through the running max.</strong>
$\mathrm{cummax}$ is monotone non-decreasing, so consecutive $d_t$ values are correlated through the max.
This violates the HMM’s conditional-independence assumption.
It’s the standard approximation in regime-detection on cumulative features (every implementation of MaxDD-based regime detection makes the same compromise) so we are not too worried here, but what it means is that the log-likelihood should be read as a relative goodness-of-fit signal rather than a calibrated probability.</p>
  </li>
  <li>
    <p><strong>Slow exits from bear.</strong>
The bear state ends when $d_t$ climbs back toward zero – which requires the price to approach the running ATH from below.
A multi-year recovery that ends in a <em>new</em> ATH gets correctly classified as ranging → bull.
A V-shaped recovery that <em>doesn’t</em> re-make the ATH stays in ranging indefinitely, which is technically correct but conservative.</p>
  </li>
  <li>
    <p><strong>Pure depth signal.</strong>
No volume, no funding, no on-chain signals.
The model is making strong calls from one number, which is the appeal – but it also means it has no view on, e.g., the difference between a low-volume drift down and a high-volume capitulation flush.</p>
  </li>
</ol>

<h1 id="-the-live-dashboard"><a name="dashboard"></a> The live dashboard</h1>

<p>The same recipe, re-fit over the full 2017-08 → today window, drives the public dashboard at <a href="/regimes/">/regimes/</a>.
<!-- It renders from a single `feed.json` produced by the notebook -- the page is model-agnostic, so swapping in a different K or a different observation is a feed regeneration, not a frontend change. --></p>

<!-- On the longer window the fitted parameters shift a little (the bear state's $\mu$ becomes less deep at $-1.09$ because the 2017-18 cycle bottom was less severe than 2022; its $\sigma$ widens to $0.25$ because the bear state now spans two different bears with somewhat different depths) but the structural conclusion is the same: three near-evenly-spaced states, $\approx 0.99$ diagonals in the transition matrix, and Viterbi-decoded regime spans that line up with the named episodes. -->

<p>The dashboard surfaces:</p>

<ul>
  <li>Today’s posterior $P(\mathrm{bear})$, $P(\mathrm{ranging})$, $P(\mathrm{bull})$ as bars.</li>
  <li>The one-step-ahead distribution $\pi_t \cdot A$.</li>
  <li>The full posterior tape over price history.</li>
  <li>Zoomed-in panels for four named episodes (2018 bear, COVID Q1 2020, 2020-21 bull, 2022 bear) with brief context on each.</li>
  <li>The fitted emission parameters and transition matrix.</li>
</ul>

<p>It’s not a trading signal – it’s a regime tape, to build trading signals on top of.</p>

<h1 id="-whats-next"><a name="next"></a> What’s next</h1>

<p>The lit-review-aligned roadmap is the <strong>trinity emission + VIX-in-transitions</strong> assembled model, built in stages.
The governing principle is a clean split:</p>

<blockquote>
  <p>Emission channels are things the regime <em>produces</em>. Transition covariates are things the regime <em>responds to</em>.</p>
</blockquote>

<p>Daily returns, realised volatility, and drawdown are all <em>produced</em> by the underlying regime – they belong in the emission.
VIX, the USD index, and Treasury yields are macro stress signals that <em>drive</em> regime crossings – they belong in the transition.
Paper 1 puts VIX in the mean equation; paper 3 puts it in transitions and gets a posterior inclusion probability of 1.00 across three coins.
<!-- The principle reconciles them: VIX is a driver of regime change, not a manifestation. --></p>

<p>Ranked by expected information gain per effort:</p>

<ol>
  <li>
    <p><strong>$K=4$ on drawdown alone.</strong>
Koki et al. report BTC daily-return regime structure resolves to four states. On drawdown, $K=4$ might split the bear into “deep capitulation” vs “approaching ATH from below”, or split the ranging. Cheapest sanity check.</p>
  </li>
  <li>
    <p><strong>Bivariate $(d_t, r_t)$ then trivariate $(d_t, r_t, \sigma_t^{\mathrm{RV}})$.</strong>
Brings the per-day signal back as a second channel and the realised-volatility signal as a third. The state binds to “where in the cycle” (drawdown), “what happened today” (return), and “how violently” (realised $\sigma$). This is the lit-review’s recommended setup. Realised volatility can be upgraded from a noisy 5-day return-std estimator to intra-day-aggregated realised variance (Andersen-Bollerslev-Diebold-Labys (2001)) at the cost of one Binance klines call, with no change to the HMM frequency.</p>
  </li>
  <li>
    <p><strong>NH-HMM with VIX in transitions.</strong>
The literature-aligned top of the stack (papers 2, 3, 4 all agree). Softmax-parameterised transition matrix $\log A_{ij}(t) = \mathrm{softmax}<em>j(\beta</em>{ij} \cdot x_t)$ with $x_t = (1, \mathrm{VIX}_t)$. M-step is a per-iteration logistic regression.</p>
  </li>
  <li>
    <p><strong>Fixed-lookback rolling max for $d_t$.</strong>
The current fit uses an expanding <code class="language-plaintext highlighter-rouge">cummax</code> over the full window, so $d_t$ never forgets the 2021 ATH and the bear state implicitly anchors to a specific historical peak. Swap to a true rolling max with lookback $L$ (e.g. $L = 200$ days, matched to the bear-regime duration we’re trying to detect): bounded memory, strictly stationary, and the model stops binding state identity to “how far we are from <em>the</em> ATH” and starts binding it to “how far we are from the recent ATH”. Cheap to implement; the trade-off is shorter $L$ is more responsive but loses the deep multi-quarter bear signal, longer $L$ preserves it but slows edge response. $L$ is a natural candidate for a parameter sweep – e.g. $L \in {60, 100, 200, 365, \infty}$ scored by the existing pass criteria (median bear-posterior in 2022, Viterbi flip count) – which would replace the current judgement call with a principled out-of-sample choice.</p>
  </li>
</ol>

<!-- (1) is next -- it's the smallest change from the current passing recipe and directly tests Koki et al.'s $K=4$ claim on the drawdown observation rather than the standard log-return one. -->

<h1 id="-glossary"><a name="glossary"></a> Glossary</h1>

<ul>
  <li><strong>VIX</strong> – CBOE Volatility Index. The implied 30-day volatility of S&amp;P 500 options, in annualised percentage points. Widely used as a “fear gauge”; spikes during equity stress (2008, March 2020) and tends to lead or coincide with risk-off regimes across asset classes, including crypto.</li>
  <li><strong>HMM (Hidden Markov Model)</strong> – A model where observations $y_t$ are drawn from a state-dependent distribution conditional on a latent discrete state $z_t \in {1, \ldots, K}$ that evolves as a first-order Markov chain. The states are not observed directly; they are inferred from the observations.
<!-- - **K** -- Number of hidden states in the HMM. Hyperparameter; pick by out-of-sample score or by domain reasoning (bear / ranging / bull $\to K=3$). --></li>
  <li><strong>Emission distribution</strong> – The conditional distribution $p(y_t \mid z_t = k)$. In this post each state’s emission is a Gaussian (or Student’s-t) over the drawdown observation.</li>
  <li><strong>Transition matrix $A$</strong> – The $K \times K$ matrix with $A_{ij} = P(z_{t+1} = j \mid z_t = i)$. Diagonal-heavy in regime models – once you’re in a bear, you tend to stay in a bear.
<!-- - **Baum-Welch / EM** -- The Expectation-Maximisation algorithm specialised to HMMs. Iteratively re-estimates emission and transition parameters by alternating posterior computation (E-step) with parameter updates (M-step). Local optimum; needs random restarts. --></li>
  <li><strong>Viterbi algorithm</strong> – Dynamic-programming decoder that returns the single most likely state sequence $\hat z_{1:T}$ given the observations and fitted parameters. Hard label per timestep.
<!-- - **Forward-backward posterior** -- The smoothed marginal $P(z_t = k \mid y_{1:T})$ at each timestep. Soft label -- a probability over states rather than a single choice. --></li>
  <li><strong>Viterbi flip</strong> – A timestep at which the Viterbi-decoded state changes from one regime to another. Low flip counts indicate a stable, non-oscillating fit.</li>
  <li><strong>Drawdown</strong> – $d_t = \log(p_t) - \max_{s \leq t} \log(p_s)$. The log-distance from the current price to the running all-time high; always $\leq 0$, equals zero exactly when a new ATH is made.</li>
  <li><strong>Log-return</strong> – $r_t = \log(p_t) - \log(p_{t-1})$. The standard finance observation: stationary, local, but cycle-blind.</li>
  <li><strong>Stationarity</strong> – A time series is (weakly) stationary if its mean, variance, and autocovariance don’t depend on $t$. HMMs assume emissions are stationary conditional on the state. Log-prices fail this; log-returns satisfy it, which is why for log prices are “stationarized” to e.g. sliding window maximum drawdown.</li>
  <li><strong>Secular drift</strong> – A slow, persistent trend in a time series that operates on a much longer timescale than the cyclical dynamics of interest (think decade-scale vs month-scale). BTC’s log-price has a positive secular drift from long-run adoption growth; an HMM fit directly on it tends to identify “early in the window” vs “late in the window” as the latent state rather than bear vs bull. “Secular” here is the economics sense (long-term, non-cyclical), not the religious one.</li>
  <li><strong>Realised volatility ($\sigma^{\mathrm{RV}}$)</strong> – Sample volatility computed from realised returns over a rolling window (or, at higher frequency, from intraday squared returns). An ex-post estimate, in contrast to VIX which is ex-ante and implied.</li>
  <li><strong>Shocks (innovations)</strong> – The random error term $\varepsilon_t$ in a time-series model, e.g. $r_t = \mu + \phi r_{t-1} + \varepsilon_t$ in an AR(1). The “new information” each period that isn’t predicted by the past. “Gaussian shocks” means $\varepsilon_t \sim \mathcal{N}(0, \sigma^2)$; “Student’s-t shocks” allow fat tails.</li>
  <li><strong>Student’s-t emissions</strong> – Emission family with an extra degrees-of-freedom parameter $\nu$ that controls tail heaviness. As $\nu \to \infty$ it becomes Gaussian; as $\nu \to 3$ tails get very fat. Used when the data has occasional extreme values a Gaussian would underweight.</li>
  <li><strong>Emission channels</strong> – The components of the observation vector $y_t$ that the HMM models conditional on the state. In a univariate setup there’s one channel (e.g. drawdown); in a multivariate setup there are several (e.g. $(r_t, \sigma_t^{\mathrm{RV}}, d_t)$ = return, realised vol, drawdown). Heuristic: emission channels are quantities the regime <em>produces</em> – things you’d expect to look different in a bear than in a bull, by definition of “regime”.</li>
  <li><strong>Transition covariates</strong> – External, time-varying inputs $x_t$ that drive the transition probabilities in an NH-HMM (e.g. $A_{ij}(t) = \mathrm{softmax}<em>j(\beta</em>{ij} \cdot x_t)$). Heuristic: transition covariates are quantities the regime <em>responds to</em> – things that don’t themselves describe the regime but help predict when it will flip (VIX, USD index, 10Y yield). Putting a variable in the emission says “this is part of what the regime <em>is</em>”; putting it in the transition says “this is what makes the regime <em>change</em>”.</li>
  <li><strong>NH-HMM (non-homogeneous HMM)</strong> – An HMM where the transition matrix $A_t$ depends on time-varying covariates (e.g. $A_{ij}(t) = \mathrm{softmax}<em>j(\beta</em>{ij} \cdot x_t)$ with $x_t$ including VIX). Lets external drivers modulate regime crossings.</li>
  <li><strong>MS-AR (Markov-switching autoregression)</strong> – Hamilton’s original specification: an AR(q) process whose coefficients (and/or variance) switch with a hidden Markov state. The HMM with an autoregressive observation equation.
<!-- - **MAE (Mean Absolute Error)** -- $\frac{1}{N} \sum_{i} \left|y_{i} - \hat y_i\right|$. Average absolute deviation between a point forecast $\hat y$ and the realised value $y$. Same units as the target; lower is better. More robust to outliers as compared to MSE (which squares errors), and the natural baseline for evaluating a single-number forecast. --></li>
  <li><strong>CRPS (Continuous Ranked Probability Score)</strong> – Proper scoring rule for probabilistic forecasts. Generalises MAE to distributions: instead of comparing a point forecast to the realisation, it compares the <em>full predictive CDF</em> $F$ to a point mass at the realisation $y$, integrating $(F(z) - \mathbf{1}{z \geq y})^2$ over $z$. Reduces to MAE when $F$ is a point mass. Lower is better; used to compare HMMs with different $K$ out-of-sample.</li>
  <li><strong>Pólya-Gamma augmentation</strong> – A latent-variable trick (Polson, Scott, Windle 2013) that makes logistic / multinomial-logit likelihoods conjugate to Gaussian priors. Lets you Gibbs-sample over Bayesian logistic regressions; used here for the NH-HMM’s softmax transition parameters.
<!-- - **PIP (posterior inclusion probability)** -- In Bayesian variable selection (e.g. spike-and-slab priors), the posterior probability that a given covariate has non-zero coefficient. PIP $\approx 1.00$ means the data strongly favours including the covariate. --></li>
</ul>

<h1 id="-references"><a name="refs"></a> References</h1>

<ul>
  <li>Hamilton, J. D. (1989). <a href="https://www.jstor.org/stable/1912559"><em>A new approach to the economic analysis of nonstationary time series and the business cycle.</em></a> Econometrica 57(2), 357–384.</li>
  <li>Rabiner, L. R. (1989). <a href="https://www.cs.ubc.ca/~murphyk/Bayes/rabiner.pdf"><em>A tutorial on hidden Markov models and selected applications in speech recognition.</em></a> Proceedings of the IEEE 77(2), 257–286.</li>
  <li>Andersen, T. G., Bollerslev, T., Diebold, F. X., Labys, P. (2001). <em>The distribution of realized exchange rate volatility.</em> Journal of the American Statistical Association 96, 42–55.</li>
  <li>Koki, C., Leonardos, S., Piliouras, G. (2020). <a href="https://arxiv.org/abs/2011.03741"><em>Exploring the predictability of cryptocurrencies via Bayesian hidden Markov models.</em></a> Daily percentage log-returns on BTC/ETH/XRP (2014–2019), $K=4$ NH-HMM, Bayesian MCMC with Pólya-Gamma augmentation. VIX (PIP 1.00) and 10Y Treasury yield (PIP 0.90) are the dominant transition predictors; the bull regime splits into bull-vol and bull-quiet sub-states.</li>
  <li>Shih, K.-S., Huang, P.-S., Hsu, C.-Y. (2024). <em>Bitcoin cycle through Markov regime-switching model.</em> Weekly BTC (2018–2022), MS-AR(q), $K=2$, Gaussian shocks. VIX and USD index as mean-equation covariates (not transitions). High-$\sigma$ state $\sigma \approx 0.053$ vs low-$\sigma$ state $\sigma \approx 0.007$.</li>
  <li>Pakštaitė, V. et al. (2025). <em>Bayesian MCMC HMM and NH-HMM for BTC with macro covariates.</em> Daily BTC (2016–2024), $K=2$, observation is log-transformed scaled price (a level, not a return). Pólya-Gamma logistic transitions, MCMC covariate selection over 16 macro / on-chain predictors on a 100-day rolling window.</li>
  <li>Malekinezhad, M., Rafati, P. (2026). <em>NH-HMM on 4-hour BTC with multivariate observation.</em> 4-hour BTC/USDT (2024–2026), $K=3$ NH-HMM with locally-varying transitions. Observation vector $(r_t, \sigma_t^{\mathrm{RV}}, v_t)$ = (log-return, rolling realised volatility, normalised volume).</li>
</ul>

<p>Notebook with the full fit, plots, and the <code class="language-plaintext highlighter-rouge">feed.json</code> exporter: <a href="https://github.com/nodrama-labs/research/blob/main/notebooks/regime_student_t_drawdown_2022.org">nodrama-labs/research/notebooks/regime_student_t_drawdown_2022.org</a>.
Live dashboard: <a href="/regimes/">/regimes/</a>.
Nothinh here is a financial advice.</p>]]></content><author><name>Filip Bielejec</name></author><category term="python" /><category term="bitcoin" /><category term="quantitative-finance" /><category term="hmm" /><category term="regime-detection" /><category term="time-series" /><category term="statistics" /><summary type="html"><![CDATA[A 3-state Gaussian Hidden Markov Model identifies bear / ranging / bull regimes in BTC from a single causal feature -- drawdown from the running all-time high -- with median bear-posterior of 1.00 across calendar 2022 and exactly one Viterbi flip. Why drawdown beats log-return and log-price as the HMM observation.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.nodrama.io/images/logo.png" /><media:content medium="image" url="https://blog.nodrama.io/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Aggressive bear: GEM shorting in downwards markets</title><link href="https://blog.nodrama.io/aggressive-bear-short-gem/" rel="alternate" type="text/html" title="Aggressive bear: GEM shorting in downwards markets" /><published>2026-05-15T00:00:00+00:00</published><updated>2026-05-15T00:00:00+00:00</updated><id>https://blog.nodrama.io/aggressive-bear-short-gem</id><content type="html" xml:base="https://blog.nodrama.io/aggressive-bear-short-gem/"><![CDATA[<h1 id="-introduction"><a name="intro"></a> Introduction</h1>

<p>In a previous post we looked at <a href="/gem-bear-market-models/">GEM market bear models</a>, who can show a win even in a market in a adverse conditions.
The bear specialist GEM model won by <em>mostly not playing</em>.
It sat in cash for six and a half months of 2022, then entered EURUSDT once a clean uptrend emerged – with a brief rotation through PAXG (tokenized gold) in late December – finishing the window at +6.40% while BTC was down roughly 65% from its all-time high.</p>

<p>In this post we research a different, more aggressive strategy.
Instead of a safe haven of tokens, we look at a broader spectrum.
We invert the GEM filters, weight by the magnitude of the negative trend, and short the weakest tokens on Binance USDⓈ-M perps.</p>

<hr />

<p><em>TL;DR</em>
On an 11-token mixed bear-market universe, the best aggressive short-mode GEM config (<code class="language-plaintext highlighter-rouge">lin-n1-w15</code>) returns <strong>+87.2%</strong> over May–Dec 2022 with a <strong>-27.4%</strong> drawdown, for a <strong>Calmar of 5.64</strong> – beating the long-only bear specialist’s Calmar 4.6.
Yet it loses on Calmar to a naive equal-weight short of the same universe (+57.72% returns, -16.41% max drawdown, Calmar 5.92). The single most meaningful drawdown comes from an ADA short squeeze  - something that a model looking just at the daily candles cannot see coming.
<!-- The signal is real and clustered on `fit_window=15` regardless of regression model. --></p>

<hr />

<h1 id="-from-defense-to-offense"><a name="short-mechanics"></a> From defense to offense</h1>

<p>The bear specialist was long-only and held a single token at a time.
The aggressive variant inherits the GEM ranking core, with the adjustments below.</p>

<p><strong>Short mechanics on USDⓈ-M perps.</strong>
A position is $(\text{quantity}, \text{entry_price})$.
PnL on close is $\text{quantity} \times (\text{entry_price} - \text{exit_price})$ – gains when the price falls.
Funding is paid (or received) every 8 hours; in a bear market with crowded shorts, funding is usually slightly negative for shorts, so the position accrues a small penalty each settlement.
Aggregated to daily, mean funding rates over the window ranged from roughly -5% to -30% annualised across tokens.
<!-- -- a real but secondary contribution. --></p>

<p><strong>Sign-flipped filters.</strong>
The GEM core rejects positive $a_1$ candidates and requires $\text{momentum} &lt; -\text{momentum_floor}$.
The exponential model also wants $a_1 &lt; 1$ (a compounding decline); the linear model wants a negative normalised slope.
R-squared and ATR play the same roles as in the bull/bear long specialist models.</p>

<p><strong>Momentum-magnitude weighting.</strong>
The previous specialists weighted by inverse ATR – resulting in less exposure to the more volatile assets.
For an aggressive short, we want the opposite: the strongest negative trend deserves the largest allocation.
Weights are $|m_i| / \sum_{j} |m_j|$ over the top $N$ picks.</p>

<p>The rest – fitting window, R-squared threshold, differential rebalancing, cooldown – is identical to the bear specialist.</p>

<h1 id="-experiment-setup"><a name="setup"></a> Experiment Setup</h1>

<p><strong>Window</strong>: Same May 1 – December 31, 2022 window as previously: BTC already down ~50% from ATH, Luna collapsed (May 9), 3AC liquidated (June), FTX collapsed (November). Unambiguously a bear market.</p>

<p><strong>Universe</strong>: 11 Binance USDⓈ-M perpetuals, deliberately mixing fallers and outperformers over the period, such that the selector has a discrimination problem to solve.</p>

<table>
  <thead>
    <tr>
      <th>Group</th>
      <th>Tokens</th>
      <th>End-of-window return</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Extreme fallers</td>
      <td>SOL, AVAX, ADA, DOT</td>
      <td>-69% to -89%</td>
    </tr>
    <tr>
      <td>Moderate fallers</td>
      <td>BTC, ETH, LINK, MATIC</td>
      <td>-31% to -58%</td>
    </tr>
    <tr>
      <td>Outperformers</td>
      <td>BNB, TRX, XMR</td>
      <td>-23% to -37%</td>
    </tr>
  </tbody>
</table>

<p>The outperformers are the controls: a working momentum selector should not waste capital shorting them when fallers are available.</p>

<p><strong>Data</strong>: daily klines and 8-hour funding history from Binance.
$10,000 starting capital, 0.3% round-trip fee, 10-day rebalance cooldown.</p>

<p><strong>Sweep</strong>: 32 configurations.</p>

\[\text{fit_window} \in \{15, 30, 60, 90\} \quad\times\quad
\text{top_n} \in \{1, 3, 5, 8\} \quad\times\quad
\text{model} \in \{\text{exp}, \text{lin}\}\]

<p><strong>Experiment verdict structure</strong>: Three stages, each PASS / NO-PASS:</p>

<ul>
  <li><strong>Stage 1</strong>: best-config Calmar &gt; naive equal-weight short Calmar</li>
  <li><strong>Stage 2</strong>: best-config Calmar &gt; long-only bear specialist (Calmar 4.6 from the previous post)</li>
  <li><strong>Stage 3</strong>: robust across configs ($\geq$ 1/3 positive Calmar AND a dominant <code class="language-plaintext highlighter-rouge">fit_window</code> in the top 6 models)</li>
</ul>

<h1 id="-sweep-results"><a name="sweep"></a> Sweep Results</h1>

<p>Top of the 32-config sweep, sorted by Calmar:</p>

<table>
  <thead>
    <tr>
      <th>Config</th>
      <th>Return</th>
      <th>Max DD</th>
      <th>Calmar</th>
      <th>Sharpe</th>
      <th>Rebalances</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>lin-n1-w15</strong></td>
      <td><strong>+87.2%</strong></td>
      <td><strong>-27.4%</strong></td>
      <td><strong>5.64</strong></td>
      <td>1.39</td>
      <td>22</td>
    </tr>
    <tr>
      <td>exp-n1-w15</td>
      <td>+84.3%</td>
      <td>-27.4%</td>
      <td>5.42</td>
      <td>1.36</td>
      <td>22</td>
    </tr>
    <tr>
      <td>exp-n8-w15</td>
      <td>+62.5%</td>
      <td>-24.6%</td>
      <td>4.31</td>
      <td>1.23</td>
      <td>22</td>
    </tr>
    <tr>
      <td>lin-n8-w15</td>
      <td>+61.8%</td>
      <td>-24.6%</td>
      <td>4.26</td>
      <td>1.22</td>
      <td>22</td>
    </tr>
    <tr>
      <td>exp-n5-w15</td>
      <td>+58.9%</td>
      <td>-24.9%</td>
      <td>3.99</td>
      <td>1.15</td>
      <td>22</td>
    </tr>
    <tr>
      <td>lin-n5-w15</td>
      <td>+57.7%</td>
      <td>-24.9%</td>
      <td>3.90</td>
      <td>1.14</td>
      <td>22</td>
    </tr>
    <tr>
      <td>lin-n3-w30</td>
      <td>+50.4%</td>
      <td>-25.6%</td>
      <td>3.27</td>
      <td>1.01</td>
      <td>21</td>
    </tr>
    <tr>
      <td>lin-n3-w15</td>
      <td>+51.0%</td>
      <td>-27.0%</td>
      <td>3.14</td>
      <td>1.02</td>
      <td>22</td>
    </tr>
  </tbody>
</table>

<p>Three observations, mirroring the bear-long specialist’s findings:</p>

<p><strong>1. <code class="language-plaintext highlighter-rouge">fit_window=15</code> dominates the top quartile.</strong>
All top six configs share <code class="language-plaintext highlighter-rouge">w=15</code>.
Tight windows track the bear’s punctuated drawdowns; wider windows (60, 90) catch the intermittent relief rallies and the max drawdown blows out to ~50% (data not included, we refer to the <a href="https://github.com/fbielejec/fbielejec.github.io/blob/master/notebooks/gem_bear_short.org#10-in-progress-sweep">notebook</a>).
Similar finding as in the <a href="/gem-bear-market-models/">previous post</a>: the bear-long specialist preferred <code class="language-plaintext highlighter-rouge">w=30</code>.</p>

<p><strong>2. Concentration outperforms diversification on this universe.</strong>
<code class="language-plaintext highlighter-rouge">top_n=1</code> produces the highest return (+87%) and Calmar (5.64).
<code class="language-plaintext highlighter-rouge">top_n=8</code> reduces both return (+62%) and DD (-24.6%), but the Calmar drops to 4.31.
With only 7 real fallers in the universe, diluting into 5 or 8 names forces the selector to short weaker signals alongside the strongest, and that costs more in return than it saves in drawdown.
The token universe is however probably too small to draw any larger conclusions from this fact.
The naive model winning on overall Calmar is due to the risk diversification.</p>

<p><strong>3. The choice of regression model is, again, irrelevant.</strong>
<code class="language-plaintext highlighter-rouge">lin-n1-w15</code> and <code class="language-plaintext highlighter-rouge">exp-n1-w15</code> are within 0.2 Calmar of each other.
Same pattern as the previous post: the fitting window dominates the model family.</p>

<h1 id="-what-the-winner-does"><a name="best"></a> What the winner does</h1>

<p>The best config holds a single token at a time, rebalances every 10 days when momentum changes hands, and goes to cash when no token clears the entry threshold.</p>

<p>Twenty-two rebalances over 245 days.
The equity story splits into two phases:</p>

<table>
  <thead>
    <tr>
      <th>Phase</th>
      <th>Days</th>
      <th>Equity move</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Capture</td>
      <td>10 → 60 (May 11 – Jun 30)</td>
      <td>$10,000 → $17,338  (+73%)</td>
    </tr>
    <tr>
      <td>Survive</td>
      <td>60 → 242</td>
      <td>$17,338 → $19,291 (+11%)</td>
    </tr>
  </tbody>
</table>

<p>Most of the gains happen in the first seven weeks.
The model catches SOL → AVAX → SOL → BNB → ETH in the May–June crash, rides each for 10 days, and exits to cash on June 30 when the momentum-cross trigger fires.
It then sits in cash for sixty days through July and August – the bear-market relief rally that wiped out anyone holding shorts through the chop – and re-engages in late August.</p>

<p>The strategy is “catch the trend, then survive the chop.”
About 30% of the window is spent in cash.
<!-- Not a malfunction: the design. --></p>

<p>The most-shorted name was SOL at 17.6% of the window, followed by ETH, ADA, and BNB at ~8% each.</p>

<h1 id="-the-short-squeeze-problem"><a name="squeeze"></a> The short squeeze problem</h1>

<p>The single meaningful drawdown comes from an ADA short squeeze.</p>

<p>On October 8 the model shorted ADA which at that time had a clean, 15-day downtrend.
On October 18 the trend was even cleaner; momentum-magnitude weighting kept ADA at 100% of the book.
On October 28 ADA was up roughly 15% from the Oct-18 reentry.
The peak equity at Oct 18 was $18,817; by Oct 28 it was $16,622.
~12% drawdown in ten days, on one position.</p>

<!-- Looking at the ADA chart,  -->
<p>But than what happened is a textbook short squeeze: a token already down 70% from its highs, heavily shorted (funding rates flipped to “shorts pay longs” in the days before), price ricocheting back from $0.30s to $0.40 as overcrowded shorts got liquidated, forcing even more buying.</p>

<p><img src="/images/2026-05-15-aggressive-bear-short-gem/ada_squeeze.png" alt="ADAUSDT daily candles around the Oct 2022 short squeeze, with the model's entry, re-entry and forced-exit dates annotated" style="width: 100%;" /></p>

<p><strong>Daily OHLCV cannot anticipate this.</strong>
The price trend was clean and negative – exactly what the model is built to detect.
The information that would have flagged the risk – funding rate inversion, open interest divergence, liquidation cluster proximity – is not in the kline feed.</p>

<p>This is the meta-finding the post is about: the model’s risk is mostly squeeze risk, and the information lives outside just the price data.</p>

<h1 id="-baselines-and-the-stage-report"><a name="baselines"></a> Baselines and the Stage Report</h1>

<p>The two reference strategies on the same universe:</p>

<table>
  <thead>
    <tr>
      <th>Strategy</th>
      <th>Return</th>
      <th>Max DD</th>
      <th>Calmar</th>
      <th>Sharpe</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Buy &amp; Hold (11-token equal weight)</td>
      <td>-54.5%</td>
      <td>-58.1%</td>
      <td>-1.19</td>
      <td>-0.85</td>
    </tr>
    <tr>
      <td>Naive equal-weight short, no rebalance</td>
      <td>+57.7%</td>
      <td>-16.4%</td>
      <td><strong>5.92</strong></td>
      <td>1.48</td>
    </tr>
    <tr>
      <td><strong>GEM short, lin-n1-w15</strong></td>
      <td><strong>+87.2%</strong></td>
      <td><strong>-27.4%</strong></td>
      <td><strong>5.64</strong></td>
      <td><strong>1.39</strong></td>
    </tr>
    <tr>
      <td>Long-only bear specialist (previous post)</td>
      <td>n/a</td>
      <td>n/a</td>
      <td><strong>4.60</strong></td>
      <td>1.59</td>
    </tr>
  </tbody>
</table>

<p><img src="/images/2026-05-15-aggressive-bear-short-gem/equity_curves.png" alt="Equity curves of all 32 sweep configurations (grey) overlaid with Buy &amp; Hold, Naive equal-weight short, and the best Calmar GEM short config" style="width: 100%;" /></p>

<p>Stage Report:</p>

<table>
  <thead>
    <tr>
      <th>Stage</th>
      <th>Criterion</th>
      <th>Result</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Stage 1</strong></td>
      <td>best Calmar &gt; naive Calmar</td>
      <td><strong>NO-PASS</strong> (5.64 vs 5.92, $\Delta$ -0.28)</td>
    </tr>
    <tr>
      <td><strong>Stage 2</strong></td>
      <td>best Calmar &gt; long-only bear (4.6)</td>
      <td><strong>PASS</strong> ($\Delta$ +1.04)</td>
    </tr>
    <tr>
      <td><strong>Stage 3a</strong></td>
      <td>$\geq$ 1/3 configs positive Calmar</td>
      <td><strong>PASS</strong> (32/32)</td>
    </tr>
    <tr>
      <td><strong>Stage 3b</strong></td>
      <td>dominant <code class="language-plaintext highlighter-rouge">fit_window</code> in top 6</td>
      <td><strong>PASS</strong> (w15, 6/6)</td>
    </tr>
  </tbody>
</table>

<p>The Stage 1 NO-PASS deserves comment, because it would be easy to read it as “the model adds nothing over a naive baseline.”, but that is not what is happening.
The naive equal-weight short holds 11 tokens equally, all the way through.
Its max drawdown is structurally smaller than any 1-asset short can match – the diversification across 11 names naturally limits the impact from DD events – at the cost of lower returns (+57.7% vs +87.2%).
The GEM short with <code class="language-plaintext highlighter-rouge">top_n=1</code> is, by construction, more exposed to token event risks (see <a href="/aggressive-bear-short-gem/#squeeze">short squeeze</a>).
<!-- The naive short earns less return (+57.7% vs +87.2%) but loses less on its worst day, and Calmar rewards that. --></p>

<!-- So the GEM short *generates more return* than naive ranking would suggest is possible. -->
<!-- What it cannot do at `top_n=1` is match a 11-asset portfolio's drawdown floor. -->
<!-- This is a portfolio-construction limit, not a signal failure. -->

<p>Stage 2 confirms the signal is there: the model clears the long-only bear specialist, despite carrying the big drawdown event.
Stage 3 confirms the signal is clustered, not a single-config artifact: all top 6 configs share the same fit window, and 32 of 32 configurations produce positive Calmar.</p>

<h1 id="-verdict-the-signal-is-real-but-marginal"><a name="verdict"></a> Verdict: the signal is real but marginal</h1>

<p>The headline number is good but the universe is too small to know if the strategy generalises.</p>

<p>At <code class="language-plaintext highlighter-rouge">top_n=1</code> the model is a “find the biggest faller” oracle and pays the concentration cost.
<!-- is the cost of concentration return. -->
At <code class="language-plaintext highlighter-rouge">top_n=5</code> and <code class="language-plaintext highlighter-rouge">top_n=8</code> it dilutes into weaker signals – but only because there are only seven real fallers in the universe to begin with.
A 25–35 token universe with the same <code class="language-plaintext highlighter-rouge">top_n=5</code> or <code class="language-plaintext highlighter-rouge">top_n=8</code> could more than likely let the diversification do its work without forcing the selector to short noise.</p>

<!-- The Stage 1 gap vs naive likely persists on a wider universe, because the structural diversification floor remains. -->
<!-- But two things should change: -->

<!-- - Naive's edge should shrink: 30 tokens diversifies better than 11, but its absolute return depends on average universe drawdown, not number of names. -->
<!-- - The GEM short at `top_n=5` or higher should retain return *and* shrink DD, possibly enough to flip Stage 1. -->

<!-- This is what the next experiment needs to test. -->

<h1 id="-defences-against-squeezes"><a name="defense"></a> Defences against squeezes</h1>

<p>Daily OHLCV cannot see ADA-style snap-backs coming.
The model needs information from outside the kline feed.</p>

<p>CoinGlass or e.g. Hyblock offer (paid) liquidation heatmaps, but the sam esignal can be hand-built.
Below are some ideas:</p>

<p><strong>1. Per-token concentration cap + trailing stop.</strong>
Cap any single short at 8–10% of NAV. Apply a 15–20% trailing stop from the entry-adjusted peak P&amp;L.
Pure portfolio logic, no new data pipeline.
This cheap defense alone would have capped the ADA hit at roughly half of its actual DD cost.</p>

<p><strong>2. Funding rate signal.</strong>
Binance, Bybit or OKX all publish funding-rate history (already used in the experiment to calculate per-position cost accrual).
Potential heuristic: if funding crosses above +0.05% for three consecutive periods on an asset we are short, halve the position, if it crosses +0.1%, exit the position.
A crowded-short detector.</p>

<p><strong>3. Intraday volume-spike + reversal exit.</strong>
If a shorted name prints $\ge 3 \cdot \sigma$ volume on a green candle that reclaims the prior day’s high, exit at next bar.
Requires 1h or 15m kline ingestion.</p>

<!-- **3. Open interest deltas.** -->
<!-- MOst exchanges publish OI. -->
<!-- The discriminating signal is OI rising while price drifts sideways or down -- building shorts. -->
<!-- Combine with funding into a composite "crowded short" score, for example: -->
<!-- $z(\text{funding}) + z(\Delta\text{OI} / \Delta\text{price})$. -->

<p>Together these cover most of what a paid liquidation-heatmap API service would.</p>

<!-- # <a name="composition"/> Composition with the bear-long specialist -->

<!-- This experiment produces a third specialist alongside the bull and bear-long that already exist. -->
<!-- The orchestration question: how do these compose when the regime detector reports probabilities over `{bull, bear, chop}`? -->

<!-- **Two specialists, not one combined model.** -->
<!-- A single regression could in principle rank by $|\text{momentum}|$ and use the sign to pick direction. -->
<!-- That collapses three things worth keeping separate: asymmetric risk parameters (shorts want tighter stops, smaller per-position caps, squeeze filters that do not apply long-side), independent fitting windows (the bear-long specialist prefers `w=30`, the aggressive short prefers `w=15`), and debuggability -- when the combined book bleeds, the source side is not recoverable from a single PnL line. -->
<!-- The shared GEM regression core can be a library; the specialists differ only in selection, sizing, and risk overlays. -->

<!-- **Three specialists, regime-weighted gross exposure.** -->
<!-- Let $G_{\max}$ be target gross (e.g. 1.0 of NAV). Given HMM-output probabilities $P_{\text{bull}}, P_{\text{bear}}, P_{\text{chop}}$: -->

<!-- $$ -->
<!-- \begin{aligned} -->
<!-- w_{\text{bull}}        &= P_{\text{bull}} \\ -->
<!-- w_{\text{bear-long}}   &= P_{\text{bear}} \cdot (1 - \alpha) \\ -->
<!-- w_{\text{bear-short}}  &= P_{\text{bear}} \cdot \alpha \\ -->
<!-- \text{gross}_{\text{long}}  &= G_{\max} \cdot (w_{\text{bull}} + w_{\text{bear-long}}) \\ -->
<!-- \text{gross}_{\text{short}} &= G_{\max} \cdot w_{\text{bear-short}} -->
<!-- \end{aligned} -->
<!-- $$ -->

<!-- $\alpha \in [0.4, 0.6]$ controls bear-mode aggressiveness -- start at $0.5$ and tune on Calmar. -->
<!-- The bear allocation splits between the conservative long-only specialist (capital preservation) and the aggressive short (offensive return), proportional to confidence in the bear regime. -->

<!-- The alternative -- blend long/short direction first via $P_{\text{bull}} - P_{\text{bear}}$ and then run a single direction-aware specialist -- throws away the bear-long specialist's stablecoin-allocation logic, which is exactly the capital-preservation property that earned the bear specialist its Calmar 4.6 in the first place. -->

<!-- **$P(\text{chop}) \rightarrow$ cash.** -->
<!-- Momentum ranking degrades in both directions during chop. -->
<!-- Forcing trades there is where Calmar dies. -->
<!-- Default: hold $P_{\text{chop}} \cdot \text{NAV}$ in USDT. Whipsaw squeezes -- the regime where ADA-style events are most likely -- live in chop, so the short specialist must not run there. -->

<h1 id="-next-steps"><a name="next"></a> Next Steps</h1>

<!-- The headline result is real but the universe is the binding constraint. -->

<p>Capital preservation as a strategy works – the previous post showed that.
Aggressive shorting works too, with caveats – as the results of this brief experiment have shown.
An interesting question is what happens when the regime detector decides how much of each to deploy.</p>

<p>Below some ideas:</p>

<ol>
  <li>
    <p><strong>Wider universe</strong>: All Binance USDⓈ-M majors that existed in May 2022. See if diversified models start to prevail.
<!-- This is the experiment that decides whether the GEM short is a concentration oracle or a portfolio strategy. -->
<!-- Re-derive the long-only bear Calmar on the new universe before comparing Stage 2 -- the 4.6 number is universe-specific. --></p>
  </li>
  <li>
    <p><strong>Squeeze defenses</strong>: Add the funding+OI composite score and a trailing stop rules. Akin to liquidation heatmaps.
Rerun the same sweep with the defenses on. If the ADA-shaped drawdown shrinks meaningfully without killing the returns, the defenses earn the keep.</p>
  </li>
  <li>
    <p><strong>Regime-weighted composition</strong>: connect the specialists (bull, rangin, bear-long, bear-short) to the HMM regime probabilities. Bear sub-specialist models could be blended using a tuned parameter - learned on the historical regime exposure.</p>
  </li>
</ol>]]></content><author><name>Filip Bielejec</name></author><category term="python" /><category term="trading" /><category term="quantitative-finance" /><category term="GEM" /><category term="momentum" /><category term="regression" /><category term="bear-market" /><category term="short-selling" /><summary type="html"><![CDATA[An aggressive short-mode counterpart to the conservative bear specialist: shorting the weakest tokens by negative momentum on Binance USDⓈ-M perps, 2022 bear market POC]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.nodrama.io/images/logo.png" /><media:content medium="image" url="https://blog.nodrama.io/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Scoring under tension: Karpathy-style autoresearch loop on a GEM stack</title><link href="https://blog.nodrama.io/autoresearch-gem-strategy/" rel="alternate" type="text/html" title="Scoring under tension: Karpathy-style autoresearch loop on a GEM stack" /><published>2026-05-07T00:00:00+00:00</published><updated>2026-05-07T00:00:00+00:00</updated><id>https://blog.nodrama.io/autoresearch-gem-strategy</id><content type="html" xml:base="https://blog.nodrama.io/autoresearch-gem-strategy/"><![CDATA[<h1 id="-introduction"><a name="intro"></a> Introduction</h1>

<p>In the <a href="/gem-bear-market-models/">previous post</a> I compared exponential vs. linear regression GEM models on a hand-curated 4-token bear portfolio.
The main finding from that experiment: the choice of regression model was almost irrelevant; the <strong>fitting window</strong> dominated the returns.</p>

<p>That experiment had two limitations:</p>
<ul>
  <li>The universe was tiny – four hand-picked safe-haven tokens.</li>
  <li>The manual parameter sweep – I picked window sizes, ran the sweep code, verified the results, iterated again.</li>
</ul>

<p>This post is about replacing the human-in-the-loop with an LLM agent following a written experimental research recipe.
The agent iterates on: pick a hypotheses, edit the allowed parts of the codebase, run the experiment, score the results, keep or revert. Repeat.
Human goes to sleep and wakes up to read the overnight results.</p>

<hr />

<p><em>TL;DR</em>
On the full Binance + DefiLlama universe (437 tokens, 5 years), an LLM-driven autoresearch loop ran 215 configurations across 18 hours, lifted the ensemble score from <code class="language-plaintext highlighter-rouge">-inf</code> (every config failing under realistic fees) to <strong>1175.2</strong>, and beat a BTC+ETH buy-and-hold baseline by a factor of <strong>142</strong>.</p>

<hr />

<p>Three structural findings emerged: two were genuinely new, one was a confirmation of an earlier 4-token result, but this time at scale.</p>

<h1 id="-the-autoresearch-loop"><a name="autoresearch"></a> The Autoresearch Loop</h1>

<p>The pattern is borrowed from Andrej Karpathy’s <a href="https://github.com/karpathy/autoresearch">autoresearch</a> experiment.
The structure is a two-file one:</p>

<ul>
  <li>A <strong>read-only file</strong> <code class="language-plaintext highlighter-rouge">harness.py</code> – data loader, evaluation metric, and any constants the agent is not allowed to touch.</li>
  <li>A <strong>modifiable</strong> <code class="language-plaintext highlighter-rouge">sweep.py</code> file – the actual model and the training loop, fair-game for the agent to rewrite.</li>
</ul>

<p>A <code class="language-plaintext highlighter-rouge">program.md</code> tells the agent what is the metric (the scoring function computed from the backtest output) to iterate and improve on, which files it can touch and which are off-limits.
Human starts the agent and walks away.</p>

<h1 id="-keeping-the-loop-honest"><a name="score"></a> Keeping The Loop Honest</h1>

<p>The experiments metric is a single scalar:</p>

\[\text{score} = \text{annualized_return} \cdot \text{drawdown_dampener} \cdot \text{diversification_bonus}\]

<p>where:</p>

<ul>
  <li>\(\text{drawdown_dampener} = \frac{1}{(1 + \max(0, \text{dd} - 0.15))^{2}}\) (15% drawdown free zone, then quadratic decay)</li>
</ul>

<p>and</p>

<p>\(\text{diversification_bonus} = 1 + 0.1 \cdot (1 - \text{HHI})\) (up to +10% bonus for a non-concentrated portfolio, where HHI is the Herfindahl-Hirschman index of position weights).</p>

<p>The terms are multiplied, which puts them in tension against each other and doesn’t allow the agent to game the metric.</p>

<p>If you had only the drawdown dampener, the optimal strategy is to sit in cash forever – zero drawdown.
If you had only the returns term, the agent can chase any tail-risk strategy that produces a high headline number and accept any drawdown it comes with.
If you had only the diversification bonus, equal-weight buy-and-hold trivially wins.</p>

<p>One more loop rule is a hard rejection (<code class="language-plaintext highlighter-rouge">-inf</code>) on either one of:</p>

<ul>
  <li>Annualized return below -50%.</li>
  <li>Stress-test Calmar ratio (1.5× fees) is negative.</li>
</ul>

<p>The hard-rejection gate adds one more tension: a config can’t game the dampener by accepting a moderate base-fee drawdown that collapses into a negative-Calmar disaster the moment slippage gets worse.
Both conditions have to hold: positive expected return at the base 30 bps round-trip, <em>and</em> survive a fee-and-slippage shock.</p>

<h1 id="-reproducibility-note"><a name="repro"></a> Reproducibility note</h1>

<p>The scaled-down Python version at <a href="https://github.com/fbielejec/trader-research">trader-research</a> shows the methodology used – the contract surface, the modifiable interior, the program.md spec the agent operates under – on a 4-token, bear-specialist-only, 2022-only subset.
It is a small runnable example with the same scoring and loop structure – perfect to clone and run on a laptop in minutes.
The full Loop 3 run was executed against a Rust implementation over a much larger token universe – not something you can clone and run on a laptop without the Rust codebase and ~18 hours of compute.</p>

<h1 id="-phased-sweeping"><a name="phases"></a> Phased Sweeping</h1>

<p>The full ensemble has three GEM specialists (bull / bear / ranging) gated by a 3-state HMM regime detector, plus a meta-allocator model that blends specialist portfolios when the HMM outcome is uncertain (&lt; 90%).
That is roughly 15 sweepable parameters.</p>

<p>A grid over all 15 is combinatorially prohibitive, which is why the autoresearch loop instead sweeps in phases:</p>

<ol>
  <li><strong>Phase 1 – HMM hyperparameters</strong> (refit interval, hard-switch threshold, min observations). Specialists held at defaults.</li>
  <li><strong>Phase 2 – per-specialist GemParams</strong> (<code class="language-plaintext highlighter-rouge">top_n</code>, $R^2$ threshold, rebalance cooldown). One specialist at a time, the other two at the Phase 1 winner.</li>
  <li><strong>Verification – combined grid</strong> with the new defaults locked in, varying one specialist at a time off the new baseline to try and spot interactions.</li>
</ol>

<h1 id="-loop-3-437-tokens-18-hours-215-configs"><a name="loop3"></a> Loop 3: 437 Tokens, 18 Hours, 215 Configs</h1>

<p><strong>Universe:</strong> 431 Binance OHLC tokens (2020-01-01 through 2026-04-18) plus 6 DefiLlama price feeds for tokens not on Binance (AERO, DRIFT, FLUID, GRAIL, HYPE, MNDE) – 437 tokens total, ~508K daily candles.</p>

<p><strong>Evaluation window:</strong> 2021-01-01 to 2025-12-31 with a 250-day warmup buffer. Causal walk-forward (No peeking past day <code class="language-plaintext highlighter-rouge">t-1</code> when deciding for day <code class="language-plaintext highlighter-rouge">t</code>).</p>

<p><strong>Baseline:</strong> the existing ensemble defaults under the old fee regime, score <code class="language-plaintext highlighter-rouge">-inf</code> (every config rejected).</p>

<h2 id="phase-0-the-unblocking">Phase 0: the unblocking</h2>

<p>Before any model parameters were swept, the agent diagnosed why every baseline config was scoring <code class="language-plaintext highlighter-rouge">-inf</code>.
The old fee model was 0.001 base with a 2× stress multiplier – which sounds aggressive but actually makes the <strong>base</strong> look unrealistically cheap and the <strong>stress</strong> test punitively expensive (effective 0.002 stress).</p>

<p>The loop changed two numbers:</p>

<table>
  <thead>
    <tr>
      <th>Constant</th>
      <th>Old</th>
      <th>New</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">fee_rate</code></td>
      <td>0.001</td>
      <td>0.003 (10 bps exchange + 20 bps slippage)</td>
    </tr>
    <tr>
      <td>stress multiplier</td>
      <td>2.0×</td>
      <td>1.5×</td>
    </tr>
  </tbody>
</table>

<p>That single calibration fix, a realistic fee with a realistic stress test, unblocked 160+ viable configs.</p>

<h2 id="phase-1-hmm-hyperparameters">Phase 1: HMM hyperparameters</h2>

<p>48 configs, sweeping <code class="language-plaintext highlighter-rouge">hmm_refit_interval × hard_switch_threshold × hmm_min_observations</code>.</p>

<table>
  <thead>
    <tr>
      <th>Parameter</th>
      <th>Tested</th>
      <th>Winner</th>
      <th>Old default</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">hmm_refit_interval</code></td>
      <td>3, 7, 14</td>
      <td><strong>7</strong></td>
      <td>7</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">hard_switch_threshold</code></td>
      <td>0.60, 0.70, 0.80, 0.90</td>
      <td><strong>0.90</strong></td>
      <td>0.80</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">hmm_min_observations</code></td>
      <td>30, 60, 90, 120</td>
      <td><strong>90</strong></td>
      <td>90</td>
    </tr>
  </tbody>
</table>

<p>The threshold finding is the interesting one.
At <code class="language-plaintext highlighter-rouge">hard_switch_threshold=0.90</code> the ensemble almost never hard-switches to a single regime; instead it soft-blends the three specialist portfolios most of the time.
That scored 305.7 vs 244.8 at the previous default of 0.80 – a 25% lift from doing <strong>less</strong> of what the architecture was designed to do.</p>

<p>The HMM’s regime classification is informative but not confident enough for a binary regime decision.
Soft blending hedges against misclassification.
<!-- That's a real architectural finding, not a parameter tweak. --></p>

<h2 id="phase-2-per-specialist-gemparams">Phase 2: per-specialist GemParams</h2>

<p>Three sweeps, 36 configs each, for <code class="language-plaintext highlighter-rouge">top_n × r2_threshold × rebalance_cooldown</code> per specialist.</p>

<p><strong>Bull (best 5 of 36):</strong></p>

<table>
  <thead>
    <tr>
      <th>Config</th>
      <th>Score</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">top_n=15 r2=0.2 cd=5</code></td>
      <td><strong>156.9</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">top_n=10 r2=0.2 cd=5</code></td>
      <td>133.8</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">top_n=25 r2=0.2 cd=3</code></td>
      <td>126.9</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">top_n=25 r2=0.2 cd=5</code></td>
      <td>124.9</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">top_n=20 r2=0.2 cd=5</code></td>
      <td>123.0</td>
    </tr>
  </tbody>
</table>

<p><strong>Bear (best 5 of 36):</strong></p>

<table>
  <thead>
    <tr>
      <th>Config</th>
      <th>Score</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">top_n=1 r2=0.5 cd=10</code></td>
      <td><strong>440.6</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">top_n=3 r2=0.5 cd=10</code></td>
      <td>420.5</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">top_n=5 r2=0.5 cd=10</code></td>
      <td>419.8</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">top_n=8 r2=0.5 cd=10</code></td>
      <td>416.7</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">top_n=3 r2=0.5 cd=14</code></td>
      <td>269.7</td>
    </tr>
  </tbody>
</table>

<p><strong>Ranging (best 5 of 36):</strong></p>

<table>
  <thead>
    <tr>
      <th>Config</th>
      <th>Score</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">top_n=8 r2=0.3 cd=5</code></td>
      <td><strong>1174.5</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">top_n=12 r2=0.3 cd=5</code></td>
      <td>1023.6</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">top_n=15 r2=0.3 cd=5</code></td>
      <td>990.0</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">top_n=12 r2=0.5 cd=5</code></td>
      <td>631.2</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">top_n=15 r2=0.5 cd=5</code></td>
      <td>625.3</td>
    </tr>
  </tbody>
</table>

<h2 id="verification-the-combined-defaults">Verification: the combined defaults</h2>

<p>With each specialist’s winners locked in, the verification grid hit <strong>score 1175.2</strong> on the combined defaults.
<!-- (1829.9% annualized return, HHI 0.319). -->
The buy-and-hold BTC+ETH baseline scored 8.3.
<!-- (12.9% return). --></p>

<p>That is a 142× improvement over the buy-and-hold.
<!-- by score, almost entirely from the realized returns. --></p>

<h1 id="-what-the-loop-found"><a name="findings"></a> What the Loop Found</h1>

<p>Two new findings, plus one confirmation of an earlier result at scale. Ranked by how surprising they were:</p>

<p><strong>1. Soft blending beats hard switching.</strong>
Increasing <code class="language-plaintext highlighter-rouge">hard_switch_threshold</code> from 0.80 to 0.90 is <em>less</em> of what the ensemble architecture was designed to do, and it scored +25%.
The HMM is a good signal, but not a confident enough signal to act on as a binary classifier.
The right way to use it is as a probability-weighted blend.</p>

<p><strong>2. All three specialists prefer lower $R^2$ thresholds than the priors said.</strong>
The pre-loop defaults were 0.3 / 0.7 / 0.5 for bull / bear / ranging.
The winners were 0.2 / 0.5 / 0.3.
The $R^2$ filter was rejecting tokens with weak-but-real momentum signals.
Across three independent sweeps the loop found the same direction of correction.</p>

<p><strong>3. The bear-portfolio <code class="language-plaintext highlighter-rouge">top_n=1</code> result holds at scale.</strong>
The previous post’s 4-token result – concentrating on the single best opportunity beats diversification in a bear regime – survived the jump to 437 tokens.
<code class="language-plaintext highlighter-rouge">top_n=1</code> won the bear sweep cleanly: in bear regimes there is typically exactly one token with a genuine positive momentum signal (usually PAXG or a stablecoin), and diversifying across 3-8 tokens dilutes the safe-haven effect.
This isn’t a new discovery, but it’s a non-trivial confirmation – the loop arrived at the same answer independently, on a universe roughly 100× larger.</p>

<p>The first finding – soft-blend dominance – is the kind of result that’s worth more than any specific parameter value.
It tells you the ensemble’s architecture is over-relying on a part of the system (binary regime calls) that the data doesn’t support.</p>

<h1 id="-limitation"><a name="limits"></a> Limitation</h1>

<p>This optimization approach has one strong limitation: <strong>One-at-a-time phased sweeping cannot find between-parameter interactions.</strong>.
For example: If the bull and bear specialists models need <em>jointly</em> different parameter values in order to reach a high score, this loop will not find that configuration.
This is a structural limitation, one that drives the motivation for the <a href="/autoresearch-gem-strategy/#next">next-phase ideas</a>.</p>

<h1 id="-next-steps"><a name="next"></a> Next Steps</h1>

<h2 id="lifting-the-phased-sweep-limitation">Lifting the phased-sweep limitation</h2>

<p>The current loop is a one-parameter-at-a-time scan.
The search heuristics – “sweep <code class="language-plaintext highlighter-rouge">top_n</code> first, then $R^2$ threshold, then sweep again with the new defaults locked in” – lives in <code class="language-plaintext highlighter-rouge">program.md</code> as natural language instructions for the agent.</p>

<p>That’s a workaround for the high-dimensional parameter space, yet a different ordering, a different grid resolution, a different fallback when a sweep stalls – any of these could move the metric meaningfully, and currently there is no no way to discover that.</p>

<p>A potential successor is to replace the loop with a <strong>reinforcement-learning</strong> approach, with a state.</p>

<h2 id="improving-the-regime-detector">Improving the regime detector</h2>

<p>The current HMM uses Gaussian emissions, which is a known poor fit for stocks (or crypto) data  – the empirical distributions are fat-tailed and asymmetric, with the kind of tail events the loop’s hard-rejection gate exists to defend against.
A single 30%-down day can fool a Gaussian-emission HMM into a regime call that doesn’t reflect the underlying dynamics.</p>

<p>Finding #1 – soft blending dominating hard switching – is consistent with this: the HMM’s regime calls aren’t confident not because the regimes don’t exist, but because the emission model is too simple to assign them confidently.</p>

<p>Replacements worth trying, ranked by implementation cost:</p>

<ul>
  <li><strong>Student’s $t$ emissions.</strong> Minimal change to the existing HMM, just heavier tails. Cheap to implement, immediate test of whether soft-blend dominance survives a fatter-tailed emission model.</li>
  <li><strong>Hidden semi-Markov models</strong> with explicit state-duration distributions, capturing the empirical fact that regimes don’t switch every day.</li>
  <li><strong>Mixture-of-Gaussians or GARCH-style emissions</strong> to model volatility clustering inside a regime rather than across them.</li>
  <li><strong>Neural sequence models</strong> (LSTM / Transformer) that learn regime structure end-to-end without the Markov assumption – higher capacity, harder to interpret, and the most aggressive departure from the current contract surface.</li>
</ul>

<h2 id="more-aggresive-bear-specialist-model">More aggresive bear specialist model</h2>

<p>The bear specialist currently sits in cash when no token has a positive trend.
With the use of perpetual funds (such as offered by <a href="https://www.kraken.com/features/futures">Kraken</a> or <a href="https://www.dydx.xyz/">dYdX</a>)
the defensive (long safe-haven) startegy could flip int an offensive one (short weak-momentum tokens).</p>

<p>The same $R^2 \cdot (a_1 - 1)$ momentum signal becomes a short-entry signal when negated, and the inverse-volatility weighting carries over directly.</p>

<p>Currently <code class="language-plaintext highlighter-rouge">top_n=1</code> picks PAXG; with futures the specialist can profit from crashes instead of just hiding.</p>

<p>The open questions are funding-rate cost vs. the current 30 bps fee budget, sizing under leverage, and whether the Calmar hard-rejection gate still makes sense once shorting is allowed.</p>]]></content><author><name>Filip Bielejec</name></author><category term="python" /><category term="rust" /><category term="trading" /><category term="quantitative-finance" /><category term="GEM" /><category term="momentum" /><category term="autoresearch" /><category term="LLM" /><summary type="html"><![CDATA[Applying the Karpathy-style autoresearch loop to an HMM + per-regime GEM specialist + meta-allocator trading stack. Headline result from a 215-config / 18-hour run on 437 Binance + DefiLlama tokens, plus a scaled-down Python version over a 4-token bear-portfolio subset.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.nodrama.io/images/logo.png" /><media:content medium="image" url="https://blog.nodrama.io/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Winning with the Bear: GEM strategies comparison</title><link href="https://blog.nodrama.io/gem-bear-market-models/" rel="alternate" type="text/html" title="Winning with the Bear: GEM strategies comparison" /><published>2026-04-24T00:00:00+00:00</published><updated>2026-04-24T00:00:00+00:00</updated><id>https://blog.nodrama.io/gem-bear-market-models</id><content type="html" xml:base="https://blog.nodrama.io/gem-bear-market-models/"><![CDATA[<h1 id="-introduction"><a name="intro"></a> Introduction</h1>

<p>Momentum based strategies work well in up-trending markets.
Fit a curve to the price data, rank tokens by the quality and strength of their trend, select a portfolio of winners.
In a bull market, there are plenty of winners to choose from.</p>

<p><em>But what happens in a bear market?</em></p>
<ul>
  <li>the winners are few and apart</li>
  <li>the rare upwards trends are short-lived</li>
</ul>

<p>The 2022 is but the most textbook example of a crypto bear market – especially the period from May through December, when BTC had already fallen 50% from its all-time high, Luna had collapsed, and the market was unambiguously grinding down.
I ran a series of experiments on historical data, trying to gauge whether a simpler linear model can be more responsive to the short upward trends as compared to a compounding curve.</p>

<p><em>TL;DR</em> The choice of regression model turns out to be almost irrelevant, what matters the most seems to be the length of the fitting window.</p>

<h1 id="-the-gem-pipeline"><a name="gem"></a> The GEM Pipeline</h1>

<p>The GEM pipeline fits a regression model to each token’s daily closing prices, then selects a portfolio based on the fit quality and trend strength.</p>

<p>For each token on each day:</p>

<ol>
  <li><strong>Fit</strong>: Normalize timestamps to sequential day indices (0, 1, 2, …) and fit a trend model to the closing prices</li>
  <li><strong>Evaluate</strong>: Compute R-squared and a momentum score</li>
  <li><strong>Filter</strong>: Keep tokens with positive trend, R-squared above a threshold, and the momentum below a cap (to exclude pump-and-dump spikes)</li>
  <li><strong>Weight</strong>: Rank by momentum, take the top N, and assign inverse-volatility weights using Average True Range (ATR). This is the portfolio.</li>
</ol>

<p>The baseline model uses a two-step exponential regression:</p>

\[y = a_0 \cdot a_1^{x}\]

<p>First, a log-linear OLS fit ($\ln y = b_0 + b_1 x$) provides initial parameter estimates.
These are refined via Levenberg-Marquardt optimization into the exponential form.
Momentum is then $R^{2} \cdot (a_1 - 1) \cdot 100$, where the R-squared exponent controls how much low-quality fits are penalized.</p>

<p>The experimental linear model fits directly in price-space:</p>

\[y = b_0 + b_1 \cdot x\]

<p>Momentum becomes $R^{2} \cdot (b_1 / \bar{p}) \cdot 100$, where $b_1 / \bar{p}$ normalizes the slope by the mean price to produce a percentage daily trend, analogous to $a_1 - 1$ in the exponential case.</p>

<p>The rest of the pipeline – ATR computation, portfolio construction, differential rebalancing, and cooldown – is identical between the two models.</p>

<h1 id="-experiment-setup"><a name="setup"></a> Experiment Setup</h1>

<p><strong>Window</strong>: May 1 – December 31, 2022.
By May, BTC had already dropped roughly 50% from its November 2021 ATH.
The Luna collapse (May 9), 3AC liquidation (June), and FTX collapse (November) all fall within this window.
No doubt the bear is already here.</p>

<p><strong>Token universe</strong>: A curated “bear portfolio” of safe-haven assets available on Binance:</p>

<table>
  <thead>
    <tr>
      <th>Token</th>
      <th>Type</th>
      <th>Coverage</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>PAXGUSDT</td>
      <td>Tokenized gold</td>
      <td>Full (May–Dec, 245 candles)</td>
    </tr>
    <tr>
      <td>EURUSDT</td>
      <td>EUR stablecoin</td>
      <td>Full (May–Dec, 245 candles)</td>
    </tr>
    <tr>
      <td>USDCUSDT</td>
      <td>USD stablecoin</td>
      <td>Partial (May–Sep, 149 candles)</td>
    </tr>
    <tr>
      <td>TUSDUSDT</td>
      <td>USD stablecoin</td>
      <td>Partial (May–Sep, 149 candles)</td>
    </tr>
  </tbody>
</table>

<p><strong>Models and their parameter configs</strong>:</p>

<p>We use the so called “bear” and a “ranging” specialist models, GEM models with their parameters selected towards capital preservation and cautious exploration of the token universe, respectively:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Bear</th>
      <th>Ranging</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>top_n</td>
      <td>1</td>
      <td>8</td>
    </tr>
    <tr>
      <td>R-squared threshold</td>
      <td>0.5</td>
      <td>0.3</td>
    </tr>
    <tr>
      <td>Rebalance cooldown</td>
      <td>10 days</td>
      <td>5 days</td>
    </tr>
    <tr>
      <td>Momentum cap</td>
      <td>0.14</td>
      <td>0.20</td>
    </tr>
    <tr>
      <td>R-squared exponent</td>
      <td>2.0</td>
      <td>1.5</td>
    </tr>
  </tbody>
</table>

<p><strong>Fee model</strong>: We assume 0.3% per trade (0.10% exchange fee + 0.20% slippage estimate).</p>

<p><strong>Benchmark</strong>: Equal-weight buy-and-hold across all four tokens. This is what we want to beat.</p>

<h1 id="-first-attempt-full-history-fitting"><a name="full-history"></a> First Attempt: Full History Fitting</h1>

<p>First run used the standard GEM approach: fit the regression model to all available history from the start of the window up to the current day.
On day 100, the model sees all 100 preceding candles.</p>

<p>The results:</p>

<table>
  <thead>
    <tr>
      <th>Strategy</th>
      <th>Return</th>
      <th>Max Drawdown</th>
      <th>Sharpe</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Exp GEM, bear params</td>
      <td>0.00%</td>
      <td>0.00%</td>
      <td>0.00</td>
    </tr>
    <tr>
      <td>Lin GEM, bear params</td>
      <td>0.00%</td>
      <td>0.00%</td>
      <td>0.00</td>
    </tr>
    <tr>
      <td>Buy &amp; Hold</td>
      <td>-0.65%</td>
      <td>-6.01%</td>
      <td>-0.12</td>
    </tr>
    <tr>
      <td>Exp GEM, ranging params</td>
      <td>-4.01%</td>
      <td>-4.21%</td>
      <td>-1.91</td>
    </tr>
    <tr>
      <td>Lin GEM, ranging params</td>
      <td>-4.01%</td>
      <td>-4.21%</td>
      <td>-1.91</td>
    </tr>
  </tbody>
</table>

<p>The bear specialist didn’t trade at all, it sat in cash for the entire eight months - winning on capital preservation.</p>

<p>At the same time it failed to capture any of the intermediate rallies that occurred during this period.
PAXG, for instance, rallied roughly 10% from its June lows through August as gold spiked during the 3AC liquidation chaos.</p>

<p>To understand why, we examined what the models actually computed on several randomly sampled dates:</p>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Token</th>
      <th>Model</th>
      <th>a1</th>
      <th>R-squared</th>
      <th>Growth filter</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2022-08-01</td>
      <td>PAXGUSDT</td>
      <td>exp</td>
      <td>0.9990</td>
      <td>0.68</td>
      <td>FAIL</td>
    </tr>
    <tr>
      <td>2022-08-01</td>
      <td>PAXGUSDT</td>
      <td>lin</td>
      <td>0.9990</td>
      <td>0.69</td>
      <td>FAIL</td>
    </tr>
    <tr>
      <td>2022-08-01</td>
      <td>EURUSDT</td>
      <td>exp</td>
      <td>0.9995</td>
      <td>0.52</td>
      <td>FAIL</td>
    </tr>
    <tr>
      <td>2022-10-15</td>
      <td>PAXGUSDT</td>
      <td>exp</td>
      <td>0.9992</td>
      <td>0.82</td>
      <td>FAIL</td>
    </tr>
  </tbody>
</table>

<p>Every token, on every date, had a growth parameter below 1.0 (negative overall slope).
The R-squared was often strong – meaning the model was <em>confidently</em> fitting a downtrend.
But the growth filter ($a_1 &gt; 1$) rejected everything, because the cumulative history from May 1 was dominated by the initial hard crash.</p>

<p>The ranging specialist, with its looser R-squared threshold (0.3), did enter positions – and promptly lost money, because it was catching the tail end of downtrends, not rallies.</p>

<p>The core problem: <strong>fitting over the entire history anchors the model to the dominant long-term bear trend</strong>.
The exponential model compounds this: it assumes multiplicative growth, making it even harder for a short 2-week price rally to overcome the long negative history.</p>

<h1 id="-rolling-window"><a name="rolling"></a> Rolling Window</h1>

<p>The solution is to limit the regression to a rolling window of recent data.</p>

<p>This reframes the model from a cumulative trend detector to a short-term momentum detector, catching the short-lived rallies.</p>

<p>R-squared plays a dual role in this setup:</p>
<ul>
  <li>A genuine rally over a 30-day window produces a clean uptrend and high R-squared</li>
  <li>A “dead cat bounce” is noisy and V-shaped – the linear fit is poor, R-squared is low, and the position gets filtered out</li>
</ul>

<p><img src="/images/2026-04-24-gem-bear-market-models/r2_bounce_filter.png" alt="R-squared as a dead cat bounce filter" style="width: 130%;" /></p>

<p>I tested three window sizes – 15, 30, and 60 days – across both models and both parameter configs:</p>

<table>
  <thead>
    <tr>
      <th>Config</th>
      <th>Return</th>
      <th>Max DD</th>
      <th>Sharpe</th>
      <th>Sortino</th>
      <th>Calmar</th>
      <th>Rebalances</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>bear-w30</strong></td>
      <td><strong>+6.40%</strong></td>
      <td><strong>-1.39%</strong></td>
      <td><strong>1.59</strong></td>
      <td><strong>3.33</strong></td>
      <td><strong>6.98</strong></td>
      <td>21</td>
    </tr>
    <tr>
      <td>bear-w60</td>
      <td>+3.77%</td>
      <td>-1.10%</td>
      <td>1.13</td>
      <td>2.34</td>
      <td>5.14</td>
      <td>19</td>
    </tr>
    <tr>
      <td>Buy &amp; Hold</td>
      <td>-0.65%</td>
      <td>-6.01%</td>
      <td>-0.12</td>
      <td>-0.17</td>
      <td>-0.16</td>
      <td>0</td>
    </tr>
    <tr>
      <td>ranging-w30</td>
      <td>-0.28%</td>
      <td>-9.42%</td>
      <td>-0.02</td>
      <td>-0.03</td>
      <td>-0.04</td>
      <td>29</td>
    </tr>
    <tr>
      <td>ranging-w60</td>
      <td>-1.02%</td>
      <td>-5.30%</td>
      <td>-0.27</td>
      <td>-0.40</td>
      <td>-0.29</td>
      <td>37</td>
    </tr>
    <tr>
      <td>bear-w15</td>
      <td>-6.27%</td>
      <td>-8.77%</td>
      <td>-1.46</td>
      <td>-2.09</td>
      <td>-1.05</td>
      <td>24</td>
    </tr>
    <tr>
      <td>ranging-w15</td>
      <td>-9.28%</td>
      <td>-10.42%</td>
      <td>-1.94</td>
      <td>-2.51</td>
      <td>-1.30</td>
      <td>45</td>
    </tr>
  </tbody>
</table>

<p>Three observations stand out:</p>

<p><strong>1. The 30-day window seems to be the sweet spot.</strong>
It captures the PAXG (gold) summer rally: long enough for R-squared to recognize a genuine consistent trend, short enough to not get anchored by the preceding months of decline.
The 15-day window is too reactive – it enters and exits on noise, bleeding fees across 24+ rebalances.
The 60-day window is too slow – it captures the rally, but enters late and exits late, leaving profits on the table.</p>

<p><strong>2. Bear params (top_n=1) dominate ranging params (top_n=8).</strong>
In a 4-token safe-haven universe, concentrating on the single best opportunity outperforms diversification.
<!-- The ranging specialist's broader portfolio dilutes the PAXG signal with stablecoins that contribute nothing. -->
<!-- This makes intuitive sense: in a bear market with few assets, conviction beats diversification. --></p>

<p><strong>3. The exponential and linear based GEM models produced almost identical results at every window size.</strong>
The choice of regression model is irrelevant.</p>

<h1 id="-what-the-winner-holds"><a name="holdings"></a> What the Winner Holds</h1>

<p>This is <em>how</em> the bear-w30 model earns its return – every rebalance event over the 8-month window:</p>

<table>
  <thead>
    <tr>
      <th>Day</th>
      <th>Date</th>
      <th>Action</th>
      <th>Holding</th>
      <th>Momentum</th>
      <th>R-squared</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td>2022-05-01</td>
      <td>TO CASH</td>
      <td>CASH</td>
      <td> </td>
      <td> </td>
      <td>$10,000</td>
    </tr>
    <tr>
      <td>10</td>
      <td>2022-05-11</td>
      <td>TO CASH</td>
      <td>CASH</td>
      <td> </td>
      <td> </td>
      <td>$10,000</td>
    </tr>
    <tr>
      <td>…</td>
      <td>…</td>
      <td>TO CASH</td>
      <td>CASH</td>
      <td> </td>
      <td> </td>
      <td>$10,000</td>
    </tr>
    <tr>
      <td>162</td>
      <td>2022-10-10</td>
      <td>TO CASH</td>
      <td>CASH</td>
      <td> </td>
      <td> </td>
      <td>$10,000</td>
    </tr>
    <tr>
      <td>192</td>
      <td>2022-11-09</td>
      <td>ENTER</td>
      <td>EURUSDT</td>
      <td>0.022</td>
      <td>0.50</td>
      <td>$9,970</td>
    </tr>
    <tr>
      <td>202</td>
      <td>2022-11-19</td>
      <td>HOLD</td>
      <td>EURUSDT</td>
      <td>0.087</td>
      <td>0.68</td>
      <td>$10,341</td>
    </tr>
    <tr>
      <td>212</td>
      <td>2022-11-29</td>
      <td>HOLD</td>
      <td>EURUSDT</td>
      <td>0.118</td>
      <td>0.75</td>
      <td>$10,313</td>
    </tr>
    <tr>
      <td>222</td>
      <td>2022-12-09</td>
      <td>HOLD</td>
      <td>EURUSDT</td>
      <td>0.044</td>
      <td>0.71</td>
      <td>$10,525</td>
    </tr>
    <tr>
      <td>232</td>
      <td>2022-12-19</td>
      <td>ROTATE</td>
      <td>PAXGUSDT</td>
      <td>0.086</td>
      <td>0.79</td>
      <td>$10,539</td>
    </tr>
    <tr>
      <td>242</td>
      <td>2022-12-29</td>
      <td>ROTATE</td>
      <td>EURUSDT</td>
      <td>0.029</td>
      <td>0.70</td>
      <td>$10,575</td>
    </tr>
  </tbody>
</table>

<p>Model kept patiently for six and a half months of in all cash, then entered into a clean trend:</p>

<ul>
  <li>From May through October, every rebalance evaluates candidates and finds nothing.
    <ul>
      <li>No token sustains a positive 30-day slope with R-squared above 0.5 and the model (correctly) stays out of the market.</li>
    </ul>
  </li>
  <li>On November 9, the EUR begins a sustained rally against the dollar.
    <ul>
      <li>The 30-day window picks up a clean uptrend – R-squared just clears 0.50 – and the model enters EURUSDT.</li>
      <li>Over the next 6 weeks, the trend strengthens: R-squared climbs to 0.75, momentum rises, and the portfolio value grows to $10,525.</li>
    </ul>
  </li>
  <li>On December 19, PAXG briefly shows a stronger trend (R-squared 0.79) and the model rotates for one brief period before returning to EUR.</li>
  <li>The strategy finishes at $10,640 – a 6.40% return in a period where BTC fell 65% from ATH and the equal-weight buy-and-hold benchmark lost 0.65%.</li>
</ul>

<p>It waits for a trend that passes strict quality filters, then concentrates its entire portfolio on that single opportunity.</p>

<h1 id="-next-steps"><a name="next"></a> Next Steps</h1>

<p>The above results come from a hand-curated 4-token safe-haven portfolio.
The natural folow-up question is: what happens when we run the same experiment on the full Binance token universe?</p>

<p>With hundreds of tokens, the model has a much richer candidate pool on each rebalance day.
Does it converge to the same safe-haven tokens (PAXG, EUR) that we hand-picked, or does it find opportunities we didn’t anticipate?</p>

<p>The full universe also gives the exponential and linear models a chance to diverge on the more volatile altcoin data.</p>]]></content><author><name>Filip Bielejec</name></author><category term="python" /><category term="trading" /><category term="quantitative-finance" /><category term="GEM" /><category term="momentum" /><category term="regression" /><category term="bear-market" /><summary type="html"><![CDATA[GEM based strategies for a safe-haven portfolio selection during the 2022 crypto bear market]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.nodrama.io/images/logo.png" /><media:content medium="image" url="https://blog.nodrama.io/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">TheButton - A Smart Contracts-Based Game on the Aleph Zero Blockchain</title><link href="https://blog.nodrama.io/how-the-button-is-made/" rel="alternate" type="text/html" title="TheButton - A Smart Contracts-Based Game on the Aleph Zero Blockchain" /><published>2023-06-08T00:00:00+00:00</published><updated>2023-06-08T00:00:00+00:00</updated><id>https://blog.nodrama.io/how-the-button-is-made</id><content type="html" xml:base="https://blog.nodrama.io/how-the-button-is-made/"><![CDATA[<h1 id="-introduction"><a name="intro"></a> Introduction</h1>

<p>March 2023 has brought an important milestone to the <a href="https://alephzero.org/">Aleph Zero</a>: the long-awaited launch of smart contracts capabilities.</p>

<p>In this blog post we welcome you to <a href="https://the-button.azero.dev/">TheButton</a>, a smart-contract based game deployed on the Aleph Zero blockchain.
Inspired by the famous <a href="https://en.wikipedia.org/wiki/The_Button_(Reddit)">Reddit game and social experiment</a>, TheButton utilizes <a href="https://use.ink/">ink!</a> smart contracts and the lightning-fast finality of Aleph Zero to deliver a captivating gaming experience.</p>

<p>At its core, TheButton features a timer countdown that decreases with each finalized block on the Aleph Zero blockchain.
Players’ clicks on the button reset the timer, ensuring its survival for 9,000 blocks (around 25 hours).</p>

<p>With three unique buttons, each governed by different rules, players strategically time their engagement to reap the greatest rewards.</p>

<p>Players utilize ticket tokens (<a href="https://medium.com/supercolony/psp22-the-first-smart-contract-standard-on-the-polkadot-ecosystem-fef3f6c27d88">PSP22 standard</a>) to enter TheButton’s games and earn reward tokens.
These tokens open the doors to TheButton’s Marketplace, where players can partake in a Dutch auction for ticket sales, taking advantage of fluctuating prices.</p>

<p>Additionally, the game introduces the SimpleDex, a Decentralized Exchange that enables seamless swapping between reward tokens and the wAzero token - a PSP22-wrapped native token of Aleph Zero, ensuring liquidity and flexibility.</p>

<p>The integration of the Marketplace and Decentralized Exchange empowers players to devise winning strategies to optimize their rewards, taking advantage of the market conditions.
Aleph Zero’s rapid block finality, with blocks produced and finalized every second on average, adds a time-sensitive dimension to gameplay, further fueling strategic thinking.</p>

<p>In the following sections, we will describe the key components that comprise TheButton.
This introductory overview serves as a starting point for our series of technical deep-dives, where we will delve into each component</p>

<h1 id="-thebutton-game-high-level-diagram"><a name="hld"></a> TheButton Game: high level diagram</h1>

<p>Following diagram summarizes communication and interaction between the core components of TheButton game.</p>

<p><img src="/images/2023-06-06-how-was-the-button-made/diagram.svg" alt="_config.yml" /></p>

<h1 id="-the-button-contract-governing-gameplay-tracking-rewards-and-minting-tokens"><a name="button"></a> The Button Contract: Governing Gameplay, Tracking Rewards, and Minting Tokens</h1>

<p>At the heart of TheButton lies the Button contract, a pivotal smart contract that governs the game’s rules, records player interactions, and mints the rewards.
Let’s dive into the inner workings of this crucial component, which forms the backbone of the game’s mechanics.</p>

<p>When players enter the game, their interaction with the contract begins by granting permission to the Button contract to spend one ticket token on their behalf.
<!-- This ticket token serves as the entry fee, granting players access to the thrilling gameplay that awaits them.  -->
Upon receiving the ticket, the Button contract records the player’s score and initiates the payout of rewards based on the game’s specific rules.</p>

<p>The rewards themselves are a PSP22 token, distinct from the ticket tokens used for entry.
The Button contract possesses a special role that grants it the authority to mint these rewards directly into the players’ accounts.</p>

<p>As players engage with the game, the Button contract diligently tracks all rewards disbursed throughout the lifespan of the button.
This collection of rewards constitutes the coveted pool for ThePressiah - the individual fortunate enough to make the final click on the button before its demise.
As the game progresses, the Button contract keeps a meticulous record of the rewards paid out, setting the stage for the climax as the countdown timer nears its end.</p>

<p>Once the button’s life cycle concludes, and before the next round commences, the designated Pressiah account is bestowed with 50% of the total rewards minted during the preceding round.</p>

<p>To initiate a fresh round, the game can be reset by invoking a specific contract message.
This crucial action serves multiple purposes:</p>
<ul>
  <li>Firstly, it rewards the Pressiah, acknowledging their achievement.</li>
  <li>Secondly, it transfers all the tickets held by the Button contract to the Marketplace, ensuring their availability for the upcoming round.</li>
  <li>And finally, it resets the state of the contract, effectively wiping the slate clean and paving the way for a new round to begin, brimming with exciting opportunities and heralding the potential rise of a new ThePressiah.</li>
</ul>

<p>As we journey deeper into TheButton’s architecture, we will now unravel additional components that contribute to the gameplay.</p>

<h1 id="-the-marketplace-contract-facilitating-ticket-sales-and-dynamic-pricing"><a name="marketplace"></a> The Marketplace Contract: Facilitating Ticket Sales and Dynamic Pricing</h1>

<p>The Marketplace contract plays a pivotal role in TheButton’s ecosystem by facilitating the sale of tickets to eager players through a dutch auction mechanism.
Let’s explore the inner workings of this contract, which ensures a fair and dynamic pricing model for ticket acquisition.</p>

<p>The primary function of the Marketplace contract is to sell tickets to players using a dutch auction approach.
The pricing structure follows a linear decrease from a starting price with each block that is finalized on the blockchain.
This downward trajectory continues until the auction has spanned a specific number of blocks, at which point the price reaches its minimum threshold.
Once the minimum price is reached, tickets remain available for purchase at this fixed rate.</p>

<p>Additionally, the Marketplace contract tracks the average price of all tickets sold.
With each new auction, the contract sets the starting price based on the average price of tickets sold up to that point.
This mechanism ensures that pricing remains responsive to market demand and reflects the collective value perceived by players.</p>

<p>By implementing a dutch auction model and leveraging the average price as a starting point, the Marketplace contract establishes a fair and dynamic system for ticket sales.
This approach allows players to engage in the game at various stages and obtain tickets at a price that aligns with market trends and player participation.</p>

<p>The Marketplace contract serves as a cornerstone of TheButton’s ecosystem, enabling players to enter the game and embark on their quest for rewards. Through its innovative auction mechanism and adaptive pricing strategy, the Marketplace ensures an equitable and engaging experience for all participants.</p>

<p>As we continue our exploration, we will uncover additional components that contribute to the vibrant and dynamic nature of TheButton’s gameplay.</p>

<h1 id="-the-simpledex-contract-empowering-decentralized-token-swaps"><a name="dex"></a> The SimpleDex Contract: Empowering Decentralized Token Swaps</h1>

<p>The SimpleDex contract within TheButton’s ecosystem introduces a decentralized exchange functionality, allowing players to seamlessly swap between the reward tokens.
SimpleDex utilizes a simplified version of the Uniswap v1 pricing mechanism.</p>

<p>Uniswap revolutionized decentralized exchanges by implementing an automated market maker (AMM) model.
This model eliminates the need for traditional order books, and instead relies on a constant product formula to determine token prices.
It ensures that trades occur based on the ratio of token reserves in a liquidity pool.</p>

<p>The SimpleDex contract maintains a single multi-token pool where all reward tokens for each game coexist alongside wAzero (wrapped Azero).
wAzero acts as a representation of the native A0 token within the ecosystem, consistently exchangeable at a 1:1 ratio.</p>

<p>However, a deliberate design choice was made to restrict swaps only from tokens to wAzero, not the other way around.
This limitation was driven by legal considerations, as the possibility of purchasing ticket tokens directly with the native token could potentially classify the game as gambling according to Swiss laws.
To mitigate this risk, ticket tokens are pre-minted and airdropped to actively staking accounts on the Aleph Zero blockchain, ensuring fair participation.</p>

<p>It’s worth noting that on an anonymous blockchain, anyone can write and deploy additional contracts, such as an escrow contract, to enable trustless exchanges between Azero and ticket tokens.
As long as these contracts are developed by the community members and not directly associated with the Aleph Zero Foundation, it aligns with our core principles of decentralization and community-driven innovation.</p>

<p>Given the straightforward design of the game, there is no need for external liquidity providers within the SimpleDex contract.
Liquidity for wAzero is periodically topped up by the Foundation itself, while liquidity for reward tokens is sourced from the swaps executed by players themselves.</p>

<p>The pricing mechanism employed by the SimpleDex holds a strategic value within the game.
Players can leverage this mechanism to devise strategies that optimize their token swaps, providing them with a competitive edge in navigating TheButton’s gameplay dynamics.</p>

<h1 id="-conclusion"><a name="outro"></a> Conclusion</h1>

<p>With this, we conclude our exploration of the core components that drive TheButton, a captivating smart contract-based game deployed on the Aleph Zero blockchain.
We have delved into the Button contract, which governs gameplay and rewards, the Marketplace contract that facilitates ticket sales, and the SimpleDex contract, empowering decentralized token swaps.</p>

<p>But our journey is far from over: in the upcoming series of blog posts, we will venture deeper into TheButton’s design and uncover additional components that make it possible.
We will discuss design choices we made, and unveil some innovative features, diving into a more technical intricacies.</p>

<p>Stay tuned!</p>

<h1 id="-keep-reading"><a name="outro"></a> Keep reading</h1>

<ul>
  <li>Next in the series: <a href="https://www.blog.nodrama.io/access-control-in-ink-smart-contracts/">implementing Access Control</a></li>
  <li><a href="https://github.com/Cardinal-Cryptography/aleph-node/tree/59ac2dd8c106bb5e44bdbb14ccc58bc4476d2f32/contracts">Source code</a></li>
</ul>]]></content><author><name>Filip Bielejec</name></author><category term="rust" /><category term="ink!" /><category term="smart-contracts" /><category term="substrate" /><category term="pokadot" /><category term="alephzero" /><category term="wasm" /><category term="psp22" /><category term="dex" /><category term="defi" /><summary type="html"><![CDATA[In this bog post we describe TheButton, a smart-contract based game deployed on the Aleph Zero blockchain.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.nodrama.io/images/logo.png" /><media:content medium="image" url="https://blog.nodrama.io/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Spread on TV</title><link href="https://blog.nodrama.io/francqui-collen-prize-philippe-lemey/" rel="alternate" type="text/html" title="Spread on TV" /><published>2023-06-06T00:00:00+00:00</published><updated>2023-06-06T00:00:00+00:00</updated><id>https://blog.nodrama.io/francqui-collen-prize-philippe-lemey</id><content type="html" xml:base="https://blog.nodrama.io/francqui-collen-prize-philippe-lemey/"><![CDATA[<hr />

<p>If you’re a geek like I am, you must remember that <a href="https://www.youtube.com/watch?v=0PxTAn4g20U">scene in The Matrix Reloaded</a> when Trinity fires up <a href="https://nmap.org/">nmap</a> on 10.2.2.2 and finds a vulnerability in SSH to exploit.</p>

<p>Its <a href="https://github.com/fyodor">author</a> was apparently dancing in the aisles of the cinema when he saw his creation in the movie. Well, <a href="https://spreadviz.org/">Spread</a> sorta-kinda had its first moment of fame too!</p>

<hr />

<h1 id="-congratulations-to-professor-philippe-lemey"><a name="congratulations"></a> Congratulations to Professor Philippe Lemey!</h1>

<p>First and foremost, I want to start this post by extending my congratulations to my former PhD supervisor, <a href="https://rega.kuleuven.be/cev/ecv/evolutionary-and-computational-virology-publications/00036765">Professor Philippe Lemey</a>, who has received the prestigious <a href="https://en.wikipedia.org/wiki/Francqui_Prize">Francqui-Collen Prize</a> in Brussels on June 6th, 2023, from the hands of the King of the Belgians, Filip.
The prize, aptly referred to as the “Belgian Nobel Prize,” was awarded to Philippe for his pioneering work against the spread of viruses.</p>

<p>Professor Lemey is not only a great researcher but also a fantastic person and a real role model. It’s always great to see when such individuals receive the recognition they deserve.
So once again, a huge congratulations to him!</p>

<h1 id="-spread-on-the-national-news"><a name="vrtnws"></a> Spread on the national news</h1>

<p>On this occasion, VRT, the national public broadcaster for the Dutch-speaking part of Belgium, has aired a segment about Professor Lemey and the work of him and his lab.</p>

<p><img src="/images/2023-06-06-francqui-collen-prize/spread_on_news.jpg" alt="_config.yml" /></p>

<p>It was really cool to see the material include a clear shot of Spread in use 😄.
The entire segment can be found <a href="https://www.vrt.be/vrtnws/nl/2023/06/06/francqui-collen-prijzen/">here</a>, and at 58 seconds into the second video, there is a close-up of the tool being used.</p>]]></content><author><name>Filip Bielejec</name></author><category term="spread" /><category term="viruses" /><category term="phylogeography" /><category term="FrancquiCollenPrize" /><category term="PhilippeLemey" /><category term="vrt" /><category term="news" /><summary type="html"><![CDATA[Spread has been featured in the Belgian news broadcast VRT NWS]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.nodrama.io/images/logo.png" /><media:content medium="image" url="https://blog.nodrama.io/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Spread security incident</title><link href="https://blog.nodrama.io/attack-on-spread/" rel="alternate" type="text/html" title="Spread security incident" /><published>2023-04-26T00:00:00+00:00</published><updated>2023-04-26T00:00:00+00:00</updated><id>https://blog.nodrama.io/attack-on-spread</id><content type="html" xml:base="https://blog.nodrama.io/attack-on-spread/"><![CDATA[<hr />
<p><strong>TL;DR</strong></p>

<p><a href="https://spreadviz.org/">Spread</a> recently experienced a minor security incident related to increased email login attempts.
However, no user data was compromised during the incident.</p>

<hr />

<h1 id="-introduction"><a name="intro"></a> Introduction</h1>

<p>In this blog post, I’ll discuss a recent security incident at Spread and the steps taken to address it.
I’ll provide an overview of our authentication approach, explain the incident details, outline the actions I took, and discuss the future directions.</p>

<h1 id="-authentication-approach"><a name="auth"></a> Authentication Approach</h1>

<p>When <a href="https://www.blog.nodrama.io/spread/">designing Spread</a>, I adopted a passwordless approach for user authorization and authentication.
Spread doesn’t store any credentials or passwords.
Instead, users can choose to authenticate using OAuth 2.0 with Google or any other email.
In the latter case, Spread sends an email containing a “magic link” with a short-lived token.
Upon clicking the link, the token is validated, and a longer-lived JWT access token is issued, signed with Spread’s RSA key.</p>

<h1 id="-incident-details"><a name="details"></a> Incident Details</h1>

<p>On April 26th, I received an alert from Sendgrid, our email service provider, notifying us that our monthly quotas were almost exceeded.
Given our focus on cost-effectiveness<sup><a href="#footnote1">1</a></sup>, I had chosen a very modest plan that had been sufficient until now.
My concern was that a potential leak of our API key could result in an unauthorized usage, affecting our reputation as a trusted sender.</p>

<h1 id="-steps-taken"><a name="steps"></a> Steps taken</h1>

<p>To investigate the situation, I reviewed the logs and noticed a significant increase in email login attempts.
While this was not ideal, it indicated an attempt to overload the endpoint responsible for sending login emails, rather than credentials leak or a sophisticated attack.</p>

<p>To mitigate the issue, I took the following steps:</p>

<ul>
  <li>Revoked the Sendgrid API token to prevent further unauthorized usage.</li>
  <li>Temporarily disabled the “magic link” login/sign-on functionality until the API token was rotated.</li>
  <li>Developed a solution to handle similar attacks by implementing an IP-based “jailer” decorator that limits the number of login attempts within a specific timeframe.</li>
  <li>Collaborated with <a href="https://github.com/jpmonettas?tab=repositories">Juan</a> to improve the decorator implementation, resulting in a more efficient and testable state machine-based approach.</li>
  <li>The solution can be found in the following <a href="https://github.com/phylogeography/spread/blob/5964d016665270d960e94a193b44f5dff75578b1/src/clj/api/server.clj#L33-L78">code</a>.</li>
</ul>

<hr />
<p><a name="footnote1">1</a>: sadly the academia is notoriously underfunded.</p>]]></content><author><name>Filip Bielejec</name></author><category term="Clojure" /><category term="spread" /><category term="authorization" /><category term="authentication" /><category term="security" /><summary type="html"><![CDATA[In this blog post I describe a minor security incident]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.nodrama.io/images/logo.png" /><media:content medium="image" url="https://blog.nodrama.io/images/logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>