Live Algorithmic Trading Bot

Overview

This is a crypto paper-trading simulator I built to scratch a specific itch: I wanted to watch a mechanical buy-the-dip strategy run against real, live memecoin prices without risking a single actual dollar. It pulls live quotes from Binance's public REST API for eight memecoins, runs a deterministic strategy in a background daemon thread, and renders everything in a self-refreshing Dash plus Plotly dashboard. It is for anyone (mostly me) who wants to see how a simple threshold strategy behaves on genuinely volatile assets, with full trade history and PnL, but with all the capital being imaginary.

Architecture and data flow

The whole thing lives in one file and runs as a single process with two concurrent flows sharing in-memory state.

The write side is a background thread named TradingThread. It loops forever, and on each tick it calls Binance's bookTicker endpoint for every valid symbol, computes a mid price, checks the strategy rules, mutates the global portfolio state (cash, holdings, trade log, time series), and sleeps two seconds. Every ten minutes it dumps the entire state to a pickle file.

The read side is the Dash callback. A dcc.Interval component fires every two seconds in the browser, which triggers a single callback that reads the same global state, builds Plotly figures and HTML tables, and returns them. The two sides never talk directly; they only share module-level dictionaries and lists, guarded where it matters by a re-entrant lock.

On import the module also bootstraps itself: it validates the symbol list against Binance, restores any prior state from the pickle, registers a save-on-exit handler, and starts the thread. So just importing the page is enough to bring the bot fully alive.

The interesting internals

1. Config and the strategy constants

CANDIDATE_SYMBOLS = [
    "DOGEUSDT", "SHIBUSDT", "PEPEUSDT", "FLOKIUSDT",
    "WIFUSDT", "BONKUSDT", "TURBOUSDT", "MEMEUSDT"
]

LIVE_REFRESH_SEC = 2
MAX_POINTS = 600
BACKUP_FILE = "portfolio_backup.pkl"
BACKUP_INTERVAL_SEC = 600  # every 10 minutes

INITIAL_CASH = 10000.0
MAX_PER_TRADE = 1000.0
BUY_TRIGGER = -5   # % vs hour-open
SELL_PROFIT = 3    # % vs entry
SELL_STOP = -5    # % vs entry

Every knob lives at the top so I can tune behaviour without spelunking through logic. The choices matter: MAX_POINTS = 600 caps each time series at the last 600 samples, which at a two-second cadence is twenty minutes of history. That is a deliberate trade-off: it keeps the in-memory series bounded and Plotly fast, rather than letting the lists grow without limit until the page chugs. MAX_PER_TRADE = 1000.0 against INITIAL_CASH = 10000.0 means at most ten positions can be open at once, which is exactly the eight-coin universe plus headroom. The three percent thresholds are the entire trading edge expressed as three integers.

2. Defensive symbol validation on startup

def get_valid_symbols(candidates):
    j = http_get(f"{BINANCE}/api/v3/exchangeInfo", timeout=8)
    if not j:
        print("[WARN] Failed to load symbols - fallback to DOGEUSDT.")
        return ["DOGEUSDT"], []
    valid = [s["symbol"] for s in j["symbols"] if s.get("status") == "TRADING"]
    valids = [s for s in candidates if s in valid]
    invalids = [s for s in candidates if s not in valid]
    return (valids or ["DOGEUSDT"]), invalids

VALID_SYMBOLS, INVALID_SYMBOLS = get_valid_symbols(CANDIDATE_SYMBOLS)
qty = {s: 0.0 for s in VALID_SYMBOLS}
buy = {s: None for s in VALID_SYMBOLS}
series = {s: [] for s in VALID_SYMBOLS}

Memecoins get delisted. If I blindly looped over my hardcoded list and one of them was no longer TRADING, every tick would burn a failed request on a dead symbol. So before anything starts, I hit exchangeInfo once, keep only the symbols Binance currently lists as trading, and rebuild the qty, buy and series dictionaries to key off the survivors. The valids or ["DOGEUSDT"] guard is the gotcha-handler: if the API is down or returns nothing usable, I degrade to a single known-good symbol rather than starting with an empty universe and a dashboard that shows nothing forever.

3. The core strategy in one tick

