<?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>Forem: Savvas Claw Arbs</title>
    <description>The latest articles on Forem by Savvas Claw Arbs (@clawarbs).</description>
    <link>https://forem.com/clawarbs</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%2F3866118%2F2d70dac6-1313-4c87-bcf5-822b0d9b7859.png</url>
      <title>Forem: Savvas Claw Arbs</title>
      <link>https://forem.com/clawarbs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/clawarbs"/>
    <language>en</language>
    <item>
      <title>I Built a Bot That Watches multiple Markets at Once and Finds Risk-Free Trades (arbitrage)</title>
      <dc:creator>Savvas Claw Arbs</dc:creator>
      <pubDate>Tue, 07 Apr 2026 15:03:39 +0000</pubDate>
      <link>https://forem.com/clawarbs/i-built-a-bot-that-watches-multiple-markets-at-once-and-finds-risk-free-trades-arbitrage-2mmn</link>
      <guid>https://forem.com/clawarbs/i-built-a-bot-that-watches-multiple-markets-at-once-and-finds-risk-free-trades-arbitrage-2mmn</guid>
      <description>&lt;p&gt;Here's something most people don't realize: the same sporting event can be priced differently on different platforms at the exact same moment. Kalshi might have "Lakers win" at 62 cents. Polymarket might have "Lakers lose" at 35 cents. That's 62 + 35 = 97 cents for a guaranteed 100-cent payout. Three cents of risk-free profit.&lt;/p&gt;

&lt;p&gt;Sounds trivial. It's not.&lt;/p&gt;

&lt;p&gt;Those windows last seconds. Sometimes less. You need to see the prices, do the math, and execute on &lt;em&gt;both&lt;/em&gt; platforms before either side moves. By hand, you'll never catch them. So I built a bot that does it. And then it got way, way more complicated than I planned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The system at a glance
&lt;/h2&gt;

&lt;p&gt;Claw Arbs watches four venues simultaneously:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kalshi&lt;/strong&gt;, a US-regulated prediction market (prices in cents, 0-100)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polymarket&lt;/strong&gt;, a crypto prediction market on Polygon (prices 0.00-1.00, USDC)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PS3838/Pinnacle&lt;/strong&gt;, the sharpest sportsbook in the world (decimal odds like 1.95)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BetInAsia&lt;/strong&gt;, a sportsbook aggregator (more on &lt;em&gt;how&lt;/em&gt; I get data from this one later)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The backend is a single async Python process running ~15 concurrent asyncio tasks. FastAPI + uvicorn. React frontend consuming REST + Server-Sent Events. PostgreSQL for persistence. The whole thing runs on one Ubuntu VPS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why async Python (and not Go or Node)
&lt;/h2&gt;

&lt;p&gt;I get asked this a lot. "Python for a trading bot? Isn't it slow?"&lt;/p&gt;

&lt;p&gt;Here's the thing: the bottleneck isn't CPU. It's I/O. I'm waiting on WebSocket messages, HTTP responses, database writes. I'm not doing matrix multiplication. Python's &lt;code&gt;asyncio&lt;/code&gt; handles thousands of concurrent I/O operations just fine in a single thread, and the ecosystem for what I needed (WebSocket clients, HTTP clients, Playwright, SQLAlchemy async) is all Python-first or Python-best.&lt;/p&gt;

&lt;p&gt;Go would've been faster at raw throughput, but I don't need raw throughput. I need to react to a price update within ~25ms. Python does that easily. And the development speed difference is real. I've rewritten core logic dozens of times, and doing that in Go would've taken me twice as long each time.&lt;/p&gt;

&lt;p&gt;Node was tempting because of its event loop model, but I've done enough production Node to know that once you're past 5,000 lines, TypeScript's type system doesn't save you the way Python's type hints + runtime validation (Pydantic) do. Especially when you're juggling four different price formats.&lt;/p&gt;

&lt;h2&gt;
  
  
  The WebSocket nightmare
&lt;/h2&gt;

&lt;p&gt;Four feeds. Four different protocols. Four different reconnection behaviors. Four different definitions of "the connection is dead."&lt;/p&gt;

&lt;p&gt;Kalshi sends orderbook deltas and ticker updates over a single WS connection. Polymarket uses their CLOB WebSocket, which has a completely different message format and auth flow. And then there's PS3838, which doesn't even have a WebSocket API. I'm doing REST delta-polling every 200ms, which &lt;em&gt;feels&lt;/em&gt; like a WebSocket but is really just me hammering their API as fast as they'll let me.&lt;/p&gt;

&lt;p&gt;The real pain is reconnection. Each feed has its own failure mode:&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_run_feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed_name&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;connect_fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handle_fn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_shutdown&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;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;connect_fn&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;ws&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_connected&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;feed_name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
                &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handle_fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ConnectionClosed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;ConnectionError&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;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&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;feed_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; disconnected: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&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;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_connected&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;feed_name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_retries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;feed_name&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_retries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;feed_name&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="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="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exception&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;feed_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; unexpected error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exponential backoff, capped at 30 seconds. I learned the hard way at 2am that without the cap, a prolonged Kalshi outage means your reconnection delay grows to &lt;em&gt;minutes&lt;/em&gt;, and by the time you reconnect, you've missed an hour of opportunities.&lt;/p&gt;

