<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: FoxyyyBusiness</title>
    <description>The latest articles on DEV Community by FoxyyyBusiness (@foxyyybusiness).</description>
    <link>https://dev.to/foxyyybusiness</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3870571%2F4e0f35eb-daa9-41df-86fd-f5432fe83410.png</url>
      <title>DEV Community: FoxyyyBusiness</title>
      <link>https://dev.to/foxyyybusiness</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/foxyyybusiness"/>
    <language>en</language>
    <item>
      <title>Three crypto exchange volume bugs that were hiding in plain sight</title>
      <dc:creator>FoxyyyBusiness</dc:creator>
      <pubDate>Thu, 09 Apr 2026 23:03:30 +0000</pubDate>
      <link>https://dev.to/foxyyybusiness/three-crypto-exchange-volume-bugs-that-were-hiding-in-plain-sight-5hho</link>
      <guid>https://dev.to/foxyyybusiness/three-crypto-exchange-volume-bugs-that-were-hiding-in-plain-sight-5hho</guid>
      <description>&lt;p&gt;I run a service that pulls funding rates from 16 perpetual futures exchanges every five minutes and exposes a unified API for cross-venue arbitrage. Each fetcher is ~50 lines of Python — request, parse, normalize, store. Boring stuff, in theory.&lt;/p&gt;

&lt;p&gt;In practice, three of those 50-line fetchers were silently broken in ways that produced &lt;em&gt;plausible-looking&lt;/em&gt; numbers. My unit tests passed. The data looked sane. But when I added a simple &lt;code&gt;/api/funding/by_volume&lt;/code&gt; endpoint that ranks base coins by their &lt;strong&gt;total cross-exchange 24h dollar volume&lt;/strong&gt;, the leaderboard came back with &lt;code&gt;SATS&lt;/code&gt; showing $180 trillion in daily turnover.&lt;/p&gt;

&lt;p&gt;That number is roughly the entire global stock-and-bond market, traded in one day, in one shitcoin, on perpetual futures. Something was off.&lt;/p&gt;

&lt;p&gt;Here are the three bugs I dug out, in the order I found them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #1 — OKX is denominated in BASE coin, not USDT
&lt;/h2&gt;

&lt;p&gt;OKX's &lt;code&gt;/api/v5/market/tickers?instType=SWAP&lt;/code&gt; endpoint returns a &lt;code&gt;volCcy24h&lt;/code&gt; field for every perp. The name reads like "volume in counter currency" — i.e. the quote, USDT. That's how Binance, Bybit, Bitget, Gate.io, MEXC, and most others structure it.&lt;/p&gt;

&lt;p&gt;OKX does not. For SWAPs, &lt;code&gt;volCcy24h&lt;/code&gt; is in &lt;strong&gt;base coin units&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So my fetcher was treating &lt;code&gt;138000&lt;/code&gt; (138k BTC traded in 24h, which is correct) as &lt;code&gt;$138k in USD volume&lt;/code&gt;. OKX BTC volume came back as $136k against Binance's $16.5B. I had been staring at this in the dashboard for two days without noticing because BTC was already at the top of the list — &lt;em&gt;the ranking was right, the magnitude was off by five orders of magnitude&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The fix is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_okx_tickers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://www.okx.com/api/v5/market/tickers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SWAP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]:&lt;/span&gt;
        &lt;span class="n"&gt;inst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;base_vol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;volCcy24h&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;last&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# volCcy24h is in BASE coin units for SWAPs — multiply by last price for USD.
&lt;/span&gt;        &lt;span class="n"&gt;vol_usd&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base_vol&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;inst&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vol_usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;vol_usd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the fix, OKX BTC volume jumped from $136k to $9.7B. That matches every other public reporter (CoinGecko, Coinglass, etc).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: field names are descriptions, not contracts. Read the docs, but trust the cross-exchange comparison even more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #2 — BTSE counts CONTRACTS, not coins
&lt;/h2&gt;

&lt;p&gt;BTSE's &lt;code&gt;/futures/api/v2.1/market_summary&lt;/code&gt; returns a &lt;code&gt;volume&lt;/code&gt; field for each market. It is, like everywhere else, a number. But it's not a number of base coins. It's a number of &lt;em&gt;contracts&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A contract on BTSE has a configurable &lt;code&gt;contractSize&lt;/code&gt;. For BTC perps, the contract size is &lt;code&gt;0.001&lt;/code&gt;. For some alts, it's &lt;code&gt;1&lt;/code&gt;. For some new perps, it's a weird fraction like &lt;code&gt;0.0001&lt;/code&gt;. The &lt;code&gt;volume&lt;/code&gt; field is the count of contracts traded — and the actual notional in base coin units is &lt;code&gt;volume * contractSize&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I had defaulted my BTSE fetcher to assuming one contract = one base coin (a reasonable Binance-shaped assumption). Result: BTSE BTC was reporting $60 trillion of 24h volume, because I was interpreting 1 million contracts (= 1000 BTC) as 1 million BTC.&lt;/p&gt;

