feat: Add data server for market data loading

This commit introduces a new data server module (`src/ast/data_server`)
designed for high-performance, thread-safe loading and caching of market
data.

Key components:
- `DataServer`: Manages symbol indexing and delegates loading/caching.
- `SymbolIndex`: Scans the data directory and maintains a sorted index
  of data files per symbol.
- `FileCache`: Implements a thread-safe cache using `RwLock` and a
  loading guard to prevent duplicate work.
- `loader`: Handles ZIP decompression and binary record parsing from
  `.bin` files.
- `records`: Defines raw and parsed data structures for M1 and tick
  data.
- `SymbolChunkIter`: Provides an iterator over pre-parsed data chunks,
  prefetching subsequent files.

This architecture allows multiple VM threads to access market data
concurrently without redundant I/O, mirroring the strategy used in the
original Delphi `TDataServer`.
This commit is contained in:
2026-03-29 20:28:55 +02:00
parent 1f3fb40bcc
commit 38f657076b
8 changed files with 1601 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
//! Thread-safe file cache for parsed market data chunks.
//!
//! Designed for a many-readers / rare-writer pattern: hundreds of VM threads
//! read concurrently via `RwLock`, while the loader writes each file exactly
//! once. Duplicate loads are prevented by a `loading` guard set.
use super::records::{DataFormat, M1Parsed, TickParsed};
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex, RwLock};
/// Uniquely identifies one data file on disk.
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
pub struct FileKey {
pub symbol: Arc<str>,
pub year: u16,
pub month: u8,
pub format: DataFormat,
}
/// A file's worth of pre-parsed chunks, shared across threads.
pub type ChunkVec<T> = Arc<Vec<Arc<[T]>>>;
/// Cache map: file key → shared chunk vector.
type CacheMap<T> = RwLock<HashMap<FileKey, ChunkVec<T>>>;
/// Thread-safe cache storing pre-parsed chunks of market data.
///
/// Each file is stored as a `Vec` of `Arc<[T]>` chunks (1024 records each).
/// The outer `Arc` allows the entire file to be shared without copying,
/// and individual chunk `Arc`s let iterators hold references to just the
/// chunk they are consuming.
pub struct FileCache {
m1: CacheMap<M1Parsed>,
tick: CacheMap<TickParsed>,
/// Tracks files currently being loaded to prevent duplicate work.
/// A key is inserted before loading starts and removed after the
/// result is written to the cache.
loading: Mutex<HashSet<FileKey>>,
}
impl Default for FileCache {
fn default() -> Self {
Self::new()
}
}
impl FileCache {
pub fn new() -> Self {
Self {
m1: RwLock::new(HashMap::new()),
tick: RwLock::new(HashMap::new()),
loading: Mutex::new(HashSet::new()),
}
}
// -- M1 -----------------------------------------------------------------
/// Returns cached M1 chunks for the given key, or `None` if not yet loaded.
pub fn get_m1(&self, key: &FileKey) -> Option<ChunkVec<M1Parsed>> {
self.m1.read().unwrap().get(key).cloned()
}
/// Inserts M1 chunks into the cache and clears the loading guard.
pub fn insert_m1(&self, key: FileKey, chunks: Vec<Arc<[M1Parsed]>>) {
self.m1
.write()
.unwrap()
.insert(key.clone(), Arc::new(chunks));
self.loading.lock().unwrap().remove(&key);
}
// -- Tick ----------------------------------------------------------------
/// Returns cached tick chunks for the given key, or `None` if not yet loaded.
pub fn get_tick(&self, key: &FileKey) -> Option<ChunkVec<TickParsed>> {
self.tick.read().unwrap().get(key).cloned()
}
/// Inserts tick chunks into the cache and clears the loading guard.
pub fn insert_tick(&self, key: FileKey, chunks: Vec<Arc<[TickParsed]>>) {
self.tick
.write()
.unwrap()
.insert(key.clone(), Arc::new(chunks));
self.loading.lock().unwrap().remove(&key);
}
// -- Loading guard -------------------------------------------------------
/// Attempts to claim the loading slot for `key`. Returns `true` if this
/// thread should load the file (first caller wins), `false` if another
/// thread is already loading it or it is already cached.
pub fn try_claim_loading(&self, key: &FileKey) -> bool {
// Fast path: already cached?
if key.format == DataFormat::M1 {
if self.m1.read().unwrap().contains_key(key) {
return false;
}
} else if self.tick.read().unwrap().contains_key(key) {
return false;
}
// Slow path: try to claim the loading slot
self.loading.lock().unwrap().insert(key.clone())
}
/// Checks whether the given key is currently being loaded by another thread.
pub fn is_loading(&self, key: &FileKey) -> bool {
self.loading.lock().unwrap().contains(key)
}
/// Checks whether the given key is available in the cache (already loaded).
pub fn is_cached(&self, key: &FileKey) -> bool {
match key.format {
DataFormat::M1 => self.m1.read().unwrap().contains_key(key),
DataFormat::Tick => self.tick.read().unwrap().contains_key(key),
}
}
/// Spins briefly until the key is no longer in the loading set, i.e.
/// another thread has finished loading it. Returns `true` if the key
/// is now cached, `false` on timeout.
pub fn wait_for_load(&self, key: &FileKey) -> bool {
// Simple spin-wait with yield. In practice, file loads take
// milliseconds and this is only hit during prefetch races.
for _ in 0..10_000 {
if self.is_cached(key) {
return true;
}
std::thread::yield_now();
}
// Fallback: still not cached, caller should load it themselves.
false
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_key(symbol: &str, year: u16, month: u8, format: DataFormat) -> FileKey {
FileKey {
symbol: Arc::from(symbol),
year,
month,
format,
}
}
#[test]
fn test_insert_and_get_m1() {
let cache = FileCache::new();
let key = test_key("EURUSD", 2017, 3, DataFormat::M1);
assert!(cache.get_m1(&key).is_none());
let chunk: Arc<[M1Parsed]> = Arc::from(vec![M1Parsed {
time_ms: 0,
open: 1.0,
high: 1.1,
low: 0.9,
close: 1.05,
spread: 0.1,
volume: 100,
}]);
cache.insert_m1(key.clone(), vec![chunk]);
let result = cache.get_m1(&key).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].len(), 1);
assert_eq!(result[0][0].open, 1.0);
}
#[test]
fn test_loading_guard() {
let cache = FileCache::new();
let key = test_key("EURUSD", 2017, 3, DataFormat::M1);
// First claim succeeds
assert!(cache.try_claim_loading(&key));
// Second claim fails (already loading)
assert!(!cache.try_claim_loading(&key));
assert!(cache.is_loading(&key));
// After insert, loading guard is cleared
cache.insert_m1(key.clone(), vec![]);
assert!(!cache.is_loading(&key));
// After caching, claim also returns false
assert!(!cache.try_claim_loading(&key));
}
#[test]
fn test_is_cached() {
let cache = FileCache::new();
let m1_key = test_key("EURUSD", 2017, 3, DataFormat::M1);
let tick_key = test_key("EURUSD", 2017, 3, DataFormat::Tick);
assert!(!cache.is_cached(&m1_key));
assert!(!cache.is_cached(&tick_key));
cache.insert_m1(m1_key.clone(), vec![]);
assert!(cache.is_cached(&m1_key));
assert!(!cache.is_cached(&tick_key));
cache.insert_tick(tick_key.clone(), vec![]);
assert!(cache.is_cached(&tick_key));
}
}
+220
View File
@@ -0,0 +1,220 @@
//! ZIP decompression and binary record parsing for tick data files.
//!
//! Each data file is a ZIP archive containing a single `.bin` entry with
//! tightly packed binary records. This module reads and parses them into
//! `Arc`-shared chunks of 1024 records, mirroring the Delphi
//! `TDataServer<T,R>.ReadUncompressedData` chunking strategy.
use super::records::{M1Parsed, RawM1Record, RawTickRecord, TickParsed};
use std::io::Read;
use std::path::Path;
use std::sync::Arc;
/// Number of records per chunk. Matches the Delphi `ChunkSize = 1024` constant.
pub const CHUNK_SIZE: usize = 1024;
/// Loads an M1 (minute OHLCV) data file and returns pre-parsed chunks.
///
/// The file must be a ZIP archive containing a `.bin` entry with packed
/// `RawM1Record` structs (48 bytes each).
pub fn load_m1_file(path: &Path) -> std::io::Result<Vec<Arc<[M1Parsed]>>> {
let buf = read_bin_entry(path)?;
Ok(parse_chunks(&buf, std::mem::size_of::<RawM1Record>(), |ptr| {
// SAFETY: `ptr` points to `size_of::<RawM1Record>()` valid bytes.
let raw = unsafe { &*(ptr as *const RawM1Record) };
M1Parsed::from_raw(raw)
}))
}
/// Loads a tick data file and returns pre-parsed chunks.
///
/// The file must be a ZIP archive containing a `.bin` entry with packed
/// `RawTickRecord` structs (24 bytes each).
pub fn load_tick_file(path: &Path) -> std::io::Result<Vec<Arc<[TickParsed]>>> {
let buf = read_bin_entry(path)?;
Ok(parse_chunks(&buf, std::mem::size_of::<RawTickRecord>(), |ptr| {
let raw = unsafe { &*(ptr as *const RawTickRecord) };
TickParsed::from_raw(raw)
}))
}
/// Opens a ZIP archive and reads the first `.bin` entry into memory.
///
/// Mirrors Delphi's `ReadCompressedData`: opens the archive, locates the
/// `.bin` entry, decompresses it, and returns the raw bytes.
fn read_bin_entry(path: &Path) -> std::io::Result<Vec<u8>> {
let file = std::fs::File::open(path)?;
let mut archive = zip::ZipArchive::new(file)?;
// Find the .bin entry (like Delphi's search loop in ReadCompressedData)
let entry_index = (0..archive.len())
.find(|&i| {
archive
.name_for_index(i)
.is_some_and(|name| name.ends_with(".bin"))
})
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("No .bin entry found in {}", path.display()),
)
})?;
let mut entry = archive.by_index(entry_index)?;
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf)?;
Ok(buf)
}
/// Parses a byte buffer into chunks of parsed records.
///
/// Generic over the output type `T` via a conversion closure that receives
/// a pointer to each raw record in the buffer. This is the Rust equivalent
/// of Delphi's `ReadUncompressedData` with its `MapRecord` virtual call.
fn parse_chunks<T: Copy>(
buf: &[u8],
record_size: usize,
convert: impl Fn(*const u8) -> T,
) -> Vec<Arc<[T]>> {
let total_records = buf.len() / record_size;
if total_records == 0 {
return Vec::new();
}
let chunk_count = total_records.div_ceil(CHUNK_SIZE);
let mut chunks = Vec::with_capacity(chunk_count);
for chunk_idx in 0..chunk_count {
let start = chunk_idx * CHUNK_SIZE;
let end = (start + CHUNK_SIZE).min(total_records);
let mut parsed = Vec::with_capacity(end - start);
for i in start..end {
let ptr = buf[i * record_size..].as_ptr();
parsed.push(convert(ptr));
}
chunks.push(Arc::from(parsed.into_boxed_slice()));
}
chunks
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::data_server::records::RawM1Record;
use std::io::Write;
/// Creates a minimal ZIP file containing a `.bin` entry with raw M1 records.
fn create_test_m1_zip(records: &[RawM1Record]) -> tempfile::NamedTempFile {
let tmp = tempfile::NamedTempFile::new().unwrap();
let mut zip = zip::ZipWriter::new(tmp.reopen().unwrap());
zip.start_file::<String, ()>("TEST.bin".into(), Default::default())
.unwrap();
for rec in records {
let bytes: &[u8] = unsafe {
std::slice::from_raw_parts(rec as *const RawM1Record as *const u8, 48)
};
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap();
tmp
}
#[test]
fn test_load_m1_single_chunk() {
// Create 3 test records
let records = vec![
RawM1Record {
time: 42795.0,
open: 1.05,
high: 1.06,
low: 1.04,
close: 1.055,
spread: 0.5,
volume: 100,
},
RawM1Record {
time: 42795.001,
open: 1.055,
high: 1.07,
low: 1.05,
close: 1.065,
spread: 0.4,
volume: 200,
},
RawM1Record {
time: 42795.002,
open: 1.065,
high: 1.08,
low: 1.06,
close: 1.075,
spread: 0.3,
volume: 300,
},
];
let zip_file = create_test_m1_zip(&records);
let chunks = load_m1_file(zip_file.path()).unwrap();
// 3 records < 1024, so only 1 chunk
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].len(), 3);
assert_eq!(chunks[0][0].open, 1.05);
assert_eq!(chunks[0][1].volume, 200);
assert_eq!(chunks[0][2].close, 1.075);
}
#[test]
fn test_load_m1_multiple_chunks() {
// Create exactly CHUNK_SIZE + 1 records to verify chunking
let records: Vec<RawM1Record> = (0..CHUNK_SIZE + 1)
.map(|i| RawM1Record {
time: 42795.0 + i as f64 * 0.001,
open: 1.0 + i as f64 * 0.001,
high: 1.1,
low: 0.9,
close: 1.05,
spread: 0.1,
volume: i as i32,
})
.collect();
let zip_file = create_test_m1_zip(&records);
let chunks = load_m1_file(zip_file.path()).unwrap();
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].len(), CHUNK_SIZE);
assert_eq!(chunks[1].len(), 1);
// Last record should have volume == CHUNK_SIZE
assert_eq!(chunks[1][0].volume, CHUNK_SIZE as i64);
}
#[test]
fn test_empty_bin_entry() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let mut zip = zip::ZipWriter::new(tmp.reopen().unwrap());
zip.start_file::<String, ()>("empty.bin".into(), Default::default())
.unwrap();
zip.finish().unwrap();
let chunks = load_m1_file(tmp.path()).unwrap();
assert!(chunks.is_empty());
}
#[test]
fn test_no_bin_entry() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let mut zip = zip::ZipWriter::new(tmp.reopen().unwrap());
zip.start_file::<String, ()>("data.csv".into(), Default::default())
.unwrap();
zip.write_all(b"not binary").unwrap();
zip.finish().unwrap();
let result = load_m1_file(tmp.path());
assert!(result.is_err());
}
}
+518
View File
@@ -0,0 +1,518 @@
//! High-performance data server for binary market data files.
//!
//! Provides thread-safe (`Arc`-based) loading and caching of tick data,
//! designed to serve hundreds of single-threaded VMs in parallel without
//! redundant I/O. Each file is read and parsed once, then shared across
//! all consumers via `Arc<[T]>` chunk references.
//!
//! # Architecture
//!
//! ```text
//! Arc<DataServer>
//! ┌──────────────────────────┐
//! │ SymbolIndex FileCache │
//! │ (HashMap) (RwLock) │
//! └─────┬────────────┬───────┘
//! │ │
//! VM Thread 1 │ VM Thread N
//! Generator ──┘ Generator ──┘
//! ```
pub mod cache;
pub mod loader;
pub mod records;
use cache::{ChunkVec, FileCache, FileKey};
use records::{DataFormat, M1Parsed, TickParsed};
use regex::Regex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
/// Global data server instance, initialized once before any VM threads start.
static DATA_SERVER: OnceLock<Arc<DataServer>> = OnceLock::new();
/// Initializes the global data server for the given base path.
///
/// Scans the directory for data files and builds the symbol index.
/// Must be called once before any `get_data_server()` calls (typically
/// at program startup). Returns the `Arc` for optional local use.
///
/// # Panics
///
/// Panics if called more than once (the `OnceLock` is already set).
pub fn init_data_server(base_path: impl AsRef<Path>) -> Arc<DataServer> {
let server = Arc::new(DataServer::new(base_path));
DATA_SERVER
.set(server.clone())
.expect("DataServer already initialized");
server
}
/// Returns the global data server, or `None` if not yet initialized.
pub fn get_data_server() -> Option<Arc<DataServer>> {
DATA_SERVER.get().cloned()
}
/// Pre-scanned directory index mapping symbols to their sorted data files.
struct SymbolIndex {
base_path: PathBuf,
/// Symbol name (e.g. "EURUSD") → sorted list of file keys (chronological).
symbols: HashMap<Arc<str>, Vec<FileKey>>,
}
impl SymbolIndex {
/// Scans `base_path` for files matching `SYMBOL_YYYY_MM.(m1|tick)` and
/// builds a grouped, chronologically sorted index.
///
/// Mirrors Delphi's `TDataServer.UpdateSymbols`.
fn scan(base_path: &Path) -> Self {
let re = Regex::new(r"^(.+)_(\d{4})_(\d{2})\.(m1|tick)$").unwrap();
let mut map: HashMap<Arc<str>, Vec<FileKey>> = HashMap::new();
if let Ok(entries) = std::fs::read_dir(base_path) {
for entry in entries.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if let Some(caps) = re.captures(&name) {
let symbol: Arc<str> = Arc::from(&caps[1]);
let year: u16 = caps[2].parse().unwrap_or(0);
let month: u8 = caps[3].parse().unwrap_or(0);
let format = match &caps[4] {
"m1" => DataFormat::M1,
"tick" => DataFormat::Tick,
_ => continue,
};
if year > 0 && (1..=12).contains(&month) {
let key = FileKey {
symbol: symbol.clone(),
year,
month,
format,
};
map.entry(symbol).or_default().push(key);
}
}
}
}
// Sort each symbol's files chronologically (year, month, format)
for files in map.values_mut() {
files.sort_by(|a, b| {
(a.year, a.month, a.format as u8).cmp(&(b.year, b.month, b.format as u8))
});
}
Self {
base_path: base_path.to_path_buf(),
symbols: map,
}
}
/// Returns the filesystem path for a given file key.
fn file_path(&self, key: &FileKey) -> PathBuf {
let ext = match key.format {
DataFormat::M1 => "m1",
DataFormat::Tick => "tick",
};
self.base_path
.join(format!("{}_{:04}_{:02}.{}", key.symbol, key.year, key.month, ext))
}
}
/// High-performance data server for binary market data files.
///
/// Thread-safe (`Send + Sync`). Designed to be wrapped in `Arc` and shared
/// across hundreds of VM threads. Each file is loaded and parsed once,
/// then served from the in-memory cache.
pub struct DataServer {
index: SymbolIndex,
cache: FileCache,
}
impl std::fmt::Debug for DataServer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"DataServer({:?}, {} symbols)",
self.index.base_path,
self.index.symbols.len()
)
}
}
impl DataServer {
/// Creates a new data server, scanning the base path for data files.
pub fn new(base_path: impl AsRef<Path>) -> Self {
Self {
index: SymbolIndex::scan(base_path.as_ref()),
cache: FileCache::new(),
}
}
/// Returns a sorted list of all known symbol names.
pub fn symbols(&self) -> Vec<Arc<str>> {
let mut syms: Vec<_> = self.index.symbols.keys().cloned().collect();
syms.sort();
syms
}
/// Returns the number of data files for a given symbol and format,
/// or `None` if the symbol is unknown.
pub fn file_count(&self, symbol: &str, format: DataFormat) -> Option<usize> {
self.index.symbols.get(symbol).map(|files| {
files.iter().filter(|f| f.format == format).count()
})
}
/// Returns a chunk iterator over all M1 data for a symbol.
///
/// The iterator yields `Arc<[M1Parsed]>` chunks in chronological order,
/// prefetching the next file while the current one is consumed.
pub fn stream_m1(self: &Arc<Self>, symbol: &str) -> Option<SymbolChunkIter<M1Parsed>> {
let files: Vec<FileKey> = self
.index
.symbols
.get(symbol)?
.iter()
.filter(|f| f.format == DataFormat::M1)
.cloned()
.collect();
if files.is_empty() {
return None;
}
// Eagerly load the first file and prefetch the second
self.ensure_m1_loaded(&files[0]);
if files.len() > 1 {
self.prefetch_m1(&files[1]);
}
Some(SymbolChunkIter {
server: Arc::clone(self),
files,
file_idx: 0,
chunk_idx: 0,
current_file_chunks: None,
})
}
/// Returns a chunk iterator over all tick data for a symbol.
pub fn stream_tick(self: &Arc<Self>, symbol: &str) -> Option<SymbolChunkIter<TickParsed>> {
let files: Vec<FileKey> = self
.index
.symbols
.get(symbol)?
.iter()
.filter(|f| f.format == DataFormat::Tick)
.cloned()
.collect();
if files.is_empty() {
return None;
}
self.ensure_tick_loaded(&files[0]);
if files.len() > 1 {
self.prefetch_tick(&files[1]);
}
Some(SymbolChunkIter {
server: Arc::clone(self),
files,
file_idx: 0,
chunk_idx: 0,
current_file_chunks: None,
})
}
// -- M1 loading ----------------------------------------------------------
/// Ensures the given M1 file is loaded into the cache (blocking).
fn ensure_m1_loaded(&self, key: &FileKey) {
if self.cache.is_cached(key) {
return;
}
if self.cache.is_loading(key) {
self.cache.wait_for_load(key);
if self.cache.is_cached(key) {
return;
}
}
self.load_m1_sync(key);
}
/// Synchronously loads an M1 file into the cache.
fn load_m1_sync(&self, key: &FileKey) {
if !self.cache.try_claim_loading(key) {
// Another thread is loading or already cached — wait for it
self.cache.wait_for_load(key);
return;
}
let path = self.index.file_path(key);
match loader::load_m1_file(&path) {
Ok(chunks) => self.cache.insert_m1(key.clone(), chunks),
Err(e) => {
eprintln!("Warning: failed to load {}: {}", path.display(), e);
// Insert empty to unblock waiters and prevent retries
self.cache.insert_m1(key.clone(), vec![]);
}
}
}
/// Prefetches an M1 file in a background thread.
///
/// Requires `Arc<Self>` to safely share the server with the spawned thread.
fn prefetch_m1(self: &Arc<Self>, key: &FileKey) {
if self.cache.is_cached(key) || !self.cache.try_claim_loading(key) {
return;
}
let path = self.index.file_path(key);
let cache_key = key.clone();
let server = Arc::clone(self);
std::thread::spawn(move || {
match loader::load_m1_file(&path) {
Ok(chunks) => server.cache.insert_m1(cache_key, chunks),
Err(e) => {
eprintln!("Warning: prefetch failed for {}: {}", path.display(), e);
server.cache.insert_m1(cache_key, vec![]);
}
}
});
}
// -- Tick loading --------------------------------------------------------
/// Ensures the given tick file is loaded into the cache (blocking).
fn ensure_tick_loaded(&self, key: &FileKey) {
if self.cache.is_cached(key) {
return;
}
if self.cache.is_loading(key) {
self.cache.wait_for_load(key);
if self.cache.is_cached(key) {
return;
}
}
self.load_tick_sync(key);
}
/// Synchronously loads a tick file into the cache.
fn load_tick_sync(&self, key: &FileKey) {
if !self.cache.try_claim_loading(key) {
self.cache.wait_for_load(key);
return;
}
let path = self.index.file_path(key);
match loader::load_tick_file(&path) {
Ok(chunks) => self.cache.insert_tick(key.clone(), chunks),
Err(e) => {
eprintln!("Warning: failed to load {}: {}", path.display(), e);
self.cache.insert_tick(key.clone(), vec![]);
}
}
}
/// Prefetches a tick file in a background thread.
fn prefetch_tick(self: &Arc<Self>, key: &FileKey) {
if self.cache.is_cached(key) || !self.cache.try_claim_loading(key) {
return;
}
let path = self.index.file_path(key);
let cache_key = key.clone();
let server = Arc::clone(self);
std::thread::spawn(move || {
match loader::load_tick_file(&path) {
Ok(chunks) => server.cache.insert_tick(cache_key, chunks),
Err(e) => {
eprintln!("Warning: prefetch failed for {}: {}", path.display(), e);
server.cache.insert_tick(cache_key, vec![]);
}
}
});
}
}
/// Iterator yielding chunks of parsed records for a single symbol.
///
/// Traverses all files for the symbol in chronological order, yielding
/// each 1024-record chunk as an `Arc<[T]>`. When advancing to the next
/// file, the file after that is prefetched in the background.
///
/// This is the Rust equivalent of Delphi's `ProcessFile` chain with its
/// `LoadDataFile(nextFileInfo)` prefetch call.
pub struct SymbolChunkIter<T> {
server: Arc<DataServer>,
files: Vec<FileKey>,
file_idx: usize,
chunk_idx: usize,
/// Cached reference to the current file's chunks to avoid repeated
/// cache lookups within the same file.
current_file_chunks: Option<ChunkVec<T>>,
}
impl SymbolChunkIter<M1Parsed> {
/// Returns the next chunk of M1 records, or `None` when exhausted.
pub fn next_chunk(&mut self) -> Option<Arc<[M1Parsed]>> {
loop {
// Try to get chunks for the current file
if self.current_file_chunks.is_none() && self.file_idx < self.files.len() {
let key = &self.files[self.file_idx];
self.server.ensure_m1_loaded(key);
self.current_file_chunks = self.server.cache.get_m1(key);
self.chunk_idx = 0;
}
let chunks = self.current_file_chunks.as_ref()?;
if self.chunk_idx < chunks.len() {
let chunk = chunks[self.chunk_idx].clone();
self.chunk_idx += 1;
return Some(chunk);
}
// Current file exhausted — advance to next file
self.file_idx += 1;
self.current_file_chunks = None;
if self.file_idx >= self.files.len() {
return None; // All files exhausted
}
// Prefetch the file AFTER the next one (N+2), since N+1 was
// already prefetched when we started N.
if self.file_idx + 1 < self.files.len() {
self.server.prefetch_m1(&self.files[self.file_idx + 1]);
}
}
}
}
impl SymbolChunkIter<TickParsed> {
/// Returns the next chunk of tick records, or `None` when exhausted.
pub fn next_chunk(&mut self) -> Option<Arc<[TickParsed]>> {
loop {
if self.current_file_chunks.is_none() && self.file_idx < self.files.len() {
let key = &self.files[self.file_idx];
self.server.ensure_tick_loaded(key);
self.current_file_chunks = self.server.cache.get_tick(key);
self.chunk_idx = 0;
}
let chunks = self.current_file_chunks.as_ref()?;
if self.chunk_idx < chunks.len() {
let chunk = chunks[self.chunk_idx].clone();
self.chunk_idx += 1;
return Some(chunk);
}
self.file_idx += 1;
self.current_file_chunks = None;
if self.file_idx >= self.files.len() {
return None;
}
if self.file_idx + 1 < self.files.len() {
self.server.prefetch_tick(&self.files[self.file_idx + 1]);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_symbol_index_scan() {
// Create a temp directory with mock files
let dir = tempfile::tempdir().unwrap();
let names = [
"EURUSD_2017_01.m1",
"EURUSD_2017_02.m1",
"EURUSD_2017_03.m1",
"EURUSD_2017_01.tick",
"GBPUSD_2020_06.m1",
"invalid_file.txt",
"AAPL.US_2006_08.m1",
];
for name in &names {
std::fs::write(dir.path().join(name), b"").unwrap();
}
let index = SymbolIndex::scan(dir.path());
// EURUSD has both m1 and tick files
let eur = index.symbols.get("EURUSD").unwrap();
assert_eq!(eur.len(), 4); // 3 m1 + 1 tick
// Should be sorted chronologically, M1 before Tick within same month
assert_eq!(eur[0].month, 1);
assert_eq!(eur[0].format, DataFormat::M1);
// The tick file for month 1 comes right after the m1 file for month 1
assert_eq!(eur[1].month, 1);
assert_eq!(eur[1].format, DataFormat::Tick);
// AAPL.US with dot in symbol name
let aapl = index.symbols.get("AAPL.US").unwrap();
assert_eq!(aapl.len(), 1);
assert_eq!(aapl[0].year, 2006);
// GBPUSD
assert!(index.symbols.contains_key("GBPUSD"));
// Invalid file should not appear
assert!(!index.symbols.contains_key("invalid_file"));
}
#[test]
fn test_file_path_construction() {
let index = SymbolIndex {
base_path: PathBuf::from("/mnt/tickdata/Pepperstone"),
symbols: HashMap::new(),
};
let key = FileKey {
symbol: Arc::from("EURUSD"),
year: 2017,
month: 3,
format: DataFormat::M1,
};
assert_eq!(
index.file_path(&key),
PathBuf::from("/mnt/tickdata/Pepperstone/EURUSD_2017_03.m1")
);
let tick_key = FileKey {
symbol: Arc::from("AAPL.US"),
year: 2006,
month: 8,
format: DataFormat::Tick,
};
assert_eq!(
index.file_path(&tick_key),
PathBuf::from("/mnt/tickdata/Pepperstone/AAPL.US_2006_08.tick")
);
}
#[test]
fn test_data_server_symbols() {
let dir = tempfile::tempdir().unwrap();
for name in ["EURUSD_2017_01.m1", "GBPUSD_2020_06.m1"] {
std::fs::write(dir.path().join(name), b"").unwrap();
}
let server = DataServer::new(dir.path());
let syms = server.symbols();
assert_eq!(syms.len(), 2);
assert_eq!(syms[0].as_ref(), "EURUSD");
assert_eq!(syms[1].as_ref(), "GBPUSD");
}
}
+189
View File
@@ -0,0 +1,189 @@
//! Binary record types for Pepperstone tick data files.
//!
//! The raw types (`RawM1Record`, `RawTickRecord`) mirror the Delphi `packed record`
//! layout used by the C# data exporter. The parsed types (`M1Parsed`, `TickParsed`)
//! hold converted values ready for consumption by the VM.
/// Delphi `TDateTime` epoch offset: days between 1899-12-30 and 1970-01-01.
const DELPHI_EPOCH_OFFSET_DAYS: f64 = 25569.0;
/// Milliseconds per day.
const MS_PER_DAY: f64 = 86_400_000.0;
/// Converts a Delphi `TDateTime` (f64 days since 1899-12-30) to Unix milliseconds.
///
/// Compatible with the existing `Value::DateTime(i64)` representation.
#[inline]
pub fn delphi_to_unix_ms(dt: f64) -> i64 {
((dt - DELPHI_EPOCH_OFFSET_DAYS) * MS_PER_DAY) as i64
}
/// Raw M1 (minute) record as stored on disk. 48 bytes, packed.
///
/// Matches the Delphi `TM1Record = packed record` layout exactly:
/// `Time: Double; Open: Double; High: Double; Low: Double; Close: Double; Spread: Single; Volume: Integer;`
#[repr(C, packed)]
#[derive(Clone, Copy)]
pub struct RawM1Record {
pub time: f64,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub spread: f32,
pub volume: i32,
}
/// Raw tick record as stored on disk. 24 bytes, packed.
///
/// Matches the Delphi `TTickRecord = packed record` layout:
/// `Time: Double; Ask: Double; Bid: Double;`
#[repr(C, packed)]
#[derive(Clone, Copy)]
pub struct RawTickRecord {
pub time: f64,
pub ask: f64,
pub bid: f64,
}
/// Parsed M1 record with converted field types.
///
/// All fields are native-width (`f64`/`i64`) for direct mapping to `Value` variants.
/// `time_ms` is Unix milliseconds (converted from Delphi TDateTime).
#[derive(Debug, Clone, Copy)]
pub struct M1Parsed {
pub time_ms: i64,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub spread: f64,
pub volume: i64,
}
/// Parsed tick record with converted field types.
#[derive(Debug, Clone, Copy)]
pub struct TickParsed {
pub time_ms: i64,
pub ask: f64,
pub bid: f64,
}
/// Distinguishes the two supported data file formats.
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
pub enum DataFormat {
M1,
Tick,
}
impl M1Parsed {
/// Converts a raw on-disk record to the parsed representation.
///
/// Uses `read_unaligned` because `RawM1Record` is `packed` and field
/// access would be unaligned on architectures that require alignment.
#[inline]
pub fn from_raw(raw: &RawM1Record) -> Self {
// SAFETY: `raw` points to a valid `RawM1Record`. We use `read_unaligned`
// to safely read potentially unaligned fields from the packed struct.
unsafe {
Self {
time_ms: delphi_to_unix_ms(std::ptr::addr_of!(raw.time).read_unaligned()),
open: std::ptr::addr_of!(raw.open).read_unaligned(),
high: std::ptr::addr_of!(raw.high).read_unaligned(),
low: std::ptr::addr_of!(raw.low).read_unaligned(),
close: std::ptr::addr_of!(raw.close).read_unaligned(),
spread: std::ptr::addr_of!(raw.spread).read_unaligned() as f64,
volume: std::ptr::addr_of!(raw.volume).read_unaligned() as i64,
}
}
}
}
impl TickParsed {
/// Converts a raw on-disk tick record to the parsed representation.
#[inline]
pub fn from_raw(raw: &RawTickRecord) -> Self {
unsafe {
Self {
time_ms: delphi_to_unix_ms(std::ptr::addr_of!(raw.time).read_unaligned()),
ask: std::ptr::addr_of!(raw.ask).read_unaligned(),
bid: std::ptr::addr_of!(raw.bid).read_unaligned(),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_delphi_datetime_conversion() {
// 2017-03-01 00:00:00 UTC = Unix timestamp 1488326400000 ms
// Delphi TDateTime for 2017-03-01 = 42795.0
let delphi_dt = 42795.0;
let unix_ms = delphi_to_unix_ms(delphi_dt);
// Allow 1ms tolerance for floating-point rounding
assert!(
(unix_ms - 1_488_326_400_000).abs() <= 1,
"Expected ~1488326400000, got {unix_ms}"
);
}
#[test]
fn test_delphi_epoch() {
// The Unix epoch (1970-01-01) in Delphi TDateTime is exactly 25569.0
let unix_ms = delphi_to_unix_ms(25569.0);
assert_eq!(unix_ms, 0);
}
#[test]
fn test_raw_m1_record_size() {
assert_eq!(std::mem::size_of::<RawM1Record>(), 48);
}
#[test]
fn test_raw_tick_record_size() {
assert_eq!(std::mem::size_of::<RawTickRecord>(), 24);
}
#[test]
fn test_m1_from_raw() {
// Construct a raw record with known values
let raw = RawM1Record {
time: 42795.0, // 2017-03-01
open: 1.05,
high: 1.06,
low: 1.04,
close: 1.055,
spread: 0.5_f32,
volume: 1000,
};
let parsed = M1Parsed::from_raw(&raw);
assert!((parsed.time_ms - 1_488_326_400_000).abs() <= 1);
assert_eq!(parsed.open, 1.05);
assert_eq!(parsed.high, 1.06);
assert_eq!(parsed.low, 1.04);
assert_eq!(parsed.close, 1.055);
assert!((parsed.spread - 0.5).abs() < f64::EPSILON);
assert_eq!(parsed.volume, 1000);
}
#[test]
fn test_tick_from_raw() {
let raw = RawTickRecord {
time: 42795.5, // 2017-03-01 12:00:00
ask: 1.05684,
bid: 1.05689,
};
let parsed = TickParsed::from_raw(&raw);
assert_eq!(parsed.ask, 1.05684);
assert_eq!(parsed.bid, 1.05689);
// 12:00:00 = half a day = 43200000 ms offset
let expected_ms = 1_488_326_400_000 + 43_200_000;
assert!(
(parsed.time_ms - expected_ms).abs() <= 1,
"Expected ~{expected_ms}, got {}",
parsed.time_ms
);
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod closure;
pub mod compiler;
pub mod data_server;
pub mod diagnostics;
pub mod environment;
pub mod lexer;