diff --git a/Cargo.lock b/Cargo.lock index 26db041..c4f4593 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -994,6 +994,16 @@ dependencies = [ "syn", ] +[[package]] +name = "data-server" +version = "0.1.0" +source = "git+http://192.168.178.103:3000/Brummel/data-server.git?branch=main#c0b1268e14245d11d32def39517cbcf77c731aea" +dependencies = [ + "chrono", + "regex", + "zip", +] + [[package]] name = "deflate64" version = "0.1.12" @@ -1821,7 +1831,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.58.0", ] [[package]] @@ -2257,6 +2267,7 @@ version = "0.1.0" dependencies = [ "chrono", "clap", + "data-server", "eframe", "egui_extras", "fastrand", diff --git a/Cargo.toml b/Cargo.toml index c91d119..910d43f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ rmcp = { version = "1.2", features = ["server", "transport-io"] } tokio = { version = "1", features = ["rt", "io-std", "macros"] } serde = { version = "1", features = ["derive"] } zip = "2" +data-server = { git = "http://192.168.178.103:3000/Brummel/data-server.git", branch = "main" } [dev-dependencies] insta = { version = "1.39", features = ["yaml"] } diff --git a/src/ast/data_server/cache.rs b/src/ast/data_server/cache.rs deleted file mode 100644 index 6bcd112..0000000 --- a/src/ast/data_server/cache.rs +++ /dev/null @@ -1,318 +0,0 @@ -//! 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, and waiting -//! threads are woken via `Condvar` (not spin-waiting). - -use super::records::{DataFormat, M1Parsed, TickParsed}; -use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, Condvar, Mutex, RwLock}; - -/// Per-symbol reference count for cache eviction. -type SymbolRefCounts = Mutex, usize>>; - -/// Uniquely identifies one data file on disk. -#[derive(Clone, Debug, Hash, Eq, PartialEq)] -pub struct FileKey { - pub symbol: Arc, - pub year: u16, - pub month: u8, - pub format: DataFormat, -} - -/// A file's worth of pre-parsed chunks, shared across threads. -pub type ChunkVec = Arc>>; -/// Cache map: file key → shared chunk vector. -type CacheMap = RwLock>>; - -/// 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. -/// -/// Waiting threads are notified via a [`Condvar`] when a file finishes -/// loading — no spin-waiting required. -pub struct FileCache { - m1: CacheMap, - tick: CacheMap, - /// Tracks files currently being loaded to prevent duplicate work. - loading: Mutex>, - /// Signaled whenever a file finishes loading (insert completes). - /// Waiters re-check `is_cached` after each wake-up. - loaded: Condvar, - /// Per-symbol reference counts. When a count drops to zero, all - /// cached files for that symbol are evicted from both maps. - symbol_refs: SymbolRefCounts, -} - -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()), - loaded: Condvar::new(), - symbol_refs: Mutex::new(HashMap::new()), - } - } - - // -- M1 ----------------------------------------------------------------- - - /// Returns cached M1 chunks for the given key, or `None` if not yet loaded. - pub fn get_m1(&self, key: &FileKey) -> Option> { - self.m1.read().unwrap().get(key).cloned() - } - - /// Inserts M1 chunks into the cache, clears the loading guard, and - /// notifies all waiting threads. - pub fn insert_m1(&self, key: FileKey, chunks: Vec>) { - self.m1 - .write() - .unwrap() - .insert(key.clone(), Arc::new(chunks)); - self.loading.lock().unwrap().remove(&key); - self.loaded.notify_all(); - } - - // -- Tick ---------------------------------------------------------------- - - /// Returns cached tick chunks for the given key, or `None` if not yet loaded. - pub fn get_tick(&self, key: &FileKey) -> Option> { - self.tick.read().unwrap().get(key).cloned() - } - - /// Inserts tick chunks into the cache, clears the loading guard, and - /// notifies all waiting threads. - pub fn insert_tick(&self, key: FileKey, chunks: Vec>) { - self.tick - .write() - .unwrap() - .insert(key.clone(), Arc::new(chunks)); - self.loading.lock().unwrap().remove(&key); - self.loaded.notify_all(); - } - - // -- 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 self.is_cached(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), - } - } - - /// Blocks until the given key is present in the cache. - /// - /// Uses a [`Condvar`] to sleep efficiently — the thread is woken when - /// any file finishes loading. This replaces the earlier spin-wait and - /// is equivalent to Delphi's `TEvent.WaitFor()`. - pub fn wait_for_load(&self, key: &FileKey) { - let mut guard = self.loading.lock().unwrap(); - while !self.is_cached(key) && guard.contains(key) { - guard = self.loaded.wait(guard).unwrap(); - } - } - - // -- Symbol-level reference counting ------------------------------------ - - /// Increments the reference count for a symbol. Call this when creating - /// a new [`SymbolChunkIter`] so the cached data stays alive. - pub fn retain_symbol(&self, symbol: &Arc) { - *self - .symbol_refs - .lock() - .unwrap() - .entry(Arc::clone(symbol)) - .or_insert(0) += 1; - } - - /// Decrements the reference count for a symbol. When the count reaches - /// zero, all cached files (M1 and tick) for that symbol are evicted. - pub fn release_symbol(&self, symbol: &Arc) { - let mut refs = self.symbol_refs.lock().unwrap(); - if let Some(count) = refs.get_mut(symbol) { - *count -= 1; - if *count == 0 { - refs.remove(symbol); - // Drop the lock before acquiring write locks on the caches - drop(refs); - self.evict_symbol(symbol); - } - } - } - - /// Removes all cached entries for the given symbol from both maps. - fn evict_symbol(&self, symbol: &Arc) { - self.m1 - .write() - .unwrap() - .retain(|key, _| key.symbol != *symbol); - self.tick - .write() - .unwrap() - .retain(|key, _| key.symbol != *symbol); - } -} - -#[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)); - } - - #[test] - fn test_condvar_wakeup() { - let cache = Arc::new(FileCache::new()); - let key = test_key("TEST", 2020, 1, DataFormat::M1); - - // Claim loading on main thread - assert!(cache.try_claim_loading(&key)); - - // Spawn a waiter thread - let cache2 = Arc::clone(&cache); - let key2 = key.clone(); - let handle = std::thread::spawn(move || { - cache2.wait_for_load(&key2); - cache2.is_cached(&key2) - }); - - // Give the waiter time to block - std::thread::sleep(std::time::Duration::from_millis(10)); - - // Insert data — this wakes the waiter - cache.insert_m1(key, vec![]); - - // Waiter should now see the cached data - assert!(handle.join().unwrap()); - } - - #[test] - fn test_symbol_retain_release() { - let cache = FileCache::new(); - let sym: Arc = Arc::from("EURUSD"); - let key1 = test_key("EURUSD", 2017, 1, DataFormat::M1); - let key2 = test_key("EURUSD", 2017, 2, DataFormat::M1); - let key3 = test_key("EURUSD", 2017, 1, DataFormat::Tick); - let other = test_key("GBPUSD", 2020, 6, DataFormat::M1); - - // Insert data for both symbols - cache.insert_m1(key1.clone(), vec![]); - cache.insert_m1(key2.clone(), vec![]); - cache.insert_tick(key3.clone(), vec![]); - cache.insert_m1(other.clone(), vec![]); - - // Two consumers retain EURUSD - cache.retain_symbol(&sym); - cache.retain_symbol(&sym); - - // First release: refcount drops to 1 — data stays - cache.release_symbol(&sym); - assert!(cache.is_cached(&key1)); - assert!(cache.is_cached(&key2)); - assert!(cache.is_cached(&key3)); - - // Second release: refcount drops to 0 — all EURUSD evicted - cache.release_symbol(&sym); - assert!(!cache.is_cached(&key1)); - assert!(!cache.is_cached(&key2)); - assert!(!cache.is_cached(&key3)); - - // GBPUSD is untouched - assert!(cache.is_cached(&other)); - } -} diff --git a/src/ast/data_server/loader.rs b/src/ast/data_server/loader.rs deleted file mode 100644 index 409b5c8..0000000 --- a/src/ast/data_server/loader.rs +++ /dev/null @@ -1,220 +0,0 @@ -//! 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.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>> { - let buf = read_bin_entry(path)?; - Ok(parse_chunks(&buf, std::mem::size_of::(), |ptr| { - // SAFETY: `ptr` points to `size_of::()` 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>> { - let buf = read_bin_entry(path)?; - Ok(parse_chunks(&buf, std::mem::size_of::(), |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> { - 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( - buf: &[u8], - record_size: usize, - convert: impl Fn(*const u8) -> T, -) -> Vec> { - 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::("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 = (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::("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::("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()); - } -} diff --git a/src/ast/data_server/mod.rs b/src/ast/data_server/mod.rs deleted file mode 100644 index 909aef1..0000000 --- a/src/ast/data_server/mod.rs +++ /dev/null @@ -1,764 +0,0 @@ -//! 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 -//! ┌──────────────────────────┐ -//! │ 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, HasTimestamp, M1Parsed, TickParsed}; - -use regex::Regex; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, OnceLock}; - -/// Default path for Pepperstone tick data files. -pub const DEFAULT_DATA_PATH: &str = "/mnt/tickdata/Pepperstone"; - -/// Global data server instance, initialized once before any VM threads start. -static DATA_SERVER: OnceLock> = 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) -> Arc { - let server = Arc::new(DataServer::new(base_path)); - DATA_SERVER - .set(server.clone()) - .expect("DataServer already initialized"); - server -} - -/// Initializes the global data server with [`DEFAULT_DATA_PATH`] if the -/// directory exists. Does nothing if the path is missing or the server -/// is already initialized. Safe to call multiple times. -pub fn init_default_data_server() { - if DATA_SERVER.get().is_some() { - return; - } - if Path::new(DEFAULT_DATA_PATH).is_dir() { - // Ignore the error if another thread raced us - let server = Arc::new(DataServer::new(DEFAULT_DATA_PATH)); - let _ = DATA_SERVER.set(server); - } -} - -/// Returns the global data server, or `None` if not yet initialized. -pub fn get_data_server() -> Option> { - 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, Vec>, -} - -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, Vec> = 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 = 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) -> Self { - Self { - index: SymbolIndex::scan(base_path.as_ref()), - cache: FileCache::new(), - } - } - - /// Returns `true` if the symbol exists in the index (has any data files). - pub fn has_symbol(&self, symbol: &str) -> bool { - self.index.symbols.contains_key(symbol) - } - - /// Returns a sorted list of all known symbol names. - pub fn symbols(&self) -> Vec> { - 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 { - 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, symbol: &str) -> Option> { - self.stream_m1_windowed(symbol, None, None) - } - - /// Returns a chunk iterator over M1 data within an optional time window. - /// - /// Files outside `[from_ms, to_ms]` are skipped entirely (no I/O). - /// Records in boundary files are filtered by exact timestamp. - pub fn stream_m1_windowed( - self: &Arc, - symbol: &str, - from_ms: Option, - to_ms: Option, - ) -> Option> { - let symbol_arc = self.index.symbols.get_key_value(symbol)?.0.clone(); - let files = self.filter_files(symbol, DataFormat::M1, from_ms, to_ms)?; - - // Eagerly load the first file and prefetch the second - self.ensure_m1_loaded(&files[0]); - if files.len() > 1 { - self.prefetch_m1(&files[1]); - } - - self.cache.retain_symbol(&symbol_arc); - - Some(SymbolChunkIter { - server: Arc::clone(self), - symbol: symbol_arc, - files, - file_idx: 0, - chunk_idx: 0, - current_file_chunks: None, - from_ms, - to_ms, - exhausted: false, - }) - } - - /// Returns a chunk iterator over all tick data for a symbol. - pub fn stream_tick(self: &Arc, symbol: &str) -> Option> { - self.stream_tick_windowed(symbol, None, None) - } - - /// Returns a chunk iterator over tick data within an optional time window. - pub fn stream_tick_windowed( - self: &Arc, - symbol: &str, - from_ms: Option, - to_ms: Option, - ) -> Option> { - let symbol_arc = self.index.symbols.get_key_value(symbol)?.0.clone(); - let files = self.filter_files(symbol, DataFormat::Tick, from_ms, to_ms)?; - - self.ensure_tick_loaded(&files[0]); - if files.len() > 1 { - self.prefetch_tick(&files[1]); - } - - self.cache.retain_symbol(&symbol_arc); - - Some(SymbolChunkIter { - server: Arc::clone(self), - symbol: symbol_arc, - files, - file_idx: 0, - chunk_idx: 0, - current_file_chunks: None, - from_ms, - to_ms, - exhausted: false, - }) - } - - /// Filters and returns file keys for a symbol within an optional time window. - /// - /// Files are selected by format and then narrowed by `(year, month)` range - /// derived from the timestamps. Returns `None` if no matching files exist. - fn filter_files( - &self, - symbol: &str, - format: DataFormat, - from_ms: Option, - to_ms: Option, - ) -> Option> { - let from_ym = from_ms.map(records::unix_ms_to_year_month); - let to_ym = to_ms.map(records::unix_ms_to_year_month); - - let files: Vec = self - .index - .symbols - .get(symbol)? - .iter() - .filter(|f| f.format == format) - .filter(|f| { - if let Some((y, m)) = from_ym { - (f.year, f.month) >= (y, m) - } else { - true - } - }) - .filter(|f| { - if let Some((y, m)) = to_ym { - (f.year, f.month) <= (y, m) - } else { - true - } - }) - .cloned() - .collect(); - - if files.is_empty() { None } else { Some(files) } - } - - // -- M1 loading ---------------------------------------------------------- - - /// Ensures the given M1 file is loaded into the cache (blocking). - /// - /// If another thread is already loading this file, blocks on a `Condvar` - /// until the load completes. If no one is loading, loads synchronously. - fn ensure_m1_loaded(&self, key: &FileKey) { - if self.cache.is_cached(key) { - return; - } - if !self.cache.try_claim_loading(key) { - // Another thread owns this load — block until it finishes. - self.cache.wait_for_load(key); - return; - } - // We own the load — do it now. - 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` to safely share the server with the spawned thread. - fn prefetch_m1(self: &Arc, 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.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, 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 { - server: Arc, - /// Symbol name, held for `Drop`-based cache release. - symbol: Arc, - files: Vec, - 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>, - /// Inclusive lower bound for record timestamps. Records before this - /// are skipped in boundary files. - from_ms: Option, - /// Inclusive upper bound for record timestamps. When a record exceeds - /// this, iteration stops immediately. - to_ms: Option, - /// Set to `true` when a `to_ms` boundary is crossed, preventing - /// further chunk loading. - exhausted: bool, -} - -/// Filters a chunk of records by an optional `[from_ms, to_ms]` time window. -/// -/// Returns `None` if the entire chunk falls outside the window, or a new -/// `Arc<[T]>` containing only the matching records. Returns `Some(original)` -/// unchanged when no filtering is needed (the common case for middle files). -/// -/// When a record exceeds `to_ms`, returns the truncated slice and sets -/// `exhausted` to `true` so the iterator can stop. -fn filter_chunk( - chunk: &Arc<[T]>, - from_ms: Option, - to_ms: Option, - exhausted: &mut bool, -) -> Option> { - // Fast path: no filtering needed - if from_ms.is_none() && to_ms.is_none() { - return Some(Arc::clone(chunk)); - } - - let from = from_ms.unwrap_or(i64::MIN); - let to = to_ms.unwrap_or(i64::MAX); - - // Quick rejection: if the last record is before `from`, skip the whole chunk - if let Some(last) = chunk.last() - && last.time_ms() < from - { - return None; - } - - // Quick rejection: if the first record is past `to`, we're done - if let Some(first) = chunk.first() - && first.time_ms() > to - { - *exhausted = true; - return None; - } - - // Quick acceptance: entire chunk is within bounds - let first_ok = chunk.first().is_none_or(|r| r.time_ms() >= from); - let last_ok = chunk.last().is_none_or(|r| r.time_ms() <= to); - if first_ok && last_ok { - return Some(Arc::clone(chunk)); - } - - // Partial filtering: collect matching records - let mut filtered = Vec::new(); - for rec in chunk.iter() { - let t = rec.time_ms(); - if t > to { - *exhausted = true; - break; - } - if t >= from { - filtered.push(rec.clone()); - } - } - - if filtered.is_empty() { None } else { Some(Arc::from(filtered)) } -} - -impl Drop for SymbolChunkIter { - fn drop(&mut self) { - self.server.cache.release_symbol(&self.symbol); - } -} - -impl SymbolChunkIter { - /// Returns the next chunk of M1 records, or `None` when exhausted. - /// - /// When a time window is set, records in boundary chunks are filtered - /// and iteration stops early once `to_ms` is exceeded. - pub fn next_chunk(&mut self) -> Option> { - loop { - if self.exhausted { - return None; - } - - // 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 raw_chunk = &chunks[self.chunk_idx]; - self.chunk_idx += 1; - - match filter_chunk(raw_chunk, self.from_ms, self.to_ms, &mut self.exhausted) { - Some(filtered) => return Some(filtered), - None if self.exhausted => return None, - None => continue, // chunk was before `from_ms`, skip it - } - } - - // 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 { - /// Returns the next chunk of tick records, or `None` when exhausted. - pub fn next_chunk(&mut self) -> Option> { - loop { - if self.exhausted { - return None; - } - - 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 raw_chunk = &chunks[self.chunk_idx]; - self.chunk_idx += 1; - - match filter_chunk(raw_chunk, self.from_ms, self.to_ms, &mut self.exhausted) { - Some(filtered) => return Some(filtered), - None if self.exhausted => return None, - None => continue, - } - } - - 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"); - } - - #[test] - fn test_filter_files_by_time_window() { - let dir = tempfile::tempdir().unwrap(); - for name in [ - "EURUSD_2019_06.m1", - "EURUSD_2020_01.m1", - "EURUSD_2020_06.m1", - "EURUSD_2020_12.m1", - "EURUSD_2021_03.m1", - ] { - std::fs::write(dir.path().join(name), b"").unwrap(); - } - - let server = DataServer::new(dir.path()); - - // Full range — all 5 files - let all = server.filter_files("EURUSD", DataFormat::M1, None, None).unwrap(); - assert_eq!(all.len(), 5); - - // 2020-01-01 to 2020-12-31 — should include 2020_01, 2020_06, 2020_12 - let from = chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap() - .and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis(); - let to = chrono::NaiveDate::from_ymd_opt(2020, 12, 31).unwrap() - .and_hms_opt(23, 59, 59).unwrap().and_utc().timestamp_millis(); - let windowed = server.filter_files("EURUSD", DataFormat::M1, Some(from), Some(to)).unwrap(); - assert_eq!(windowed.len(), 3); - assert_eq!(windowed[0].month, 1); - assert_eq!(windowed[1].month, 6); - assert_eq!(windowed[2].month, 12); - - // Only from — 2020-06 onwards - let from_june = chrono::NaiveDate::from_ymd_opt(2020, 6, 15).unwrap() - .and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis(); - let from_only = server.filter_files("EURUSD", DataFormat::M1, Some(from_june), None).unwrap(); - assert_eq!(from_only.len(), 3); // 2020_06, 2020_12, 2021_03 - - // Window outside all data — returns None - let future = chrono::NaiveDate::from_ymd_opt(2025, 1, 1).unwrap() - .and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis(); - assert!(server.filter_files("EURUSD", DataFormat::M1, Some(future), None).is_none()); - } - - #[test] - fn test_filter_chunk_boundaries() { - // Create a chunk with timestamps 100, 200, 300, 400, 500 - let chunk: Arc<[M1Parsed]> = Arc::from(vec![ - M1Parsed { time_ms: 100, open: 1.0, high: 1.0, low: 1.0, close: 1.0, spread: 0.0, volume: 0 }, - M1Parsed { time_ms: 200, open: 1.0, high: 1.0, low: 1.0, close: 1.0, spread: 0.0, volume: 0 }, - M1Parsed { time_ms: 300, open: 1.0, high: 1.0, low: 1.0, close: 1.0, spread: 0.0, volume: 0 }, - M1Parsed { time_ms: 400, open: 1.0, high: 1.0, low: 1.0, close: 1.0, spread: 0.0, volume: 0 }, - M1Parsed { time_ms: 500, open: 1.0, high: 1.0, low: 1.0, close: 1.0, spread: 0.0, volume: 0 }, - ]); - - let mut exhausted = false; - - // No filter — returns original chunk - let result = filter_chunk(&chunk, None, None, &mut exhausted); - assert_eq!(result.unwrap().len(), 5); - assert!(!exhausted); - - // from=250 — skips 100, 200 - let result = filter_chunk(&chunk, Some(250), None, &mut exhausted); - assert_eq!(result.unwrap().len(), 3); // 300, 400, 500 - - // to=350 — keeps 100, 200, 300, sets exhausted - exhausted = false; - let result = filter_chunk(&chunk, None, Some(350), &mut exhausted); - assert_eq!(result.unwrap().len(), 3); // 100, 200, 300 - assert!(exhausted); - - // from=200, to=400 — keeps 200, 300, 400 - exhausted = false; - let result = filter_chunk(&chunk, Some(200), Some(400), &mut exhausted); - assert_eq!(result.unwrap().len(), 3); // 200, 300, 400 - assert!(exhausted); // 500 > 400 - - // Entirely before from — returns None - exhausted = false; - let result = filter_chunk(&chunk, Some(600), None, &mut exhausted); - assert!(result.is_none()); - assert!(!exhausted); - - // Entirely after to — returns None and sets exhausted - exhausted = false; - let result = filter_chunk(&chunk, None, Some(50), &mut exhausted); - assert!(result.is_none()); - assert!(exhausted); - } -} diff --git a/src/ast/data_server/records.rs b/src/ast/data_server/records.rs deleted file mode 100644 index b79569c..0000000 --- a/src/ast/data_server/records.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! 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 -} - -/// Extracts the (year, month) from a Unix-millisecond timestamp. -/// -/// Used for coarse file-level filtering: data files are named `SYMBOL_YYYY_MM.ext`, -/// so we can skip entire files that fall outside the requested time window. -#[inline] -pub fn unix_ms_to_year_month(ms: i64) -> (u16, u8) { - use chrono::Datelike; - let dt = chrono::DateTime::from_timestamp_millis(ms) - .expect("unix_ms_to_year_month: invalid timestamp"); - (dt.year() as u16, dt.month() as u8) -} - -/// Common timestamp access for parsed record types. -/// -/// Enables generic time-window filtering in [`super::SymbolChunkIter`] without -/// duplicating the logic for M1 and tick records. -pub trait HasTimestamp { - fn time_ms(&self) -> 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 HasTimestamp for M1Parsed { - #[inline] - fn time_ms(&self) -> i64 { - self.time_ms - } -} - -impl HasTimestamp for TickParsed { - #[inline] - fn time_ms(&self) -> i64 { - self.time_ms - } -} - -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_unix_ms_to_year_month() { - // 2017-03-01 00:00:00 UTC = 1488326400000 ms - assert_eq!(unix_ms_to_year_month(1_488_326_400_000), (2017, 3)); - // 2020-12-31 23:59:59.999 UTC - assert_eq!(unix_ms_to_year_month(1_609_459_199_999), (2020, 12)); - // Unix epoch - assert_eq!(unix_ms_to_year_month(0), (1970, 1)); - } - - #[test] - fn test_raw_m1_record_size() { - assert_eq!(std::mem::size_of::(), 48); - } - - #[test] - fn test_raw_tick_record_size() { - assert_eq!(std::mem::size_of::(), 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 - ); - } -} diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 85f2507..1144285 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -1,6 +1,6 @@ pub mod closure; pub mod compiler; -pub mod data_server; +pub use ::data_server; pub mod diagnostics; pub mod environment; pub mod lexer; diff --git a/src/ast/rtl/data_streams.rs b/src/ast/rtl/data_streams.rs index 0d6e1fd..b726bcd 100644 --- a/src/ast/rtl/data_streams.rs +++ b/src/ast/rtl/data_streams.rs @@ -1,6 +1,6 @@ //! RTL registration for file-based market data streams. //! -//! Bridges the `Arc`-based [`DataServer`] cache with the `Rc`-based VM world +//! Bridges the `Arc`-based [`::data_server::DataServer`] cache with the `Rc`-based VM world //! by registering generator closures that read pre-parsed chunks and convert //! them into `Value::Record` items for `RootStream::tick()`.