preloader
post-thumb

Last Update: October 20, 2026 4 min read


BYauthor-thumberic

|Loading...

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:

python
# 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?

python
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.

python
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.

python
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?

python
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.

python
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.

python
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:

python
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:

Pitfall
Guard
Lookahead
Only use information available at the snapshot timestamp. Rolling volumes must be trailing.
Mid-price fantasy
Mid moves are not tradeable. Re-test with entry at the touch you would actually pay.
Costs
Subtract brokerage and the spread. Many imbalance signals vanish after costs.
Latency
Add a realistic delay between signal and order. A one second delay changes a lot.
Overlapping samples
Snapshots seconds apart are not independent. Evaluate per event or per day.
Regime
Split by period and by market condition. A feature that only worked in one month is noise.
Universe
Test on stocks you would actually trade, with similar liquidity and tick size.

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.

python
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
Your email won't be published. We'll only use it to notify you of replies to your comment.
Loading comments...

Keywords

  • order book imbalance
  • microprice
  • market microstructure
  • level 2 data
  • market depth
  • python
  • quantitative trading
  • feature engineering
  • backtesting
  • order flow
  • algorithmic trading
  • ASX
  • Interactive Brokers market depth
Previous Article
post-thumb

Oct 21, 2026

Win Rate vs Payoff: Two Shapes of a Profitable Trader

A 75% win rate can lose money and a 40% win rate can make a fortune. Expectancy, profit factor and breakeven win rates explained with synthetic examples, plus why the two profitable trader shapes feel completely different to trade and how to improve payoff without wrecking your win rate.

Next Article
post-thumb

Oct 19, 2026

When the Tape Stops Confirming: Time Stops and Liquidity Exits

Most traders only exit on price. The scalpers in the journals I read exited on time and on liquidity: if the move didn't come quickly, or the order they were leaning on disappeared, they were out. Why time stops work for intraday trading and how to measure your own.

agico

We transform visions into reality. We specializes in crafting digital experiences that captivate, engage, and innovate. With a fusion of creativity and expertise, we bring your ideas to life, one pixel at a time. Let's build the future together.

Copyright ©  2026  TYO Lab · v0.0.36