&lt;p&gt;The fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;vol_contracts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;volume&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;contract_size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contractSize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;vol_base_units&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vol_contracts&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;contract_size&lt;/span&gt;
&lt;span class="n"&gt;vol_usd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vol_base_units&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;mark_price&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the fix: BTSE BTC ~$0.6B per day. That matches BTSE's own published volume widget on their landing page, which I should have checked first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: any time an exchange exposes a &lt;code&gt;contractSize&lt;/code&gt; field, you almost certainly have to apply it. Even if the default for BTC happens to be 1 on the venue you tested first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #3 — Kraken Futures funding rate is in dollars, not percent
&lt;/h2&gt;

&lt;p&gt;This one was the most expensive bug because it took the longest to notice.&lt;/p&gt;

&lt;p&gt;Kraken Futures' &lt;code&gt;/derivatives/api/v3/tickers&lt;/code&gt; endpoint returns a &lt;code&gt;fundingRate&lt;/code&gt; field. Every other exchange I integrate (16 of them now) returns this as a decimal: &lt;code&gt;0.0001&lt;/code&gt; means 0.01% per funding period. Annualized at 8h funding intervals, that's about 11% APY. Sane.&lt;/p&gt;

&lt;p&gt;Kraken Futures does not. Their &lt;code&gt;fundingRate&lt;/code&gt; is the &lt;strong&gt;USD payment per contract per period&lt;/strong&gt;. So a Kraken &lt;code&gt;fundingRate&lt;/code&gt; of &lt;code&gt;7.0&lt;/code&gt; doesn't mean 700% per period — it means $7 paid per contract held over the funding period.&lt;/p&gt;

&lt;p&gt;To convert to a comparable decimal rate, you divide by the mark price:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Kraken Futures returns fundingRate as USD-per-contract per period.
# Divide by mark price to get the normalized decimal rate.
&lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw_rate&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;mark_price&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bug was visible in my dashboard for over a day before I caught it. Kraken ETH was showing &lt;strong&gt;1893% APY&lt;/strong&gt; — clearly wrong, but it sat in the "extreme rates" view alongside legitimately weird shitcoin funding rates (some alts genuinely run at hundreds of percent), so it didn't pop out. After the fix, Kraken ETH funding came back to ~7% APY, which lines up with the rest of the market.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: extreme outliers in financial data are sometimes real, sometimes wrong, and almost always worth a five-minute manual sanity check before you ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line sanity check that catches all three
&lt;/h2&gt;

&lt;p&gt;Here's the trick I now run after every new exchange integration:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For BTC, sort all exchanges by 24h volume in USD. The numbers should be within one order of magnitude of each other, and no single exchange should be more than ~5x larger than the median.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Concretely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;btc_rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BTC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;btc_rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;volume_24h_usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;btc_rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;exchange&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;volume_24h_usd&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What healthy looks like (at the time of writing, end of March 2026):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;binance      $ 17,960,723,449
okx          $  9,153,894,221
bybit        $  7,983,221,887
gateio       $  6,510,448,775
bitget       $  4,869,514,230
hyperliquid  $  3,420,118,002
mexc         $  2,914,702,118
bingx        $  1,822,978,884
htx          $    902,541,003
btse         $    614,923,937
phemex       $    321,108,775
kucoin       $    298,992,881
bitfinex     $    132,414,498
kraken       $    101,302,440
bitmex       $     78,200,116
dydx         $     45,109,332
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ol&gt;
&lt;li&gt;The leader (Binance) is about 250x the smallest entrant (dYdX) — that's the realistic span of CEX/DEX liquidity for the dominant pair.&lt;/li&gt;
&lt;li&gt;There are no zeros, and there are no $1T entries.&lt;/li&gt;
&lt;li&gt;The shape of the curve is roughly log-linear when plotted, which is what you'd expect from the long tail of crypto venues.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any single exchange comes back with three more zeros than its neighbors, you have a unit bug. If it comes back with three fewer zeros, same thing. Both my OKX and BTSE bugs were instantly visible in this view — I just hadn't built the view until after the bugs had been live for two days.&lt;/p&gt;

&lt;p&gt;For funding rates, the equivalent sanity check is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;No exchange's funding rate for a major (BTC/ETH/SOL) should annualize to more than ±100%. If one does, you have a normalization bug or the venue is in liquidation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A 1893% APY does not survive this filter. Build the filter first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed in my workflow
&lt;/h2&gt;

