<?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: Ray</title>
    <description>The latest articles on DEV Community by Ray (@qcautomation).</description>
    <link>https://dev.to/qcautomation</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%2F3833105%2F0e073b89-2eab-47df-8434-c52c3b20ef73.png</url>
      <title>DEV Community: Ray</title>
      <link>https://dev.to/qcautomation</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/qcautomation"/>
    <language>en</language>
    <item>
      <title>I Ran 10 Trading Strategies for 30 Days — Here Is What Actually Worked</title>
      <dc:creator>Ray</dc:creator>
      <pubDate>Tue, 31 Mar 2026 00:26:32 +0000</pubDate>
      <link>https://dev.to/qcautomation/i-ran-10-trading-strategies-for-30-days-here-is-what-actually-worked-3mng</link>
      <guid>https://dev.to/qcautomation/i-ran-10-trading-strategies-for-30-days-here-is-what-actually-worked-3mng</guid>
      <description>&lt;h1&gt;
  
  
  I Ran 10 Trading Strategies for 30 Days — Here's What Actually Worked
&lt;/h1&gt;

&lt;p&gt;I built a paper trading framework, ran 10 different algorithmic strategies through it for a full month, and the results were... humbling.&lt;/p&gt;

&lt;p&gt;Some strategies I expected to crush it. Most didn't. One I almost skipped ended up being the standout.&lt;/p&gt;

&lt;p&gt;Here's the unfiltered breakdown.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;I built TradeSight (&lt;a href="https://github.com/rmbell09-lang/tradesight" rel="noopener noreferrer"&gt;https://github.com/rmbell09-lang/tradesight&lt;/a&gt;) specifically for this kind of systematic testing. It's a Python paper trading framework that connects to Alpaca's paper API, runs strategies against live market data without real money, and tracks every metric I care about: win rate, max drawdown, Sharpe ratio, average trade duration.&lt;/p&gt;

&lt;p&gt;Test period: 30 days. Universe: S&amp;amp;P 500 components. Capital: $100k paper.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Strategies
&lt;/h2&gt;

&lt;p&gt;I ran 10 total but I'll focus on the ones with interesting results.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Winners
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Bollinger Band Mean Reversion&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Win rate: 61%&lt;/li&gt;
&lt;li&gt;Max drawdown: 8.2%&lt;/li&gt;
&lt;li&gt;Sharpe: 1.34&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This was the surprise. Mean reversion on the 2-hour chart, buying oversold bounces at the lower band with a tight stop. The key was filtering to stocks with high relative volume — it kept me out of the low-liquidity traps that kill this strategy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RSI + MACD Confluence&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Win rate: 58%&lt;/li&gt;
&lt;li&gt;Max drawdown: 11.3%&lt;/li&gt;
&lt;li&gt;Sharpe: 1.12&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Classic setup but it works. I only take trades when RSI is below 35 &lt;em&gt;and&lt;/em&gt; MACD is crossing bullish. The confluence filter cut trade frequency by 60% but also cut losers proportionally more. Quality over quantity.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Middle of the Pack
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Simple momentum&lt;/strong&gt; (20-day breakout): 52% win rate, Sharpe 0.71. Works in trending markets, gets destroyed in chop. March was choppy. Neutral verdict.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VWAP reversion&lt;/strong&gt;: 54% win rate but average winner smaller than average loser. Positive expectancy only in high-volatility environments.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Failures
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;RSI divergence&lt;/strong&gt;: 44% win rate, -14.7% max drawdown. I thought spotting divergence would be a signal edge. It wasn't. Divergences resolve in the wrong direction more than you'd expect in a momentum-heavy tape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Earnings momentum&lt;/strong&gt;: I had high hopes. Buy stocks gapping up post-earnings, hold for continuation. Reality: 41% win rate, brutal drawdown when gaps filled. The few big winners didn't overcome the frequent reversals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pure momentum (highest RS stocks, long-only)&lt;/strong&gt;: 48% win rate but the distribution was brutal — lots of small losses, occasional big winners. Psychologically hard to stick with even in a paper account.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Metrics That Actually Matter
&lt;/h2&gt;

&lt;p&gt;After 30 days, I stopped caring about win rate as a primary metric. Here's what I track now:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expectancy&lt;/strong&gt; = (Win Rate × Avg Win) - (Loss Rate × Avg Loss)&lt;/p&gt;

&lt;p&gt;A 45% win rate strategy can outperform a 60% win rate strategy if the winners are 3x the size of losers. The confluence RSI+MACD strategy had a 2.4:1 reward-risk ratio. The divergence strategy had 0.8:1. That explains everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Max Drawdown as % of initial capital&lt;/strong&gt;: I set a kill switch at 15%. Two strategies hit it and got shut down automatically. This feature alone made TradeSight worth building — letting losing strategies run is how you blow up accounts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sharpe ratio&lt;/strong&gt; across the test period: anything above 1.0 I consider worth continuing to live paper test. Below 0.5? Shelved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm Testing Next
&lt;/h2&gt;

&lt;p&gt;The Bollinger mean reversion strategy is going into extended testing. I want to see if it holds up across different market regimes — specifically a trending environment vs. the sideways chop of the past month.&lt;/p&gt;

&lt;p&gt;I'm also building a sector rotation overlay. The hypothesis: most of these strategies perform better when you're only playing the 2-3 sectors with the strongest relative strength. More filtering, fewer but higher-quality setups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;TradeSight is open source and the setup is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/rmbell09-lang/tradesight
&lt;span class="nb"&gt;cd &lt;/span&gt;tradesight
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;span class="c"&gt;# Add your Alpaca paper trading API keys&lt;/span&gt;
python main.py &lt;span class="nt"&gt;--strategy&lt;/span&gt; bollinger_reversion
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The paper trading account is free through Alpaca. You can run this on a $5 VPS and leave it running indefinitely with no financial risk.&lt;/p&gt;

&lt;p&gt;The strategies I tested are in &lt;code&gt;/strategies/&lt;/code&gt;. They're modular — you can copy one, modify the entry/exit logic, and run it as a new strategy without touching the core engine.&lt;/p&gt;




&lt;p&gt;30 days of data isn't a definitive sample, but it's enough to kill the bad ideas before they cost real money. That's the whole point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try TradeSight on GitHub&lt;/strong&gt;: &lt;a href="https://github.com/rmbell09-lang/tradesight" rel="noopener noreferrer"&gt;https://github.com/rmbell09-lang/tradesight&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you've run systematic strategy tests, I'd love to hear what you've found. Comment below.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>machinelearning</category>
      <category>programming</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Stop Paying for Monitoring: The Self-Hosted Stack for Indie SaaS Founders</title>
      <dc:creator>Ray</dc:creator>
      <pubDate>Tue, 31 Mar 2026 00:26:22 +0000</pubDate>
      <link>https://dev.to/qcautomation/stop-paying-for-monitoring-the-self-hosted-stack-for-indie-saas-founders-2c6n</link>
      <guid>https://dev.to/qcautomation/stop-paying-for-monitoring-the-self-hosted-stack-for-indie-saas-founders-2c6n</guid>
      <description>&lt;h1&gt;
  
  
  Stop Paying for Monitoring: The Self-Hosted Stack for Indie SaaS Founders
&lt;/h1&gt;

&lt;p&gt;I'm going to say the quiet part loud: most indie SaaS founders are wildly overpaying for monitoring.&lt;/p&gt;

