Home PythonBuilding a Paper Trading Bot on a Raspberry Pi

Building a Paper Trading Bot on a Raspberry Pi

by Marc

I built a paper trading bot that runs on a Raspberry Pi 5, executes 8 strategies across stocks and crypto, and has been trading a simulated 10,000 euro portfolio since early 2026. This post covers the technical details: the strategy code, the backtester that validated the strategies against 195,000 candles, the yfinance pitfalls that cost me days of debugging, and the web dashboard that lets me monitor everything from my phone.

The Strategies

Eight strategies in total. Four for crypto, four for stocks. Two stock strategies were dropped during backtesting (under 1% expectancy over 20 years of data), so six are live.

  • C1_fear: Extreme Fear Contrarian — buy BTC/ETH when the Fear & Greed Index drops below 20 and RSI is under 35
  • C2_funding: Funding Squeeze — buy when perpetual funding rates go deeply negative (shorts paying longs) and short interest exceeds 55%
  • C3_stablecoin: Stablecoin Inflow — buy BTC when USDT market cap grows over 0.5% in 7 days and price is above EMA20
  • C4_crowd: Crowd Contrarian — buy when shorts exceed 60%, RSI is under 45, and price touches the lower Bollinger Band
  • S1_insider: Insider Cluster Buy — 2+ insiders buying within a window, RSI under 45, price above SMA200
  • S2_squeeze: Short Squeeze — short interest above 45% and declining, RSI above 35, price above SMA50 (US stocks only — FINRA data)
  • S3_dip: Fear Dip Buy — stock has dipped over 15% from recent high, RSI under 30, price above SMA200
  • S6_breakout: Bollinger Band Squeeze Breakout — the most technically involved strategy

S6_breakout: The Squeeze Detection Code

S6 looks for Bollinger Band squeezes — moments where volatility contracts to historically low levels, often preceding explosive moves. The entry conditions are: bandwidth at or below the 20th percentile (current bar only, not a lookback window), price breaking above the upper band, ADX rising (not an absolute threshold — ADX lags too much for breakout timing), and volume at least 2x the 20-day average.

class S6_breakout(Strategy):
    """Bollinger Band Squeeze Breakout.

    Entry: squeeze at 20th percentile + price > upper BB + ADX rising + vol > 2x avg
    Exit priority: BB mid revert -> 2xATR chandelier trailing stop -> TP +15% -> SL -7% -> time 21d
    """

    def __init__(self, squeeze_pctile=20, vol_mult=2.0, tp=0.15, sl=0.07, time_days=21):
        self.squeeze_pctile = squeeze_pctile
        self.vol_mult = vol_mult
        self.tp = tp
        self.sl = sl
        self.time_days = time_days

    def check_entry(self, data, symbol, context=None):
        close = data["Close"].dropna()
        volume = data["Volume"].dropna()
        if len(close) < 100:
            return None

        # Bollinger Bands (20-period, 2 std dev)
        sma20 = close.rolling(20).mean()
        std20 = close.rolling(20).std()
        upper = sma20 + 2 * std20
        lower = sma20 - 2 * std20
        bandwidth = (upper - lower) / sma20

        # Squeeze detection: current bandwidth <= 20th percentile of last 252 bars
        bw_lookback = bandwidth.iloc[-252:]
        threshold = bw_lookback.quantile(self.squeeze_pctile / 100)
        current_bw = bandwidth.iloc[-1]
        in_squeeze = current_bw <= threshold

        if not in_squeeze:
            return None

        # Price must break above upper band
        price = close.iloc[-1]
        if price <= upper.iloc[-1]:
            return None

        # ADX must be rising (current > previous)
        adx = self._compute_adx(data, period=14)
        if len(adx) < 2 or adx.iloc[-1] <= adx.iloc[-2]:
            return None

        # Volume surge: current > 2x 20-day average
        vol_avg = volume.rolling(20).mean().iloc[-1]
        if volume.iloc[-1] < self.vol_mult * vol_avg:
            return None

        return {
            "signal": "BUY",
            "strategy": "S6_breakout",
            "symbol": symbol,
            "price": price,
            "bandwidth": current_bw,
            "bw_threshold": threshold,
            "adx": adx.iloc[-1],
            "volume_ratio": volume.iloc[-1] / vol_avg,
        }

    def check_exit(self, position, data):
        close = data["Close"].dropna()
        price = close.iloc[-1]
        entry_price = position["entry_price"]
        days_held = position["days_held"]

        # Priority 1: BB mid revert (price drops back below SMA20)
        sma20 = close.rolling(20).mean().iloc[-1]
        if price < sma20:
            return {"signal": "SELL", "reason": "bb_mid_revert"}

        # Priority 2: 2x ATR chandelier trailing stop
        atr = self._compute_atr(data, period=14)
        highest_since_entry = close.iloc[-days_held:].max()
        chandelier_stop = highest_since_entry - 2 * atr.iloc[-1]
        if price < chandelier_stop:
            return {"signal": "SELL", "reason": "atr_trailing_stop"}

        # Priority 3: Take profit at +15%
        if price >= entry_price * (1 + self.tp):
            return {"signal": "SELL", "reason": "take_profit"}

        # Priority 4: Stop loss at -7%
        if price <= entry_price * (1 - self.sl):
            return {"signal": "SELL", "reason": "stop_loss"}

        # Priority 5: Time-based exit at 21 days
        if days_held >= self.time_days:
            return {"signal": "SELL", "reason": "time_exit"}

        return None