&lt;p&gt;After fixing these three I added two things to the integration template for any new exchange:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A regression test that pins the unit semantics&lt;/strong&gt;, not just the happy path. For example, my OKX test now constructs a ticker with &lt;code&gt;volCcy24h: "138000"&lt;/code&gt; and &lt;code&gt;last: "70000"&lt;/code&gt; and asserts that &lt;code&gt;vol_usd&lt;/code&gt; equals &lt;code&gt;138_000 * 70_000&lt;/code&gt;, NOT just that the function returns a non-empty dict. If a future refactor accidentally drops the multiplication, the test fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A post-deploy cross-exchange diff check&lt;/strong&gt; that runs after every collector restart. It pulls the BTC volume across all exchanges and screams if any single one is more than 50x or less than 1/50th of the median. It's a 30-line cron job and it would have caught all three of my bugs within five minutes of deployment.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both of these are straightforward, but they're easy to skip when you're heads-down adding venues. I skipped them, and it cost me two days of looking at a leaderboard with &lt;code&gt;SATS&lt;/code&gt; showing $180T in volume and thinking "huh, that's weird, I'll deal with it later."&lt;/p&gt;

&lt;p&gt;The bugs were never in the parsing code. They were in the &lt;em&gt;units of the inputs&lt;/em&gt;. The exchanges that ship the cleanest documentation and most consistent field semantics make this kind of normalization invisible. The exchanges that don't will silently corrupt your dashboard until the day you build a leaderboard.&lt;/p&gt;

&lt;p&gt;If you're aggregating data across venues, build the leaderboard first.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you found this useful and you're trading crypto perps, the dashboard I was debugging is live at &lt;a href="https://fundingfinder.foxyyy.com" rel="noopener noreferrer"&gt;https://fundingfinder.foxyyy.com&lt;/a&gt; (free tier, no signup) — it now covers 16 exchanges and ~6,200 USDT-margined perps with five-minute refresh. The fixes from this article are deployed in v0.6.10.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cryptocurrency</category>
      <category>python</category>
      <category>api</category>
      <category>debugging</category>
    </item>
    <item>
      <title>5 boring patterns I used to ship two production services on a $5 VPS in 10 days</title>
      <dc:creator>FoxyyyBusiness</dc:creator>
      <pubDate>Thu, 09 Apr 2026 22:58:05 +0000</pubDate>
      <link>https://dev.to/foxyyybusiness/5-boring-patterns-i-used-to-ship-two-production-services-on-a-5-vps-in-10-days-1kli</link>
      <guid>https://dev.to/foxyyybusiness/5-boring-patterns-i-used-to-ship-two-production-services-on-a-5-vps-in-10-days-1kli</guid>
      <description>&lt;p&gt;In the last 10 days I shipped two production web services on a single $5 Hetzner VPS:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Funding Finder&lt;/strong&gt; — a 20-exchange perpetual futures funding rate aggregator. ~6,800 USDT-margined symbols, refreshed every 5 minutes. 5 systemd services, ~80 MB resident memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cronviz&lt;/strong&gt; — a stdlib-only CLI tool for unified cron + systemd timer observability. Zero dependencies. 48 unit tests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both shipped with the same boring stack: &lt;strong&gt;Python 3.12 + Flask + SQLite (WAL mode) + systemd + vanilla HTML&lt;/strong&gt;. Nothing exotic. Nothing trendy. Things that have worked since 2010.&lt;/p&gt;

&lt;p&gt;This post is 5 patterns from that work. They're not the &lt;em&gt;only&lt;/em&gt; thing I used — there are about 25 more in the full collection — but they're the 5 that did the most leverage. If you're a solo dev shipping a side project on weekends and you keep getting told you need Postgres / Redis / Docker / Kubernetes / FastAPI / asyncio to be "production-ready", read these first. You probably don't.&lt;/p&gt;

&lt;p&gt;Each pattern follows the same shape: the pain it solves, the code, when &lt;em&gt;not&lt;/em&gt; to use it, and a real number from production.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The 12-line systemd unit
&lt;/h2&gt;

&lt;p&gt;You wrote a Flask app. It runs locally with &lt;code&gt;python app.py&lt;/code&gt;. You SSH into the VPS, &lt;code&gt;git pull&lt;/code&gt;, and now you need it to run &lt;em&gt;forever&lt;/em&gt;: restart on crash, restart on reboot, log to a place you can &lt;code&gt;tail -f&lt;/code&gt;, let you &lt;code&gt;systemctl restart&lt;/code&gt; it on deploy.&lt;/p&gt;

