Data Preparation
hftbacktest · Data Preparation · Working lab
The hftbacktest docs open their tutorials with the least glamorous page in the set, and they are right to. Every result a backtester produces is a claim about what you could have known and when, and that claim is made entirely by the data preparation step. Get it wrong and nothing breaks: you get a run, a P&L curve and a Sharpe ratio, and they are all fiction.
The tutorial page itself is short: two calls, a snapshot, and a second data source. What it does not
explain is the interesting part. Why one message becomes eighty-two rows. What 3758096385 is. Why
convert prints Correcting the event order and then hands you back more rows than you gave it.
Why a venue that never closes needs a snapshot at all, and why the trades file goes in before the
depth file.
This lab is that page with the machinery exposed and running. Everything below is computed from the
five feed lines the docs print and from a port of the library’s own conversion functions, so the
ev values are assembled from the constants, the fan-out is counted off the real message, and the
latency correction lands on the tutorial’s printed timestamp to the nanosecond.
One line of feed, and what survives it
A raw feed file is one line per message: nineteen digits of nanoseconds, a space, and the JSON the exchange sent. Conversion reads every line and keeps about a third of what is in it.
Tick-by-tick full order book data is not something you download. There is no Yahoo Finance for the book, and the docs are blunt about it. For crypto your realistic options are to collect the raw feed yourself with the project’s Data Collector, or to buy it. Either way what lands on disk is a gzipped log of messages, and the first job is to turn it into the eight-column array the backtester eats.
The whole conversion, for one day of one symbol
# the whole Binance Futures conversion
from hftbacktest.data.utils import binancefutures
data = binancefutures.convert(
'usdm/btcusdt_20240808.gz',
combined_stream=True
)
# Correcting the latency
# local_timestamp is ahead of exch_timestamp by 1272156851
# Correcting the event order The five lines the tutorial prints. Click any token.
becomes local_ts becomes exch_ts read and used read and dropped envelope
btcusdt@depth@0ms · 1918 bytes on the wire
What convert emits for this line
| ev | exch_ts | local_ts | px | qty |
|---|---|---|---|---|
| 536,870,913 | 1723161256.298000000 | 1723161255.030314667 | 58710.2 | 0.014 |
| 536,870,913 | 1723161256.298000000 | 1723161255.030314667 | 61496.5 | 0.01 |
| 536,870,913 | 1723161256.298000000 | 1723161255.030314667 | 61510.9 | 0 |
| … | … | … | … | … |
| 268,435,457 | 1723161256.298000000 | 1723161255.030314667 | 63113.7 | 0 |
| 268,435,457 | 1723161256.298000000 | 1723161255.030314667 | 65880.7 | 15.918 |
Two things about these rows are not finished yet, and both are the next two sections. ev reads 536,870,913, not the 3758096385 the tutorial’s table shows: neither EXCH_EVENT nor LOCAL_EVENT is set, because at this point in the pipeline nothing has decided which timeline consumes the row. And local_ts is still 1723161255.030314667, which is earlier than exch_ts: this row claims to have been received before it happened.
btcusdt@bookTicker · 199 bytes on the wire
t in optWith the default opt='' this whole line is dropped. With opt='t' it becomes two rows carrying the custom ids 103 and 104. Note what those rows do not carry: no DEPTH_EVENT, no side flag. They are a separate channel, not depth.t in optThe qty of the 103 row, if it is emitted.t in optThe px of the 104 row, if it is emitted.t in optThe qty of the 104 row, if it is emitted.What convert emits for this line
no rows
btcusdt@trade · 182 bytes on the wire
What convert emits for this line
| ev | exch_ts | local_ts | px | qty |
|---|---|---|---|---|
| 536,870,914 | 1723161256.322000000 | 1723161255.088176367 | 61800.3 | 0.006 |
Two things about these rows are not finished yet, and both are the next two sections. ev reads 536,870,914, not the 3758096385 the tutorial’s table shows: neither EXCH_EVENT nor LOCAL_EVENT is set, because at this point in the pipeline nothing has decided which timeline consumes the row. And local_ts is still 1723161255.088176367, which is earlier than exch_ts: this row claims to have been received before it happened.
btcusdt@bookTicker · 199 bytes on the wire
t in optWith the default opt='' this whole line is dropped. With opt='t' it becomes two rows carrying the custom ids 103 and 104. Note what those rows do not carry: no DEPTH_EVENT, no side flag. They are a separate channel, not depth.t in optThe qty of the 103 row, if it is emitted.t in optThe px of the 104 row, if it is emitted.t in optThe qty of the 104 row, if it is emitted.What convert emits for this line
no rows
btcusdt@bookTicker · 199 bytes on the wire
t in optWith the default opt='' this whole line is dropped. With opt='t' it becomes two rows carrying the custom ids 103 and 104. Note what those rows do not carry: no DEPTH_EVENT, no side flag. They are a separate channel, not depth.t in optThe qty of the 103 row, if it is emitted.t in optThe px of the 104 row, if it is emitted.t in optThe qty of the 104 row, if it is emitted.What convert emits for this line
no rows
Three things in that strip are worth stopping on, and all three are things the tutorial’s output does not say out loud.
The 19-digit prefix
Taken by position, not parsed: line[:19]. It is the moment the bytes hit your machine, on your
clock, and it becomes local_ts. Everything anyone can honestly say about latency in a backtest
is the distance between this number and the next one.
T, not E
A Binance message carries both. E is when the server sent it; T is when the matching engine
did the thing. The line that reads E is commented out in the library. Sending time is not
event time, and a fill simulated against sending time is a fill you did not get.
U, u, pu
Binance’s sequence numbers: first id, last id, previous last id. Chaining them is how a live
client detects that it missed an update and has to re-snapshot. convert reads none of the
three. A gap in your recording is not detected here.
The fan-out is the shape of the whole file. 5 lines and 2697 bytes go in;
83 rows come out, and 3 of the five lines produce nothing at all. One
depthUpdate carrying 53 bid levels and 29 ask levels becomes
82 rows, every one of them stamped with the same exch_ts and the same
local_ts, because they arrived in one message about one moment.
So a normalized file is not a log of messages. It is a log of level changes, and the count that
matters is levels-per-day rather than messages-per-day. The tutorial’s four-minute sample of one
symbol is 491,973 rows. A full day of BTCUSDT is a few million; the Tardis file later in the
tutorial is 27.5 million for one symbol-day, which is why buffer_size is a parameter you will
eventually have to think about.
And the three lines that vanish are worth more attention than the two that survive. They are
bookTicker messages, and convert’s default opt='' skips them without printing a word. Pass
opt='t' and each becomes two rows with the custom ids 103 and 104. Those rows carry no
DEPTH_EVENT and no side flag, because best-bid-and-offer is a separate channel and not depth. The
sample output of a working conversion is mostly the stream it is throwing away, and nothing tells you
so.
ev is not a number, it is a sentence
The docs’ table has a column of ten-digit integers and a link to a Rust constants page. The integers are the most important thing on the page, and they are the one thing the tutorial never decodes.
Eight columns, in this order: ev, exch_ts, local_ts, px, qty, order_id, ival, fval.
Six of them are obvious. order_id is only ever set by a level-3 market-by-order feed, and ival
and fval are reserved: an integer and a float you can put anything in, which is how the custom
data tutorial later attaches your own signals to the same array.
That leaves ev, which looks like one bitfield and is really two things sharing a word: a small
event type in the low byte, and four independent flags in the top nibble. Twenty bits in the
middle are unused. That is why a depth update on the bid side is 3758096385 and not 5.
Build a value, or paste one out of the tutorial’s output
The four flags · bits 31–28
The event type · bits 7–0, a small integer and not a flag
3,758,096,385 the value stored in the ev column
Or paste a value out of the tutorial’s own output
px is the level and qty is its new total, not a delta, and qty of zero deletes the level. Here it is the bid. Both timelines consume it: the exchange at exch_ts, your strategy at local_ts.The two timeline bits
EXCH_EVENT and LOCAL_EVENT are the ones to understand. They are not metadata about the row;
they decide who is allowed to see it. The exchange simulator consumes rows carrying bit 31,
at exch_ts, and builds the book that fills your orders. Your strategy consumes rows carrying
bit 30, at local_ts, and that is the book you are allowed to look at. It is always the stale
one, and the gap between them is the latency you are being charged.
The type is not a flag
Try it in the decoder: the nine types are values, not bits, so picking one replaces the low byte
rather than adding to it. DEPTH_EVENT is 1 and DEPTH_SNAPSHOT_EVENT is 4, and a field holding
5 is not “both”; it is DEPTH_BBO_EVENT. Set the low byte to 7 and hftbacktest has no idea what
you mean.
The nine types, and what each one does to the book:
| type | value | what it means |
|---|---|---|
DEPTH_EVENT | 1 | One price level changed. px is the level and qty is its new total, not a delta, and qty of zero deletes the level. |
TRADE_EVENT | 2 | A trade printed. The side flag is the aggressor’s side, which is the side that crossed the spread. |
DEPTH_CLEAR_EVENT | 3 | Throw away every level on this side out to px. Emitted immediately before a snapshot, so the snapshot replaces the book rather than merging into whatever was left of it. |
DEPTH_SNAPSHOT_EVENT | 4 | One level of a snapshot. A run of these rebuilds a side of the book outright; both timestamps are zero because a snapshot happens at no time in particular. |
DEPTH_BBO_EVENT | 5 | Best bid or offer only: the top of the book without the depth behind it. What a bookTicker stream carries. |
ADD_ORDER_EVENT | 10 | An individual order joined the queue. order_id names it. |
CANCEL_ORDER_EVENT | 11 | An individual order left the queue. |
MODIFY_ORDER_EVENT | 12 | An individual order changed size or price. |
FILL_EVENT | 13 | An individual order was filled. |
Worth trying two nonsense values in the decoder, because both are real mistakes people make writing
their own converter. Set BUY_EVENT and SELL_EVENT together: nothing stops you, and nothing
downstream can then tell which side of the book to touch. Then clear both timeline bits: that is
exactly the state convert leaves its rows in before the corrections run, and it is the setup for
the next two sections.
Two clocks, 1.27 seconds apart
Feed latency is local_ts − exch_ts. In the tutorial’s file the worst row has a latency of minus
1.27 seconds, which describes a message received before it was sent.
It is not, of course. It is a clock offset: the machine doing the recording and the exchange’s
matching engine disagree about what time it is, and no amount of care with the recording will fix
that after the fact. So the conversion does the only honest thing available. It finds the most
negative latency in the whole file and shifts every local_ts forward by that much, which
preserves every gap between rows and moves the file just far enough that the worst row stops being
impossible.
validation.py · correct_local_timestamp, in full
# validation.py, the whole correction
latency = sys.maxsize
for row_num in range(len(data)):
latency = min(latency, data[row_num].local_ts - data[row_num].exch_ts)
if latency < 0:
offset = -latency + base_latency
print('local_timestamp is ahead of exch_timestamp by', -latency)
for row_num in range(len(data)):
data[row_num].local_ts += offset The correction, with the parameter the tutorial never passes
| row | exch_ts | latency, as recorded | local_ts, corrected | latency, corrected |
|---|---|---|---|---|
| the depth line | 1723161256.298000000 | −1,267,685,333 | 1723161256.302471518 | +4,471,518 |
| the trade line | 1723161256.322000000 | −1,233,823,633 | 1723161256.360333218 | +38,333,218 |
| the file’s worst row | – | −1,272,156,851 | – | +0 |
The cost is the last column. The minimum latency in the file is now exactly 0 ns. The worst row is now infinitely fast, and a backtest will happily fill you on information you could not have had. That is what base_latency is for: it is the floor you are willing to claim, and the default of 0 claims none.
The default base_latency=0 is the setting to be suspicious of, and the last column is why. After
the shift, the worst row in the file has a latency of exactly zero. It now claims you learned about
an event at the instant it happened at the matching engine, and a backtest will cheerfully fill you
on that information. The library’s own docstring says as much: the conversion “may still produce zero
latency cases,” and base_latency exists so you can put a floor under them. The floor is a claim
about your own infrastructure, and zero claims nothing.
What survives the correction is the shape of the distribution, and that is what you actually came for:
Feed latency, from the docs’ own converted rows
local_ts − exch_ts for five rows of the docs’ own converted table. The spread runs from
4 ms to 52 ms across four minutes of one symbol, and it is the thing a backtest either respects or
quietly ignores.
Four milliseconds to fifty-two, in four minutes, on one symbol. Not an average anybody could have told you in advance, and not a constant. This is the number a backtest is either respecting or quietly ignoring, and it is the reason the whole two-timeline apparatus in the next section exists.
One event, two timelines
convert prints Correcting the event order and moves on. What that function does is the single
best idea in the library, and it is the reason EXCH_EVENT and LOCAL_EVENT are separate bits.
Here is the problem, stated plainly. Two feeds, a throttled depth stream and a trade stream, are two queues, and they do not preserve each other’s order. So it happens constantly that event A happened before event B at the matching engine, and yet B reached your socket first. Both facts are true. Neither is a bug.
Now try to put those two rows in one array. Sorted by exch_ts the array is a lie about what you
knew. Sorted by local_ts it is a lie about what happened. There is no third option, because no
single ordering of two rows can ascend in two clocks that disagree.
Two walks over the same rows, and a merge
# the call that does the splitting
data = correct_event_order(
tmp,
np.argsort(tmp['exch_ts'], kind='mergesort'), # walk 1
np.argsort(tmp['local_ts'], kind='mergesort') # walk 2
)
validate_event_order(data) So hftbacktest stops trying to find one. correct_event_order walks the rows twice: once in
exchange order, once in reception order. Then it merges the two walks. Where both walks agree on a row
it emits that row once, carrying both bits. Where they disagree it emits the row twice: once with
EXCH_EVENT at the moment the exchange had it, and once with LOCAL_EVENT at the moment you did.
Two interleaved timelines in one array.
Move the trade’s T across the depth update’s
Walk 1 · by exch_ts
trade1723161256.290000000depth1723161256.298000000Walk 2 · by local_ts
depth1723161256.302471518trade1723161256.360333218| row | ev | reads as | at |
|---|---|---|---|
| trade | 2,684,354,562 | BUY_EVENT | TRADE_EVENT | EXCH_EVENT | 1723161256.290000000 |
| depth | 3,758,096,385 | BUY_EVENT | DEPTH_EVENT | EXCH_EVENT | LOCAL_EVENT | 1723161256.298000000 |
| trade | 1,610,612,738 | BUY_EVENT | TRADE_EVENT | LOCAL_EVENT | 1723161256.360333218 |
So the merge stops looking for one and emits the trade twice. The copy carrying EXCH_EVENT sits where the matching engine had it, and the exchange simulator reads that one. The copy carrying LOCAL_EVENT sits where you learned about it, and your strategy reads that one. Neither copy carries both bits, so neither timeline sees the trade twice, and the array now ascends in both clocks at once, which is what the two checks below are testing.
as convert emitted itexch_ts ascending across EXCH_EVENT rowslocal_ts ascending across LOCAL_EVENT rowsafter correct_event_orderexch_ts ascending across EXCH_EVENT rowslocal_ts ascending across LOCAL_EVENT rows
The two verdicts under it are validate_event_order, the check the library runs last, applied twice:
once to the array as convert emitted it and once to the corrected one. Push the slider negative
and the first check fails while the second passes. That failure is the entire justification for the
function: without it the array is not ascending in exchange time, and an exchange simulator stepping
through it would process a trade it had already processed the consequences of.
What the simulator reads
Rows with bit 31, in exch_ts order. This is the true book, and it is what decides whether your
resting order filled. You never get to look at it.
What your strategy reads
Rows with bit 30, in local_ts order. Late, incomplete, and occasionally in a different order
from what really happened. This is the one you write signals against.
Two practical consequences. convert can return up to twice the rows it was handed, so a row count
is not a message count and not a level-change count either. And the duplicated rows are not
redundancy you can strip out to save space. Deleting either copy silently breaks one of the
two timelines.
A book on a venue that never closes
An equities feed hands you a fresh book every morning. Binance Futures does not have mornings, so your day file starts in the middle of a book you have never seen.
The collector fetches a REST snapshot when it opens its socket and never again. So the first day of a recording has a real starting book and every day after it has nothing, and a depth update only tells you about the levels that changed. Levels nobody touches stay invisible. Deletions arrive for prices your book has never heard of and do nothing at all.
| ev | reads as | exch_ts | local_ts | px | qty |
|---|---|---|---|---|---|
| 3,758,096,388 | BUY_EVENT | DEPTH_SNAPSHOT_EVENT | EXCH_EVENT | LOCAL_EVENT | 0 | 0 | 61,800.20 | 2.507 |
| 3,758,096,388 | BUY_EVENT | DEPTH_SNAPSHOT_EVENT | EXCH_EVENT | LOCAL_EVENT | 0 | 0 | 61,798.70 | 0.153 |
| 3,758,096,388 | BUY_EVENT | DEPTH_SNAPSHOT_EVENT | EXCH_EVENT | LOCAL_EVENT | 0 | 0 | 61,788.10 | 0.140 |
| 3,489,660,932 | SELL_EVENT | DEPTH_SNAPSHOT_EVENT | EXCH_EVENT | LOCAL_EVENT | 0 | 0 | 61,800.30 | 3.330 |
| 3,489,660,932 | SELL_EVENT | DEPTH_SNAPSHOT_EVENT | EXCH_EVENT | LOCAL_EVENT | 0 | 0 | 61,804.60 | 0.057 |
| 3,489,660,932 | SELL_EVENT | DEPTH_SNAPSHOT_EVENT | EXCH_EVENT | LOCAL_EVENT | 0 | 0 | 61,810.00 | 0.285 |
The real snapshot for this file has 9,597 rows. Nothing prunes it, so it also holds a bid at 5,000.00 and an ask at 617,050.00: levels somebody parked years out of the market that no update has touched since. 9,597 levels, and not 9,597 useful ones.
Replay the day, keep the book it ends with
# build 20240808's end-of-day book, for 20240809 to start from
from hftbacktest.data.utils.snapshot import create_last_snapshot
_ = create_last_snapshot(
['usdm/btcusdt_20240808.npz'],
tick_size=0.1,
lot_size=0.001,
output_snapshot_filename='usdm/btcusdt_20240808_eod.npz'
) Two details in that call are easy to skim past. The first is that create_last_snapshot is not a
parser. It builds a BacktestAsset, calls _goto_end() to run the entire day through the real
replay engine, and dumps depth.snapshot(). The end-of-day book is produced by the same machinery
your strategy will run against, which is the only way it is guaranteed to agree with it.
The second is that it will not run without a tick size and a lot size, because prices in hftbacktest are integer numbers of ticks rather than floats.
Pick a tick size for a book quoted in 0.1
| price on the wire | tick index stored | price recovered |
|---|---|---|
| 61,800.20 | 618,002 | 61,800.20 |
| 61,800.30 | 618,003 | 61,800.30 |
Choose a tick coarser than the venue’s and the best bid and the best ask land on the same integer.
The book loses its spread, nothing raises an error, and the backtest runs to completion. tick_size
is not a display setting; it is the resolution at which prices exist, and it is a property of the
venue that you have to look up, because nothing in the feed states it.
Chaining a day onto the previous one. Two ways, same answer.
# 20240809, starting from where 20240808 left off
_ = create_last_snapshot(
['usdm/btcusdt_20240809.npz'],
tick_size=0.1,
lot_size=0.001,
output_snapshot_filename='usdm/btcusdt_20240809_last.npz',
initial_snapshot='usdm/btcusdt_20240808_eod.npz',
)
# or replay both days instead of chaining. same answer
_ = create_last_snapshot(
['usdm/btcusdt_20240808.npz', 'usdm/btcusdt_20240809.npz'],
tick_size=0.1,
lot_size=0.001,
output_snapshot_filename='usdm/btcusdt_20240809_last.npz'
) Buying it instead
The other way to get tick data is to pay for it, which trades a problem you can measure for one you cannot.
Your own socket
- local_ts is real. Your machine wrote it, on your clock, at your colocation. It is the only honest latency measurement in the whole pipeline.
- exch_ts is
T, the matching time, which is the timestamp you actually want. - You own the gaps. A dropped socket is your dropped socket, and you know when.
- You have to have been running. There is no history. Whatever you did not collect does not exist, and the first day of any recording is the only one that comes with a REST snapshot.
A vendor
- History exists. The tutorial pulls a day from February 2020, which is the whole reason to consider a vendor at all.
- local_ts is somebody else’s machine. Their network path, their location, their clock. Useful as a relative ordering; not a measurement of your latency.
- exch_ts is
E, notT. Sending time rather than matching time. The library’s own docstring says so: “so the latency is slightly less than it actually is.” Slightly, and always in the flattering direction. - CSV, not JSON. One row per level already, so there is no fan-out, and 238 MB gzipped for one symbol-day of depth, which is why
buffer_sizeis a parameter you will meet.
The trade-off is in the timestamps, and the library’s own docstring is refreshingly direct about it:
for Binance futures, Tardis uses E, the sending time, rather than T, “so the latency is
slightly less than it actually is.” Slightly, and always in the flattering direction. You are also
inheriting somebody else’s network path and somebody else’s clock for local_ts, which makes it a
usable relative ordering and not a measurement of your own latency at all.
Trades first. The order is the point.
# trades first. the order is the whole point. see below
from hftbacktest.data.utils import tardis
data = tardis.convert(
['BTCUSDT_trades.csv.gz', 'BTCUSDT_book.csv.gz']
)
# Reading BTCUSDT_trades.csv.gz
# Reading BTCUSDT_book.csv.gz
# Correcting the latency
# Correcting the event order Two files, one exchange timestamp, one stable sort
One buffer, one stable sort by exch_ts
| from | exch_ts | ev | what it says |
|---|---|---|---|
| trades | 1580515202.342000000 | BUY_EVENT | TRADE_EVENT | a buyer lifts 1.197 of the offer at 9,364.51 |
| depth | 1580515202.342000000 | SELL_EVENT | DEPTH_EVENT | the offer at 9,364.51 goes from 2.000 to 0.803 |
The docs’ reason for this is precise and worth quoting: “if a depth update generated by a trade is handled first, the queue position is reduced twice — once by the depth message and again by the subsequent trade message. Because the depth message already subtracts the traded quantity, the associated trade message must be processed first.” You have an offer resting at 9,364.51 with 2.000 lots ahead of you. 1.197 of them traded, so your queue position should move by 1.197, and only once.
This is the sharpest lesson on the tutorial page and it is stated in half a sentence. convert reads
its input files in order into one buffer and sorts that buffer with kind='mergesort', which is
stable, so two rows sharing an exchange timestamp come out in whichever order their files were
read. A trade and the depth decrease it caused share that timestamp exactly, because they are one
matching-engine event described twice.
Get the order backwards and nothing looks wrong. The rows are identical, the count is identical, the timestamps are identical. But the depth decrease is applied before the trade that explains it, both reduce your queue position, and 1.197 lots of volume moves you 2.394 lots forward. Every simulated fill arrives a little early and every simulated edge comes out a little better than it was. A data preparation bug does not crash a backtest. It flatters it.
process · the default
Every snapshot in the file is turned into a DEPTH_CLEAR_EVENT followed by a run of DEPTH_SNAPSHOT_EVENT rows, per side. The clear goes out to the worst price the snapshot mentions, so the snapshot replaces that region of the book instead of merging into it.
This is what you want for a snapshot that exists because something went wrong: a sequence gap, a reconnect. It is also what you get for the one Tardis inserts at the top of every daily file for no reason other than that a day started.
ignore_sod · for a continuous run
Drops only the start-of-day snapshot and keeps every mid-file one. If you are backtesting days in sequence, day two’s opening snapshot tells you nothing day one’s closing book did not already say, and it costs a full book clear and rebuild at every midnight boundary.
The tutorial ends on exactly this point: “if you continuously backtest multiple days, you don’t need the snapshot every start of days and it may incur more time to backtest.” The trade-off is not accuracy, it is that you now depend on day one having been replayed.
ignore · start from nothing
Throws away every snapshot, including the ones covering a real gap. The book starts empty and, as the docstring puts it, “will converge to a complete order book over time”: each level becomes known the first time it changes.
Which is the same incompleteness the Binance section had, chosen on purpose. Reasonable if you only trade near the touch and are willing to discard the first minutes of every run; quietly wrong if your signal reads depth.
Tardis CSV: one row per level already, so no fan-out
# tardis incremental_book_L2: one row per level already exchange, symbol, timestamp, local_timestamp, is_snapshot, side, price, amount # tardis trades exchange, symbol, timestamp, local_timestamp, id, side, price, amount
27.5 million rows for one symbol-day
# 27.5M rows for one symbol-day. the default buffer is 100M,
# and the snapshot buffer is a separate 1M
_ = tardis.convert(
['BTCUSDT_trades.csv.gz', 'BTCUSDT_book.csv.gz'],
output_filename='btcusdt_20200201.npz',
buffer_size=200_000_000
) What conversion actually is
- Collect the raw feed, or buy it, and know which timestamp the vendor put in the exchange column.
- Convert per line: branch on
e, takeTforexch_ts, keep the 19-digit prefix aslocal_ts, fan a depth message out to one row per level. - Correct the latency: find the most negative
local_ts − exch_tsin the file and shift everylocal_tsby it, plus whateverbase_latencyyou are willing to claim. - Correct the event order: merge the exchange walk with the local walk, splitting any row the two disagree about into an
EXCH_EVENTcopy and aLOCAL_EVENTcopy. - Validate: exchange timestamps monotonic among exchange rows, local timestamps monotonic among local rows.
- Build the end-of-day snapshot by replaying the day to its end, and hand it to the next day as
initial_snapshot.
Every step is a place where a plausible shortcut produces a backtest that runs. Read E instead of
T and you get results, flattering ones. Skip the latency correction and rows with negative latency
sort into the past. Skip the event-order correction and one of the two timelines runs backwards.
Skip the snapshot and your book has holes you will never be told about. Pass the depth file first and
your queue moves twice as fast as the tape.
None of those raise an exception. That is the actual subject of this tutorial, and it is why it comes first.
Did it stick?
1. A raw feed line begins 1723161255030314667 {"stream":…. What is that first token?
convert takes it by position: line[:19]. It becomes local_ts, and it is the only timestamp in the line your own clock is the authority on. 2. A Binance depthUpdate carries both E and T. Which becomes exch_ts, and why?
T. The line reading E is commented out in the library. A fill simulated against sending time is a fill you did not get. And note that Tardis.dev’s Binance futures data uses E, which is why its measured latency comes out slightly too small. 3. One depthUpdate message with 53 bid levels and 29 ask levels becomes how many rows?
exch_ts and one local_ts. A normalized file is a log of level changes, not a log of messages. 4. Three of the five lines the tutorial prints are bookTicker. With the default opt='', what happens to them?
bookTicker branch is guarded by 't' in opt. Pass opt='t' and each line becomes rows with the custom ids 103 and 104, which carry no DEPTH_EVENT and no side flag, because they are a separate channel rather than depth. 5. ev = 3489660929. What is that row?
SELL_EVENT | DEPTH_EVENT | EXCH_EVENT | LOCAL_EVENT. Bits 31 and 30 are the two timelines, bit 28 is the ask side, and the low byte holds 1: the event type is a small integer, not a flag. 6. Why does correct_local_timestamp shift every row in the file rather than only the rows with negative latency?
7. After the latency correction with the default base_latency=0, what is the smallest feed latency in the file?
base_latency is for: a floor you are willing to claim. 8. Why can correct_event_order return more rows than it was given?
EXCH_EVENT and LOCAL_EVENT are separate bits, and the output can be up to twice the input. 9. Binance Futures runs 24/7. What does that cost you, in data terms?
create_last_snapshot, then chaining that in as initial_snapshot. Without it the book has holes, and deletions arrive for levels it has never heard of. 10. Why should trade files be passed to tardis.convert before depth files?
Answered 0 of 10 · 0 correct
The terms this tutorial installs
Data Preparation in one breath. hftbacktest eats a numpy structured array of eight columns, and
the only interesting one is ev, an event type in its low byte and four flags in its top nibble,
two of which decide whether the exchange or your strategy is allowed to see the row. Getting
there from a raw feed means four things. Parse each line, taking the 19-digit nanosecond prefix as
local_ts and the matching-engine timestamp T as exch_ts, and fan a depth message out into one
row per level. Correct the latency by shifting every local_ts until the most negative feed
latency in the file reaches zero. Then think hard about base_latency, because zero latency on any
row is a licence to trade on information nobody had. Correct the event order by merging an
exchange-time walk with a reception-time walk, splitting any row the two disagree about into an
EXCH_EVENT copy and a LOCAL_EVENT copy, which is the only way one array can ascend in two clocks
at once. And because a crypto venue has no start of day, build the book by replaying the previous
day to its end and hand that snapshot to the next one.
Buying the data instead moves the problem rather than removing it: a vendor’s local_ts is a
stranger’s machine, its exch_ts may be a sending time, and its files must go into the converter
trades before depth or the queue position you simulate moves twice for every trade. Every one of
these failures produces a complete, plausible, profitable-looking backtest. The point of this page is
that the errors are silent, and the only defence is knowing what each column means.
Working lab built for recall practice, from the hftbacktest documentation’s Data Preparation
tutorial. The feed format, constants, conversion functions, sample lines and recommendations are
nkaz001’s. Go to the
docs and the
hftbacktest.data source for the originals. The numbers here are not quoted from that page:
src/lib/hft1.ts holds the five sample lines verbatim and ports convert’s per-line body,
correct_local_timestamp, correct_event_order and validate_event_order, and every count, ev
value and timestamp in the exhibits is computed by running them. The corrected local_ts the
clock exhibit produces is the one the tutorial prints, to the nanosecond, which is the check that
the port is faithful.