Multi-Currency Handling

The portfolio is denominated in EUR, but stocks trade in their native currencies. US stocks are in USD. German, French, Spanish, Dutch, and Italian stocks are in EUR. UK stocks are in GBp (pence, not pounds — another yfinance trap). The bot detects the currency from the ticker suffix and applies conversion on every open, close, and exit calculation.

def get_stock_currency(ticker: str) -> str:
    """Detect native currency from ticker suffix.

    yfinance returns prices in the stock's native currency:
    - US stocks (no suffix): USD
    - .DE, .PA, .MC, .AS, .MI: EUR
    - .L: GBp (pence, divide by 100 for GBP)
    """
    suffix = ticker.split(".")[-1] if "." in ticker else ""
    EUR_SUFFIXES = {"DE", "PA", "MC", "AS", "MI"}

    if suffix in EUR_SUFFIXES:
        return "EUR"
    elif suffix == "L":
        return "GBP"  # yfinance returns GBp, we convert to GBP
    else:
        return "USD"


def convert_to_eur(amount: float, currency: str, fx_rates: dict) -> float:
    """Convert any currency amount to EUR using cached FX rates."""
    if currency == "EUR":
        return amount
    elif currency == "USD":
        return amount / fx_rates["EURUSD"]
    elif currency == "GBP":
        return amount / fx_rates["EURGBP"]
    else:
        raise ValueError(f"Unsupported currency: {currency}")

yfinance Gotchas

yfinance is free and convenient. It is also a minefield. Three bugs cost me about two full days of debugging combined:

  • MultiIndex columns (v1.2+): Even for a single ticker, yf.download("AAPL") returns columns as a MultiIndex: ("Close", "AAPL") instead of just "Close". You must always access via data["Close"]["AAPL"], never data["Close"] directly.
  • NaN with mixed US+EU tickers: When you batch-download US and EU tickers together, yfinance aligns them to the same date index. On days when European markets are open but US markets are closed, the US columns are NaN. Always use .dropna().iloc[-1] instead of .iloc[-1].
  • period=”1d” fails for EU tickers: Outside market hours, European tickers return empty DataFrames with period="1d". Always use period="5d" and take the last valid row.

NaN Sanitisation for JSON APIs

Python’s float('nan') is truthy and breaks JSON.parse() in the browser. Every API response goes through a sanitiser before being returned.

import math