&lt;p&gt;Half the internet will tell you to use Docker. On a $5 VPS, for one process, Docker is overkill. systemd has been on every Linux box since 2015 and does exactly this in 12 lines.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/system/myapp.service&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;My Flask app&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;simple&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;root&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/root/myapp&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/python3 /root/myapp/app.py&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;on-failure&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;
&lt;span class="py"&gt;StandardOutput&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;journal&lt;/span&gt;
&lt;span class="py"&gt;StandardError&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;journal&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl daemon-reload
systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; myapp.service
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; myapp.service &lt;span class="nt"&gt;-f&lt;/span&gt;       &lt;span class="c"&gt;# logs&lt;/span&gt;
systemctl restart myapp.service       &lt;span class="c"&gt;# deploy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. There's nothing else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When NOT to use it.&lt;/strong&gt; If you need rolling deploys, multiple replicas, cross-machine service discovery, or you're already on Kubernetes — skip this. For literally anything below that bar, this is the right shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production number.&lt;/strong&gt; My Funding Finder API service has been running for 10+ days, ~25 MB resident memory, 12 restarts (all from &lt;code&gt;systemctl restart&lt;/code&gt; during deploys, zero from crashes).&lt;/p&gt;




&lt;h2&gt;
  
  
  2. SQLite in WAL mode is enough for almost any solo project
&lt;/h2&gt;

&lt;p&gt;You picked SQLite because you didn't want to run a separate database process. Then you started writing concurrently from a Flask request handler and a background collector, and SQLite locked up.&lt;/p&gt;

&lt;p&gt;The fix is two PRAGMAs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;contextmanager&lt;/span&gt;

&lt;span class="nd"&gt;@contextmanager&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/var/data/myapp.db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;row_factory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Row&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRAGMA journal_mode=WAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRAGMA synchronous=NORMAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;WAL&lt;/code&gt; means concurrent reads no longer block writes (and vice versa). &lt;code&gt;synchronous=NORMAL&lt;/code&gt; means SQLite calls &lt;code&gt;fsync()&lt;/code&gt; once per commit instead of once per page write — throughput goes from ~100 commits/sec to ~10,000 commits/sec on a typical SSD, and you only lose the last in-flight transaction in the (rare) case of a hard kernel crash.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When NOT to use it.&lt;/strong&gt; Networked filesystems (NFS): WAL is broken there. Use Postgres. Multi-machine deployments: same answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production number.&lt;/strong&gt; My collector inserts ~6,800 rows in one transaction every 5 minutes. With WAL: ~80 ms per batch, API server reads never block. Without WAL: random 500s during commit windows.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Per-IP rate limiting in 25 lines, no Redis
&lt;/h2&gt;

&lt;p&gt;You opened your free-tier API to the public. Within hours, one IP starts hammering it 50 req/sec. You need to throttle without (a) blocking entirely, (b) running Redis, (c) installing a "professional" rate limiting library that needs a year of config.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;deque&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Lock&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;abort&lt;/span&gt;

&lt;span class="n"&gt;_hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deque&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="n"&gt;_lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;_WINDOW_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
&lt;span class="n"&gt;_MAX_PER_WINDOW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;   &lt;span class="c1"&gt;# 60 req/min/IP
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rate_limit&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remote_addr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;_WINDOW_SECONDS&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;_lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;dq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;deque&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;dq&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;dq&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;dq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;popleft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;_MAX_PER_WINDOW&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;retry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dq&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;_WINDOW_SECONDS&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
            &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rate limit, retry in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;retry&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;dq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/expensive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;api_expensive&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nf"&gt;rate_limit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each IP gets a deque of recent timestamps. Drop expired ones, count, abort if over the limit, otherwise append. ~25 lines, one global dict, one lock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When NOT to use it.&lt;/strong&gt; Multi-process deployments (gunicorn &lt;code&gt;--workers=4&lt;/code&gt;) where each worker has its own dict and the limit becomes per-worker. The fix is gunicorn &lt;code&gt;--workers=1 --threads=8&lt;/code&gt; (fine for I/O-bound services), or move to Redis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production number.&lt;/strong&gt; My free tier is 60 req/min/IP, paid tiers are 600 and 3000 req/min. 10 days, 10k+ unique IPs, ~5 MB total memory cost across all the tracked deques. Zero issues.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. ThreadPoolExecutor for fan-out HTTP fetching, when async is overkill
&lt;/h2&gt;

