Extract data_server into external data-server crate
The data_server module had no dependency on the rest of myc (only chrono/regex/zip), so it was moved verbatim into a standalone leaf crate at its own Gitea repo and is now consumed as a git dependency. A 'pub use ::data_server;' re-export in src/ast/mod.rs keeps all existing myc::ast::data_server::… paths valid; no call sites changed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+12
-1
@@ -994,6 +994,16 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "deflate64"
|
name = "deflate64"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
@@ -1821,7 +1831,7 @@ dependencies = [
|
|||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.58.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2257,6 +2267,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
"data-server",
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui_extras",
|
"egui_extras",
|
||||||
"fastrand",
|
"fastrand",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ rmcp = { version = "1.2", features = ["server", "transport-io"] }
|
|||||||
tokio = { version = "1", features = ["rt", "io-std", "macros"] }
|
tokio = { version = "1", features = ["rt", "io-std", "macros"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
zip = "2"
|
zip = "2"
|
||||||
|
data-server = { git = "http://192.168.178.103:3000/Brummel/data-server.git", branch = "main" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
insta = { version = "1.39", features = ["yaml"] }
|
insta = { version = "1.39", features = ["yaml"] }
|
||||||
|
|||||||
@@ -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<HashMap<Arc<str>, usize>>;
|
|
||||||
|
|
||||||
/// 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.
|
|
||||||
///
|
|
||||||
/// Waiting threads are notified via a [`Condvar`] when a file finishes
|
|
||||||
/// loading — no spin-waiting required.
|
|
||||||
pub struct FileCache {
|
|
||||||
m1: CacheMap<M1Parsed>,
|
|
||||||
tick: CacheMap<TickParsed>,
|
|
||||||
/// Tracks files currently being loaded to prevent duplicate work.
|
|
||||||
loading: Mutex<HashSet<FileKey>>,
|
|
||||||
/// 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<ChunkVec<M1Parsed>> {
|
|
||||||
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<Arc<[M1Parsed]>>) {
|
|
||||||
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<ChunkVec<TickParsed>> {
|
|
||||||
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<Arc<[TickParsed]>>) {
|
|
||||||
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<str>) {
|
|
||||||
*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<str>) {
|
|
||||||
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<str>) {
|
|
||||||
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<str> = 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<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, 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<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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<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 `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<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>> {
|
|
||||||
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<Self>,
|
|
||||||
symbol: &str,
|
|
||||||
from_ms: Option<i64>,
|
|
||||||
to_ms: Option<i64>,
|
|
||||||
) -> Option<SymbolChunkIter<M1Parsed>> {
|
|
||||||
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<Self>, symbol: &str) -> Option<SymbolChunkIter<TickParsed>> {
|
|
||||||
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<Self>,
|
|
||||||
symbol: &str,
|
|
||||||
from_ms: Option<i64>,
|
|
||||||
to_ms: Option<i64>,
|
|
||||||
) -> Option<SymbolChunkIter<TickParsed>> {
|
|
||||||
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<i64>,
|
|
||||||
to_ms: Option<i64>,
|
|
||||||
) -> Option<Vec<FileKey>> {
|
|
||||||
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<FileKey> = 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<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.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>,
|
|
||||||
/// Symbol name, held for `Drop`-based cache release.
|
|
||||||
symbol: Arc<str>,
|
|
||||||
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>>,
|
|
||||||
/// Inclusive lower bound for record timestamps. Records before this
|
|
||||||
/// are skipped in boundary files.
|
|
||||||
from_ms: Option<i64>,
|
|
||||||
/// Inclusive upper bound for record timestamps. When a record exceeds
|
|
||||||
/// this, iteration stops immediately.
|
|
||||||
to_ms: Option<i64>,
|
|
||||||
/// 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<T: HasTimestamp + Clone>(
|
|
||||||
chunk: &Arc<[T]>,
|
|
||||||
from_ms: Option<i64>,
|
|
||||||
to_ms: Option<i64>,
|
|
||||||
exhausted: &mut bool,
|
|
||||||
) -> Option<Arc<[T]>> {
|
|
||||||
// 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<T> Drop for SymbolChunkIter<T> {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.server.cache.release_symbol(&self.symbol);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SymbolChunkIter<M1Parsed> {
|
|
||||||
/// 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<Arc<[M1Parsed]>> {
|
|
||||||
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<TickParsed> {
|
|
||||||
/// Returns the next chunk of tick records, or `None` when exhausted.
|
|
||||||
pub fn next_chunk(&mut self) -> Option<Arc<[TickParsed]>> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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::<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
-1
@@ -1,6 +1,6 @@
|
|||||||
pub mod closure;
|
pub mod closure;
|
||||||
pub mod compiler;
|
pub mod compiler;
|
||||||
pub mod data_server;
|
pub use ::data_server;
|
||||||
pub mod diagnostics;
|
pub mod diagnostics;
|
||||||
pub mod environment;
|
pub mod environment;
|
||||||
pub mod lexer;
|
pub mod lexer;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! RTL registration for file-based market data streams.
|
//! 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
|
//! by registering generator closures that read pre-parsed chunks and convert
|
||||||
//! them into `Value::Record` items for `RootStream::tick()`.
|
//! them into `Value::Record` items for `RootStream::tick()`.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user