def _safe_float(val) -> float | None:
    """Convert to float, replacing NaN/Inf with None for JSON safety."""
    if val is None:
        return None
    try:
        f = float(val)
        if math.isnan(f) or math.isinf(f):
            return None
        return round(f, 6)
    except (TypeError, ValueError):
        return None


def _sanitize_nan(obj):
    """Recursively replace NaN/Inf in nested dicts/lists for JSON serialization."""
    if isinstance(obj, dict):
        return {k: _sanitize_nan(v) for k, v in obj.items()}
    elif isinstance(obj, list):
        return [_sanitize_nan(v) for v in obj]
    elif isinstance(obj, float):
        return _safe_float(obj)
    return obj

Signal Logger: Building an ML Training Set

Every run logs every signal evaluation to a SQLite table: one row per symbol per strategy per run. Each row includes the full global context (Fear & Greed Index, derivatives data, stablecoin flows, prediction market probabilities), per-symbol technicals (RSI, Bollinger Bands, SMA, EMA, ADX), portfolio state, and whether the signal fired. After 24 hours, a backfill job enriches each row with the actual price outcome: 1h/4h/24h/7d price changes, max gain, and max drawdown. This is training data for a future ML meta-model that will learn which signal combinations actually predict profitable trades.

The Web Dashboard

A single HTML file. Vanilla JavaScript. No React, no build tools, no node_modules. It shows the portfolio overview, open positions with live P&L and exit signal proximity (how close each position is to hitting each exit condition), market signals (Fear & Greed, derivatives, stablecoins), strategy performance, and trade history with filters. Auto-refreshes every 5 minutes. Password authentication via a login form that hits /api/login and stores the token in localStorage.

The Backtester

Before going live, I validated every strategy against historical data. The backtester uses a SQLite database with 195,000 daily candles (20 years for 21 tickers plus SPY as a benchmark), 58,000 FRED macroeconomic data points, 26,000 FINRA short volume records, 1,000 insider transactions, and ~250 days of Fear & Greed Index data. Each strategy can be tested with default parameters or across a sweep of 48 parameter variants.

Backtest Results (Top 3)

These are the results from the March 2026 backtest run across 20 years of data:

| Strategy               | Expectancy | Profit Factor | Sharpe | Total Return |
|------------------------|-----------|---------------|--------|--------------|
| S3_dip (15%/rsi30)     | 7.96%     | 3.95x         | 1.61   | 247%         |
| S6_breakout (sq20/vol2)| 2.72%     | 2.55x         | 1.52   | 117%         |
| S2_squeeze (short0.45) | 2.35%     | 1.69x         | 0.60   | 574%         |

Dropped strategies:
| S4_mean_rev            | <1%       | -             | -      | Dropped      |
| S5_momentum            | <1%       | -             | -      | Dropped      |

S3_dip has the best expectancy per trade (7.96%) but trades infrequently — it needs a 15%+ dip with RSI under 30, which only happens a few times per year per ticker. S2_squeeze has the highest total return (574%) because it trades much more frequently, but with lower expectancy per trade.

Sentiment Overlay: Adjusting Position Size

The bot polls prediction markets (Kalshi, Polymarket) and social media for sentiment signals. When prediction markets show bearish consensus (recession probability up, rate hikes expected) or a high-impact political post is detected, position sizes are automatically scaled down: 50% size for high-alert conditions, 75% for cautious/watch conditions. Both factors stack — if prediction markets are bearish AND social media sentiment is negative, the position might be as small as 37.5% of the base size.

Deployment

The bot runs on a Raspberry Pi 5 behind Apache reverse proxy with Let’s Encrypt SSL. A systemd timer triggers a paper trade run every 30 minutes. Stock strategies only run during market hours (per-ticker exchange detection), while crypto strategies run 24/7. A deploy script pushes to GitHub, pulls on the Pi via SSH, restarts the service, and runs 35 smoke tests covering all endpoints, authentication, currency handling, and input validation.

Total cost: the Pi I already had, zero monthly fees, and a free DuckDNS domain. The only expenses are the FRED API key (free tier) and electricity.

You may also like

Leave a Comment