<?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: SerhiiBogomaz</title>
    <description>The latest articles on DEV Community by SerhiiBogomaz (@serhiibogomaz).</description>
    <link>https://dev.to/serhiibogomaz</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4030493%2F1c11a889-0a19-470c-a343-0cf1cc30a36f.png</url>
      <title>DEV Community: SerhiiBogomaz</title>
      <link>https://dev.to/serhiibogomaz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/serhiibogomaz"/>
    <language>en</language>
    <item>
      <title>Building a Telegram crypto-alert bot with Redis Streams — and the false-positive problem nobody warns you about</title>
      <dc:creator>SerhiiBogomaz</dc:creator>
      <pubDate>Wed, 15 Jul 2026 13:31:27 +0000</pubDate>
      <link>https://dev.to/serhiibogomaz/building-a-telegram-crypto-alert-bot-with-redis-streams-and-the-false-positive-problem-nobody-ane</link>
      <guid>https://dev.to/serhiibogomaz/building-a-telegram-crypto-alert-bot-with-redis-streams-and-the-false-positive-problem-nobody-ane</guid>
      <description>&lt;p&gt;I've been building a side project for the last few months — a Telegram bot that watches crypto prices and pings you when something you care about happens (price crosses a threshold, a coin hits a new ATH, volume spikes, whatever). Nothing groundbreaking on the surface, but getting it &lt;em&gt;reliable&lt;/em&gt; turned into a much more interesting engineering problem than I expected. Wanted to write up the parts that actually taught me something.&lt;/p&gt;

&lt;p&gt;Stack: Python 3.12, aiogram 3 for the bot layer, PostgreSQL for storage, Redis for everything hot-path, Docker for deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;Market data comes in from CoinGecko every 5 minutes via APScheduler, gets normalized, and lands on a Redis Stream (&lt;code&gt;crypto:prices_stream&lt;/code&gt;). A worker reads it with a consumer group (&lt;code&gt;XREADGROUP&lt;/code&gt;), fans out per-coin to every active subscription watching that coin, and runs each one through a small pipeline: a guard (is this condition active, has cooldown passed) → a checker (does the condition actually hold) → an accumulator → and if all that passes, a push to a second stream (&lt;code&gt;crypto:notification_stream&lt;/code&gt;) that a separate worker drains to actually send the Telegram message.&lt;/p&gt;

&lt;p&gt;Two streams instead of one queue was a deliberate choice — price ingestion and notification delivery have completely different failure modes and completely different consumers. Decoupling them means a slow Telegram API call never backs up price processing, and a CoinGecko hiccup never blocks notifications that are already queued.&lt;/p&gt;

&lt;h2&gt;
  
  
  The false-positive problem
&lt;/h2&gt;

&lt;p&gt;This is the part I didn't expect to spend the most time on. Crypto prices are noisy — checking "is price &amp;gt;= threshold" on every 5-minute tick sounds simple, but it isn't, because there are two very different kinds of alert semantics hiding inside one word ("trigger"):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Holding conditions&lt;/strong&gt; (price threshold): should fire &lt;em&gt;every&lt;/em&gt; tick the condition holds, so users get reminded, but not so often it's spam.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crossing conditions&lt;/strong&gt; (% change over a period, ATH/ATL, daily breakout): should fire &lt;em&gt;once&lt;/em&gt; when the state flips, not on every subsequent tick where the crossed state is still true.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Get this wrong and you either spam users into muting the bot, or you silently drop the one alert they actually wanted. I ended up modeling each checker as a small state machine: it reads its previous state from a JSONB column, computes the new state, and returns both the trigger decision and the state to persist — checkers themselves hold zero in-memory state, which also means they're trivially restartable and testable in isolation with no mocking needed beyond the state dict.&lt;/p&gt;

&lt;p&gt;On top of that, single-tick signals turned out to be genuinely noisy — a 5-minute price wobble can look identical to the start of a real move. The fix was a "confirm_checks" counter: a condition has to hold for N &lt;em&gt;consecutive&lt;/em&gt; ticks before it fires, and the counter resets to zero on any non-triggering tick. Combined with a per-condition cooldown after firing, this cut false positives dramatically without adding real latency for genuine moves (most of them stay confirmed well past N ticks anyway).&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliability details that mattered more than I expected
&lt;/h2&gt;

&lt;p&gt;A few things that seemed like nice-to-haves during design turned out to matter a lot in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;XAUTOCLAIM&lt;/code&gt; on every tick.&lt;/strong&gt; If a worker process dies between &lt;code&gt;XREADGROUP&lt;/code&gt; and &lt;code&gt;XACK&lt;/code&gt;, that message just sits in the consumer's pending entries list forever unless something reclaims it. A periodic &lt;code&gt;XAUTOCLAIM&lt;/code&gt; with a 30s idle threshold closes that gap — worker restarts (which happen, especially during deploys) stop silently losing in-flight messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deferred cache/notification writes until after DB commit.&lt;/strong&gt; Each pipeline run happens inside its own session; Redis writes (cache sync, notification push) are buffered in memory and only flushed after the transaction actually commits. Otherwise a rolled-back DB write could still produce a notification for data that technically doesn't exist yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache-miss reconciliation.&lt;/strong&gt; Redis runs &lt;code&gt;allkeys-lru&lt;/code&gt;, so under memory pressure a subscription detail key can get evicted independently of the index that references it. Instead of silently treating that as "no active subscription," a miss triggers a Postgres lookup to reconcile — restore to cache if still active, drop from the index if genuinely gone. An evicted cache entry should never be the reason a user misses an alert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watchdog + circuit breaker on the workers.&lt;/strong&gt; Each stream consumer runs under a supervisor that restarts it with exponential backoff on crash, but stops retrying after 10 crashes in 30 minutes rather than restart-looping forever and hammering the DB/Redis.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;If I started over, I'd model the checker state machine (holding vs. crossing vs. first-tick-safe) explicitly from day one instead of discovering the distinction empirically per alert type. Also would've put the confirm-check accumulator in from the start — retrofitting stateful accumulation onto checkers that were originally written as pure functions took more care than it should have.&lt;/p&gt;

&lt;p&gt;If anyone's dealt with similar noisy-signal problems (trading systems, monitoring/alerting, IoT sensor thresholds) I'd be curious how you handled the holding-vs-crossing distinction — did you converge on something similar, or a different model entirely?&lt;/p&gt;

&lt;p&gt;Full architecture writeup (with the Redis key reference, DB schema, and middleware stack) is up on GitHub Pages if anyone wants the deeper dive: &lt;a href="https://serhiibogomaz.github.io/crypto-alert-bot/" rel="noopener noreferrer"&gt;https://serhiibogomaz.github.io/crypto-alert-bot/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>redis</category>
      <category>architecture</category>
      <category>telegram</category>
    </item>
  </channel>
</rss>
