“The fill arrived at $100,060. The signal expected $100,000.”

That $60 difference looks trivial in isolation, but a five-basis-point execution penalty repeated across thousands of orders becomes a measurable drag on realized returns. The more important question is whether the market moved, the order was oversized, or the execution route failed.

Live trading exposes a backtest’s most fragile assumption: that an order executes at the price shown when the signal was generated. In production, network latency, order-book movement, partial fills, venue routing, and exchange-side delays all separate signal price from execution price.

This article builds a production-oriented slippage monitor that calculates adverse execution slippage, adapts thresholds with a rolling market-liquidity window, polls broker order state, and sends deduplicated webhook alerts. The reference implementation uses Binance Spot as a concrete adapter, while the design applies to other broker and exchange APIs.

1. The Problem: A Signal Price Is Not a Fill Price

A signal records an intended price at a specific decision time. A fill records the quantity and price actually executed by the venue or broker. Comparing them without controlling for timestamps, side, fees, and partial execution produces misleading alerts.

The monitoring system must answer five distinct questions:

  1. Was the fill adverse? Did the buy execute above the signal price, or did the sell execute below it?
  2. Was the slippage unusual? Was the execution cost above the strategy’s normal distribution for that symbol and order size?
  3. Was the signal stale? Did the order enter the book long after the reference price became invalid?
  4. Was the fill complete? Was this a partial execution that later worsened?
  5. Was the alert actionable? Did the execution engine, symbol, account, and direction match the active strategy?

The distinction between “slippage” and “transaction cost” matters. Slippage is the difference between the expected reference price and the execution price. Trading fees, funding, and spread crossing are separate cost components. A monitor that mixes them will either hide genuine execution defects or trigger on every ordinary fee.

2. Slippage Metrics and Quantified Failure Cases

2.1 Signed execution difference

For a buy order, define adverse slippage as:

[
\text{slippage}_{bps}

\frac{\text{fill price} - \text{signal price}}
{\text{signal price}}
\times 10{,}000
]

For a sell order, reverse the comparison:

[
\text{slippage}_{bps}

\frac{\text{signal price} - \text{fill price}}
{\text{signal price}}
\times 10{,}000
]

A positive value represents adverse execution. A negative value represents a price improvement.

The implementation uses side-adjusted slippage so that one threshold and one alert rule work for both directions. Raw price differences remain unsuitable because the same $60 difference represents different costs at $100 and $100,000.

2.2 Weighted fill price

A partially filled order can contain multiple executions. Comparing only the latest fill can produce misleading results. The monitor calculates the quantity-weighted average fill:

[
\bar{P}_{fill}

\frac{\sum_i q_iP_i}{\sum_iq_i}
]

The order endpoint’s cumulative quote volume and executed quantity provide the same aggregate for systems that do not expose individual fills.

2.3 Illustrative alert decisions

The following values illustrate how the calculation works. They are not empirical performance results.

Order Side Signal price Average fill Adverse slippage Rolling threshold Result
A Buy $100,000 $100,060 6.0 bps 6.0 bps No alert
B Buy $100,000 $100,075 7.5 bps 6.0 bps Alert
C Sell $100,000 $99,920 8.0 bps 6.0 bps Alert
D Buy $100,000 $99,970 −3.0 bps 6.0 bps No alert
E Buy $100,000 $100,090 9.0 bps 6.0 bps Alert: stale signal

Order D represents favorable execution. The monitor should not convert a price improvement into an incident merely because the absolute difference exceeded a naïve threshold.

2.4 What slippage level should trigger an alert?

There is no universal “5 bps” or “10 bps” threshold. The appropriate threshold depends on:

  • Bid-ask spread and market depth
  • Symbol volatility
  • Order size relative to available liquidity
  • Participation rate
  • Signal-to-order latency
  • Venue and execution route
  • Time of day
  • Historical fill dispersion for comparable orders

A useful starting framework is:

[
T_t

\max
\left(
T_{floor},
S_{95,t}\times M,
S_{99,historical}\times B
\right)
]

Where:

  • (T_{floor}) is a minimum operational threshold, such as 5 bps.
  • (S_{95,t}) is the 95th percentile spread over the preceding rolling window.
  • (M) is a spread multiplier, commonly between 1.5 and 3.0.
  • (S_{99,historical}) is the historical 99th percentile slippage for comparable orders.
  • (B) is a buffer for latency, size, and regime changes.