&lt;p&gt;And ordering. Oh god, ordering. When Kalshi sends you an orderbook delta, it assumes you have the previous state. If you miss a message during a reconnect, your local orderbook is garbage. So I timestamp every entry and mark anything older than 30 seconds as stale. Stale entries get ignored by the arb engines. Entries older than 5 minutes get evicted entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The price cache: the beating heart of the system
&lt;/h2&gt;

&lt;p&gt;Every price update from every feed flows into one place: &lt;code&gt;price_cache&lt;/code&gt;. It's a singleton, in-memory, and it's the most important object in the entire codebase.&lt;/p&gt;

&lt;p&gt;Why centralized? Because when Kalshi's price for "Lakers win" changes, I don't just need to recalculate the Kalshi-vs-Polymarket arb. I also need to check it against PS3838's odds and BetInAsia's odds. That's three different arb engines that all care about the same price update.&lt;/p&gt;

&lt;p&gt;So the cache uses a cascading notification pattern:&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;class&lt;/span&gt; &lt;span class="nc"&gt;PriceCache&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;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_kalshi&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;KalshiEntry&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_poly&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;PolyEntry&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_listeners&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="n"&gt;Callable&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_stale_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;30.0&lt;/span&gt;  &lt;span class="c1"&gt;# seconds
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_evict_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;300.0&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update_kalshi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ticker&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;yes_bid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;yes_ask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="n"&gt;yes_depth&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;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_kalshi&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="n"&gt;ticker&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;entry&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;yes_bid&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;yes_bid&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;yes_ask&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;yes_ask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;  &lt;span class="c1"&gt;# no change, skip notification storm
&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_kalshi&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;KalshiEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;yes_bid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;yes_bid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;yes_ask&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;yes_ask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;yes_depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;yes_depth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ts&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;listener&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_listeners&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kalshi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;if entry and entry.yes_bid == yes_bid&lt;/code&gt; check? That's a 25ms debounce by another name. Kalshi sends a &lt;em&gt;lot&lt;/em&gt; of duplicate updates, same price with a new timestamp. Without that guard, every arb engine fires on every heartbeat. My first version didn't have it. CPU usage was insane.&lt;/p&gt;

&lt;p&gt;Each listener is one of the arb engines. When they get notified, they pull the latest prices for both sides of a pair, normalize them into probability space (0-1), and run the edge calculation. Everything happens within the same asyncio event loop. No cross-thread synchronization, no locks, no mutexes. That's the real win of single-threaded async.&lt;/p&gt;

&lt;h2&gt;
  
  
  The arb detection math
&lt;/h2&gt;

&lt;p&gt;The concept is simple. Execution is not.&lt;/p&gt;

&lt;p&gt;Every venue quotes prices differently. Kalshi uses cents (62 means $0.62). Polymarket uses decimals (0.62). PS3838 uses decimal odds (1.61 means you get $1.61 for every $1 wagered). So the first step is normalizing everything to implied probability in 0-1 space, and that's what the &lt;code&gt;NormalizedMarket&lt;/code&gt; dataclass does.&lt;/p&gt;

