AlgoPro UniversityCourse home
Part 8 · Lesson

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.

pythonA deterministic, human-readable client order id
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"># identical

Tying 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*.

pythonReading what actually filled, not what you asked for
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.

pythonorder_manager.py — submit, track, and reconcile
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 order

The reconciliation loop, visualised

positions + orderssetnow correctLocal cacheExchange (truth)Reconcile → overwrite cacheDecide + act
Every cycle, local state is thrown away and rebuilt from the exchange before any decision is made. The cache never gets a chance to drift.

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.