&lt;p&gt;You need to fetch funding rates for 285 OKX perpetual contracts. The exchange rate-limits public endpoints to ~10 req/s. Your options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sequential.&lt;/strong&gt; 285 × 100 ms = 28.5 seconds. Too slow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;aiohttp&lt;/code&gt; + &lt;code&gt;asyncio.gather&lt;/code&gt;.&lt;/strong&gt; Fast, but you're now rewriting the codebase in &lt;code&gt;async def&lt;/code&gt;, your tests need &lt;code&gt;pytest-asyncio&lt;/code&gt;, your stack traces are 80% framework noise, and you're debugging "RuntimeError: This event loop is already running" in REPL sessions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;concurrent.futures.ThreadPoolExecutor&lt;/code&gt;.&lt;/strong&gt; 8 worker threads, 8 seconds end-to-end, zero new dependencies, your code stays synchronous.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;concurrent.futures&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;as_completed&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp/1.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inst_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://www.okx.com/api/v5/public/funding-rate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inst_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inst_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;futures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetch_one&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inst&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;inst&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;inst&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;inst_ids&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;fut&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;as_completed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;futures&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;result&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick worker count just below &lt;code&gt;(rate_limit_per_sec × p95_latency_seconds) × 2&lt;/code&gt;. For OKX (10 req/s, 150 ms median): 8 workers, comfortably under the limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When NOT to use it.&lt;/strong&gt; CPU-bound work (the GIL doesn't help — use ProcessPoolExecutor or NumPy). Or millions of concurrent connections (~1 MB stack per thread; 10k threads = 10 GB RAM — use async). For "fan out 100-500 HTTP calls under a rate limit", threads are the boring, working answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production number.&lt;/strong&gt; My OKX fetcher: 285 instruments, 8 worker threads, ~8 seconds end-to-end, &amp;lt; 0.1% failure rate over 10 days. The async version would shave ~3 seconds off and require rewriting half my codebase. Not worth it.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Telegram bot as monitoring transport — €0/month
&lt;/h2&gt;

&lt;p&gt;Your service crashes at 3 AM. PagerDuty exists for this and costs €19/user/month. Telegram exists for this and costs €0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setup (90 seconds):&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open Telegram, search &lt;code&gt;@BotFather&lt;/code&gt;, send &lt;code&gt;/newbot&lt;/code&gt;. Get a token.&lt;/li&gt;
&lt;li&gt;Send your new bot a message (you must message it first, before it can message you).&lt;/li&gt;
&lt;li&gt;Visit &lt;code&gt;https://api.telegram.org/bot&amp;lt;TOKEN&amp;gt;/getUpdates&lt;/code&gt; and find &lt;code&gt;"chat":{"id":12345678}&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Code:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TELEGRAM_BOT_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;CHAT_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TELEGRAM_CHAT_ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TOKEN&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;CHAT_ID&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.telegram.org/bot&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TOKEN&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/sendMessage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CHAT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parse_mode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Markdown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use it from anywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;disk_usage&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;send_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;⚠️ disk at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;disk_usage&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; on `&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;collector_age&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;send_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;❌ collector stale: last fetch &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;collector_age&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s ago&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;send_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;✅ deploy of `&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;commit_sha&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;` complete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your phone buzzes within 2 seconds. No SaaS account, no SDK, no webhook UI, no escalation policy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When NOT to use it.&lt;/strong&gt; On-call rotation. Acknowledgment + escalation. Compliance audit trails. &amp;gt; 30 messages/sec sustained.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production number.&lt;/strong&gt; I've used this pattern in every service I've shipped for 3 years. Zero missed alerts. Zero subscription cost. Telegram has not become a product they're trying to monetize — the Bot API has been stable since 2015.&lt;/p&gt;




&lt;h2&gt;
  
  
  The take-away
&lt;/h2&gt;

&lt;p&gt;Shipping a paid web service on a $5 VPS doesn't require 47 config files, a microservices architecture, or a year of devops yak-shaving. It requires:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One systemd unit per service&lt;/li&gt;
&lt;li&gt;SQLite in WAL mode&lt;/li&gt;
&lt;li&gt;A 25-line rate limiter&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;ThreadPoolExecutor&lt;/code&gt; instead of asyncio&lt;/li&gt;
&lt;li&gt;A free Telegram bot for alerts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total dependencies introduced by the 5 patterns above: &lt;strong&gt;&lt;code&gt;requests&lt;/code&gt;&lt;/strong&gt; (and even that is optional — you could use &lt;code&gt;urllib.request&lt;/code&gt; from stdlib). That's it.&lt;/p&gt;

&lt;p&gt;If you're still in "I should learn Kubernetes before I ship anything" mode, please consider that this entire stack costs €5/month, fits on a $5 VPS, has been running two production services for 10 days with zero downtime, and is fundamentally easier to reason about than any "modern" alternative. Use what works. The boring stack works.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where this comes from
&lt;/h2&gt;

