Order & position management
The unglamorous core of a live bot: client order IDs for idempotency, handling partial fills, and reconciling your local state against the exchange on every single loop.
A strategy decides *what* to do. Order management makes sure *what actually happened* matches what you intended — despite duplicate requests, half-filled orders, dropped responses, and a process that can die between "submit" and "confirm". This is where amateur bots quietly go wrong: they assume every order they send fills completely and exactly once. Real orders do neither.
Idempotency: the client order ID
Picture this: your bot sends a market buy, the exchange fills it, but the network drops the *response*. Your bot never hears back. Does it retry? If it blindly retries, you just bought twice. The fix is a client order ID — a unique string *you* generate and attach to the order. If you retry with the same ID, the exchange recognises the duplicate and refuses to create a second order. The operation becomes idempotent: sending it once or five times has the same effect.
import time
import hashlib
def make_client_id(symbol: str, side: str, bar_ts: int) -> str:
class="s">""class="s">"One id per (symbol, side, bar). Retrying the SAME decision reuses it,
so the exchange dedupes; a NEW bar produces a new id."class="s">""
raw = fclass="s">"{symbol}:{side}:{bar_ts}"
digest = hashlib.sha1(raw.encode()).hexdigest()[:class="n">12]
return fclass="s">"bot-{side}-{digest}"
class="c"># Same decision on the same bar -> same id -> exchange rejects the duplicate.
print(make_client_id(class="s">"BTC/USDT", class="s">"buy", class="n">1720000000)) class="c"># bot-buy-....
print(make_client_id(class="s">"BTC/USDT", class="s">"buy", class="n">1720000000)) class="c"># identicalTying the ID to the bar timestamp (not the wall clock) is the trick: if the bot restarts mid-loop and re-decides "buy on this bar", it regenerates the *same* ID and the exchange dedupes it. A genuinely new bar yields a new ID and a new order. Your intent, not your retries, drives what exists.
Partial fills
You ask to buy 1.0 BTC. The book only has 0.6 at your price, so you get filled 0.6, with 0.4 still working (or cancelled, depending on order type). If your code assumes "order placed = 1.0 held", it now thinks it owns more than it does — and will size the exit wrong. Always read back the filled amount and drive your position from that, never from the amount you *requested*.
def summarize_fill(order: dict) -> dict:
class="s">""class="s">"Normalise a ccxt order into what actually happened."class="s">""
requested = float(order.get(class="s">"amount") or class="n">0.0)
filled = float(order.get(class="s">"filled") or class="n">0.0)
remaining = float(order.get(class="s">"remaining") or max(requested - filled, class="n">0.0))
avg = order.get(class="s">"average") or order.get(class="s">"price")
return {
class="s">"id": order.get(class="s">"id"),
class="s">"status": order.get(class="s">"status"), class="c"># open | closed | canceled
class="s">"requested": requested,
class="s">"filled": filled, class="c"># <-- drive position from THIS
class="s">"remaining": remaining,
class="s">"avg_price": float(avg) if avg else None,
class="s">"fully_filled": order.get(class="s">"status") == class="s">"closed" and remaining == class="n">0.0,
}The OrderManager
We centralise all of this in a small OrderManager: it owns the local view of open orders and position, generates client IDs, submits through the exchange wrapper, and — crucially — reconciles against the exchange every loop. Reconciliation is the golden rule from Lesson 1 made concrete: after every cycle, ask the exchange what is really true and overwrite the local cache with it.
import json
import time
from pathlib import Path
from exchange import Exchange
from order_manager_ids import make_client_id class="c"># the helper above
STATE_FILE = Path(class="s">"state.json")
class OrderManager:
def __init__(self, exchange: Exchange):
self.ex = exchange
class="c"># Local CACHE only. The exchange is the source of truth.
self.position = class="n">0.0 class="c"># net base amount we believe we hold
self.avg_entry = None
self.open_ids = {} class="c"># client_id -> exchange order id
self._load()
class="c"># ---- persistence -------------------------------------------------
def _load(self):
if STATE_FILE.exists():
data = json.loads(STATE_FILE.read_text())
self.position = data.get(class="s">"position", class="n">0.0)
self.avg_entry = data.get(class="s">"avg_entry")
self.open_ids = data.get(class="s">"open_ids", {})
def _save(self):
STATE_FILE.write_text(json.dumps({
class="s">"position": self.position,
class="s">"avg_entry": self.avg_entry,
class="s">"open_ids": self.open_ids,
class="s">"saved_at": time.time(),
}, indent=class="n">2))
class="c"># ---- reconciliation: make the cache match reality ----------------
def reconcile(self):
class="s">""class="s">"Overwrite local beliefs with what the exchange reports.
Call this at the TOP of every loop, before deciding anything."class="s">""
class="c"># class="n">1) Position comes straight from the exchange balance.
self.position = self.ex.position_amount()
class="c"># class="n">2) Rebuild the set of orders that are actually still open.
live_open = {o[class="s">"id"] for o in self.ex.open_orders()}
self.open_ids = {
cid: oid for cid, oid in self.open_ids.items() if oid in live_open
}
self._save()
return self.position
class="c"># ---- acting ------------------------------------------------------
def enter_long(self, amount, bar_ts):
class="s">""class="s">"Idempotent entry: the client id is fixed for this bar, so a
retry after a dropped response cannot double the position."class="s">""
cid = make_client_id(self.ex.cfg.symbol, class="s">"buy", bar_ts)
if cid in self.open_ids:
return None class="c"># already submitted this bar
order = self.ex.market_buy(amount, client_id=cid)
self.open_ids[cid] = order.get(class="s">"id")
self._save()
return order
def close_all(self, bar_ts):
if self.position <= class="n">0:
return None
cid = make_client_id(self.ex.cfg.symbol, class="s">"sell", bar_ts)
order = self.ex.market_sell(self.position, client_id=cid)
self.open_ids[cid] = order.get(class="s">"id")
self._save()
return orderThe reconciliation loop, visualised
With idempotent entries, honest fill accounting, and per-loop reconciliation, your bot can now be killed, restarted, retried, and half-filled without losing track of what it holds. That resilience is worthless, though, unless the loop itself keeps running through errors and outages — which is exactly what we build next.