AlgoPro UniversityCourse home
Part 8 · Lesson

The live loop, scheduling & resilience

The main while-loop that ties it together: run the signal on new bars, wrap everything in try/except, reconnect with exponential backoff, respect rate limits, and log structurally.

Now we assemble the pieces — config, exchange, order manager, strategy — into the loop that actually runs your bot 24/7. The loop has one job beyond "decide and trade": it must not die. A backtest that throws an exception simply stops and you fix it. A live loop that throws leaves an open position with nobody watching. So the guiding principle here is: *a single failure should delay the bot, never end it.*

Run on closed bars, not on every tick

Most strategies operate on bar closes — decide once per hour on the 1h candle, not on every price wobble. The loop therefore does not spin as fast as it can; it wakes, checks whether a *new* bar has closed since last time, acts only if so, and otherwise sleeps. Acting once per closed bar also matches your backtest exactly, which is the whole point of the strategy/exchange separation from Lesson 1.

pythonA simple strategy — pure, no I/O, identical to the backtest version
class="c"># strategy.py  —  a moving-average cross. Takes closes, returns a target.
def signal(closes, fast=class="n">20, slow=class="n">50) -> str:
    class="s">""class="s">"Return the DESIRED state: 'long' or 'flat'. Pure function."class="s">""
    if len(closes) < slow + class="n">1:
        return class="s">"flat"
    def sma(vals, n):
        return sum(vals[-n:]) / n
    fast_now, slow_now = sma(closes, fast), sma(closes, slow)
    return class="s">"long" if fast_now > slow_now else class="s">"flat"

Structured logging

When a bot runs unattended for weeks, logs are your only window into what it did. Use Python’s logging with timestamps and levels so that later you can answer "what did it think at 04:00 last Tuesday?" A print cannot do that; a structured log can.

pythonlog.py — timestamped, levelled logging to stdout and a file
import logging

def get_logger(name=class="s">"livebot"):
    logger = logging.getLogger(name)
    if logger.handlers:
        return logger
    logger.setLevel(logging.INFO)
    fmt = logging.Formatter(
        class="s">"%(asctime)s %(levelname)-7s %(message)s",
        datefmt=class="s">"%Y-%m-%d %H:%M:%S",
    )
    stream = logging.StreamHandler()          class="c"># -> stdout (journald/systemd)
    stream.setFormatter(fmt)
    logger.addHandler(stream)
    fileh = logging.FileHandler(class="s">"livebot.log")  class="c"># -> rotating file (Lesson class="n">5)
    fileh.setFormatter(fmt)
    logger.addHandler(fileh)
    return logger

Reconnect with exponential backoff

The network *will* fail — a timeout, a 5xx, an exchange maintenance window. When it does, hammering the API immediately makes things worse and can get you rate-limited or banned. The correct response is exponential backoff: wait 1s, then 2s, 4s, 8s… up to a cap, retrying until the API answers, then reset. This turns a transient outage into a pause rather than a crash.

pythonbackoff.py — retry a call with exponential backoff and a cap
import time
import random
import ccxt

from log import get_logger

log = get_logger()


def with_backoff(fn, *args, max_delay=class="n">60.0, **kwargs):
    class="s">""class="s">"Call fn(); on a transient network/exchange error, retry forever
    with exponentially growing, jittered delay (capped)."class="s">""
    delay = class="n">1.0
    while True:
        try:
            return fn(*args, **kwargs)
        except (ccxt.NetworkError, ccxt.ExchangeNotAvailable,
                ccxt.RequestTimeout, ccxt.DDoSProtection) as e:
            jitter = random.uniform(class="n">0, delay * class="n">0.25)
            wait = min(delay + jitter, max_delay)
            log.warning(class="s">"transient error %s — backing off %.1fs",
                        type(e).__name__, wait)
            time.sleep(wait)
            delay = min(delay * class="n">2, max_delay)
        except ccxt.AuthenticationError:
            log.error(class="s">"AUTH error — bad API keys. Stopping.")
            raise                              class="c"># never retry a bad key