A separate hard ceiling should catch events that breach risk policy even if the rolling threshold expands during unusually volatile conditions.

2.5 Do not adapt the threshold after the fact

The threshold must be calculated from information available before the fill. If a severe fill enters the rolling dataset and immediately raises the threshold, the system can normalize the loss it was supposed to detect.

The monitor therefore snapshots the threshold at evaluation time. A separate calibration process can update historical baselines on a slower schedule.

3. Monitoring Architecture

The monitoring service sits between the strategy’s signal record and the broker’s execution state. It does not need to intercept every exchange event when a reliable order-status endpoint is available.

┌────────────────────┐
│ Strategy / Signal   │
│ Generator          │
└─────────┬──────────┘
          │ signal price, side, order ID, timestamp
          ▼
┌────────────────────┐
│ Signal Registry     │◄──────────────────────────┐
│ SQLite / Postgres  │                           │
└─────────┬──────────┘                           │
          │                                      │
          ▼                                      │
┌────────────────────┐                           │
│ Execution Router    │                           │
└─────────┬──────────┘                           │
          │ place / replace order                │
          ▼                                      │
┌────────────────────┐                           │
│ Broker / Exchange   │                           │
│ Order State API    │                           │
└─────────┬──────────┘                           │
          │ status + executed qty + avg fill      │
          ▼                                      │
┌────────────────────┐                           │
│ Slippage Evaluator │                           │
│ - side adjustment │                           │
│ - partial fills   │                           │
│ - stale signals   │                           │
└──────┬───────┬─────┘                           │
       │       │                                 │
       ▼       ▼                                 │
┌──────────┐ ┌────────────────────┐              │
│ Threshold│ │ Alert Dispatcher   │──────────────┘
│ Window   │ │ - dedupe           │
│ - spread │ │ - retry            │
│ - p95    │ │ - webhook          │
└──────────┘ └────────────────────┘

3.1 Component responsibilities

Component Responsibility
Signal registry Persist the reference price, side, order ID, signal time, and expected quantity
Execution router Place or modify orders through the broker or exchange
Order-state adapter Retrieve order status, executed quantity, and average fill price
Slippage evaluator Apply the side-aware formula and partial-fill rules
Threshold window Maintain a time-based rolling distribution of observed spread
Alert dispatcher Deduplicate events, retry transient failures, and route notifications
State store Prevent duplicate alerts and preserve audit records

3.2 Execution events must be idempotent

Order updates can arrive more than once, and partial fills can be reported after a later state. Every alert should have a deterministic deduplication key, such as:

{account}:{order_id}:{fill_status}:{cumulative_executed_quantity}

A reconnect must not generate a second incident for an event already processed.

3.3 Clock alignment matters

A five-second difference between the strategy clock and the broker clock can invalidate a comparison even when the prices match. Production deployments should use:

  • NTP or chrony synchronization
  • UTC timestamps
  • Monotonic clocks for internal durations
  • Explicit exchange or broker event timestamps
  • A maximum tolerated clock offset

4. Production-Grade Reference Implementation

The following Python service accepts signed execution alerts, polls Binance Spot order state, calculates side-adjusted slippage, maintains a rolling spread window, and sends webhook alerts.

4.1 Dependencies

pip install fastapi "uvicorn[standard]" aiohttp websockets

4.2 Required environment variables

export BINANCE_API_KEY="your-api-key"
export BINANCE_API_SECRET="your-api-secret"
export SIGNAL_TOKEN="a-long-random-token"
export ALERT_WEBHOOK="https://your-alert-service.example/slippage"
export MIN_SLIPPAGE_BPS="5"
export SPREAD_MULTIPLIER="1.5"
export MAX_SIGNAL_AGE_SECONDS="30"

The API secret, signal token, and webhook URL must remain outside source control. The service should run under a dedicated system account with a read-only API key unless order-management operations are required separately.

4.3 Complete service

import asyncio
import contextlib
import hashlib
import hmac
import json
import logging
import os
import secrets
import sqlite3
import statistics
import time
import urllib.parse
from collections import deque
from datetime import datetime, timezone
from typing import Literal

import aiohttp
import websockets
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel, Field


logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
)