&lt;p&gt;I was too. Then I stopped.&lt;/p&gt;

&lt;p&gt;Here's what I was running before I built my own stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Datadog: ~$15/host/month (that's the "cheap" tier)&lt;/li&gt;
&lt;li&gt;PagerDuty: $21/user/month&lt;/li&gt;
&lt;li&gt;Baremetrics: $129/month to see my own Stripe data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's $165+ every single month before I made a single dollar. For a solo founder? That's obscene.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Cost Problem
&lt;/h2&gt;

&lt;p&gt;The monitoring industry is built for teams. Datadog's pricing assumes you have 10+ engineers who need dashboards, alerts, runbooks, and incident management workflows. PagerDuty's on-call rotation features are genuinely useless if you're the only person getting paged.&lt;/p&gt;

&lt;p&gt;For indie hackers and small SaaS shops, we don't need enterprise observability. We need:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Know when the app is down&lt;/li&gt;
&lt;li&gt;Know when something weird is happening with billing&lt;/li&gt;
&lt;li&gt;Know the basic health metrics without a PhD in PromQL&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Self-Hosted Stack
&lt;/h2&gt;

&lt;p&gt;Here's what I run now, total cost: $0 (beyond VPS that I already pay for).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Uptime Kuma&lt;/strong&gt; — replaces StatusPage, Uptime Robot, etc. Runs on your VPS, beautiful UI, Telegram/Slack/email alerts. 5 minute setup. Free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Netdata&lt;/strong&gt; — replaces Datadog for host metrics. Lightweight, real-time, runs on the same VPS. The free tier covers everything a solo founder needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BillingWatch&lt;/strong&gt; — this one I built myself because nothing else did what I needed. It's a self-hosted Stripe anomaly detector that watches for: sudden revenue drops, subscription spikes, unusual refund patterns, card decline surges. Runs as a lightweight Python daemon, connects to your Stripe webhook, alerts you on Slack or email when something looks off.&lt;/p&gt;

&lt;p&gt;The gap I kept hitting was: Stripe sends you raw webhook data, but it doesn't tell you when something &lt;em&gt;trends&lt;/em&gt; in a bad direction. BillingWatch does pattern analysis over your event stream and fires alerts based on configurable thresholds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real Numbers
&lt;/h2&gt;

&lt;p&gt;Before: $165/month in monitoring tools&lt;br&gt;
After: $0/month&lt;/p&gt;

&lt;p&gt;Time to set up the full stack: about 3 hours (most of that was Netdata configuration)&lt;/p&gt;

&lt;p&gt;What I gave up: fancy dashboards nobody looks at, on-call rotation features for a team of one, and Baremetrics charts I could build in 20 lines of Python.&lt;/p&gt;

&lt;p&gt;What I kept: knowing when things break, knowing when billing looks weird, peace of mind.&lt;/p&gt;
&lt;h2&gt;
  
  
  The BillingWatch Setup
&lt;/h2&gt;

&lt;p&gt;Since this one isn't as well-known as Uptime Kuma, here's the quick version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/rmbell09-lang/billingwatch
&lt;span class="nb"&gt;cd &lt;/span&gt;billingwatch
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;span class="c"&gt;# Add your Stripe webhook secret and Slack webhook URL&lt;/span&gt;
python main.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You configure thresholds in &lt;code&gt;config.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;alerts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;revenue_drop_percent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;  &lt;span class="c1"&gt;# alert if MRR drops 30% in 24h&lt;/span&gt;
  &lt;span class="na"&gt;refund_spike_count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;     &lt;span class="c1"&gt;# alert if 5+ refunds in 1 hour&lt;/span&gt;
  &lt;span class="na"&gt;churn_rate_daily&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.05&lt;/span&gt;    &lt;span class="c1"&gt;# alert if daily churn exceeds 5%&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point your Stripe dashboard at the BillingWatch webhook endpoint and you're done. It runs headlessly, stores nothing sensitive, and fires to your Slack channel when thresholds are crossed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Broader Point
&lt;/h2&gt;

&lt;p&gt;The SaaS monitoring market optimizes for ACV, not indie founders. Every tool is priced assuming a sales conversation and a multi-seat contract.&lt;/p&gt;

&lt;p&gt;Building your own stack with open-source tools isn't a compromise — for our use case, it's genuinely better. Simpler, faster to configure, no vendor lock-in, and you actually understand what it's doing.&lt;/p&gt;

&lt;p&gt;If you're paying $100+/month to monitor a one-person SaaS, stop. The open-source alternatives are legitimately good now.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;BillingWatch is open source on GitHub&lt;/strong&gt;: &lt;a href="https://github.com/rmbell09-lang/billingwatch" rel="noopener noreferrer"&gt;https://github.com/rmbell09-lang/billingwatch&lt;/a&gt; — give it a star if this was useful.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Questions about the setup? Drop them in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>opensource</category>
      <category>devops</category>
      <category>startup</category>
    </item>
    <item>
      <title>Self-Hosted Stripe Billing Monitor vs PagerDuty: Real Cost Breakdown</title>
      <dc:creator>Ray</dc:creator>
      <pubDate>Mon, 30 Mar 2026 23:27:26 +0000</pubDate>
      <link>https://dev.to/qcautomation/self-hosted-stripe-billing-monitor-vs-pagerduty-real-cost-breakdown-2bje</link>
      <guid>https://dev.to/qcautomation/self-hosted-stripe-billing-monitor-vs-pagerduty-real-cost-breakdown-2bje</guid>
      <description>&lt;h1&gt;
  
  
  Self-Hosted Stripe Billing Monitor vs PagerDuty: Real Cost Breakdown
&lt;/h1&gt;

&lt;p&gt;======================================================&lt;/p&gt;

&lt;p&gt;As an SRE or indie SaaS developer, you've probably encountered the issue of monitoring billing activity for your platform. While tools like PagerDuty are often touted as the ultimate solution, they come with a hefty price tag that can quickly add up at scale.&lt;/p&gt;

&lt;p&gt;In this article, we'll explore the real cost breakdown between self-hosted solutions and managed services like PagerDuty using BillingWatch as an example. By the end of this article, you'll have a clear understanding of what to expect in terms of setup complexity, alerting features, multi-tenant support, and webhook monitoring.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup Complexity
&lt;/h3&gt;

&lt;p&gt;Self-hosted solutions like BillingWatch require some technical expertise to set up, but they offer unparalleled flexibility and customization options. With BillingWatch, you can run the entire application on a VPS, eliminating the need for expensive managed services. This not only saves you money but also gives you complete control over your infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Alerting Features
&lt;/h3&gt;

&lt;p&gt;When it comes to alerting features, both self-hosted solutions like BillingWatch and managed services like PagerDuty have their strengths and weaknesses. BillingWatch allows for custom alerting using webhooks, which can be easily integrated with existing monitoring tools. On the other hand, PagerDuty offers a more comprehensive set of features out of the box, but at a higher cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-Tenant Support
&lt;/h3&gt;

&lt;p&gt;For platforms that serve multiple clients or customers, multi-tenant support is a critical feature to have. BillingWatch makes it easy to implement this by allowing you to define separate tenants with their own billing plans and subscriptions. While PagerDuty also offers multi-tenant support, its costs can quickly add up as your platform grows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Webhook Monitoring
&lt;/h3&gt;