&lt;p&gt;These 5 patterns are extracted from a longer collection I'm putting together: &lt;strong&gt;30 Boring Patterns for Solo Devs Who Ship&lt;/strong&gt;. The other 25 cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Theme 2&lt;/strong&gt; — SQLite at solo-dev scale (migrations, backups, single-writer pattern, cursor pagination)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Theme 3&lt;/strong&gt; — HTTP and Flask (API key auth with revocation, CSV exports, OpenAPI by hand, vanilla HTML/SVG dashboards)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Theme 4&lt;/strong&gt; — More external API patterns (mocked-HTTP testing, the structural sanity check on aggregate output, per-source field normalization)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Theme 5&lt;/strong&gt; — Operations and observability (health endpoints, /metrics in 15 lines, &lt;code&gt;make&lt;/code&gt; as your only deploy tool)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Theme 6&lt;/strong&gt; — Going from free to paid (per-tier rate limiting, Lemon Squeezy vs Stripe, pricing a niche dev tool)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full pack (standalone HTML + companion code zip) is €19, one-time, no subscription. Live now at &lt;a href="http://178.104.60.252:8083/boring-patterns" rel="noopener noreferrer"&gt;http://178.104.60.252:8083/boring-patterns&lt;/a&gt; — instant download after checkout via Stripe. The 5 patterns above are the full content of those 5 patterns — no teaser cuts. If they're useful, the other 25 are too. If they're not, you've still got 5 production-tested patterns for free. Patterns are CC BY-SA 4.0, code is MIT, 14-day no-questions refund.&lt;/p&gt;

&lt;p&gt;Comments / corrections / "you're wrong about X" replies very welcome.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>sqlite</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>30 days of solo dev shipping: 9 projects, 1 VPS, no Docker — what I actually learned</title>
      <dc:creator>FoxyyyBusiness</dc:creator>
      <pubDate>Thu, 09 Apr 2026 22:53:44 +0000</pubDate>
      <link>https://dev.to/foxyyybusiness/30-days-of-solo-dev-shipping-9-projects-1-vps-no-docker-what-i-actually-learned-47a3</link>
      <guid>https://dev.to/foxyyybusiness/30-days-of-solo-dev-shipping-9-projects-1-vps-no-docker-what-i-actually-learned-47a3</guid>
      <description>&lt;p&gt;I'm a solo dev. Over the last 30 days I shipped 9 distinct projects on a single $5 Hetzner VPS, all running concurrently, all publicly accessible right now. Total Docker containers: zero. Total Postgres processes: zero. Total cumulative downtime: zero.&lt;/p&gt;

&lt;p&gt;This is a retrospective. Not a Show HN, not a launch announcement, not a "look at my projects" gallery. I want to write down what I actually learned doing this — the bets that paid off, the bets that humbled me, and the framework I converged on by accident around day 12.&lt;/p&gt;

&lt;p&gt;If you're a solo dev who keeps reading "ultimate stack for indie hackers" posts and thinking "but I just want to ship something already", this is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 9 projects in one paragraph
&lt;/h2&gt;

&lt;p&gt;A cross-exchange perpetual futures funding rate scanner across 20 venues (data SaaS, the primary product). A stdlib-only CLI for unified cron and systemd timer observability (an OSS dev tool). An info-product book of 30 production-tested patterns for shipping side projects on a $5 VPS. An autonomous bot that posts hourly funding rate signals to Telegram and a public web feed. An auto-generated daily research blog that templates a structured Markdown post from live data. A directory of 10 trader calculators, each at its own URL for long-tail SEO. A curated directory of infra resources for solo devs. An uptime tracker for the 20 exchanges, with 24h status pages per venue. A packaged historical funding-rate dataset (2.58M rows, daily rebuild, CC BY 4.0). Nine distinct domains, nine distinct customer types, all running on the same Flask + SQLite + systemd stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The framework that emerged by accident
&lt;/h2&gt;

&lt;p&gt;I started with one project (the cross-exchange scanner). I assumed I'd spend the full 30 days polishing it. Within ~10 days the scanner had everything it needed technically, and I was about to start working on features that the scanner &lt;em&gt;didn't need&lt;/em&gt; because I had nothing else to do. That's the moment I realized the gating items were no longer technical — they were external. Distribution accounts (Reddit, HN, Twitter), payment processor (Lemon Squeezy), domain name. None of these are problems I can code my way out of.&lt;/p&gt;

&lt;p&gt;So I parked the scanner and started a second project in a completely different domain. Then a third. By day 18 the framework had crystallized into three rules:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 1 — Park when blocked, not when bored.&lt;/strong&gt; A "real blocker" is something I cannot resolve alone (a credential, a decision from someone else, a payment, a validation). Not "I want to add another feature". Not "I could improve the test coverage". If I can still write code, prose, tests, or design — that's not a blocker, that's continuation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 2 — Always have ≥2 projects in flight.&lt;/strong&gt; When I park a project, I start (or resume) something else immediately. The work queue is never empty. This sounds inefficient but it's actually the opposite: it forces me to never be in the "I'm waiting for X" passive state. There's always a concrete next action.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 3 — When the projects start to look alike, the rule kicks in differently.&lt;/strong&gt; When I had 3 projects and they were all "Flask data API for crypto", I stopped and asked: am I just rebuilding the same thing in different domains? The answer was yes. So I started forcing the new projects to be &lt;em&gt;structurally&lt;/em&gt; different from existing ones — different audience, different distribution model, different format. The 9 projects ended up covering 9 distinct shapes (data SaaS, OSS CLI, info product, autonomous notifications, auto-generated content, calculators, curated directory, status pages, packaged dataset). Each one would teach me something the others couldn't.&lt;/p&gt;

