Building a SOL Trading Bot with Python

Trading crypto manually is a losing game — at least for me. I kept second-guessing entries, panic-selling dips, and checking charts at 2 AM. So I did what any reasonable engineer would do: I automated the whole thing.

This post walks through the SOL/EUR trading bot I built and now run 24/7 on my home lab. It trades Solana on Kraken using a multi-indicator strategy, manages its own risk, and serves a live dashboard so I can keep an eye on it.

The stack

  • Python 3.12 — the whole bot is pure Python
  • CCXT — unified exchange API library, connected to Kraken
  • pandas + pandas_ta — OHLCV data handling and technical indicator calculation
  • FastAPI — lightweight web dashboard
  • Docker Compose — two containers: the bot loop and the web UI
  • Loguru — structured logging

Why SOL/EUR?

Before writing any code, I pulled 24-hour stats from Kraken across a bunch of EUR pairs to figure out what to trade. The criteria: enough volatility for the strategy to work, enough liquidity to not get killed on spreads.

Here’s what I found:

PairATR % (5m)Daily range24h Volume (EUR)
SOL/EUR0.19%4.7%~€9M
ETH/EUR0.19%5.1%~€16M
BTC/EUR0.14%3.2%~€25M
AVAX/EUR0.22%8.1%~€500K
DOT/EUR0.20%8.5%~€300K
PEPE/EUR0.25%9.2%~€130K

Pairs like AVAX, DOT, and PEPE look great on paper — big daily ranges, high ATR. But check their volumes: €130K–500K. That means wider bid/ask spreads, slippage on market orders, and potentially inflated volume numbers. The bot uses market orders, so on a thin order book you’d buy at worse prices and sell at worse prices. The volatility looks attractive but the spreads eat your edge.

SOL/EUR hits the sweet spot: tied for the highest ATR percentage, with €9M daily volume — enough for tight spreads and minimal slippage at my account size. The only real alternative is ETH/EUR (same ATR, nearly double the liquidity, slightly larger daily range), but at ~€1,871 per coin it’s a higher price point. That doesn’t technically matter since the bot trades in fractions, but SOL’s lower unit price makes the position sizing and mental math a bit more intuitive.

How it works

The bot runs on a simple 5-minute loop. Every tick it:

  1. Fetches the latest 5-minute and 1-hour OHLCV candles from Kraken
  2. Calculates technical indicators on both timeframes
  3. Evaluates buy/sell signals using a confluence strategy
  4. Checks risk limits before acting
  5. Executes trades (or holds) and updates the dashboard

The strategy: confluence of indicators

I didn’t want the bot to act on a single indicator — that’s how you get whipsawed. Instead, it requires at least 2 out of 4 indicators to agree before entering or exiting a position.

The indicators:

IndicatorBuy signalSell signal
RSI (14)RSI < 35 (oversold)RSI > 65 (overbought)
MACD (12/26/9)MACD crosses above signalMACD crosses below signal
EMA (20/50)EMA 20 > EMA 50 (bullish)EMA 20 < EMA 50 (bearish)
Bollinger Bands (20, 2σ)Price in bottom 20% of bandPrice in top 20% of band

On top of that, there are two filters that can block or gate trades:

  • OBV (On-Balance Volume): buy signals are zeroed out if OBV is below its 20-period EMA — no volume confirmation, no entry
  • Higher-timeframe trend filter: buys are only allowed when the 1-hour EMA 20 is above the 1-hour EMA 50 — this keeps the bot from buying into a macro downtrend

Here’s the core of the signal logic:

def get_signals(df):
    latest = df.iloc[-1]
    prev = df.iloc[-2]
    buy, sell = 0, 0

    # RSI
    if latest["rsi"] < 35:
        buy += 1
    elif latest["rsi"] > 65:
        sell += 1

    # MACD crossover
    if prev["macd"] <= prev["macd_signal"] and latest["macd"] > latest["macd_signal"]:
        buy += 1
    elif prev["macd"] >= prev["macd_signal"] and latest["macd"] < latest["macd_signal"]:
        sell += 1

    # EMA trend
    if latest["ema_short"] > latest["ema_long"]:
        buy += 1
    else:
        sell += 1

    # Bollinger Band position
    bb_pos = (latest["close"] - latest["bb_lower"]) / (latest["bb_upper"] - latest["bb_lower"])
    if bb_pos < 0.2:
        buy += 1
    elif bb_pos > 0.8:
        sell += 1

    # OBV gate — no volume, no buy
    if latest["obv"] <= latest["obv_ema"]:
        buy = 0

    return buy, sell