BINANCE_API_KEY = os.environ.get("BINANCE_API_KEY")
BINANCE_API_SECRET = os.environ.get("BINANCE_API_SECRET")
SIGNAL_TOKEN = os.environ.get("SIGNAL_TOKEN")
ALERT_WEBHOOK = os.environ.get("ALERT_WEBHOOK")

MIN_SLIPPAGE_BPS = float(os.environ.get("MIN_SLIPPAGE_BPS", "5"))
SPREAD_MULTIPLIER = float(os.environ.get("SPREAD_MULTIPLIER", "1.5"))
MAX_SIGNAL_AGE_SECONDS = float(os.environ.get("MAX_SIGNAL_AGE_SECONDS", "30"))

BINANCE_REST_URL = "https://api.binance.com"
BINANCE_WS_URL = "wss://stream.binance.com:9443/ws"
SPREAD_WINDOW_SECONDS = 300

if not all(
    [
        BINANCE_API_KEY,
        BINANCE_API_SECRET,
        SIGNAL_TOKEN,
        ALERT_WEBHOOK,
    ]
):
    raise RuntimeError(
        "BINANCE_API_KEY, BINANCE_API_SECRET, SIGNAL_TOKEN, and "
        "ALERT_WEBHOOK must be set"
    )


def now_utc() -> datetime:
    return datetime.now(timezone.utc)


def parse_timestamp(value: str) -> datetime:
    parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=timezone.utc)
    return parsed.astimezone(timezone.utc)


def side_adjusted_slippage_bps(
    side: Literal["buy", "sell"],
    signal_price: float,
    fill_price: float,
) -> float:
    """Return positive values for adverse execution."""
    if signal_price <= 0 or fill_price <= 0:
        raise ValueError("Signal and fill prices must be positive")

    if side == "buy":
        return ((fill_price - signal_price) / signal_price) * 10_000
    return ((signal_price - fill_price) / signal_price) * 10_000


class BinanceError(RuntimeError):
    pass


class BinanceIpBan(BinanceError):
    pass


class AsyncRequestLimiter:
    """Limit private REST requests to avoid avoidable rate-limit events."""

    def __init__(self, requests_per_second: float = 5.0):
        self.minimum_interval = 1.0 / requests_per_second
        self.lock = asyncio.Lock()
        self.next_allowed = 0.0

    async def acquire(self):
        async with self.lock:
            wait_for = self.next_allowed - time.monotonic()
            if wait_for > 0:
                await asyncio.sleep(wait_for)

            self.next_allowed = time.monotonic() + self.minimum_interval


class BinanceRestClient:
    def __init__(self, session: aiohttp.ClientSession):
        self.session = session
        self.limiter = AsyncRequestLimiter(requests_per_second=5.0)

    async def signed_get(
        self,
        path: str,
        params: dict | None = None,
    ) -> dict:
        await self.limiter.acquire()

        for attempt in range(5):
            request_params = dict(params or {})
            request_params["timestamp"] = int(time.time() * 1000)
            request_params["recvWindow"] = 5_000

            query = urllib.parse.urlencode(request_params)
            signature = hmac.new(
                BINANCE_API_SECRET.encode("utf-8"),
                query.encode("utf-8"),
                hashlib.sha256,
            ).hexdigest()

            url = f"{BINANCE_REST_URL}{path}?{query}&signature={signature}"
            headers = {"X-MBX-APIKEY": BINANCE_API_KEY}

            try:
                async with self.session.get(
                    url,
                    headers=headers,
                    timeout=aiohttp.ClientTimeout(total=10),
                ) as response:
                    if response.status == 429:
                        retry_after = float(
                            response.headers.get("Retry-After", "5")
                        )
                        await asyncio.sleep(retry_after)
                        continue

                    if response.status == 418:
                        # ⚠️ Stop repeated calls and investigate the IP ban.
                        raise BinanceIpBan(
                            "Binance returned 418; suspend requests and inspect "
                            "the account/network policy"
                        )

                    if response.status >= 500:
                        delay = min(2**attempt, 30)
                        jitter = secrets.randbelow(101) / 1000
                        await asyncio.sleep(delay + jitter)
                        continue

                    response.raise_for_status()
                    return await response.json()
            except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
                if attempt == 4:
                    raise BinanceError(
                        f"Binance request failed after retries: {exc}"
                    ) from exc

                delay = min(2**attempt, 30)
                jitter = secrets.randbelow(101) / 1000
                await asyncio.sleep(delay + jitter)

        raise BinanceError("Binance request exhausted all retry attempts")

    async def get_order(
        self,
        symbol: str,
        order_id: int,
    ) -> dict:
        return await self.signed_get(
            "/api/v3/order",
            {"symbol": symbol.upper(), "orderId": order_id},
        )