&lt;p&gt;I didn't plan this framework. It emerged because the alternative — sitting on one project waiting for credentials — felt obviously wasteful. Once it was named, it became operational.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring stack paid off, again
&lt;/h2&gt;

&lt;p&gt;Every project ships with Python 3.12 + Flask + SQLite (WAL mode) + systemd + vanilla HTML. No Docker, no Postgres, no Redis, no Kafka, no asyncio, no frontend framework, no microservices, no API gateway, no serverless functions.&lt;/p&gt;

&lt;p&gt;The reason this works is mostly negative: every "modern" thing I avoided has a real cost in setup time, debugging surface, and ongoing maintenance, and the value those things would provide doesn't matter at solo-dev scale. SQLite in WAL mode handles 50,000 commits per second on a $5 VPS with NVMe — every project I built combined uses maybe 200 writes per minute. That's 0.4% of capacity. Postgres would make zero perceptible difference and would add a process to manage, a separate auth surface, a backup story, and a network round-trip for every query.&lt;/p&gt;

&lt;p&gt;systemd does the job of Docker for any single-host service. Twelve lines per unit file gets you auto-restart on crash, auto-start on reboot, structured logging via &lt;code&gt;journalctl&lt;/code&gt;, and &lt;code&gt;systemctl restart&lt;/code&gt; deploys in 3 seconds. I use it for every service I ship and I've never had a moment where I missed Docker.&lt;/p&gt;

&lt;p&gt;The most surprising thing about the boring stack is how &lt;em&gt;small&lt;/em&gt; it feels. The whole 9-project ecosystem fits in your head. I can hold the entire surface area mentally: 5 systemd services, 1 SQLite file, ~25 HTML pages, ~30 API endpoints, 3 autonomous timers, 80 MB of resident memory total. There are no hidden processes, no opaque containers, no black-box managed services. If something is wrong, I know where to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four bugs that humbled me
&lt;/h2&gt;

&lt;p&gt;While integrating the 20 exchanges, I introduced four silent bugs in my own normalization code that 100% test coverage didn't catch. Each was a unit conversion error and each was off by 5+ orders of magnitude:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;OKX &lt;code&gt;volCcy24h&lt;/code&gt; is in base coin units, not USDT.&lt;/strong&gt; I was treating it as USDT. OKX BTC volume came back as $136k against Binance's $16.5B. I'd been staring at this in the dashboard for two days without noticing because BTC was already at the top of the ranking — &lt;em&gt;the order was right, the magnitude was off by 5 orders&lt;/em&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;BTSE &lt;code&gt;volume&lt;/code&gt; is contract count, not base coin units.&lt;/strong&gt; Without the contract size multiplier, my BTC volume calc was 5 orders too high. The leaderboard showed BTSE BTC at $60T (&amp;gt;$60 trillion). Anyone who read the dashboard would have noticed instantly. I didn't, because I had no aggregate view.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Kraken Futures &lt;code&gt;fundingRate&lt;/code&gt; is USD-per-contract per period, not decimal.&lt;/strong&gt; Their &lt;code&gt;fundingRate&lt;/code&gt; of &lt;code&gt;7.0&lt;/code&gt; means "$7 per contract per period", not "700% per period". I was treating it as decimal and showing Kraken ETH at 1893% APY for two days.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;BitMEX uses XBT internally, not BTC.&lt;/strong&gt; I'd written XBT→BTC normalization for Kraken and KuCoin (which also use XBT) months earlier, but forgot to add it for BitMEX when I integrated it. BitMEX BTC silently disappeared from cross-venue BTC views for the entire time it was in production. Caught only when I added the 17th venue and noticed the leaderboard had 16 entries instead of 17.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What unifies these four bugs is that &lt;strong&gt;none of them were in the parsing code&lt;/strong&gt;. The parsing code was correct in every case. The bugs were in the &lt;em&gt;assumed semantics of the input&lt;/em&gt;. My unit tests were green because they pinned the wrong invariants on hand-written JSON fixtures that I had also gotten wrong.&lt;/p&gt;