&lt;p&gt;Lastly, when monitoring billing activity for multiple clients or customers, webhook monitoring is essential. BillingWatch makes it easy to set up webhooks that notify you of any changes in billing status, allowing you to respond promptly and prevent potential issues from arising.&lt;/p&gt;

&lt;p&gt;To get started with BillingWatch, check out its GitHub repository at &lt;a href="https://github.com/rmbell09-lang/billingwatch" rel="noopener noreferrer"&gt;https://github.com/rmbell09-lang/billingwatch&lt;/a&gt;. With this self-hosted solution, you can save thousands of dollars per year compared to managed services like PagerDuty while still enjoying robust alerting features, multi-tenant support, and webhook monitoring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;References:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/rmbell09-lang/billingwatch" rel="noopener noreferrer"&gt;BillingWatch GitHub Repository&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; devops, programming, python, webdev&lt;/p&gt;

</description>
      <category>devops</category>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>5 Things Paper Trading Taught Me That Backtesting Can't</title>
      <dc:creator>Ray</dc:creator>
      <pubDate>Mon, 30 Mar 2026 23:26:59 +0000</pubDate>
      <link>https://dev.to/qcautomation/5-things-paper-trading-taught-me-that-backtesting-cant-1a1j</link>
      <guid>https://dev.to/qcautomation/5-things-paper-trading-taught-me-that-backtesting-cant-1a1j</guid>
      <description>&lt;h1&gt;
  
  
  5 Things Paper Trading Taught Me That Backtesting Can't
&lt;/h1&gt;

&lt;p&gt;====================================================&lt;/p&gt;

&lt;p&gt;As a quant beginner, I've spent countless hours studying trading strategies and backtesting them using popular libraries like TradeSight. However, there's one aspect of trading that backtesting often falls short: the reality of slippage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Slippage Reality
&lt;/h3&gt;

&lt;p&gt;Slippage is the difference between the expected price of a trade and the actual price at which it executes. Backtesting can't account for this because it assumes perfect execution, but in reality, markets move and prices fluctuate. TradeSight's implementation of slippage modeling helps to bridge this gap. For instance, you can configure it to simulate different slippage scenarios using parameters like &lt;code&gt;slippage_type&lt;/code&gt; and &lt;code&gt;slippage_amount&lt;/code&gt;. By incorporating this feature into your backtesting workflow, you'll get a more accurate picture of your strategy's performance in the wild.&lt;/p&gt;

&lt;h3&gt;
  
  
  Emotional Discipline Simulation
&lt;/h3&gt;

&lt;p&gt;Another critical aspect that paper trading can teach you is emotional discipline. When you're trading with real money, fear and greed can quickly take over, leading to impulsive decisions that tank your portfolio. Paper trading allows you to practice making trades without risking actual capital. TradeSight makes this process even more realistic by simulating order fills, allowing you to see how different scenarios play out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Live Data Quirks vs Historical
&lt;/h3&gt;

&lt;p&gt;Backtesting often relies on historical data, which can lead to differences between the simulated and real-world performance of your strategy. However, when using TradeSight, you can run your backtests against live data streams, giving you a more accurate representation of how your strategy will perform in the current market conditions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Order Fill Variability
&lt;/h3&gt;

&lt;p&gt;When trading with real money, you'll inevitably face order fill variability – the difference between the expected price and the actual price at which your trades execute. Backtesting can't account for this because it assumes perfect execution, but TradeSight helps by simulating different order fill scenarios. This allows you to fine-tune your strategies to perform better in real-world conditions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strategy Confidence Calibration
&lt;/h3&gt;

&lt;p&gt;Lastly, paper trading and backtesting can help you calibrate the confidence level of your strategies. By testing multiple iterations with varying parameters, you'll gain a deeper understanding of what works and what doesn't. TradeSight makes this process even more effective by allowing you to visualize performance metrics and make data-driven decisions.&lt;/p&gt;

&lt;p&gt;To get started with TradeSight, check out its GitHub repository at &lt;a href="https://github.com/rmbell09-lang/tradesight" rel="noopener noreferrer"&gt;https://github.com/rmbell09-lang/tradesight&lt;/a&gt;. With this library, you can take your trading strategies from backtested to live-traded, armed with the knowledge of how they'll perform in real-world conditions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;References:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/rmbell09-lang/tradesight" rel="noopener noreferrer"&gt;TradeSight GitHub Repository&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; python, programming, fintech, trading&lt;/p&gt;

</description>
      <category>python</category>
      <category>programming</category>
      <category>fintech</category>
      <category>trading</category>
    </item>
    <item>
      <title>"Why Your Paper Trading Backtests Are Lying to You (And How to Fix It)"</title>
      <dc:creator>Ray</dc:creator>
      <pubDate>Mon, 30 Mar 2026 15:30:27 +0000</pubDate>
      <link>https://dev.to/qcautomation/why-your-paper-trading-backtests-are-lying-to-you-and-how-to-fix-it-47b</link>
      <guid>https://dev.to/qcautomation/why-your-paper-trading-backtests-are-lying-to-you-and-how-to-fix-it-47b</guid>
      <description>&lt;h1&gt;
  
  
  Why Your Paper Trading Backtests Are Lying to You (And How to Fix It)
&lt;/h1&gt;

&lt;p&gt;You ran the backtest. The Sharpe ratio looks great. CAGR of 34%. Max drawdown a reasonable 12%. You switch to paper trading and watch your strategy bleed out over the next two weeks.&lt;/p&gt;

&lt;p&gt;If this sounds familiar, you're not doing anything wrong. Backtests lie by design — and paper trading in isolation doesn't fix the problem. Here's what's actually happening, and a better way to evaluate strategies before you put real capital on the line.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 1: Survivorship Bias in Your Data
&lt;/h2&gt;

&lt;p&gt;If you're testing against S&amp;amp;P 500 constituents, you're testing against &lt;strong&gt;today's winners&lt;/strong&gt;. The companies that went bankrupt, got delisted, or dropped out of the index over your test period aren't in your dataset.&lt;/p&gt;

&lt;p&gt;This matters more than people realize. A strategy that "buys the dip on large-caps" looks spectacular if you're only testing on companies that survived. In reality, buying dips on Lehman Brothers in 2008 was a one-way ticket to zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Use point-in-time data that includes delisted stocks, or at minimum acknowledge this bias when evaluating results. If your edge depends on survivorship-biased data, it's not a real edge.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 2: Look-Ahead Bias
&lt;/h2&gt;

&lt;p&gt;This one is sneaky. It happens when your strategy accidentally accesses future data during backtesting.&lt;/p&gt;

&lt;p&gt;Classic examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Using adjusted closing prices (which get recalculated retroactively when splits or dividends happen)&lt;/li&gt;
&lt;li&gt;Rebalancing at the exact open price of the next day (impossible in practice)&lt;/li&gt;
&lt;li&gt;Using a moving average that's recalculated with data you wouldn't have had
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# This looks fine but introduces look-ahead bias:
&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ma_200&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="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;close&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;rolling&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;signal&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="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;close&lt;/span&gt;&lt;span class="sh"&gt;'&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;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ma_200&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# Fine
&lt;/span&gt;
&lt;span class="c1"&gt;# This is look-ahead bias — you're shifting the signal:
&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;signal&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="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;close&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;shift&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="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ma_200&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# BAD
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second version is using tomorrow's close to make today's decision. Your real portfolio can't do that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Always check that your signal is based on data available &lt;em&gt;at signal generation time&lt;/em&gt;. When in doubt, add an explicit &lt;code&gt;.shift(1)&lt;/code&gt; to your price data before generating signals.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 3: Transaction Cost Blindness
&lt;/h2&gt;