class SpreadWindow:
    """Maintain a time-based rolling distribution of quoted spread."""

    def __init__(self, window_seconds: int = SPREAD_WINDOW_SECONDS):
        self.window_seconds = window_seconds
        self.observations: deque[tuple[float, float]] = deque()
        self.lock = asyncio.Lock()

    async def add(self, bid: float, ask: float):
        if bid <= 0 or ask <= 0 or ask < bid:
            return

        midpoint = (bid + ask) / 2
        spread_bps = ((ask - bid) / midpoint) * 10_000
        timestamp = time.monotonic()

        async with self.lock:
            self.observations.append((timestamp, spread_bps))
            cutoff = timestamp - self.window_seconds

            while self.observations and self.observations[0][0] < cutoff:
                self.observations.popleft()

    async def threshold_bps(self) -> float:
        async with self.lock:
            values = [value for _, value in self.observations]

        if not values:
            return MIN_SLIPPAGE_BPS

        values.sort()
        percentile_index = min(
            len(values) - 1,
            int(0.95 * (len(values) - 1)),
        )
        spread_p95 = values[percentile_index]

        return max(
            MIN_SLIPPAGE_BPS,
            spread_p95 * SPREAD_MULTIPLIER,
        )


class TickerStream:
    """Maintain best bid/ask state using Binance's bookTicker stream."""

    def __init__(self, symbol: str, spread_window: SpreadWindow):
        self.symbol = symbol.upper()
        self.spread_window = spread_window
        self.bid: float | None = None
        self.ask: float | None = None

    async def run(self):
        reconnect_delay = 1

        while True:
            try:
                url = (
                    f"{BINANCE_WS_URL}/"
                    f"{self.symbol.lower()}@bookTicker"
                )

                async with websockets.connect(
                    url,
                    ping_interval=20,
                    ping_timeout=20,
                    close_timeout=5,
                    max_queue=1_000,
                ) as websocket:
                    reconnect_delay = 1
                    heartbeat_task = asyncio.create_task(
                        self.heartbeat(websocket)
                    )

                    try:
                        async for message in websocket:
                            payload = json.loads(message)

                            if payload.get("method") in {
                                "PONG",
                                "pong",
                            }:
                                continue

                            try:
                                bid = float(payload["b"])
                                ask = float(payload["a"])
                            except (KeyError, TypeError, ValueError):
                                continue

                            self.bid = bid
                            self.ask = ask
                            await self.spread_window.add(bid, ask)
                    finally:
                        heartbeat_task.cancel()
                        with contextlib.suppress(asyncio.CancelledError):
                            await heartbeat_task

            except asyncio.CancelledError:
                raise
            except Exception:
                logging.exception("Ticker WebSocket disconnected")
                jitter = secrets.randbelow(1001) / 1000
                await asyncio.sleep(reconnect_delay + jitter)
                reconnect_delay = min(reconnect_delay * 2, 30)

    async def heartbeat(self, websocket):
        """Send an application-level heartbeat in addition to protocol pings."""
        while True:
            await asyncio.sleep(20)
            await websocket.send(
                json.dumps(
                    {
                        "id": int(time.time() * 1000),
                        "method": "PING",
                    }
                )
            )