Respecting rate limits

Every exchange caps how many requests you may send per minute. We already set enableRateLimit: True in the ccxt client (Lesson 2), which spaces requests automatically. The other half is *not asking for things you do not need*: fetch OHLCV once per bar, cache market metadata, and never poll the ticker in a tight loop. A bot that sleeps between bars is naturally gentle on the API.

The main loop

Here it is — the whole bot, wired together. Reconcile first, act only on a new closed bar, wrap every cycle in try/except so one bad iteration sleeps and retries instead of killing the process, and check the kill switch every pass.

pythonbot.py — the resilient live loop
import time
import os

from config import CONFIG
from exchange import Exchange
from order_manager import OrderManager
from strategy import signal
from backoff import with_backoff
from log import get_logger

log = get_logger()

POLL_SECONDS = class="n">15          class="c"># how often to check whether a new bar closed


def kill_switch_engaged() -> bool:
    class="s">""class="s">"A file OR an env flag can halt trading instantly (Lesson class="n">5)."class="s">""
    return os.path.exists(class="s">"STOP") or CONFIG.__class__().live is False


def target_amount(ex: Exchange) -> float:
    class="s">""class="s">"Risk-based size: risk_fraction of quote balance, capped."class="s">""
    quote = with_backoff(ex.balance, class="s">"USDT")
    price = with_backoff(ex.price)
    raw = (quote * ex.cfg.risk_fraction) / price
    return min(raw, ex.cfg.max_position)


def run():
    ex = Exchange()
    om = OrderManager(ex)
    last_bar_ts = None
    log.info(class="s">"bot starting | symbol=%s testnet=%s dry_run=%s live=%s",
             ex.cfg.symbol, ex.cfg.testnet, ex.cfg.dry_run, ex.cfg.live)

    while True:
        try:
            if kill_switch_engaged():
                log.warning(class="s">"KILL SWITCH engaged — flattening and idling")
                position = with_backoff(om.reconcile)
                if position > class="n">0:
                    om.close_all(bar_ts=int(time.time()))
                time.sleep(POLL_SECONDS)
                continue

            class="c"># class="n">1) Always sync reality first (the golden rule).
            position = with_backoff(om.reconcile)

            class="c"># class="n">2) Fetch closed candles (wrapper already drops the live bar).
            candles = with_backoff(ex.ohlcv, limit=class="n">200)
            if not candles:
                time.sleep(POLL_SECONDS); continue
            bar_ts = candles[-class="n">1][class="n">0]

            class="c"># class="n">3) Only act when a NEW bar has closed.
            if bar_ts == last_bar_ts:
                time.sleep(POLL_SECONDS); continue
            last_bar_ts = bar_ts

            class="c"># class="n">4) Decide desired state, then move toward it.
            closes = [c[class="n">4] for c in candles]
            want = signal(closes)
            log.info("bar=%s close=%.2f position=%.6f -> want=%sclass="s">",
                     bar_ts, closes[-class="n">1], position, want)

            if want == "long" and position <= class="n">0:
                amt = target_amount(ex)
                order = om.enter_long(amt, bar_ts)
                log.info("ENTER long %.6f -> %sclass="s">", amt,
                         order and order.get("idclass="s">"))
            elif want == "flat" and position > class="n">0:
                order = om.close_all(bar_ts)
                log.info("EXIT flat %.6f -> %sclass="s">", position,
                         order and order.get("idclass="s">"))

        except KeyboardInterrupt:
            log.info("interrupted — shutting down cleanlyclass="s">")
            break
        except Exception:                     class="c"># last-resort net: log, don't die
            log.exception("unhandled error in loop — sleeping then continuingclass="s">")
            time.sleep(POLL_SECONDS)

        time.sleep(POLL_SECONDS)


if __name__ == "__main__":
    run()

You now have a complete, resilient bot: it reconciles, decides on closed bars, survives network failures with backoff, respects rate limits, logs everything, and obeys a kill switch. The last step is doing this *safely for real* — proving it on paper, then putting it on a server that keeps it running. That is the final lesson.