Lesson 4 — Order placement & fills (testnet)

Skip to content

Lesson 4 — Order placement & fills (testnet)

Place limit and market orders on exchange testnets, handle partial fills and cancellations, account for slippage and fees, and persist order state for reconciliation.

Estimated time: 60 minutes • Skill level: Beginner → Intermediate

Use this as a starting point. Don’t commit secrets.

Prerequisites

  • Completion of Crypto Lessons 1–3 (env, auth, market data)
  • Testnet API key/secret and working signed_request or CCXT instance
  • Understanding of order types and basic risk limits

Overview

This lesson shows two common approaches:

  • Using CCXT (convenient, unified interface) — great for prototyping
  • Direct signed REST calls / WebSocket (required for exchanges or advanced features)

1) Recommended .env values

# .env
EXCHANGE_API_URL=https://testnet-api.example.com
EXCHANGE_API_KEY=your_testnet_api_key
EXCHANGE_API_SECRET=your_testnet_api_secret
EXCHANGE_WS_URL=wss://testnet-ws.example.com
DEFAULT_MARKET=BTC/USDT
MIN_TRADE_SIZE=0.0001

2) Place orders using CCXT (recommended for testnets)

CCXT simplifies order placement across many exchanges. Example below demonstrates a limit and a market order on a testnet-enabled exchange.

import os
import ccxt
from dotenv import load_dotenv
load_dotenv()

exchange = ccxt.binance({
    'apiKey': os.getenv('EXCHANGE_API_KEY'),
    'secret': os.getenv('EXCHANGE_API_SECRET'),
    'enableRateLimit': True,
})
# enable sandbox/testnet if supported
if hasattr(exchange, 'set_sandbox_mode'):
    exchange.set_sandbox_mode(True)

symbol = os.getenv('DEFAULT_MARKET','BTC/USDT')
# Load markets first
exchange.load_markets()

# 1) Place a limit buy order
order = exchange.create_limit_buy_order(symbol, 0.001, 20000.0)  # size, price
print('Placed limit order:', order)

# 2) Place a market buy (testnet)
order2 = exchange.create_market_buy_order(symbol, 0.001)
print('Placed market order:', order2)

# 3) Check order status
status = exchange.fetch_order(order['id'], symbol)
print('Order status:', status['status'], 'filled:', status.get('filled'))

Note: CCXT fields vary across exchanges. Always inspect the returned order structure and map fields (id, status, filled, remaining, price).

3) Direct signed REST order example

If you’re using a direct provider that requires custom signing, adapt the signed_request helper from Lesson 2 and post to the orders endpoint. Example skeleton:

from auth_hmac import signed_request, resilient_request

def place_limit_order(symbol, side, size, price):
    body = {
      "symbol": symbol,
      "side": side,  # BUY or SELL
      "type": "LIMIT",
      "price": price,
      "quantity": size,
      "timeInForce": "GTC"
    }
    # path for your exchange
    return resilient_request('POST', '/api/v1/orders', json_body=body)

# Example usage
resp = place_limit_order('BTC/USDT', 'BUY', 0.001, 20000.0)
print('Order response:', resp)

Important: Exact body keys differ per exchange. For example Binance uses side, type, quantity, while others may use different names.

4) Interpret order responses & handle partial fills

Order responses usually contain fields: orderId, status (NEW, PARTIALLY_FILLED, FILLED, CANCELED), filledQty, remainingQty. Use those to update your order store.

def interpret_order(resp):
    # Normalize common fields
    return {
        'order_id': resp.get('id') or resp.get('orderId') or resp.get('clientOrderId'),
        'status': resp.get('status'),
        'filled': float(resp.get('filledQty') or resp.get('filled') or 0),
        'remaining': float(resp.get('remainingQty') or resp.get('remaining') or 0),
        'price': float(resp.get('price') or resp.get('avgPrice') or 0),
        'raw': resp
    }

Partial fills: If ‘remaining’ > 0 you can cancel the rest, reprice, or wait. Record fills immediately for P/L and reconciliation.

5) Cancel/replace and idempotency

Some exchanges support direct modify/replace; otherwise implement cancel followed by new order. Use client-side idempotency keys (clientOrderId) when supported.

# CCXT cancel example
cancel_resp = exchange.cancel_order(order['id'], symbol)
print('Cancel response:', cancel_resp)

# Cancel + replace pattern
def cancel_and_replace(order_id, symbol, new_size, new_price):
    exchange.cancel_order(order_id, symbol)
    return exchange.create_limit_buy_order(symbol, new_size, new_price)

Warning: Race conditions: an order may fill between your cancel and replace. Use idempotency and reconcile with the exchange’s order history.

6) Fees, slippage & execution models

Always account for fees and expected slippage:

  • Subtract taker/maker fees when computing expected P/L
  • Estimate slippage based on current orderbook available size at target price
  • Use limit orders to control price, market orders to prioritize immediacy
# simple slippage estimate using orderbook snapshot
def estimate_slippage(orderbook, side, desired_size):
    # orderbook: {'bids':[[price,size],...],'asks':[[price,size],...]]}
    remaining = desired_size
    avg_price = 0.0
    if side == 'BUY':
        asks = orderbook.get('asks',[])
        for price, size in asks:
            take = min(remaining, size)
            avg_price += float(price) * take
            remaining -= take
            if remaining <= 0: break
    else:
        bids = orderbook.get('bids',[])
        for price, size in bids:
            take = min(remaining, size)
            avg_price += float(price) * take
            remaining -= take
            if remaining <= 0: break
    if remaining > 0:
        return None  # insufficient depth
    return avg_price / desired_size

Tip: Use small test sizes on testnet to measure real slippage behavior before trusting model estimates.

7) Persistent order store & reconciliation

Persist each order record with timestamps, idempotency keys, responses and fill updates. Reconcile by fetching exchange order history and matching by clientOrderId/orderId.

import json
from pathlib import Path

ORDERS_LOG = Path('./orders_crypto.json')

def log_order(record):
    arr = []
    if ORDERS_LOG.exists():
        try:
            arr = json.loads(ORDERS_LOG.read_text())
        except Exception:
            arr = []
    arr.append(record)
    ORDERS_LOG.write_text(json.dumps(arr, indent=2))

Reconcile job: run a periodic job: fetch recent trades/orders from exchange, match local records by clientOrderId, update filled/remaining and detect orphan remote orders.

8) Safety & test tips

  • Start with the smallest test size on live; use testnet for full flow validation.
  • Never hardcode secrets; keep in .env or a secrets manager.
  • Implement circuit breakers (max failed orders, abnormal fees/spreads) and logging/alerts.

What you’ll build next

Lesson 5 will wire webhook alerts (TradingView Pine) to your crypto webhook receiver and show how to validate and forward signals to the order placement layer.

Back to course hub

← Previous
Author: Stephane Patteux • Part of the Build your own bot series

Don’t miss our blog post!

We don’t spam! Read our privacy policy for more info.

Leave a Reply

Your email address will not be published. Required fields are marked *