class StateStore:
    def __init__(self, path: str = "slippage_monitor.db"):
        self.connection = sqlite3.connect(path, check_same_thread=False)
        self.connection.row_factory = sqlite3.Row
        self.lock = asyncio.Lock()

        self.connection.executescript(
            """
            PRAGMA journal_mode=WAL;

            CREATE TABLE IF NOT EXISTS signals (
                order_id INTEGER PRIMARY KEY,
                symbol TEXT NOT NULL,
                side TEXT NOT NULL,
                signal_price REAL NOT NULL,
                expected_qty REAL NOT NULL,
                signal_time TEXT NOT NULL,
                status TEXT NOT NULL DEFAULT 'pending'
            );

            CREATE TABLE IF NOT EXISTS alerts (
                dedupe_key TEXT PRIMARY KEY,
                created_at TEXT NOT NULL,
                payload TEXT NOT NULL,
                delivered INTEGER NOT NULL DEFAULT 0
            );
            """
        )
        self.connection.commit()

    async def insert_signal(self, signal: dict):
        async with self.lock:
            self.connection.execute(
                """
                INSERT OR IGNORE INTO signals (
                    order_id, symbol, side, signal_price,
                    expected_qty, signal_time, status
                ) VALUES (?, ?, ?, ?, ?, ?, 'pending')
                """,
                (
                    signal["order_id"],
                    signal["symbol"].upper(),
                    signal["side"],
                    signal["signal_price"],
                    signal["expected_qty"],
                    signal["signal_time"],
                ),
            )
            self.connection.commit()

    async def pending_signals(self) -> list[dict]:
        async with self.lock:
            rows = self.connection.execute(
                """
                SELECT * FROM signals
                WHERE status = 'pending'
                ORDER BY signal_time
                """
            ).fetchall()

        return [dict(row) for row in rows]

    async def mark_signal_complete(self, order_id: int):
        async with self.lock:
            self.connection.execute(
                "UPDATE signals SET status = 'done' WHERE order_id = ?",
                (order_id,),
            )
            self.connection.commit()

    async def record_alert(self, dedupe_key: str, payload: dict) -> bool:
        async with self.lock:
            cursor = self.connection.execute(
                """
                INSERT OR IGNORE INTO alerts (
                    dedupe_key, created_at, payload, delivered
                ) VALUES (?, ?, ?, 0)
                """,
                (
                    dedupe_key,
                    now_utc().isoformat(),
                    json.dumps(payload, sort_keys=True),
                ),
            )
            self.connection.commit()
            return cursor.rowcount == 1

    async def undelivered_alerts(self) -> list[dict]:
        async with self.lock:
            rows = self.connection.execute(
                """
                SELECT dedupe_key, payload
                FROM alerts
                WHERE delivered = 0
                ORDER BY created_at
                """
            ).fetchall()

        return [dict(row) for row in rows]

    async def mark_alert_delivered(self, dedupe_key: str):
        async with self.lock:
            self.connection.execute(
                "UPDATE alerts SET delivered = 1 WHERE dedupe_key = ?",
                (dedupe_key,),
            )
            self.connection.commit()


class Signal(BaseModel):
    order_id: int = Field(gt=0)
    symbol: str = Field(min_length=3, max_length=20)
    side: Literal["buy", "sell"]
    signal_price: float = Field(gt=0)
    expected_qty: float = Field(gt=0)
    signal_time: datetime


async def send_webhook(session: aiohttp.ClientSession, payload: dict):
    for attempt in range(5):
        try:
            async with session.post(
                ALERT_WEBHOOK,
                json=payload,
                timeout=aiohttp.ClientTimeout(total=10),
            ) as response:
                if response.status == 429:
                    retry_after = float(
                        response.headers.get("Retry-After", "5")
                    )
                    await asyncio.sleep(retry_after)
                    continue

                if response.status >= 500:
                    delay = min(2**attempt, 30)
                    jitter = secrets.randbelow(1001) / 1000
                    await asyncio.sleep(delay + jitter)
                    continue

                response.raise_for_status()
                return
        except (aiohttp.ClientError, asyncio.TimeoutError):
            if attempt == 4:
                raise

            delay = min(2**attempt, 30)
            jitter = secrets.randbelow(1001) / 1000
            await asyncio.sleep(delay + jitter)

    raise RuntimeError("Alert webhook exhausted all retry attempts")


app = FastAPI(title="Live Slippage Monitor")
state = StateStore()


@app.get("/health")
async def health():
    return {"status": "ok"}


@app.post("/signals")
async def register_signal(
    signal: Signal,
    x_signal_token: str = Header(default=""),
):
    if not secrets.compare_digest(x_signal_token, SIGNAL_TOKEN):
        raise HTTPException(status_code=401, detail="Invalid signal token")

    if signal.side not in {"buy", "sell"}:
        raise HTTPException(status_code=422, detail="Invalid side")

    await state.insert_signal(
        {
            "order_id": signal.order_id,
            "symbol": signal.symbol,
            "side": signal.side,
            "signal_price": signal.signal_price,
            "expected_qty": signal.expected_qty,
            "signal_time": signal.signal_time.astimezone(
                timezone.utc
            ).isoformat(),
        }
    )

    return {"status": "accepted", "order_id": signal.order_id}


