Sina Vafadar

Data Preparation

Aug 18, 2026 · 26 min

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.

The input § Getting started from Binance Futures’ raw feed data

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

This becomes local_tsNineteen digits of nanoseconds, written by the collector the moment the bytes arrived: line[:19], sliced off by position rather than parsed. It is the only timestamp in the line that your own machine is the authority on, and the only one the tutorial calls out in prose. Everything about latency in a backtest is the distance between this number and T.

What convert emits for this line

evexch_tslocal_tspxqty
536,870,9131723161256.2980000001723161255.03031466758710.20.014
536,870,9131723161256.2980000001723161255.03031466761496.50.01
536,870,9131723161256.2980000001723161255.03031466761510.90
268,435,4571723161256.2980000001723161255.03031466763113.70
268,435,4571723161256.2980000001723161255.03031466765880.715.918
1918 bytes in, 82 rows out53 bid levels and 29 ask levels, each becoming its own row, every one of them carrying the same exch_ts and the same local_ts, because they arrived in one message about one moment. That is the shape of the whole file: a normalized array is not a log of messages, it is a log of level changes, and one throttled @0ms depth message is 82 of them.
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.

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.

The schema § Data · Format

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

111031–28 · flags0000000000000000000027–8 · unused000000017–0 · event type

Or paste a value out of the tutorial’s own output

BUY_EVENT | DEPTH_EVENT | EXCH_EVENT | LOCAL_EVENTOne price level changed. 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:

typevaluewhat it means
DEPTH_EVENT1One price level changed. px is the level and qty is its new total, not a delta, and qty of zero deletes the level.
TRADE_EVENT2A trade printed. The side flag is the aggressor’s side, which is the side that crossed the spread.
DEPTH_CLEAR_EVENT3Throw 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_EVENT4One 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_EVENT5Best bid or offer only: the top of the book without the depth behind it. What a bookTicker stream carries.
ADD_ORDER_EVENT10An individual order joined the queue. order_id names it.
CANCEL_ORDER_EVENT11An individual order left the queue.
MODIFY_ORDER_EVENT12An individual order changed size or price.
FILL_EVENT13An 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.

The first correction § convert · Correcting the latency

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

rowexch_tslatency, as recordedlocal_ts, correctedlatency, corrected
the depth line1723161256.298000000−1,267,685,3331723161256.302471518+4,471,518
the trade line1723161256.322000000−1,233,823,6331723161256.360333218+38,333,218
the file’s worst row−1,272,156,851+0
base_latency = 0, the defaultThe worst row in the file was received 1,272,156,851 ns (1.272 s) before the exchange says it happened, which is not a thing that occurs; the two clocks are simply that far apart. So every local_ts in the file moves forward by 1,272,156,851, and the depth line’s becomes 1723161256.302471518. That is the number the tutorial prints in its first converted row, to the nanosecond. The correction is a translation, not a repair: it preserves every gap between rows and only moves the whole file until the worst one stops being impossible.
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

first row of the file 4.472 ms
a depth row, late in the day 13.618 ms
the bid at 5,000.00 51.793 ms
a depth row 51.777 ms
last row of the file 51.649 ms

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.

The second correction § convert · Correcting the event order

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

Walk 2 · by local_ts

depth1723161256.302471518trade1723161256.360333218
rowevreads asat
trade2,684,354,562BUY_EVENT | TRADE_EVENT | EXCH_EVENT1723161256.290000000
depth3,758,096,385BUY_EVENT | DEPTH_EVENT | EXCH_EVENT | LOCAL_EVENT1723161256.298000000
trade1,610,612,738BUY_EVENT | TRADE_EVENT | LOCAL_EVENT1723161256.360333218
2 rows in, 3 rows outNow the exchange says the trade happened 8 ms before the depth update, while it reached your socket 57.9 ms after it. That is not a contradiction and not a bug: a throttled @0ms depth stream and a trade stream are two queues, and they do not preserve each other’s order. But no single ordering of the two rows can ascend in both clocks.
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.

The seam § Creating a market depth snapshot

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.

the book · 00:00, no snapshotnothing known
A venue with no start of dayBinance Futures never closes, so a day file does not begin with an opening auction and a fresh book; it begins in the middle of one. Data Collector asks for a REST snapshot only when it opens its socket, so the first day of a recording has one and every day after it has none. Start a backtest here and the book is empty: not wrong, just unknown.

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 wiretick index storedprice recovered
61,800.20618,00261,800.20
61,800.30618,00361,800.30
tick_size = 0.1 · the venue’s own tickBTCUSDT perpetual quotes in 0.1 and trades in 0.001, so this is the pair the tutorial passes. Every price on the wire is an exact multiple, the tick index is the smallest integer that can represent it without loss, and a price comparison is an integer comparison. The lot size does the same job for quantity, and both are properties of the venue that you have to look up, because nothing in the feed states them.

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'
)
The alternative § Getting started from Tardis.dev data

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.

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

fromexch_tsevwhat it says
trades1580515202.342000000BUY_EVENT | TRADE_EVENTa buyer lifts 1.197 of the offer at 9,364.51
depth1580515202.342000000SELL_EVENT | DEPTH_EVENTthe offer at 9,364.51 goes from 2.000 to 0.803
Trade first: the recommended orderBoth rows carry the same exch_ts to the nanosecond, because they are one event at the matching engine described twice by two feeds. The sort is stable, so the tie is broken by which file was read first, and reading trades first puts the trade ahead of the depth decrease it caused.
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.

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
)
The whole thing § six steps

What conversion actually is

Two function calls on the tutorial page. Six things happening inside them.
  1. Collect the raw feed, or buy it, and know which timestamp the vendor put in the exchange column.
  2. Convert per line: branch on e, take T for exch_ts, keep the 19-digit prefix as local_ts, fan a depth message out to one row per level.
  3. Correct the latency: find the most negative local_ts − exch_ts in the file and shift every local_ts by it, plus whatever base_latency you are willing to claim.
  4. Correct the event order: merge the exchange walk with the local walk, splitting any row the two disagree about into an EXCH_EVENT copy and a LOCAL_EVENT copy.
  5. Validate: exchange timestamps monotonic among exchange rows, local timestamps monotonic among local rows.
  6. 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.

Recall check 10 questions

Did it stick?

One attempt per question; the explanation appears either way.

1. A raw feed line begins 1723161255030314667 {"stream":…. What is that first token?

2. A Binance depthUpdate carries both E and T. Which becomes exch_ts, and why?

3. One depthUpdate message with 53 bid levels and 29 ask levels becomes how many rows?

4. Three of the five lines the tutorial prints are bookTicker. With the default opt='', what happens to them?

5. ev = 3489660929. What is that row?

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?

8. Why can correct_event_order return more rows than it was given?

9. Binance Futures runs 24/7. What does that cost you, in data terms?

10. Why should trade files be passed to tardis.convert before depth files?

Answered 0 of 10 · 0 correct

Vocabulary Click to flip

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.

← all lab notes