&lt;p&gt;The thing that finally caught all four was a single 30-line function I started running after every collection cycle: sort BTC volume across all sources, look for any source where the value is more than 50× the median or less than 1/50× the median. If you have an outlier of that magnitude, it's almost always a unit bug. I now run this check in CI for every new exchange integration and it has caught zero false positives and four real bugs.&lt;/p&gt;

&lt;p&gt;I wrote this up under the working name "structural sanity check on aggregate output" but I'm not sure that's what it's called. Property-based testing is the closest formal cousin but doesn't quite fit. If anyone has a better name, I'd love it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start the launch material on day one, not day twenty.&lt;/strong&gt; I waited until I had products to launch before writing the launch drafts. By the time I wrote them, I had four projects without any distribution material at all — orphans. If I were doing this again, I'd write the Show HN draft for a project the same day I started building it, even before v0.1. The exercise of writing the draft forces clarity about what the project is &lt;em&gt;for&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build the placeholder-replacement script earlier.&lt;/strong&gt; I have 70+ placeholders scattered across drafts and static pages (&lt;code&gt;clementslowik&lt;/code&gt;, &lt;code&gt;clementslowik&lt;/code&gt;, &lt;code&gt;github.com/clementslowik/funding-collector&lt;/code&gt;, &lt;code&gt;fundingfinder.foxyyy.com&lt;/code&gt;) that all need to be replaced when credentials arrive. I wrote the replacement script in week 4. If I'd written it on day 1, every new draft would have used the placeholder syntax from the start, and the launch-day patching would have been trivial.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Take the credential blockers seriously earlier.&lt;/strong&gt; I assumed that the credentials (Reddit account, HN karma, GitHub PAT, Lemon Squeezy account) would arrive "soon" and I could focus on the build. They didn't arrive in week 1, then they didn't arrive in week 2, then I started genuinely understanding that the entire monetization pipeline was gated on items I couldn't resolve myself. If I were doing this again, I'd treat credential acquisition as the very first task, not the last.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write the meta-launch piece earlier.&lt;/strong&gt; The single Show HN that points at /shipped (the canonical "I shipped 9 projects on a $5 VPS in 30 days" page) is going to be the most important post of the entire 30 days, because it serves as proof for all 9 projects simultaneously. I wrote it on day 30. It should have been drafted on day 5, with the project list updated as new ones shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I wouldn't change
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;8-projects-in-30-days pace&lt;/strong&gt;. I expected to feel scattered and unfocused. Instead I feel like I have a much wider sense of what each kind of product feels like to build, which is exactly the kind of generalist intuition you don't get from focusing on one thing. The opportunity cost is real (none of the 8 is maximally polished), but the &lt;em&gt;learning rate&lt;/em&gt; is 8x higher than it would have been on one project.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;boring stack discipline&lt;/strong&gt;. Not once did I think "if only I had Docker" or "this would be easier with Postgres". Every time I was tempted to add a new tool, the question "what specific problem does this solve that I have right now" produced an honest "none". The boring stack saved me weeks of decision fatigue.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;work_queue framework&lt;/strong&gt;. Having a hard rule of "≥2 projects in flight, park when actually blocked, start something different when bored" turned out to be the productivity hack I needed. Not the sexy productivity hack, the boring one — the one that says you don't need a Pomodoro timer, you need a clear next action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Everything I built is publicly accessible right now. If you want to verify any of the claims above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;/shipped&lt;/strong&gt; — the canonical list of 9 projects with public URLs and metrics: &lt;a href="http://178.104.60.252:8083/shipped" rel="noopener noreferrer"&gt;http://178.104.60.252:8083/shipped&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;/now&lt;/strong&gt; — the cross-exchange funding rate dashboard: &lt;a href="http://178.104.60.252:8083/now" rel="noopener noreferrer"&gt;http://178.104.60.252:8083/now&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;/research/2026-04-09&lt;/strong&gt; — today's auto-generated research post&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;/status&lt;/strong&gt; — uptime tracker for the 20 exchanges&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;/boring-patterns&lt;/strong&gt; — the in-progress patterns book (5 free, 12 more drafted)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Source code for the OSS data collector: pip-installable from &lt;code&gt;http://178.104.60.252:8083/downloads/funding-collector-0.4.3.tar.gz&lt;/code&gt; (will move to GitHub when credentials arrive).&lt;/p&gt;

&lt;p&gt;If you found this useful, the easiest way to support is to bookmark &lt;code&gt;/now&lt;/code&gt; and check it once a day. There's a launch waitlist on every project page if you want the one-time email when the paid tiers go live — no spam, no follow-up sequence, single email.&lt;/p&gt;

&lt;p&gt;Comments / corrections / "you're wrong about X" replies are very welcome. The "structural sanity check" pattern naming question in particular is a real ask — if you have a better name for it, I'm reading every reply.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>python</category>
      <category>sysadmin</category>
    </item>
  </channel>
</rss>