async def monitor_loop(
    client: BinanceRestClient,
    spread_window: SpreadWindow,
    session: aiohttp.ClientSession,
):
    while True:
        try:
            for alert in await state.undelivered_alerts():
                await send_webhook(session, json.loads(alert["payload"]))
                await state.mark_alert_delivered(alert["dedupe_key"])

            for signal in await state.pending_signals():
                order = await client.get_order(
                    signal["symbol"],
                    signal["order_id"],
                )

                status = order.get("status")
                if status not in {"PARTIALLY_FILLED", "FILLED"}:
                    continue

                executed_qty = float(order.get("executedQty", "0"))
                cumulative_quote = float(
                    order.get("cummulativeQuoteQty", "0")
                )

                if executed_qty <= 0 or cumulative_quote <= 0:
                    continue

                average_fill = cumulative_quote / executed_qty
                adverse_bps = side_adjusted_slippage_bps(
                    signal["side"],
                    signal["signal_price"],
                    average_fill,
                )

                threshold_bps = await spread_window.threshold_bps()
                signal_time = parse_timestamp(signal["signal_time"])
                signal_age = (
                    now_utc() - signal_time
                ).total_seconds()

                stale_signal = signal_age > MAX_SIGNAL_AGE_SECONDS

                if adverse_bps > threshold_bps or stale_signal:
                    payload = {
                        "type": "execution_slippage",
                        "order_id": signal["order_id"],
                        "symbol": signal["symbol"],
                        "side": signal["side"],
                        "signal_price": signal["signal_price"],
                        "average_fill_price": average_fill,
                        "executed_qty": executed_qty,
                        "adverse_slippage_bps": round(adverse_bps, 4),
                        "threshold_bps": round(threshold_bps, 4),
                        "signal_age_seconds": round(signal_age, 3),
                        "stale_signal": stale_signal,
                        "order_status": status,
                        "detected_at": now_utc().isoformat(),
                    }

                    execution_marker = (
                        f"{executed_qty:.12f}"
                    )
                    dedupe_key = (
                        f"{signal['order_id']}:{status}:{execution_marker}"
                    )

                    await state.record_alert(dedupe_key, payload)

                if status == "FILLED":
                    await state.mark_signal_complete(
                        signal["order_id"]
                    )

            await asyncio.sleep(1)
        except asyncio.CancelledError:
            raise
        except Exception:
            logging.exception("Monitor iteration failed")
            await asyncio.sleep(3)


@app.on_event("startup")
async def startup():
    app.state.session = aiohttp.ClientSession()
    app.state.client = BinanceRestClient(app.state.session)

    symbol = os.environ.get("MONITOR_SYMBOL", "BTCUSDT").upper()
    spread_window = SpreadWindow()

    app.state.ticker = TickerStream(symbol, spread_window)
    app.state.ticker_task = asyncio.create_task(
        app.state.ticker.run()
    )
    app.state.monitor_task = asyncio.create_task(
        monitor_loop(
            app.state.client,
            spread_window,
            app.state.session,
        )
    )


@app.on_event("shutdown")
async def shutdown():
    app.state.monitor_task.cancel()
    app.state.ticker_task.cancel()

    with contextlib.suppress(asyncio.CancelledError):
        await app.state.monitor_task
    with contextlib.suppress(asyncio.CancelledError):
        await app.state.ticker_task

    await app.state.session.close()

4.4 Operational warnings

The reference implementation uses synchronous SQLite calls because the expected signal rate is low. A high-frequency strategy should move the state to PostgreSQL, Redis Streams, or another durable event store.

The service also assumes a single monitor instance. A multi-instance deployment requires a distributed lock or atomic claim around alert delivery. The current INSERT OR IGNORE operation prevents duplicate database records, but it does not guarantee exactly-once webhook delivery across competing processes.

4.5 Run the service

uvicorn slippage_monitor:app --host 0.0.0.0 --port 8000

Register a signal with:

