Watch Order Book Walls in Python with the Densities API
Order book walls act like magnets and barriers, and the usual way to watch them is a heatmap screen: one pair at a time, useless the moment you look away. Here is the watcher version: one endpoint returns every detected wall on the venue with size, distance and a spoof-resistant age, and about forty lines of Python diff each 2 second scan against the last, flag new walls and pulled ones, and ping your own webhook.
Price does strange things around big resting orders. It accelerates toward them, stalls into them, and when a two million dollar bid that held through three approaches suddenly leaves the book, the floor under the price leaves with it. Order book walls are one of the few things in crypto that show intent before any trade prints, and almost nobody watches them properly, because the standard tool is a heatmap screen: gorgeous, one pair at a time, and blind the moment you go make coffee. This post builds the watcher instead: about forty lines of Python that check every tracked pair on a venue every 2 seconds, speak up when a wall worth caring about appears, and speak louder when one vanishes, because a pulled bid is information no candle will ever show you. The hard part, maintaining live order books for hundreds of pairs and scanning them all, is the Osiris Developer API's day job; the densities endpoint serves the finished scan, and our code is a diff and a webhook. Third post in the API series, after the bot skeleton and the screener, and like them it stands alone.
What you need
The series staples: Python 3, the requests package, and an API key created in Settings → Developer on the Developer plan, kept in an environment variable. The opening block is the same one the bot post walked through line by line, including why the 429 branch sleeps exactly as long as Retry-After says:
import os
import time
import requests
BASE = "https://osiristrader.com/api"
session = requests.Session()
session.headers["Authorization"] = "Bearer " + os.environ["OSIRIS_KEY"]
def osiris(path, **params):
while True:
res = session.get(BASE + path, params=params, timeout=10)
if res.status_code == 429:
time.sleep(int(res.headers.get("Retry-After", "5")))
continue
res.raise_for_status()
return res.json()
One confession before the first call, because both previous posts ended by telling you to stop polling, and now we are going to poll. The alert engine was the right watcher for a price level, and the 30 second rankings sweep was the right clock for a screener. Walls are a different animal: the scanner rebuilds the whole picture every 2 seconds, any wall can appear or vanish between two scans, and the vanishing is half the signal. The only faithful client of a feed like that is one that reads every scan and compares. Polling is not a compromise here, it is the mechanism, and the budget section at the end shows it fits inside a key's daily quota with room to spare. And if you want wall alerts without running any code, the in-app density alerts already do this server side and even classify how a wall died; the API version exists for everything the app cannot know, your thresholds, your routing, your logs.
One call, every wall on the venue
GET /v1/densities returns the current scan for a whole venue: every pair with at least one detected wall, each snapshot carrying its own price and timestamp so distances and staleness need no extra request. Pass exchange=bybit (or okx, bitget) to scan another venue's books; the default is Binance. Pairs with nothing to show are simply absent, and the single-pair variant /v1/densities/BTCUSDT answers 404 when no walls are detected right now, an honest nothing rather than an empty 200.
{
"exchange": "binance",
"data": {
"BTCUSDT": {
"price": 63972.9,
"ts": 1783877000000,
"walls": [
{ "side": "bid", "price": 63600.0, "qty": 44.86, "usd": 2853096,
"dist": 0.58, "ratio": 466.1, "age": 1240 }
]
}
}
}
Six fields per wall and each one earns its place. side says which way it leans: a bid wall below price is potential support, an ask wall above is potential resistance. usd is the resting size in dollars, the number to compare across pairs. dist is the distance from the current price in percent: a wall half a percent away is in play this hour, a wall 3% away is context. ratio carries the detector's core idea: there is no fixed dollar threshold anywhere, a level qualifies as a wall by dwarfing the median level size of its own book. A ratio of 466 means this order is 466 times a typical level on this pair, which is what makes a $300k wall on a quiet alt exactly as loud as a $3m wall on BTC. And age gets its own section, because it is the one that keeps you honest.
Age, the spoof filter
The biggest orders in crypto books are frequently theater: placed to be seen, never to be filled, pulled the moment price gets close. Spoofing is illegal on regulated venues and routine on crypto ones, so size alone means very little. The age field is the countermeasure: it counts the seconds a wall has existed continuously at its level, and a wall that vanishes and reappears restarts from zero. Spoofers flicker, real liquidity sits. An $800k bid 15 seconds old is a rumor; the same bid forty minutes later, still there after two approaches, is infrastructure. The endpoint docs suggest weighting confidence by age times ratio rather than either alone, and the watcher below draws its line at 60 seconds: below that a wall is noise, above it a wall becomes worth telling you about.
The watcher: diff each scan against the last
The plan is small. Pull the scan, keep the walls that clear a dollar floor, key them by pair, side and price so the same wall meets itself across polls, then compare against the previous scan. Three thresholds, all yours to tune:
MIN_USD = 300_000 # ignore furniture
MAX_DIST = 1.5 # percent; alert only on walls close enough to matter
MIN_AGE = 60 # seconds; younger walls are unconfirmed
def fetch_walls(exchange="binance"):
scan = osiris("/v1/densities", exchange=exchange)
walls = {}
for symbol, snap in scan["data"].items():
for w in snap["walls"]:
if w["usd"] < MIN_USD:
continue
key = (symbol, w["side"], w["price"])
walls[key] = {**w, "symbol": symbol, "mark": snap["price"]}
return walls
The diff is where the information lives. A wall is news twice in its life: once when it has sat long enough to be believed, and once when it leaves:
def diff(prev, curr):
events = []
for key, w in curr.items():
seen = prev.get(key)
unconfirmed = seen is None or seen["age"] < MIN_AGE
if unconfirmed and w["age"] >= MIN_AGE and w["dist"] <= MAX_DIST:
events.append(("NEW", w))
for key, w in prev.items():
if key not in curr and w["age"] >= MIN_AGE:
events.append(("GONE", w))
return events
Two design choices deserve a sentence each. First, NEW fires exactly once, on the poll where a wall crosses the age line, and it also catches a wall that arrives in your set already old: age is the detector's memory, not yours, so an order that grew past your dollar floor an hour after it first appeared shows up with its age intact, already credible. Second, GONE is deliberately blunt. It fires when a wall you trusted leaves the feed, and from REST polling alone you cannot always say why: eaten by market orders, pulled by its owner, or shrunk below your own floor. There is a cheap tell in the data you already hold, the last dist. A bid wall that vanished with price right on top of it was in the fight; one that vanished 1.2% clear of the action was removed at leisure, and the second kind usually says more about what its owner expects next. One simplification to know about: a wall that matured far away and only later drifted inside your distance gate stays silent in this version. If you want approach alerts, compare dist across polls; it is four more lines and the diff already has both snapshots in hand.
Point it at something that buzzes
Print first. Run the watcher through one session with print alone and tune the three thresholds until the stream is worth interrupting you for, because an alert channel that cries twice a minute gets muted by lunch. Then give it a webhook. The examples below post to your own Discord webhook or your own Telegram bot, both free: Discord hands you a webhook URL in your server's settings under Integrations, and Telegram's @BotFather gives you a bot token in one short chat. Both URLs are credentials, keep them out of source control.
DISCORD = os.environ.get("DISCORD_WEBHOOK") # your own webhook URL
def notify(kind, w):
msg = (f"{kind}: {w['side']} wall on {w['symbol']} at {w['price']}, "
f"${w['usd']:,} resting {w['dist']:.2f}% from price, "
f"age {w['age']}s, {w['ratio']:.0f}x its book")
print(msg)
if DISCORD:
requests.post(DISCORD, json={"content": msg}, timeout=10)
The Telegram version is the same three lines pointed at your bot:
TOKEN = os.environ["TG_BOT_TOKEN"] # your bot, from @BotFather
CHAT = os.environ["TG_CHAT_ID"] # your own chat id
def notify_telegram(msg):
requests.post(
f"https://api.telegram.org/bot{TOKEN}/sendMessage",
json={"chat_id": CHAT, "text": msg},
timeout=10,
)
The loop, and what it costs
prev = fetch_walls()
while True:
time.sleep(2)
curr = fetch_walls()
for kind, w in diff(prev, curr):
notify(kind, w)
prev = curr
Seeding prev before the loop is not decoration: it makes the watcher boot silently. Without it, the first pass would announce every mature wall on the venue as new, a hundred notifications about things that have been true for hours. The watcher's whole value is that it only speaks on change.
Now the budget, counted properly. One request every 2 seconds is 43,200 a day against the Developer plan's 50,000, and 30 a minute against the 120 burst ceiling; the rate limits page names this exact loop as the worst case the quota was sized for. It fits, but it is the hungriest script in this series, so two notes. Polling faster than 2 seconds buys nothing, that is the scanner's own cadence and tighter loops return identical bytes. And if the same key also runs the screener or the bot from the earlier posts, drop this loop to 4 seconds: 21,600 a day, half the quota back, and a wall that matters at your thresholds will not live and die inside a 4 second window often enough to grieve over.
Where to take it is mostly a question of what you trade. Cross the survivors with the levels endpoint: a wall sitting on a structural support or resistance level is two independent detection methods agreeing, the strongest configuration this API can show you. Feed the symbols that keep producing confirmed walls into the bulk metrics call from the bot post and see whether order flow agrees with the book. The full watcher from this post lives in our examples repo on GitHub, next to the bot and the screener. And keep the closing thought where your sizing can see it: a wall is a statement, not a promise. Its owner can honor it, defend it, or delete it with one keystroke, and all three happen every day. The watcher tells you where someone large is standing. Whether they hold the line is the trade, and that part is still yours to judge.