
Last Update: October 20, 2026 4 min read
BY
eric
This is the final post in the order book craft track of the ASX order book series. The previous seven posts described what experienced intraday traders look at: depth over charts, protection, the opening auction, gap cause, cross-market leads, fake and hidden size and time-based exits.
All of that is discretionary. A good trader does it by eye in a few seconds. But almost every piece of it can be expressed as a number, and once it's a number we can test whether it actually predicts anything. That's the job of this post.
As I've written before, an indicator is a measurement, not a decision. Everything below is measurement. Whether any of it becomes a strategy depends on what the testing says.
Getting depth data
You need order book snapshots: at a given timestamp, the price and volume at each of the top N levels on each side. For ASX stocks, options include your broker's API if it provides market depth (Interactive Brokers, for example, offers a market depth request, subject to data subscriptions), or a commercial data vendor. Depth data is bulky, so most people capture snapshots at a fixed interval or on every change for a short watchlist.
I'll assume a simple schema:
# one row per snapshot
# ts, code, bid_px_1..N, bid_sz_1..N, ask_px_1..N, ask_sz_1..N
import numpy as np
import pandas as pd
N = 5
BID_PX = [f"bid_px_{i}" for i in range(1, N + 1)]
BID_SZ = [f"bid_sz_{i}" for i in range(1, N + 1)]
ASK_PX = [f"ask_px_{i}" for i in range(1, N + 1)]
ASK_SZ = [f"ask_sz_{i}" for i in range(1, N + 1)]
Feature 1: order book imbalance
The classic measure: how lopsided is the visible depth near the touch?
def imbalance(df: pd.DataFrame, levels: int = 3, decay: float = 0.5) -> pd.Series:
"""Weighted depth imbalance in [-1, 1]. Positive = more bid depth."""
w = np.array([decay ** i for i in range(levels)])
bids = df[BID_SZ[:levels]].to_numpy() @ w
asks = df[ASK_SZ[:levels]].to_numpy() @ w
return pd.Series((bids - asks) / (bids + asks), index=df.index)
The decay weights nearer levels more heavily, because size one tick away matters more than size five ticks away.
Feature 2: microprice
The midpoint ignores size. The microprice tilts the mid toward the side with less depth, because that side is more likely to be consumed next.
def microprice(df: pd.DataFrame) -> pd.Series:
b, a = df["bid_px_1"], df["ask_px_1"]
qb, qa = df["bid_sz_1"], df["ask_sz_1"]
return (b * qa + a * qb) / (qa + qb)
Microprice minus mid, expressed in ticks, is a neat one-number summary of top-of-book pressure.
Feature 3: protection ratio
This is the discretionary idea from the protection post, made explicit: depth behind an entry, relative to how much volume normally trades.
def protection_ratio(df: pd.DataFrame, side: int, recent_vol_per_min: pd.Series,
levels: int = 3) -> pd.Series:
"""side=+1 for a long (protection = bids), -1 for a short (protection = asks).
recent_vol_per_min: rolling average traded volume per minute, aligned to df.
"""
cols = BID_SZ[:levels] if side == 1 else ASK_SZ[:levels]
depth = df[cols].sum(axis=1)
return depth / recent_vol_per_min.replace(0, np.nan)
A ratio of 10 means it would take roughly ten minutes of normal trading to consume the protection. A ratio of 0.5 means it could be gone in thirty seconds.
Feature 4: resistance ahead
The twin of protection: how much stands in the way of the move you want?
def resistance_ratio(df: pd.DataFrame, side: int, levels: int = 3) -> pd.Series:
"""Depth ahead of the trade divided by depth behind it. Lower is better."""
ahead = df[(ASK_SZ if side == 1 else BID_SZ)[:levels]].sum(axis=1)
behind = df[(BID_SZ if side == 1 else ASK_SZ)[:levels]].sum(axis=1)
return ahead / behind.replace(0, np.nan)
Feature 5: wall persistence
To deal with spoofs, measure whether large orders survive. One simple approach: track the maximum single-level size near the touch, and how many consecutive snapshots a level above some size threshold has existed at the same price.
def wall_persistence(df: pd.DataFrame, side: int, threshold: float) -> pd.Series:
"""Consecutive snapshots that a level >= threshold has existed at the same price."""
px_cols, sz_cols = (BID_PX, BID_SZ) if side == 1 else (ASK_PX, ASK_SZ)
wall_px = []
for _, row in df.iterrows():
big = [row[p] for p, s in zip(px_cols, sz_cols) if row[s] >= threshold]
wall_px.append(big[0] if big else np.nan)
wall_px = pd.Series(wall_px, index=df.index)
same = wall_px.eq(wall_px.shift()) & wall_px.notna()
return same.groupby((~same).cumsum()).cumsum()
A wall that has persisted, and especially one that has persisted while volume traded at its price, is more trustworthy than one that just appeared.
Feature 6: auction mispricing
At the open, the most useful single feature from the earlier posts is how far the indicative gap deviates from what the overnight lead and sector peers imply.
def auction_mispricing(indicative_gap: pd.Series, lead_move: pd.Series,
beta: pd.Series, peer_median_gap: pd.Series,
w_lead: float = 0.5) -> pd.Series:
"""Positive = indicated too high versus references (fade candidate)."""
expected = w_lead * beta * lead_move + (1 - w_lead) * peer_median_gap
return indicative_gap - expected
Combined with the auction surplus sign and a protection ratio from the pre-open book, this is a reasonable first attempt at encoding the "is the open mispriced?" judgement.
Labelling: what are we predicting?
Features need a target. For scalping-style questions the natural targets are short horizons:
def forward_return_ticks(mid: pd.Series, tick: float, horizon: str) -> pd.Series:
"""Mid-price change over a forward time horizon, in ticks."""
fwd = mid.shift(freq=f"-{horizon}").reindex(mid.index, method="ffill")
return (fwd - mid) / tick
Use several horizons (5 seconds, 30 seconds, 2 minutes). Different features tend to work at different horizons, and the time-stop post suggests that most of the edge in scalps shows up very early.
Evaluating without fooling yourself
This is the part people skip. A few rules I apply:
A simple first evaluation is to bucket a feature into quantiles and look at the average forward return in each bucket, after costs, with confidence intervals computed per day rather than per snapshot.
def bucket_report(feature: pd.Series, fwd: pd.Series, q: int = 5) -> pd.DataFrame:
df = pd.concat({"f": feature, "y": fwd}, axis=1).dropna()
df["bucket"] = pd.qcut(df["f"], q, labels=False, duplicates="drop")
return df.groupby("bucket")["y"].agg(["count", "mean", "std"])
If the top and bottom buckets don't separate clearly after costs, the feature isn't useful at that horizon, however intuitive it looked.
What I expect to find
I'll be honest about priors. Raw top-of-book imbalance is one of the most studied microstructure signals there is, and most of its edge goes to participants who are much faster than any human or retail system. I don't expect it to survive costs on its own.
What I'm more interested in is the combination the discretionary traders used, at the specific moments they used it: a mispriced auction, plus protection that is large relative to volume, plus little resistance ahead, plus a fast time stop. That's a narrow, event-driven setup, not a continuous signal, and it's where human depth readers seem to have found their edge. Whether it holds up in data is exactly the kind of question I'll be testing in our own systems.
That wraps up the order book craft track. Next, we move to the business side of trading, starting with the most misunderstood pair of numbers in any trading journal.









Comments (0)
Leave a Comment