curl -X POST http://127.0.0.1:8000/signals \
  -H 'Content-Type: application/json' \
  -H 'X-Signal-Token: your-long-random-token' \
  -d '{
    "order_id": 123456789,
    "symbol": "BTCUSDT",
    "side": "buy",
    "signal_price": 100000,
    "expected_qty": 0.01,
    "signal_time": "2026-04-15T10:03:15.000Z"
  }'

The order ID must correspond to an order already visible through the authenticated Binance account.

5. The Core Algorithm

5.1 Signal creation

The execution router should write a signal record before submitting an order whenever possible. The record must include:

Field Purpose
Strategy ID Prevent alerts from unrelated strategies being merged
Account ID Support multi-account deployments
Order ID Correlate the signal with broker state
Client order ID Provide an idempotent fallback identifier
Symbol Normalize broker and exchange symbols
Side Apply the correct adverse-slippage direction
Signal price Establish the execution reference
Signal timestamp Measure latency and detect stale orders
Expected quantity Detect partial or overfills
Signal type Separate take-profit, rebalance, stop, and test orders
Metadata Store the triggering market condition

If the order is submitted before the signal is persisted, a process crash can create an unmatched order. If the signal is persisted but the order submission fails, the monitor should age out the unmatched record rather than query it indefinitely.

5.2 Evaluation sequence

The evaluation order prevents incorrect alerts:

  1. Match the order to a known signal.
  2. Confirm that the symbol and side agree.
  3. Confirm that the order belongs to the expected account.
  4. Determine whether the order is partially or fully filled.
  5. Calculate the quantity-weighted average fill.
  6. Calculate side-adjusted adverse slippage.
  7. Calculate the threshold using pre-fill market state.
  8. Check the signal-age limit.
  9. Deduplicate the event.
  10. Persist and deliver the alert.

5.3 Partial fills need explicit treatment

A partial fill can produce a misleadingly attractive average price. For example, an order may fill 90% at the expected price and the final 10% at a much worse price.

A robust monitor should either:

  • Evaluate every cumulative fill state independently, or
  • Evaluate partial fills with a lower threshold and final fills with the normal threshold.

The reference implementation reports both partial and final states under distinct deduplication keys.

5.4 Rejecting anomalous input

The monitor should not calculate a threshold from invalid data. Reject or quarantine:

  • Non-positive prices or quantities
  • Unknown sides
  • Fill prices outside a plausible instrument range
  • Orders with no matching signal
  • Duplicate client order IDs
  • Signal timestamps in the future beyond the clock-skew tolerance
  • Symbols that cannot be normalized consistently

5.5 Fees remain separate

The alert payload may include exchange-reported commission, but commission must not be added to the slippage calculation unless the metric is explicitly labeled “total execution cost.”

Separating the components improves diagnosis:

  • High slippage with normal fees indicates routing or liquidity problems.
  • Normal slippage with unexpected fees indicates account or token-configuration issues.
  • Both elevated indicates broader execution degradation.

6. Threshold Calibration Without Overfitting

A fixed threshold is easy to operate but produces poor signal quality. A fully adaptive threshold can hide persistent deterioration by reacting too quickly.

6.1 Use a layered baseline

Calibrate thresholds using four layers:

  1. Instrument floor: Covers normal spread and minimum quote granularity.
  2. Rolling market layer: Uses the current symbol’s p95 spread.
  3. Historical execution layer: Uses p99 slippage for comparable orders.
  4. Risk ceiling: Triggers regardless of the rolling market state.

For example:

Threshold = max(
    instrument_floor_bps,
    rolling_p95_spread_bps * 1.5,
    historical_p99_slippage_bps * 1.25,
)

6.2 Segment comparable orders

The historical distribution should distinguish at least:

  • Symbol
  • Buy versus sell
  • Order-size bucket
  • Time-of-day session
  • Market order versus limit order
  • Ordinary strategy orders versus emergency exits

Mixing large market orders with small passive orders produces a baseline that describes neither group well.

6.3 Update schedules

Different components require different update speeds:

Component Recommended update cadence
Bid-ask spread window Every market-data update
Order-size execution baseline Daily
Symbol volatility adjustment Hourly or daily
Risk-policy ceiling Deliberate manual changes
Alert suppression statistics Weekly retrospective

6.4 Backtest and replay the monitor