The strategy module then checks these counts against the minimum threshold (2) and applies the higher-timeframe filter before making a decision.

Risk management

This is where I spent most of the iteration time. A strategy that makes money is useless if one bad trade wipes you out.

ATR-based dynamic stops

Instead of fixed percentage stop-losses, the bot uses the Average True Range (ATR) to set stops that adapt to current volatility:

  • Stop-loss: entry price − 2× ATR
  • Trailing activation: entry price + 3× ATR
  • Trailing distance: peak price − 1× ATR

When a trade is going well and hits the activation level, the bot switches from a hard stop-loss to a trailing stop that follows the price up. This lets winners run while still protecting profits.

Portfolio-level controls

Beyond individual trades, the risk manager enforces two hard limits:

  • Daily loss limit (5%): if the portfolio drops 5% from the start-of-day balance, the bot pauses trading until the next day
  • Max drawdown (15%): if the portfolio drops 15% from its all-time peak, the bot halts completely and requires manual intervention

These are non-negotiable circuit breakers. The bot can’t talk itself out of them.

class RiskManager:
    def check(self, current_balance):
        # Daily loss check
        daily_loss = (self.daily_start_balance - current_balance) / self.daily_start_balance
        if daily_loss >= 0.05:
            return False, "Daily loss limit hit"

        # Max drawdown check
        drawdown = (self.peak_balance - current_balance) / self.peak_balance
        if drawdown >= 0.15:
            self.halted = True
            return False, "Max drawdown hit"

        return True, "OK"

The dashboard

The bot writes its state to a JSON file on every tick. A separate FastAPI container serves a web dashboard that reads this state and renders:

  • Current price and position status
  • Entry price, stop-loss, and take-profit levels
  • Portfolio balance breakdown (EUR + SOL)
  • Risk status (daily loss, drawdown from peak)
  • Trade history log
  • OHLCV chart with indicator overlays

Trading bot dashboard showing price chart with EMA/Bollinger overlays, RSI and MACD panels, position status, balance, risk metrics, and recent trades

Both containers share a data/ volume, so the dashboard always shows the latest state without any direct coupling between the bot and the web server.

Deployment

The whole thing runs in Docker Compose — two services, one build:

services:
  bot:
    build: .
    container_name: trading-bot
    restart: unless-stopped
    env_file: .env
    volumes:
      - ./data:/app/data
    command: python3 bot.py

  web:
    build: .
    container_name: trading-web
    restart: unless-stopped
    ports:
      - "8501:8501"
    volumes:
      - ./data:/app/data
    command: uvicorn web:app --host 0.0.0.0 --port 8501

API keys live in a .env file. The bot supports both paper trading and live mode — flip TRADING_MODE=live when you’re ready to go real.

What I learned

  • Confluence beats conviction. Single-indicator strategies get chopped up. Requiring multiple signals to agree filters out a lot of noise.
  • Volume matters. Adding OBV as a gate for buy signals was one of the biggest improvements. No point buying if there’s no volume behind the move.
  • ATR > fixed percentages. A 3% stop-loss means very different things in low-volatility vs. high-volatility markets. ATR-based stops adapt automatically.
  • The higher-timeframe filter is worth it. Checking the 1-hour trend before entering on 5-minute signals cut out a lot of counter-trend losses.
  • Risk management is the strategy. The actual indicator logic is maybe 30% of the code. The rest is position sizing, stop management, and circuit breakers.
  • Liquidity trumps volatility. Picking a pair with tight spreads matters more than chasing the biggest daily range. The edge comes from the strategy, not from hoping for wild swings.

What’s next

The bot is running live with a small amount of capital. I’m watching it, collecting data, and thinking about:

  • Adding more pairs beyond SOL/EUR
  • Backtesting parameter variations more systematically
  • Integrating notifications (Telegram/Discord) for trades and risk events
  • Exploring ML-based signal weighting instead of equal-weight confluence

For now, it’s doing its thing — quietly trading every 5 minutes while I do literally anything else. Which was the whole point.