if qty[sym] == 0.0 and cash >= MAX_PER_TRADE:
    if np.isfinite(openp) and ask <= openp * (1 + BUY_TRIGGER / 100.0):
        invest = min(MAX_PER_TRADE, cash)
        q = invest / max(ask, 1e-12)
        qty[sym] = q
        buy[sym] = ask
        cash -= q * ask
        trades.append({
            "time": now.strftime("%H:%M:%S"), "coin": sym, "action": "BUY",
            "price": ask, "qty": q, "cost": q * ask,
            "pnl": 0.0, "pnl_pct": 0.0, "cash_after": cash
        })

elif qty[sym] > 0.0:
    entry = buy[sym]
    if entry and entry > 0:
        change_pct = (bid - entry) / entry * 100.0
        if change_pct >= SELL_PROFIT or change_pct <= SELL_STOP:
            proceeds = qty[sym] * bid
            pnl = proceeds - qty[sym] * entry

This is the heart of it. The if/elif enforces a clean rule: a coin is either flat and a buy candidate, or it holds a position and is an exit candidate, never both in the same tick. I buy at the ask (what I would really pay) and sell at the bid (what I would really receive), so the bid-ask spread acts as a built-in cost and the simulator is not optimistic about fills. q = invest / max(ask, 1e-12) is the divide-by-zero guard: if a price ever comes back as zero, the floor of 1e-12 stops the whole thread from crashing on a ZeroDivisionError. The np.isfinite(openp) check matters because the hourly open can be np.nan when that lookup fails, and nan comparisons are always false, which would silently suppress trades; checking explicitly makes the intent clear.

4. The cached hourly open

def cached_hour_open(sym):
    now = time.time()
    val = hour_open_cache.get(sym)
    if not val or (now - val[1]) > 60:
        j = http_get(f"{BINANCE}/api/v3/klines", {"symbol": sym, "interval": "1h", "limit": 1})
        price = float(j[-1][1]) if j else np.nan
        hour_open_cache[sym] = (price, now)
    return hour_open_cache[sym][0]

The buy rule needs the price at the top of the current hour as its reference. The naive version would fetch the klines endpoint on every tick for every coin, which at two-second ticks across eight symbols is a brutal request rate for data that only changes once an hour. So I cache it per symbol as a (price, fetched_at) tuple and only refetch when the cached value is older than sixty seconds. That collapses the kline traffic to roughly one request per coin per minute while still picking up the new hourly open promptly when the hour rolls over. The j[-1][1] indexing reads field one (the open) of the most recent candle in Binance's array-of-arrays kline format.

5. State persistence with pickle and atexit

def save_backup():
    with lock:
        state = {
            "cash": cash, "qty": qty, "buy": buy,
            "trades": trades, "portfolio_rows": portfolio_rows,
            "series": series, "runtime": runtime,
        }
        with open(BACKUP_FILE, "wb") as f:
            pickle.dump(state, f)

load_backup()
atexit.register(save_backup)

Because the strategy reasons about open positions and average entry, losing state on every restart would make the bot meaningless. The whole world is plain dicts and lists, so pickling it is trivial and lossless. I snapshot under the lock so I never serialize state that the trading thread is halfway through mutating. load_backup() runs at import to restore, and atexit.register(save_backup) guarantees a clean final dump when the process exits normally, on top of the ten-minute periodic save inside the tick loop. One subtle detail: on restore I use trades[:] = ... and portfolio_rows[:] = ... rather than reassignment, so the global lists keep their identity and the Dash callback that closes over them keeps seeing live data.

The algorithms and formulas

Mid price, used for charting and as the fallback when a quote is missing:

mid = (bid + ask) / 2

Buy signal. With BUY_TRIGGER = -5, a coin is bought only when its ask has fallen at least five percent below the hourly open:

buy when  ask <= hour_open * (1 + BUY_TRIGGER / 100)
position size  q = min(MAX_PER_TRADE, cash) / ask

Position management while held, measured off the bid against the recorded entry price:

change_pct = (bid - entry) / entry * 100
sell when  change_pct >= SELL_PROFIT (take profit at +3%)
       or  change_pct <= SELL_STOP   (stop loss at -5%)

Realized PnL on exit:

proceeds = qty * bid
pnl      = proceeds - qty * entry
pnl_pct  = pnl / (qty * entry) * 100

Unrealized PnL shown live in the BUY table uses the latest mid instead of a fill price:

cur_pnl     = qty * current_mid - cost
cur_pnl_pct = cur_pnl / cost * 100

Portfolio totals each tick:

invested = sum(qty[sym] * bid for held positions)
total    = cash + invested

Tech stack: Python, Dash, Plotly, pandas, NumPy, requests, threading, pickle, and the Binance public REST API.

Back to all posts