&lt;p&gt;Backtests routinely ignore:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Commissions (even "zero commission" brokers have payment for order flow)&lt;/li&gt;
&lt;li&gt;Slippage (you don't always fill at the price you wanted)&lt;/li&gt;
&lt;li&gt;Market impact (larger orders move the price)&lt;/li&gt;
&lt;li&gt;Spread (bid-ask spread on less liquid names)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A strategy that trades 50 times a month with a theoretical 0.5% edge can easily get eaten alive by 0.1% slippage per trade. Run the math:&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;gross_return&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.005&lt;/span&gt;  &lt;span class="c1"&gt;# 0.5% per trade
&lt;/span&gt;&lt;span class="n"&gt;slippage_per_trade&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.001&lt;/span&gt;  &lt;span class="c1"&gt;# 0.1% slippage
&lt;/span&gt;&lt;span class="n"&gt;num_trades&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
&lt;span class="n"&gt;net_return&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gross_return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;slippage_per_trade&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;num_trades&lt;/span&gt;
&lt;span class="c1"&gt;# 0.004 * 50 = 0.20 (20%) vs 0.005 * 50 = 0.25 (25%)
# That 5% difference is real money
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Use realistic transaction cost assumptions. For Alpaca paper trading, simulate slippage by assuming fills 0.05–0.1% worse than the midpoint. This is conservative but forces your strategy to prove it has real edge.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 4: Single-Path Testing
&lt;/h2&gt;

&lt;p&gt;Most backtests test one path through history. But markets have many possible paths. The specific sequence of events from 2020-2023 — COVID crash, meme stock mania, rate hike cycle — was one realization of many possible worlds.&lt;/p&gt;

&lt;p&gt;Your strategy might have crushed that specific path but would have failed in 60% of plausible alternative scenarios.&lt;/p&gt;

&lt;p&gt;This is where &lt;strong&gt;tournament-style testing&lt;/strong&gt; changes the picture. Instead of one backtest, you run dozens — varying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start dates (offset by weeks or months)&lt;/li&gt;
&lt;li&gt;Market regimes (bull, bear, high vol, low vol)&lt;/li&gt;
&lt;li&gt;Parameter ranges (walk-forward optimization)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your strategy only looks good in the one path you happened to test, that's not a strategy — it's curve fitting.&lt;/p&gt;




&lt;h2&gt;
  
  
  How TradeSight Approaches This
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://github.com/rmbell09-lang/tradesight" rel="noopener noreferrer"&gt;TradeSight&lt;/a&gt; to address exactly these problems with paper trading evaluation.&lt;/p&gt;

&lt;p&gt;The core idea is &lt;strong&gt;tournament mode&lt;/strong&gt;: instead of running one strategy against one period, you run multiple strategies head-to-head across the same market conditions on Alpaca paper trading. The strategies compete for the same tickers on the same days, with identical transaction cost assumptions.&lt;/p&gt;

&lt;p&gt;Key features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Simultaneous multi-strategy comparison&lt;/strong&gt; — no cherry-picking which strategy to test when&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistent transaction cost modeling&lt;/strong&gt; — same slippage assumptions across all strategies
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Walk-forward validation&lt;/strong&gt; — out-of-sample periods baked in, not bolted on&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MC simulation&lt;/strong&gt; — Monte Carlo over resampled return sequences to stress-test equity curves&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simple comparison run looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python tradesight.py &lt;span class="nt"&gt;--mode&lt;/span&gt; tournament &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--strategies&lt;/span&gt; momentum_basic,mean_reversion_rsi,bollinger_squeeze &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tickers&lt;/span&gt; AAPL,MSFT,NVDA,GOOGL &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--period&lt;/span&gt; 90d &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--paper-account&lt;/span&gt; your_alpaca_key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output tells you not just which strategy won, but &lt;em&gt;why&lt;/em&gt; — which market conditions favored it, how it performed in drawdown periods, and whether the edge holds across parameter variations.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Honest Backtest Checklist
&lt;/h2&gt;

&lt;p&gt;Before trusting any backtest result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Is your data survivorship-bias free?&lt;/li&gt;
&lt;li&gt;[ ] Are you using adjusted close prices and accounting for it?&lt;/li&gt;
&lt;li&gt;[ ] Have you verified no look-ahead bias in signal generation?&lt;/li&gt;
&lt;li&gt;[ ] Are transaction costs modeled realistically (including slippage)?&lt;/li&gt;
&lt;li&gt;[ ] Have you tested across multiple start dates, not just one?&lt;/li&gt;
&lt;li&gt;[ ] Have you done out-of-sample validation on data the strategy never "saw"?&lt;/li&gt;
&lt;li&gt;[ ] Does the strategy perform consistently across different market regimes?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you can check all seven boxes, your backtest result means something. If you can't, treat it as a hypothesis — not a conclusion.&lt;/p&gt;




&lt;h2&gt;
  
  
  Paper Trading Is Still Useful — Just Not for Validation
&lt;/h2&gt;

&lt;p&gt;Paper trading with Alpaca is great for testing &lt;strong&gt;execution&lt;/strong&gt; — does your order routing work? Does your position sizing math hold up? Are there bugs in your live data feed handling?&lt;/p&gt;

&lt;p&gt;It's not great for strategy validation because you're only seeing one more path through market history. Combine it with rigorous backtesting (with the above fixes) and tournament comparison, and you have something worth trusting.&lt;/p&gt;

&lt;p&gt;The market doesn't care how good your backtest looks. It only cares what you do with real capital.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/rmbell09-lang/tradesight" rel="noopener noreferrer"&gt;https://github.com/rmbell09-lang/tradesight&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>algotrading</category>
      <category>trading</category>
      <category>fintech</category>
    </item>
    <item>
      <title>"5 Python Libraries That Power My Self-Hosted Billing Monitor"</title>
      <dc:creator>Ray</dc:creator>
      <pubDate>Mon, 30 Mar 2026 15:30:26 +0000</pubDate>
      <link>https://dev.to/qcautomation/5-python-libraries-that-power-my-self-hosted-billing-monitor-288j</link>
      <guid>https://dev.to/qcautomation/5-python-libraries-that-power-my-self-hosted-billing-monitor-288j</guid>
      <description>&lt;h1&gt;
  
  
  5 Python Libraries That Power My Self-Hosted Billing Monitor
&lt;/h1&gt;

&lt;p&gt;When I started building &lt;a href="https://github.com/rmbell09-lang/billingwatch" rel="noopener noreferrer"&gt;BillingWatch&lt;/a&gt;, I had one goal: catch billing anomalies before they become customer support nightmares. A refund tsunami at 2 AM. A webhook that quietly stops firing. Subscriptions flipping to "canceled" for no obvious reason.&lt;/p&gt;

&lt;p&gt;The commercial tools — Baremetrics, ChartMogul, Datadog — can do this, but they run $50–$400/month and you're trusting a third party with your entire billing data. I wanted something self-hosted, auditable, and free to run.&lt;/p&gt;

&lt;p&gt;Here are the 5 Python libraries that made BillingWatch work.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. &lt;code&gt;stripe&lt;/code&gt; — The Foundation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;stripe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stripe's official Python SDK is the backbone of the whole system. BillingWatch uses it for two things: &lt;strong&gt;pulling historical event data&lt;/strong&gt; for backfill and &lt;strong&gt;verifying webhook signatures&lt;/strong&gt; so we know events are actually from Stripe.&lt;/p&gt;

&lt;p&gt;The signature verification piece is critical:&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;stripe&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sig_header&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="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;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Webhook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;construct_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sig_header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;STRIPE_WEBHOOK_SECRET&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;event&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SignatureVerificationError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid webhook signature&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;Without this check, any bad actor could POST fake billing events to your endpoint. The &lt;code&gt;construct_event&lt;/code&gt; call verifies the HMAC signature Stripe includes in every webhook request.&lt;/p&gt;

&lt;p&gt;The SDK also handles pagination cleanly when backfilling historical charges:&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;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;charge.failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&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;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;auto_paging_iter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;process_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  2. &lt;code&gt;fastapi&lt;/code&gt; — The API Layer
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;fastapi uvicorn
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FastAPI handles the webhook receiver, the REST API for the dashboard, and the admin endpoints. I chose it over Flask for three reasons: async support (webhooks can come in fast), automatic OpenAPI docs, and Pydantic validation that makes the event parsing bulletproof.&lt;/p&gt;

&lt;p&gt;The webhook receiver is a single async endpoint:&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;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&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;HTTPException&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/webhooks/stripe&lt;/span&gt;&lt;span class="sh"&gt;"&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;def&lt;/span&gt; &lt;span class="nf"&gt;stripe_webhook&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;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;sig_header&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;stripe-signature&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="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Webhook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;construct_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sig_header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WEBHOOK_SECRET&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;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&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;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid payload&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="nf"&gt;process_billing_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;ok&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;The async design matters because Stripe has a 30-second timeout on webhook delivery. If your handler is slow, Stripe retries — and then you're processing duplicates. FastAPI's async model keeps things fast enough that this hasn't been a problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. &lt;code&gt;sqlalchemy&lt;/code&gt; + &lt;code&gt;alembic&lt;/code&gt; — Persistent Anomaly Storage
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;sqlalchemy alembic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every billing event gets persisted. SQLAlchemy handles the ORM layer, Alembic handles schema migrations. The core model is simple:&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;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JSON&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Base&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BillingEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;__tablename__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billing_events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;currency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;raw_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;anomaly_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;flagged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;anomaly_score&lt;/code&gt; field is populated by the detection logic — things like "this customer just triggered 5 failed charges in 10 minutes" or "refund amount exceeds original charge." The &lt;code&gt;flagged&lt;/code&gt; column drives the dashboard alerts.&lt;/p&gt;

&lt;p&gt;Alembic migrations mean schema changes don't break production deployments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;alembic revision &lt;span class="nt"&gt;--autogenerate&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"add anomaly score column"&lt;/span&gt;
alembic upgrade &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  4. &lt;code&gt;apscheduler&lt;/code&gt; — The Heartbeat
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;apscheduler
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;BillingWatch doesn't just react to webhooks — it also runs &lt;strong&gt;periodic digest checks&lt;/strong&gt;. Every hour, it scans for patterns that wouldn't be obvious from individual events: rolling refund rates, sudden drops in new subscriptions, failed charge clusters.&lt;/p&gt;

&lt;p&gt;APScheduler makes this dead simple:&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;from&lt;/span&gt; &lt;span class="n"&gt;apscheduler.schedulers.asyncio&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AsyncIOScheduler&lt;/span&gt;

&lt;span class="n"&gt;scheduler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AsyncIOScheduler&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@scheduler.scheduled_job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;interval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&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;def&lt;/span&gt; &lt;span class="nf"&gt;hourly_digest&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;anomalies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;check_rolling_metrics&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;anomalies&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;send_alert_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;anomalies&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The async scheduler runs inside the same FastAPI process — no Celery, no Redis, no separate worker. For a self-hosted tool running on a $5 VPS, that simplicity matters a lot.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. &lt;code&gt;httpx&lt;/code&gt; — Outbound Alerts
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;httpx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When something trips an anomaly threshold, BillingWatch needs to tell you. I went with &lt;code&gt;httpx&lt;/code&gt; over &lt;code&gt;requests&lt;/code&gt; because it's async-native, which means alert delivery doesn't block the webhook handler.&lt;/p&gt;

&lt;p&gt;The alert dispatcher is a small utility:&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;httpx&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;send_webhook_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webhook_url&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;payload&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&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="mf"&gt;10.0&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;client&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;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&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="n"&gt;webhook_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTPError&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;error&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;Alert delivery failed: &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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This powers Slack notifications, Discord alerts, or any custom webhook endpoint. BillingWatch doesn't care where the alert goes — it just needs a URL.&lt;/p&gt;




&lt;h2&gt;
  
  
  Putting It Together
&lt;/h2&gt;

&lt;p&gt;The full stack looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Stripe → Webhook → FastAPI receiver → SQLAlchemy storage
                                    → APScheduler digest checks
                                    → httpx alert dispatch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No containers required for local dev. Just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/rmbell09-lang/billingwatch
&lt;span class="nb"&gt;cd &lt;/span&gt;billingwatch
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env  &lt;span class="c"&gt;# add your STRIPE_WEBHOOK_SECRET&lt;/span&gt;
uvicorn main:app &lt;span class="nt"&gt;--reload&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why Self-Host?
&lt;/h2&gt;

&lt;p&gt;The honest reason: I don't want Baremetrics or Datadog seeing every charge, refund, and subscription churn event from my business. That's the full picture of revenue health — and it belongs on infrastructure I control.&lt;/p&gt;

&lt;p&gt;BillingWatch runs on a $6/month VPS. The only external dependency is Stripe itself.&lt;/p&gt;

&lt;p&gt;If you're running a Stripe-based product and haven't set up anomaly monitoring, it's worth 30 minutes. The next billing anomaly you miss will cost more than that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/rmbell09-lang/billingwatch" rel="noopener noreferrer"&gt;https://github.com/rmbell09-lang/billingwatch&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>stripe</category>
      <category>opensource</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>BillingWatch vs Datadog vs Baremetrics: What You Get for Free vs 0/mo for Billing Monitoring</title>
      <dc:creator>Ray</dc:creator>
      <pubDate>Mon, 30 Mar 2026 09:30:45 +0000</pubDate>
      <link>https://dev.to/qcautomation/billingwatch-vs-datadog-vs-baremetrics-what-you-get-for-free-vs-0mo-for-billing-monitoring-3gmn</link>
      <guid>https://dev.to/qcautomation/billingwatch-vs-datadog-vs-baremetrics-what-you-get-for-free-vs-0mo-for-billing-monitoring-3gmn</guid>
      <description>&lt;h1&gt;
  
  
  BillingWatch vs Datadog vs Baremetrics: A Practical Comparison
&lt;/h1&gt;

&lt;p&gt;As a developer running a SaaS application, keeping track of your customers' billing and revenue is crucial. While tools like Datadog and Baremetrics offer advanced features for billing monitoring, they come with a significant price tag. In this article, I'll compare these services with BillingWatch, an open-source platform that offers similar functionality at no cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;BillingWatch&lt;/th&gt;
&lt;th&gt;Datadog&lt;/th&gt;
&lt;th&gt;Baremetrics&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe Integration&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alerting&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom Dashboards&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;9/month&lt;/td&gt;
&lt;td&gt;9/month&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  BillingWatch: A Self-Hosted Alternative
&lt;/h2&gt;

&lt;p&gt;BillingWatch is a self-hosted billing monitoring platform built on top of Stripe's API. Its open-source nature allows for customization and modification to suit your specific needs. With BillingWatch, you can create custom dashboards, set up alerts, and integrate with your existing stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup Time
&lt;/h2&gt;

&lt;p&gt;One of the main advantages of BillingWatch is its ease of setup. The platform comes with a simple deployment process that requires only a few minutes to get started. Clone the repository, configure your Stripe API keys, and you're good to go. No vendor lock-in, no monthly subscription creeping up on you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost Comparison
&lt;/h2&gt;

&lt;p&gt;While Datadog and Baremetrics offer advanced features for billing monitoring, their pricing model may not be suitable for every business — especially early-stage SaaS where 0/month matters. With BillingWatch, you get similar core functionality at zero recurring cost. The self-hosted nature also means your billing data never leaves your infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Use Each
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BillingWatch&lt;/strong&gt;: You want self-hosted, full control, zero cost, and Stripe as your payment processor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Datadog&lt;/strong&gt;: You're already in the Datadog ecosystem and need billing metrics alongside APM/infra.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Baremetrics&lt;/strong&gt;: You want SaaS analytics with MRR/churn reporting and don't want to self-host anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;If you're running a SaaS application and want to monitor billing without paying for another SaaS tool, BillingWatch is worth a look. It's open source, self-hostable, and integrates directly with Stripe webhooks.&lt;/p&gt;

&lt;p&gt;Check out the &lt;a href="https://github.com/rmbell09-lang/billingwatch" rel="noopener noreferrer"&gt;BillingWatch GitHub repository&lt;/a&gt; to get started.&lt;/p&gt;

</description>
      <category>python</category>
      <category>stripe</category>
      <category>selfhosted</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Why I Switched from Interactive Brokers Paper Trading to Alpaca + TradeSight</title>
      <dc:creator>Ray</dc:creator>
      <pubDate>Mon, 30 Mar 2026 09:29:28 +0000</pubDate>
      <link>https://dev.to/qcautomation/why-i-switched-from-interactive-brokers-paper-trading-to-alpaca-tradesight-2ndg</link>
      <guid>https://dev.to/qcautomation/why-i-switched-from-interactive-brokers-paper-trading-to-alpaca-tradesight-2ndg</guid>
      <description>&lt;h1&gt;
  
  
  Why I Switched from Interactive Brokers Paper Trading to Alpaca + TradeSight
&lt;/h1&gt;

&lt;p&gt;As a Python developer and algo trader, I've spent countless hours setting up paper trading environments for testing my strategies. For years, I relied on Interactive Brokers (IB) for this purpose. However, after discovering Alpaca and TradeSight, I made the switch. In this article, I'll share my experience comparing IB paper trading with Alpaca + TradeSight, highlighting key differences in setup complexity, API quality, cost, data access, and speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Experience with Interactive Brokers
&lt;/h2&gt;

&lt;p&gt;I first started using IB's paper trading feature to test my trading ideas. The process was straightforward: create an account, fund it with fake money, and start trading. However, as I delved deeper into the world of algo trading, I began to encounter issues. The API documentation was extensive, but navigating the complexities of IB's API proved to be a challenge.&lt;/p&gt;

&lt;p&gt;For instance, retrieving real-time market data required additional setup steps, including creating a separate account for each exchange and configuring custom APIs. Additionally, IB's API had limitations on the number of requests per minute, which forced me to implement rate limiting in my code. These obstacles consumed more time than I care to admit, taking away from the actual development process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enter Alpaca + TradeSight
&lt;/h2&gt;

&lt;p&gt;That all changed when I discovered Alpaca and TradeSight. Alpaca provides a simple, Python-friendly API for accessing financial markets, with no account setup or funding requirements. TradeSight is an open-source platform for building custom trading dashboards and indicators, built on top of Alpaca's API.&lt;/p&gt;

&lt;p&gt;The integration between Alpaca and TradeSight was seamless. With TradeSight's intuitive dashboard, I could easily access real-time market data, create custom indicators, and backtest my strategies without worrying about the underlying infrastructure. The API quality was significantly better than IB's; requests were handled with ease, and rate limiting was a thing of the past.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost Comparison
&lt;/h2&gt;

&lt;p&gt;IB's paper trading environment can be cumbersome to set up, and their overall pricing model is complex. Alpaca, on the other hand, offers a free plan that includes generous API access, making it an attractive option for developers and algo traders who want to test their strategies without added overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data Access and Speed
&lt;/h2&gt;

&lt;p&gt;One of the main reasons I switched from IB paper trading to Alpaca + TradeSight was data access. With TradeSight's custom indicators and real-time market data, I could focus on developing my algorithms rather than wrestling with APIs or waiting for delayed quotes. The speed at which I could test and refine my strategies improved significantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;In conclusion, while IB paper trading has its advantages, the setup complexity and API friction eventually led me to try Alpaca + TradeSight. With Alpaca's Python-friendly API and TradeSight's open-source platform, I can now focus on developing my trading strategies without the hassle of infrastructure management.&lt;/p&gt;

&lt;p&gt;If you're a fellow developer or algo trader facing similar challenges with IB paper trading, I encourage you to explore Alpaca and TradeSight for yourself. Check out the &lt;a href="https://github.com/rmbell09-lang/tradesight" rel="noopener noreferrer"&gt;TradeSight GitHub repository&lt;/a&gt; for more information.&lt;/p&gt;

</description>
      <category>python</category>
      <category>algotrading</category>
      <category>fintech</category>
      <category>trading</category>
    </item>
    <item>
      <title>I Built a Stock Scanner with Python and RSI Signals — Here Is What I Learned</title>
      <dc:creator>Ray</dc:creator>
      <pubDate>Mon, 30 Mar 2026 05:58:58 +0000</pubDate>
      <link>https://dev.to/qcautomation/i-built-a-stock-scanner-with-python-and-rsi-signals-here-is-what-i-learned-3m27</link>
      <guid>https://dev.to/qcautomation/i-built-a-stock-scanner-with-python-and-rsi-signals-here-is-what-i-learned-3m27</guid>
      <description>&lt;p&gt;As a developer interested in finance, I have been experimenting with building tools to analyze stock markets using technical indicators. In this article, I will share my experience creating a simple stock scanner using Python that uses Relative Strength Index (RSI) signals.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is RSI?
&lt;/h2&gt;

&lt;p&gt;The RSI is a momentum indicator by J. Welles Wilder (1978). It measures recent price change magnitude to determine overbought (&amp;gt;70) or oversold (&amp;lt;30) conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fetching Stock Data with yfinance
&lt;/h2&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;yfinance&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;yf&lt;/span&gt;

&lt;span class="n"&gt;symbols&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;AAPL&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;GOOG&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;MSFT&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;AMZN&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;symbol&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;symbols&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ticker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;yf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Ticker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;hist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1y&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="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;hist&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Calculating RSI
&lt;/h2&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;rsi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Close&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;dropna&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="o"&gt;=&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;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="o"&gt;=&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;rs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ewm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;-&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;adjust&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ewm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;-&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;adjust&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&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;rs&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Generating Buy/Sell Signals
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;overbought_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;
&lt;span class="n"&gt;oversold_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;symbol&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;symbols&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;current_rsi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rsi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="n"&gt;iloc&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="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current_rsi&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;overbought_threshold&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;symbol&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: SELL signal (RSI=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;current_rsi&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&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;elif&lt;/span&gt; &lt;span class="n"&gt;current_rsi&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;oversold_threshold&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;symbol&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: BUY signal (RSI=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;current_rsi&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&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;else&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;symbol&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: HOLD (RSI=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;current_rsi&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;RSI alone is noisy&lt;/strong&gt; — volume and trend confirmation is essential&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backtesting is mandatory&lt;/strong&gt; — signals that look great on a chart often collapse under real conditions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paper trade first&lt;/strong&gt; — always validate with fake money before risking real capital&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to take this further, I built &lt;a href="https://qcautonomous.gumroad.com" rel="noopener noreferrer"&gt;TradeSight&lt;/a&gt; — a self-hosted Python app that runs AI-powered strategy tournaments overnight and paper trades via Alpaca. No cloud subscription, everything on your machine.&lt;/p&gt;

&lt;p&gt;Happy to answer questions in the comments!&lt;/p&gt;

</description>
      <category>python</category>
      <category>trading</category>
      <category>finance</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Scaling a Self-Hosted Billing Monitor from 1 to 100 Tenants</title>
      <dc:creator>Ray</dc:creator>
      <pubDate>Mon, 30 Mar 2026 03:27:15 +0000</pubDate>
      <link>https://dev.to/qcautomation/scaling-a-self-hosted-billing-monitor-from-1-to-100-tenants-1ah1</link>
      <guid>https://dev.to/qcautomation/scaling-a-self-hosted-billing-monitor-from-1-to-100-tenants-1ah1</guid>
      <description>&lt;p&gt;When I built &lt;a href="https://github.com/rmbell09-lang/billingwatch" rel="noopener noreferrer"&gt;BillingWatch&lt;/a&gt;, I designed it for a single Stripe account. Then people started asking about multi-tenant support. Here's what the architecture looks like when you scale from 1 to 100 tenants.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Multi-Tenancy Pattern
&lt;/h2&gt;

&lt;p&gt;The simplest approach: row-level tenant isolation with a &lt;code&gt;tenant_id&lt;/code&gt; on every table. BillingWatch uses FastAPI + SQLite (upgradeable to Postgres), with every query scoped to the requesting tenant:&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;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.orm&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&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;get_tenant_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x_tenant_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Header&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="n"&gt;x_tenant_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&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;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Missing tenant header&lt;/span&gt;&lt;span class="sh"&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;x_tenant_id&lt;/span&gt;

&lt;span class="nd"&gt;@app.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;/anomalies&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;list_anomalies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_tenant_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Anomaly&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Anomaly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every webhook endpoint, every query, every dashboard call is scoped this way. It's boring and it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhook Routing at Scale
&lt;/h2&gt;

&lt;p&gt;The tricky part is routing Stripe webhooks when you have 100 different Stripe accounts. The clean solution: each tenant gets their own webhook endpoint with a unique path token:&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="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/webhook/{tenant_token}&lt;/span&gt;&lt;span class="sh"&gt;"&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;def&lt;/span&gt; &lt;span class="nf"&gt;stripe_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_token&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;request&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;tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Tenant&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;webhook_token&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;tenant_token&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&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="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&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;404&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Verify Stripe signature against THIS tenant's secret
&lt;/span&gt;    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;sig&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;stripe-signature&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="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Webhook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;construct_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;webhook_secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SignatureVerificationError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&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;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;process_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;ok&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;This means each tenant registers &lt;code&gt;https://yourdomain.com/webhook/&amp;lt;unique-token&amp;gt;&lt;/code&gt; in their Stripe dashboard. Clean separation, no shared secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deduplication: Don't Process Twice
&lt;/h2&gt;

&lt;p&gt;Stripe retries webhooks on failure. At 100 tenants you'll hit duplicate events. A simple idempotency table:&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;ProcessedEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;__tablename__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;processed_events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;event_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;processed_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utcnow&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;process_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProcessedEvent&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&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;existing&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;# already processed
&lt;/span&gt;
    &lt;span class="c1"&gt;# ... process the event ...
&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProcessedEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;db&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a composite index on &lt;code&gt;(event_id, tenant_id)&lt;/code&gt; and this query is fast even at millions of rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Database Considerations
&lt;/h2&gt;

&lt;p&gt;For 100 tenants with moderate webhook volume, SQLite holds up fine if you enable WAL 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="nd"&gt;@event.listens_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connect&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;set_sqlite_pragma&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dbapi_connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;connection_record&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dbapi_connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cursor&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;cursor&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="n"&gt;cursor&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;For higher scale, the row-level isolation pattern migrates cleanly to Postgres — the application code doesn't change, just the connection string.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate Limiting Per Tenant
&lt;/h2&gt;

&lt;p&gt;You don't want one tenant flooding your webhook processor. A simple in-memory rate limiter per tenant_id:&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;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;defaultdict&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;request_counts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_rate_limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_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="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&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;request_counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tenant_id&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;t&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;request_counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tenant_id&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;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;window&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;request_counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tenant_id&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;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&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;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Rate limit exceeded&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_counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tenant_id&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Start with row-level isolation from day one — retrofitting is painful&lt;/li&gt;
&lt;li&gt;Use unique webhook tokens immediately, not shared secrets&lt;/li&gt;
&lt;li&gt;Add the deduplication table before you need it — much easier than adding later&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full BillingWatch source, including the multi-tenant webhook handling, is at &lt;a href="https://github.com/rmbell09-lang/billingwatch" rel="noopener noreferrer"&gt;github.com/rmbell09-lang/billingwatch&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Anything you'd handle differently at scale? I'm particularly curious about Postgres schema-per-tenant vs row-level at 1000+ tenants.&lt;/p&gt;

</description>
      <category>python</category>
      <category>stripe</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>The Hidden Costs of Paper Trading: What 6 Weeks of Data Showed</title>
      <dc:creator>Ray</dc:creator>
      <pubDate>Mon, 30 Mar 2026 03:26:33 +0000</pubDate>
      <link>https://dev.to/qcautomation/the-hidden-costs-of-paper-trading-what-6-weeks-of-data-showed-afk</link>
      <guid>https://dev.to/qcautomation/the-hidden-costs-of-paper-trading-what-6-weeks-of-data-showed-afk</guid>
      <description>&lt;p&gt;As developers, we're often tempted to test our trading strategies using paper trading platforms. These tools allow us to simulate trades without risking any real money. But what happens when we start analyzing the data from these simulations? Do they accurately reflect what would happen in the real world?&lt;/p&gt;

&lt;p&gt;I ran a 6-week experiment simulating trades across multiple strategies on various assets using TradeSight, my Python paper trading bot. The results were eye-opening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Slippage: The Silent Killer
&lt;/h2&gt;

&lt;p&gt;When executing trades, brokers incur slippage due to market conditions and order books. Paper trading platforms often assume perfect fill rates or don't account for this spread at all. In reality, even the best traders can expect some level of slippage.&lt;/p&gt;

&lt;p&gt;Here's how I started accounting for it in Python:&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;yfinance&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;yf&lt;/span&gt;

&lt;span class="c1"&gt;# Simulate slippage: assume 0.05% on each fill
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply_slippage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;buy&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slippage_pct&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0005&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;direction&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;buy&lt;/span&gt;&lt;span class="sh"&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;price&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;slippage_pct&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;price&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;slippage_pct&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="n"&gt;yf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AAPL&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1d&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;raw_price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Close&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;iloc&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="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;actual_buy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;apply_slippage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;buy&lt;/span&gt;&lt;span class="sh"&gt;'&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="s"&gt;Raw: $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;raw_price&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, After Slippage: $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;actual_buy&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&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;Over 6 weeks, slippage ate roughly 0.3% of total simulated returns — small per trade, huge compounded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fill Assumptions: The Unrealistic Benchmark
&lt;/h2&gt;

&lt;p&gt;Paper platforms assume every trade fills at the exact price requested. Real brokers don't. I added a fill simulation layer to TradeSight that randomly rejects ~3% of orders and fills another 5% at a worse 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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;simulate_fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requested_price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fill_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.97&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slippage_range&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.001&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;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&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;fill_rate&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="c1"&gt;# rejected
&lt;/span&gt;    &lt;span class="n"&gt;actual_fill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requested_price&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;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&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="n"&gt;slippage_range&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;actual_fill&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I turned this on, my best backtest strategy dropped from 12% simulated returns to 9.4% — a 22% gap just from realistic fills.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spread Modeling: The Hidden Cost
&lt;/h2&gt;

&lt;p&gt;Most paper trading setups ignore bid-ask spread entirely. I calculated average spreads from 6 weeks of tick data:&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;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;

&lt;span class="c1"&gt;# Load tick data and calculate average spread per asset
&lt;/span&gt;&lt;span class="n"&gt;spreads&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;AAPL&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.03&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# percent
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SPY&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;TSLA&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;avg_spread&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spreads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&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="s"&gt;Average Spread Cost: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;avg_spread&lt;/span&gt;&lt;span class="si"&gt;:&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="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;For high-frequency or short-holding strategies, a 0.04% average spread compounded across 50+ trades/week adds up fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Means for Strategy Development
&lt;/h2&gt;

&lt;p&gt;After 6 weeks of realistic simulation, the takeaways were clear:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never trust returns from platforms with perfect fill assumptions&lt;/strong&gt; — add 10-20% haircut&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slippage matters most for volatile assets&lt;/strong&gt; (TSLA, small caps)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Longer holding periods dilute these costs&lt;/strong&gt; — overnight strategies survive better&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;TradeSight now models all three by default. If you want to see the full simulation code, it's on GitHub: &lt;a href="https://github.com/rmbell09-lang/tradesight" rel="noopener noreferrer"&gt;github.com/rmbell09-lang/tradesight&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Curious what hidden costs you've found in your own paper trading setups — drop a comment below.&lt;/p&gt;

</description>
      <category>python</category>
      <category>algotrading</category>
      <category>fintech</category>
      <category>opensource</category>
    </item>
    <item>
      <title>7 Stripe Webhook Edge Cases That Break Billing (And How to Handle Them)</title>
      <dc:creator>Ray</dc:creator>
      <pubDate>Mon, 30 Mar 2026 02:58:38 +0000</pubDate>
      <link>https://dev.to/qcautomation/7-stripe-webhook-edge-cases-that-break-billing-and-how-to-handle-them-2hjn</link>
      <guid>https://dev.to/qcautomation/7-stripe-webhook-edge-cases-that-break-billing-and-how-to-handle-them-2hjn</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;As a developer building e-commerce platforms or subscription-based services, you're likely using Stripe for payment processing. However, Stripe webhooks can sometimes behave erratically, leading to billing issues.&lt;/p&gt;

&lt;p&gt;In this article, we'll explore 7 common Stripe webhook edge cases that break billing and provide practical solutions to handle them. We'll use the BillingWatch framework as an example, which provides an open-source library for monitoring and handling Stripe webhooks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge Case 1: Duplicate Events
&lt;/h2&gt;

&lt;p&gt;When multiple events are triggered simultaneously (e.g., during a failed payment attempt), you may receive duplicate event notifications. To handle this, you can implement a simple deduplication mechanism using a database or cache layer:&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;datetime&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;

&lt;span class="c1"&gt;# Store received event timestamps in a database or cache
&lt;/span&gt;&lt;span class="n"&gt;event_timestamps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_event_timestamps&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# Ignore duplicate events
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;event_timestamps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Process event normally
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Edge Case 2: Out-of-Order Delivery
&lt;/h2&gt;

&lt;p&gt;In some cases, Stripe webhooks may be delivered out of order, causing confusion and potential billing issues. To mitigate this, you can use a monotonic clock or a distributed locking mechanism to ensure events are processed in the correct order:&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="c1"&gt;# Use a monotonic clock to track event delivery timestamps
&lt;/span&gt;&lt;span class="n"&gt;event_delivery_timestamps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

&lt;span class="c1"&gt;# Ignore events delivered out of order
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delivery_timestamp&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_delivery_timestamps&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Process event normally
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Edge Case 3: Missing Metadata
&lt;/h2&gt;

&lt;p&gt;Stripe webhooks may sometimes lack critical metadata, such as customer information or payment details. To handle this, you can implement a fallback mechanism using caching or database lookup:&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;# Cache customer metadata for later use
&lt;/span&gt;&lt;span class="n"&gt;customer_metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_customer_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Fallback to cached metadata if necessary
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Use cached metadata
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Edge Case 4: Signature Failures
&lt;/h2&gt;

&lt;p&gt;Stripe webhooks include a signature that verifies their authenticity. However, these signatures can sometimes fail due to network errors or other issues. To handle this, you can implement a retry mechanism with exponential backoff:&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="c1"&gt;# Retry failed signature verification after a short delay
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;signature&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;verify_event_signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Retry in 10 seconds
&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;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Edge Case 5: Duplicate Customer Information
&lt;/h2&gt;

&lt;p&gt;Stripe webhooks may contain duplicate customer information, leading to incorrect billing. To handle this, you can implement a deduplication mechanism using a database or cache layer:&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;# Store received customer information in a database or cache
&lt;/span&gt;&lt;span class="n"&gt;customer_info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_customer_info&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# Ignore duplicate customer information
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_info&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;customer_info&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Process event normally
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Edge Case 6: Missing Payment Information
&lt;/h2&gt;

&lt;p&gt;Stripe webhooks may sometimes lack critical payment information, such as card details or subscription status. To handle this, you can implement a fallback mechanism using caching or database lookup:&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;# Cache payment information for later use
&lt;/span&gt;&lt;span class="n"&gt;payment_info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_payment_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payment_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Fallback to cached payment info if necessary
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payment_info&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Use cached payment info
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Edge Case 7: Signature Expiration
&lt;/h2&gt;

&lt;p&gt;Stripe webhooks include a signature that expires after a certain time. If this expiration occurs, you may need to handle the event differently. To do so, you can implement a retry mechanism with exponential backoff:&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="c1"&gt;# Retry expired signatures after a short delay
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;signature&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;verify_event_signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Retry in 10 seconds
&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;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Stripe webhooks can sometimes behave erratically, leading to billing issues. By implementing solutions for these common edge cases, you can ensure that your billing system remains accurate and reliable.&lt;/p&gt;

&lt;p&gt;Remember to experiment with different deduplication mechanisms, caching strategies, and retry policies to optimize your event handling workflow. With the right approach, you can improve your billing accuracy and provide a better experience for your customers.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/rmbell09-lang/billingwatch" rel="noopener noreferrer"&gt;BillingWatch&lt;/a&gt; is open-source — self-hosted Stripe billing anomaly detector with multi-tenant support and webhook monitoring.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>stripe</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
