Order-book abuse
Scan your organization's order flow for manipulation, client by client and book by book, and read the findings.
An order-book abuse run reads your organization's order events (placements, fills, cancels) and scores every client on every book against four manipulation patterns. Where an on-chain scan returns one verdict on one wallet, a run returns a finding per client, per book, per pattern.
| Pattern | Key | Default threshold | Looks for |
|---|---|---|---|
| Wash trading | wash_trading | 70 | a client trading with itself |
| Order lifecycle | order_lifecycle | 70 | spoofing and layering: orders placed to be cancelled |
| Ramping | ramping | 70 | accumulating, then pushing the price |
| Smurfing | smurfing | 60 | one order split into many small ones, including across venues |
Runs are asynchronous. Launching one returns a run_id immediately; the
report appears later under that id. There is no callback: poll the result
endpoint.
Every endpoint is listed in the API reference.
Launching a run
POST /v1/obmabu/runs. Every field is optional, so the simplest run, all
books of your organization over the automatic window, is an empty object:
curl -X POST https://monitoring.seqlense.com/api/v1/obmabu/runs \
-H "Authorization: Bearer sq_..." \
-H "Content-Type: application/json" \
-d '{}'{
"status": "ok",
"run_id": "0f9a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b",
"window": { "from": "2026-09-01 00:00:00", "to": "2026-09-08 09:00:12" }
}The body must be JSON even when you send no fields: {}. An empty body
returns 404.
| Field | Meaning |
|---|---|
organization | the order-data organization to scan; defaults to your own |
venue | one venue only, for example BYBIT; empty means every venue |
instrument | one instrument only, for example ETH/USDC; empty means every instrument |
from, to | the window, RFC 3339 (2026-09-01T00:00:00Z) or YYYY-MM-DD HH:MM:SS, UTC |
venue and instrument are uppercased, at most 60 characters, and limited to
letters, digits and / _ - .. to must be after from.
Leaving venue and instrument empty is the normal case. A run narrowed to one book cannot see an order split across venues, so it loses the cross-venue smurfing signal.
The organization defaults to the workspace of your key: a development key scans development data.
The window
The window actually used is echoed in the response (null on a side means
unbounded):
| You send | Window |
|---|---|
from and/or to | used as given; a side you leave out is unbounded |
| neither, first run on this scope | unbounded: as much history as the scanner's 500 000-event budget allows |
neither, a previous DONE run exists on the exact same scope | to = now, from = that run's end minus 24 hours, or 7 days ago if that reaches further back |
The 24-hour overlap matches the longest pattern the scanner looks for, so behaviour straddling two runs is not cut in half. The 7-day floor keeps enough clients in each book for the comparisons to mean something. Scope matching is exact: a run on one instrument does not chain onto a run over every book.
If the hand-off to the scanner fails, the call returns 400 with
Run created but dispatch failed: ...; the run exists and is already FAILED.
Following a run
| Status | Meaning |
|---|---|
PENDING | created, not yet handed off |
DISPATCHED | handed off and running |
DONE | its report exists |
FAILED | the hand-off failed, or nothing was published within 45 minutes |
A run reads every book of an organization, so it takes longer than an on-chain scan; 45 minutes is the point where it is declared failed.
GET /v1/obmabu/runs/search lists your runs, newest first. Filters are
optional and combine with AND; an unrecognised value is ignored.
| Parameter | Effect |
|---|---|
status | PENDING, DISPATCHED, DONE or FAILED |
organization | exact organization scanned |
venue | exact venue |
q | prefix of a run id |
limit | default 25, clamped to 1..100 |
offset | default 0, at most 100000 |
GET /v1/obmabu/runs/detail?id=... returns one run:
{
"id": "0f9a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b",
"organization": "acme",
"venue": "BYBIT",
"instrument": "",
"scope": "acme · BYBIT",
"window_from": "2026-09-01 00:00:00",
"window_to": "2026-09-08 00:00:00",
"status": "DONE",
"dispatch_error": "",
"created_by": 0,
"created_at": "2026-09-08 09:00:12",
"started_at": "2026-09-08 09:00:13",
"finished_at": "2026-09-08 09:21:40"
}An empty venue, instrument, window_from or window_to means "all" or
"unbounded". created_by is 0 for a run launched with an API key.
Reading the report
curl "https://monitoring.seqlense.com/api/v1/obmabu/runs/result?run_id=0f9a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b" \
-H "Authorization: Bearer sq_..."A 404 means the run has not published anything yet, which is normal while
it is DISPATCHED: poll, do not treat it as an error. It is also what you get
for a run that is not your organization's. A 400 means the report archive
could not be reached.
Reading the report settles the run: a run still in flight becomes DONE and
alerts are raised at that moment.
The payload is the ingestion envelope; the report is under data:
| Key | Contains |
|---|---|
runId, organization, generatedAt, durationMs | identity of the run |
fetch | what was read: books, events, orders, clients, and budgetExhausted if the 500 000-event budget ran out first |
adequacy | what the feed contains: time resolution, event types, and how often optional fields are filled |
books | one entry per book scanned, with totals |
clients | one entry per client, with its books, accounts and per-book profile |
findings | one entry per (client, book, pattern) |
summary | anyFlag, flaggedCount, flaggedClients, byPattern, dataLimitations |
Read the data limits first
data.adequacy and data.summary.dataLimitations come before the scores for a
reason: they explain why a detector could not answer. For example, a feed with
second-resolution timestamps cannot support spoofing signals measured in
milliseconds, and a feed without best bid and ask cannot tell an order at the
touch from one parked far away. dataLimitations states each gap as a
sentence.
Findings
Each finding names a client and a book and carries the pattern's verdict:
{
"pattern": "wash_trading",
"title": "Wash trading",
"clientId": "WASH01",
"venue": "BYBIT",
"instrument": "ETH/USDC",
"profile": "RETAIL",
"score": 100,
"flagThreshold": 70,
"flagged": true,
"evaluableMaxScore": 100,
"pointsNotEvaluable": 0,
"scoringCoveragePct": 100,
"confidence": 1,
"notes": ["6 exact self-match(es) on ETH/USDC at BYBIT: identical price and quantity on both sides, within 60s."],
"signals": [
{ "id": "self_trade_exact", "label": "The same client executed both sides...", "points": 50, "status": "confirmed", "reason": null, "evidence": { "matches": 6 } }
]
}flagged and flagThreshold are the scanner's own verdict against its
built-in bar. Your organization's verdict is score compared with your
threshold, and that is what alerting uses.
Signals use the same three statuses as on-chain scans:
| Status | Meaning |
|---|---|
confirmed | fired, its points count toward the score |
not_confirmed | assessed, and absent |
not_evaluable | could not be assessed; reason names the missing data |
not_evaluable is never a clean result. A score built on half-assessable
signals is a floor, not a measurement; scoringCoveragePct tells you how much
of the method could be applied.
Profiles and baselines
Each client gets a profile per book: MARKET_MAKER, DIRECTIONAL, RETAIL or
DORMANT, assigned by heuristic or declared. The profile only chooses which
population of clients the client is compared against. It never takes anyone
out of scope: a market maker can manipulate, and a self-trade flags whoever
does it.
Several order-lifecycle signals are scored against the book's population rather than a fixed limit, since there is no universal order-to-trade ratio. Their evidence shows the population median, the bar derived from it, the population size and the absolute floor next to the client's own value.
Prices and quantities in the report are strings. Keep them as strings or parse them as decimals: converting to floating point rounds exact figures.
Thresholds
Same model as on-chain scans, with its own four patterns. GET /v1/obmabu/thresholds returns each pattern's threshold, default and
whether it is customised.
POST /v1/obmabu/thresholds changes only the patterns you send and returns the
new list:
curl -X POST https://monitoring.seqlense.com/api/v1/obmabu/thresholds \
-H "Authorization: Bearer sq_..." \
-H "Content-Type: application/json" \
-d '{ "thresholds": { "order_lifecycle": 65 } }'An unknown pattern or a non-integer returns 400 and nothing is saved. Unlike
on-chain thresholds, an integer outside 0..100 is clamped into range
rather than refused, so 150 is stored as 100.
POST /v1/obmabu/thresholds/reset restores every default. Send {} as the
body:
curl -X POST https://monitoring.seqlense.com/api/v1/obmabu/thresholds/reset \
-H "Authorization: Bearer sq_..." \
-H "Content-Type: application/json" \
-d '{}'Alerts
A run that flags raises alerts through your notification channels, one per (client, book), with that pair's flagged patterns folded into it.
| Source | orderbook_abuse |
| Entity type | OB_CLIENT |
| Title | Order-book abuse: <client> on <venue> <instrument> |
| Severity | HIGH, or CRITICAL when the worst pattern reaches 1.5x its threshold |
| Metadata | run id, client, book, profile, every flagged pattern with score, threshold, scanner threshold and coverage, the not_evaluable reasons, the feed's limitations, and a link to the run |
The title is identical from one run to the next, so ongoing behaviour does not pile up: while an alert on a client and book is open or acknowledged, later runs do not raise it again. Once it is resolved, a recurrence fires anew.
A run raises at most 25 individual alerts. Any further subjects are listed
in a single digest alert, Order-book abuse: N more clients flagged in one run.
So many flags usually means a threshold needs revisiting. A clean run raises
nothing.
A channel that filters on specific sources will not receive these until
orderbook_abuse is added to its list.