The monitor itself should be replay-tested against historical order and fill logs. The test should verify:

  • Formula correctness
  • Buy and sell direction handling
  • Partial-fill aggregation
  • Alert deduplication
  • Threshold timing
  • Stale-signal detection
  • Webhook retry behavior

A backtest that assumes zero slippage must add a calibrated cost distribution rather than a fixed penalty when estimating net performance. Report the sample period, number of executions, gross and net win rates, profit factor, Sharpe ratio, maximum drawdown, benchmark, and assumed fees and slippage.

Backtest limitations: Historical simulation cannot reproduce every queue-position, venue-rejection, funding, or liquidity-exhaustion effect. Validate the cost model with out-of-sample periods and live shadow monitoring before relying on it for production allocation.

7. Deployment and Operations

7.1 Deployment by user segment

User segment Recommended deployment Alert policy
Individual developer Single service, SQLite, one symbol, webhook to a chat channel Warn above p95 spread; escalate after repeated warnings
Small quantitative team PostgreSQL, isolated execution accounts, structured logs, per-strategy dashboards Page the owning strategy owner; require incident acknowledgment
Institutional team Redundant collectors, centralized event bus, immutable audit log, distributed claim processing Page operations; block affected execution routes after policy breach
Market maker or high-frequency team Venue-native event streams, colocated collectors, nanosecond clock discipline Route violations to real-time risk controls rather than chat alerts

7.2 Alert routing

A useful escalation path is:

Severity Example Response
Informational Slippage reached the rolling threshold Log and dashboard
Warning Two consecutive threshold breaches Notify the strategy owner
Critical Hard ceiling breached or signal became stale Page operations
Execution freeze Repeated severe breaches across symbols Disable the affected route for human review

The system should not automatically stop all trading after a single event. A route-level response is safer because one symbol can experience a temporary liquidity event without affecting the entire portfolio.

7.3 Required observability

Monitor both business and infrastructure metrics:

  • Signals registered per minute
  • Orders matched to signals
  • Fill-state polling latency
  • Median and p95 adverse slippage
  • Alert count by strategy and symbol
  • Duplicate-event suppression count
  • Broker request latency and error rate
  • Rate-limit waits
  • Webhook delivery latency and failure count
  • Time from signal creation to order acceptance
  • Time from order acceptance to fill

7.4 Security checklist

The deployment should enforce:

  • API keys stored in a secret manager
  • Read-only permissions where order submission is unnecessary
  • Restricted ingress on the signal endpoint
  • Rotation procedures for API keys and webhook credentials
  • TLS for all external connections
  • Structured audit records without secret leakage
  • Network restrictions for broker and alert endpoints
  • Separate credentials per account or environment

7.5 Validate with a shadow deployment

The safest rollout begins with monitoring only:

  1. Replay historical orders through the evaluator.
  2. Run the monitor against live orders without automated execution controls.
  3. Compare alerts with known routing incidents.
  4. Adjust thresholds over several trading sessions.
  5. Add hard ceilings and route suspension.
  6. Review the first month of alerts for duplicate or low-value events.

Historical OHLCV data can help establish baseline spread and volatility regimes through TickDB’s /v1/market/kline endpoint. Historical bars remain appropriate for completed-period analysis, while live monitoring should use current execution and market-state data.

8. Closing and Next Steps

“The fill arrived at $100,060. The signal expected $100,000.”

That difference becomes useful only when the monitor can distinguish ordinary spread from an execution anomaly, partial fill from final fill, and a stale signal from genuine price deterioration.

The core operating principle is simple: calculate side-adjusted slippage against the correct reference price, evaluate it against a pre-fill adaptive threshold, and deliver only deduplicated, actionable alerts. Start with a small shadow deployment and calibrated historical baseline before allowing execution controls to depend on the monitor.

Next Steps

If you are an individual quantitative developer, begin with one symbol, one strategy, and a webhook destination. Establish the floor threshold, add the rolling spread component, and observe the alert distribution before enabling automated responses.

If you operate a production execution system, persist signal and order state outside the monitoring process, add distributed alert claims, and test broker outages, stale signals, partial fills, and duplicate events.

If you need multi-year historical OHLCV baselines for cost calibration, visit tickdb.ai to review the available data and institutional options.

If you use AI coding assistants, search for and install the tickdb-market-data SKILL in your tool’s marketplace.

This article does not constitute investment advice. Markets involve risk; past performance does not guarantee future results.