Binary Search Algorithm for Market Data Files
This article is aimed at developers who need random-access-by-date into multi-gigabyte CSV tick files. binary_search.py is a Python module that locates a specific trading date inside a large sorted CSV by performing a binary search on byte positions rather than lines. It is part of the kibot-futures-library ecosystem and depends only on Python's standard library.
Overview
Financial tick files routinely reach multiple gigabytes and contain tens of millions of lines. A naive linear scan is impractical when you need to jump to a particular trading date. The module solves this by searching byte offsets directly: it reads small windows around the midpoint of the file, locates the nearest complete line, parses the leading date field, and narrows the range in the usual binary-search fashion. Any date is found in O(log n) time regardless of file size.
The module has zero external dependencies, it uses only os, datetime, and enum from the standard library.
Performance
The algorithm runs in O(log n) time complexity relative to file size. It reads only small buffers at each step rather than loading the file into memory, so RAM usage stays constant:
| File Size | Approximate Time |
|---|---|
| 100 MB | ~0.05 s |
| 1 GB | 0.1 -- 0.5 s |
| 10 GB | 0.5 -- 1.5 s |
The forward-pass buffer is 500 to 2,000 bytes depending on file type; the refinement back-buffer runs up to 100,000 bytes for tick files. Memory consumption does not scale with file size.
Installation
binary_search.py ships as part of the kibot-futures-library package. To use it standalone, copy the file into your project and import directly:
from binary_search import find_position_in_file_binary, PositionFileType
Requirements: Python 3.6+ (the module uses f-strings). Standard library only.
API Reference
PositionFileType Enum
class PositionFileType(Enum):
DAILY = 1
INTRADAY = 2
TICK = 3
This enum controls the buffer sizes used during the search. Choose the value that matches your file's line density:
| Value | Code | Read Buffer | Back Buffer | Use Case |
|---|---|---|---|---|
DAILY |
1 | 500 bytes | 1,000 bytes | Daily OHLCV bars -- short, sparse lines |
INTRADAY |
2 | 2,000 bytes | 100,000 bytes | Minute/hour bars -- moderate density |
TICK |
3 | 2,000 bytes | 100,000 bytes | Tick-level data -- high density |
The read buffer determines how many bytes are read at each binary search step to locate a complete line. The back buffer is used during the refinement pass to scan backward and find the first occurrence of the target date. For tick data, a single trading date can span hundreds of thousands of lines, so the larger back buffer ensures the refinement pass captures the true start of the date block.
find_position_in_file_binary()
def find_position_in_file_binary(
file_name,
date_to_find,
file_type=PositionFileType.TICK,
find_next_match=False,
debug_callback=None,
error_callback=None,
)
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
file_name |
str |
(required) | Absolute or relative path to the CSV data file. |
date_to_find |
datetime.date or datetime.datetime |
(required) | The target date to locate in the file. |
file_type |
PositionFileType |
PositionFileType.TICK |
Controls buffer sizes. Set to match your file's data density. |
find_next_match |
bool |
False |
When False, performs exact date match. When True, finds the first line with date >= target. |
debug_callback |
callable or None |
None |
Optional function called with debug messages during the search. Signature: callback(message). |
error_callback |
callable or None |
None |
Optional function called when non-fatal errors occur (e.g., decode failures on a line). |
Returns a tuple:
| Scenario | Return Value |
|---|---|
| Date found | (position, found_date, file_size) |
| Date not found | (-1, None, file_size) |
position(int), Byte offset from the start of the file where the first line of the target date begins. Use withfile.seek(position)to jump directly to that point.found_date(str), The date string as it appears in the file (MM/DD/YYYY), orNoneif not found.file_size(int), Total size of the file in bytes. Useful for calculating how much data remains after the found position.
Search Modes
Exact Match (find_next_match=False)
The default mode. Returns the byte position of the first line matching the exact target date. If the date does not exist in the file (weekend, holiday, or simply not present), returns -1.
Typical uses: extracting all data for a specific trading date, validating that a particular date exists in a file, and jumping to a known date for inspection or repair.
Lower Bound / Ceiling (find_next_match=True)
Returns the byte position of the first line where the date is greater than or equal to the target. This is the more flexible mode and handles real-world scenarios where the exact date may not exist in the file.
Typical uses: finding the start of a date range when the start date might fall on a weekend or holiday (the search automatically lands on the next available trading day); building Continuous futures contracts where you need to splice data starting from a roll date that may not have tick data; and iterating through a file date-by-date without needing to know which dates have data.
Usage Examples
Basic Exact Date Search
from datetime import date
from binary_search import find_position_in_file_binary, PositionFileType
position, found_date, file_size = find_position_in_file_binary(
file_name="ES_continuous.txt",
date_to_find=date(2024, 3, 15),
file_type=PositionFileType.TICK,
)
if position >= 0:
print(f"Found {found_date} at byte offset {position:,}")
print(f"File size: {file_size:,} bytes")
else:
print("Date not found in file")
Finding the Start Position of a Date Range
from datetime import date
from binary_search import find_position_in_file_binary, PositionFileType
# Find the first available date on or after March 15
start_pos, start_date, _ = find_position_in_file_binary(
file_name="ES_continuous.txt",
date_to_find=date(2024, 3, 15),
file_type=PositionFileType.TICK,
find_next_match=True, # lands on next trading day if 3/15 is missing
)
# Find the first date on or after March 22 (marks the end boundary)
end_pos, end_date, file_size = find_position_in_file_binary(
file_name="ES_continuous.txt",
date_to_find=date(2024, 3, 22),
file_type=PositionFileType.TICK,
find_next_match=True,
)
if start_pos >= 0 and end_pos >= 0:
print(f"Week of data: {start_date} to {end_date}")
print(f"Byte range: {start_pos:,} to {end_pos:,} ({end_pos - start_pos:,} bytes)")
Using with Kibot Data Files
from datetime import date
from binary_search import find_position_in_file_binary, PositionFileType
# Daily data -- use DAILY file type for smaller buffers
position, found_date, _ = find_position_in_file_binary(
file_name="ES_daily.txt",
date_to_find=date(2024, 1, 2),
file_type=PositionFileType.DAILY,
)
# Intraday bars
position, found_date, _ = find_position_in_file_binary(
file_name="ES_1min.txt",
date_to_find=date(2024, 1, 2),
file_type=PositionFileType.INTRADAY,
)
Processing Found Data
from datetime import date
from binary_search import find_position_in_file_binary, PositionFileType
position, found_date, file_size = find_position_in_file_binary(
file_name="ES_continuous.txt",
date_to_find=date(2024, 6, 10),
file_type=PositionFileType.TICK,
)
if position >= 0:
with open("ES_continuous.txt", "r") as f:
f.seek(position)
for line in f:
# Each line: MM/DD/YYYY,HH:MM:SS,price,volume
fields = line.strip().split(",")
line_date = fields[0]
# Stop when we reach a different date
if line_date != found_date:
break
price = float(fields[2])
volume = int(fields[3])
print(f"{fields[0]} {fields[1]} price={price} vol={volume}")
How It Works
The algorithm operates directly on byte positions, treating the sorted CSV as a searchable byte stream. First, it initializes low = 0 and high = file_size. Then at each step it seeks to the midpoint mid = (low + high) // 2, reads forward until it hits a newline character, and parses the first 10 characters of the resulting line as an MM/DD/YYYY date. If the found date is earlier than the target it sets low = mid; if later it sets high = mid; otherwise the target has been located. Once the binary search converges, a refinement pass reads a back-buffer (up to 100,000 bytes for tick data) backward from the found position and scans it to locate the first line with the target date, ensuring the returned position points to the very beginning of the date block rather than somewhere in the middle.
The file is opened in binary mode (rb) for precise byte-level seeking; line content is decoded as ASCII for the date comparison. This avoids the overhead and unpredictability of text-mode line buffering on large files. Edge cases, empty files, lines too short to contain a date, and decode errors, are caught gracefully and routed through the optional error callback.
Input File Format
The module expects CSV files with the following characteristics: the date appears in the first field, formatted as MM/DD/YYYY (10 characters, zero-padded month and day); there is no header row (the first line of the file must be data); lines are chronologically sorted by date in ascending order (binary search requires sorted input); line terminators are \n or \r\n (both are handled); and fields are comma-separated.
Example lines (tick data):
03/15/2024,09:30:00,5200.25,150
03/15/2024,09:30:01,5200.50,75
03/15/2024,09:30:01,5200.25,200
Example lines (daily data):
03/15/2024,5198.50,5215.75,5190.00,5210.25,1845230
03/18/2024,5212.00,5225.00,5205.50,5220.75,1523440
See Data format reference for the full field layouts of every Kibot product.
Integration
binary_search.py is used across several tools in the Kibot data pipeline. kibot-gap-detector locates date boundaries when scanning for gaps in tick data. kibot-continuous-futures finds roll dates when splicing individual contract files into continuous series. Various data-repair tools use it to jump to specific dates for inspection and correction. The consistent (position, found_date, file_size) return format makes it straightforward to integrate anywhere random-access-by-date into sorted CSV files is needed.
Key Takeaways
- O(log n) search by date in sorted CSV files; typical 10 GB file searched in 0.5-1.5 seconds.
- Constant memory, reads small buffers (500-2,000 bytes forward, up to 100,000 bytes back) at each step.
- Zero external dependencies; Python 3.6+ standard library only.
PositionFileType(DAILY/INTRADAY/TICK) tunes buffer sizes for the file's line density.find_next_match=Truegives lower-bound/ceiling semantics for date-range queries and roll-date logic.- Returns
(byte_offset, date_string, file_size)or(-1, None, file_size)on miss; usefile.seek(offset)to jump. - Requires files in
MM/DD/YYYY,...format with no header and ascending date order.
Related
- Data completeness, context for gap-detection workflows that rely on this module.
- Data format reference, exact field layouts for every Kibot CSV product.
- Tick data, the densest format where O(log N) lookup matters most.
- Timezone conversion, the ET timestamp convention that the algorithm assumes when parsing dates.
- Continuous futures, roll-splicing uses
find_next_match=Trueto locate roll dates. - Rollover rules, the date-driven contract switches that this lookup pattern accelerates.