&lt;p&gt;For a cross-venue arb between Kalshi and Polymarket, the basic edge formula is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cost = kalshi_yes_ask + poly_no_ask   (what you pay for both sides)
payout = 1.00                          (guaranteed, one side always wins)
edge = payout - cost - fees
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But "fees" is doing a lot of work in that formula. Kalshi charges a percentage fee based on your tier (I'm at 7%). Polymarket has gas costs on Polygon. And there's slippage, because the price you see isn't the price you get if you're taking any size.&lt;/p&gt;

&lt;p&gt;So the real calculation looks more like:&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;compute_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k_ask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_ask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kalshi_fee_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="n"&gt;poly_gas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slippage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&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;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;k_cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;k_ask&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;kalshi_fee_rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# fee on top
&lt;/span&gt;    &lt;span class="n"&gt;p_cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_ask&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;poly_gas&lt;/span&gt;               &lt;span class="c1"&gt;# gas is flat
&lt;/span&gt;    &lt;span class="n"&gt;total_cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;k_cost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p_cost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;slippage&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;total_cost&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;edge &amp;gt; 0&lt;/code&gt;, there's an arb. If &lt;code&gt;edge &amp;gt; min_edge&lt;/code&gt; (I usually set this around 1.5%), it's worth executing. If &lt;code&gt;edge &amp;gt; max_edge&lt;/code&gt; (say 15%), something is probably wrong (bad data, stale quote, API glitch) and I skip it. That max_edge check has saved me from several very expensive mistakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 14-step execution pipeline
&lt;/h2&gt;

&lt;p&gt;The cross-book engine, which finds edges between sharp sportsbooks and Kalshi/Polymarket, has a 14-step pipeline that every potential trade passes through before execution. This sounds excessive. It isn't.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;cb_arb_enabled&lt;/strong&gt;: is the engine even turned on?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;kill_switch&lt;/strong&gt;: global emergency stop&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;scanning_paused&lt;/strong&gt;: temporary pause during maintenance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;view_only&lt;/strong&gt;: detection without execution (useful for monitoring)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;halt_if_naked&lt;/strong&gt;: if we have an unhedged leg from a previous trade on this match, don't pile on more risk&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sharp_freshness&lt;/strong&gt;: is the sharp-side price recent enough to trust? PS3838 data older than a few seconds is dangerous&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;min_edge&lt;/strong&gt;: is the edge big enough to bother?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;max_edge&lt;/strong&gt;: is the edge suspiciously large?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;min_depth&lt;/strong&gt;: is there enough liquidity to actually fill?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;token_check&lt;/strong&gt;: do I have enough balance/tokens on both venues?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;match_rate_limit&lt;/strong&gt;: don't hammer the same match repeatedly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cooldown&lt;/strong&gt;: per-pair cooldown window&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;exec_mode&lt;/strong&gt;: paper trade, live trade, or both?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;execute&lt;/strong&gt;: actually send the orders&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most of these exist because something went wrong. Step 5, the naked halt, exists because I once had leg 1 fill on Kalshi but leg 2 fail on Polymarket. I was sitting there with a directional bet I didn't want, exposed to the market. Not fun. Now both arb engines share a &lt;code&gt;_naked_match_set&lt;/code&gt;, and if any engine marks a match as naked, &lt;em&gt;all&lt;/em&gt; engines stop trading it.&lt;/p&gt;

&lt;p&gt;Step 8, max_edge, exists because one time BetInAsia showed me a line that was 45 minutes old. The "edge" was 12%. It wasn't real. I would've lost money chasing a phantom.&lt;/p&gt;

&lt;p&gt;Every step that rejects a trade logs the reason. I can look at the edge log and see exactly why each opportunity was passed on. That's been invaluable for tuning thresholds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Playwright problem
&lt;/h2&gt;

&lt;p&gt;Three of my four data sources have proper APIs. BetInAsia does not. It's a web app that loads odds dynamically via JavaScript. There's no public API, no WebSocket I can tap into.&lt;/p&gt;

&lt;p&gt;So I'm scraping it with Playwright. A headless Chromium browser, running inside my Python process, watching the DOM for changes.&lt;/p&gt;

&lt;p&gt;The naive approach of scraping the full page every N seconds doesn't cut it. Odds change between scrapes. So I inject a &lt;code&gt;MutationObserver&lt;/code&gt; into the page that flushes DOM changes every 20ms into a buffer I can read from Python. Every 30 seconds, I also do a full DOM scrape as a consistency check.&lt;/p&gt;

&lt;p&gt;Playwright crashes. A lot. Chromium runs out of memory if you're not careful about page lifecycle. The browser process can just... die. Sometimes at 3am on a Saturday when you're not watching. So there's an auto-recovery wrapper that detects when the browser goes away and spins up a new one. It usually recovers in under 10 seconds, which means I miss maybe 2-3 opportunities. Acceptable.&lt;/p&gt;

&lt;p&gt;But I won't pretend it's elegant. It's duct tape. If BetInAsia ever releases an API, I'm ripping this whole module out immediately.&lt;/p&gt;

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

&lt;p&gt;If I were starting over:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate processes for feeds and engines.&lt;/strong&gt; Right now everything is in one async process. It works, but if the Playwright browser eats too much memory, it affects everything. I'd split feeds into their own processes communicating via Redis pub/sub.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better state machines.&lt;/strong&gt; The 14-step pipeline is essentially a manually coded state machine. A proper FSM library would make it more testable and easier to extend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More aggressive testing of edge cases.&lt;/strong&gt; Most of my bugs have been around partial fills, stale data, and reconnection timing. Property-based testing (Hypothesis) would've caught some of these earlier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start with paper trading from day one.&lt;/strong&gt; I added paper trading mode later, but it should've been the foundation. Being able to toggle between paper-only, live-only, both, or detection-only is critical for building confidence in a system that can lose you real money.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the math yourself
&lt;/h2&gt;

&lt;p&gt;I built a set of free arbitrage calculators at &lt;a href="https://clawarbs.com/tools/" rel="noopener noreferrer"&gt;clawarbs.com/tools&lt;/a&gt;. You can plug in real odds and see how edge calculations work with fees included. I also wrote up the math in more detail in a &lt;a href="https://clawarbs.com/blog/arbitrage-betting-calculator/" rel="noopener noreferrer"&gt;blog post about arbitrage calculation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The full system is at &lt;a href="https://clawarbs.com" rel="noopener noreferrer"&gt;clawarbs.com&lt;/a&gt; if you want to see it in action. It's been running for months now, and honestly, building it taught me more about async Python architecture than any course or book ever did. Sometimes the best way to learn is to build something slightly irresponsible.&lt;/p&gt;

</description>
      <category>python</category>
      <category>asyncio</category>
      <category>architecture</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
