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.
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.
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 loggerReconnect 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.
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 keyRespecting 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.
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.