From 38f657076b0fd3e2dcdb876ac91ffbcd9e72b581 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 29 Mar 2026 20:28:55 +0200 Subject: [PATCH] feat: Add data server for market data loading This commit introduces a new data server module (`src/ast/data_server`) designed for high-performance, thread-safe loading and caching of market data. Key components: - `DataServer`: Manages symbol indexing and delegates loading/caching. - `SymbolIndex`: Scans the data directory and maintains a sorted index of data files per symbol. - `FileCache`: Implements a thread-safe cache using `RwLock` and a loading guard to prevent duplicate work. - `loader`: Handles ZIP decompression and binary record parsing from `.bin` files. - `records`: Defines raw and parsed data structures for M1 and tick data. - `SymbolChunkIter`: Provides an iterator over pre-parsed data chunks, prefetching subsequent files. This architecture allows multiple VM threads to access market data concurrently without redundant I/O, mirroring the strategy used in the original Delphi `TDataServer`. --- Cargo.lock | 356 ++++++++++++++++++++++ Cargo.toml | 1 + docs/MS_SimpleExport.cs | 108 +++++++ src/ast/data_server/cache.rs | 208 +++++++++++++ src/ast/data_server/loader.rs | 220 ++++++++++++++ src/ast/data_server/mod.rs | 518 +++++++++++++++++++++++++++++++++ src/ast/data_server/records.rs | 189 ++++++++++++ src/ast/mod.rs | 1 + 8 files changed, 1601 insertions(+) create mode 100755 docs/MS_SimpleExport.cs create mode 100644 src/ast/data_server/cache.rs create mode 100644 src/ast/data_server/loader.rs create mode 100644 src/ast/data_server/mod.rs create mode 100644 src/ast/data_server/records.rs diff --git a/Cargo.lock b/Cargo.lock index 465b6aa..c5077bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -228,6 +239,15 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arboard" version = "3.6.1" @@ -507,6 +527,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -555,6 +584,12 @@ dependencies = [ "syn", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -567,6 +602,25 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "calloop" version = "0.13.0" @@ -671,6 +725,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.59" @@ -768,6 +832,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "core-foundation" version = "0.9.4" @@ -829,6 +899,30 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -850,6 +944,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -890,6 +994,43 @@ dependencies = [ "syn", ] +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1384,6 +1525,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1401,9 +1552,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1613,6 +1766,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1777,6 +1939,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "insta" version = "1.46.3" @@ -1939,6 +2110,27 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -2021,6 +2213,7 @@ dependencies = [ "serde", "tempfile", "tokio", + "zip", ] [[package]] @@ -2085,6 +2278,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-traits" version = "0.2.19" @@ -2488,6 +2687,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2588,6 +2797,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "presser" version = "0.3.1" @@ -2972,6 +3187,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3126,6 +3352,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.116" @@ -3224,6 +3456,25 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + [[package]] name = "tiny-skia" version = "0.11.4" @@ -3371,6 +3622,12 @@ dependencies = [ "rustc-hash 2.1.1", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "uds_windows" version = "1.1.0" @@ -4557,6 +4814,15 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yoke" version = "0.8.1" @@ -4718,6 +4984,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerotrie" version = "0.2.3" @@ -4751,12 +5037,82 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.18", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index efcf4ad..d032c53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ fastrand = "2.3" rmcp = { version = "1.2", features = ["server", "transport-io"] } tokio = { version = "1", features = ["rt", "io-std", "macros"] } serde = { version = "1", features = ["derive"] } +zip = "2" [dev-dependencies] insta = { version = "1.39", features = ["yaml"] } diff --git a/docs/MS_SimpleExport.cs b/docs/MS_SimpleExport.cs new file mode 100755 index 0000000..675d067 --- /dev/null +++ b/docs/MS_SimpleExport.cs @@ -0,0 +1,108 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using cAlgo.API; + +namespace cAlgo.Robots +{ + public enum ExportMode { M1, Tick } + + [Robot(AccessRights = AccessRights.FullAccess)] + public class MS_SimpleExport : Robot + { + [StructLayout(LayoutKind.Sequential, Pack = 1)] + private struct M1Record { + public double Time; + public double O, H, L, C; + public float S; + public int V; + } + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + private struct TickRecord { + public double Time; + public double Ask, Bid; + } + + [Parameter("Export Mode", DefaultValue = ExportMode.M1)] + public ExportMode Mode { get; set; } + + [Parameter("Output File (ZIP)", DefaultValue = "")] + public string OutputFile { get; set; } + + [Parameter("Is Probing", DefaultValue = false)] + public bool IsProbing { get; set; } + + private List _m1Buffer = new List(); + private List _tickBuffer = new List(); + + protected override void OnStart() { + // No price factor needed for double/float export + } + + protected override void OnTick() { + if (Mode != ExportMode.Tick) return; + + if (IsProbing) { + Print("DATA_FOUND"); + Stop(); + return; + } + + _tickBuffer.Add(new TickRecord { + Time = Server.TimeInUtc.ToOADate(), + Ask = Symbol.Ask, + Bid = Symbol.Bid + }); + } + + protected override void OnBar() { + if (Mode != ExportMode.M1) return; + + if (IsProbing) { + Print("DATA_FOUND"); + Stop(); + return; + } + + var bar = Bars.Last(1); + _m1Buffer.Add(new M1Record { + Time = bar.OpenTime.ToOADate(), + O = bar.Open, + H = bar.High, + L = bar.Low, + C = bar.Close, + S = (float)Symbol.Spread, + V = (int)bar.TickVolume + }); + } + + protected override void OnStop() { + if (IsProbing || string.IsNullOrEmpty(OutputFile)) return; + + try { + using var fileStream = new FileStream(OutputFile, FileMode.Create); + using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create); + var entry = archive.CreateEntry($"{Symbol.Name}.bin", CompressionLevel.Fastest); + + using var entryStream = entry.Open(); + + if (Mode == ExportMode.M1) { + ReadOnlySpan span = CollectionsMarshal.AsSpan(_m1Buffer); + ReadOnlySpan byteSpan = MemoryMarshal.Cast(span); + if (!byteSpan.IsEmpty) entryStream.Write(byteSpan); + } else { + ReadOnlySpan span = CollectionsMarshal.AsSpan(_tickBuffer); + ReadOnlySpan byteSpan = MemoryMarshal.Cast(span); + if (!byteSpan.IsEmpty) entryStream.Write(byteSpan); + } + + Print("EXPORT_SUCCESS"); + } catch (Exception ex) { + Print("ERROR: {0}", ex.Message); + } + } + } +} \ No newline at end of file diff --git a/src/ast/data_server/cache.rs b/src/ast/data_server/cache.rs new file mode 100644 index 0000000..1c44407 --- /dev/null +++ b/src/ast/data_server/cache.rs @@ -0,0 +1,208 @@ +//! Thread-safe file cache for parsed market data chunks. +//! +//! Designed for a many-readers / rare-writer pattern: hundreds of VM threads +//! read concurrently via `RwLock`, while the loader writes each file exactly +//! once. Duplicate loads are prevented by a `loading` guard set. + +use super::records::{DataFormat, M1Parsed, TickParsed}; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex, RwLock}; + +/// Uniquely identifies one data file on disk. +#[derive(Clone, Debug, Hash, Eq, PartialEq)] +pub struct FileKey { + pub symbol: Arc, + 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. +pub struct FileCache { + m1: CacheMap, + tick: CacheMap, + /// Tracks files currently being loaded to prevent duplicate work. + /// A key is inserted before loading starts and removed after the + /// result is written to the cache. + loading: Mutex>, +} + +impl Default for FileCache { + fn default() -> Self { + Self::new() + } +} + +impl FileCache { + pub fn new() -> Self { + Self { + m1: RwLock::new(HashMap::new()), + tick: RwLock::new(HashMap::new()), + loading: Mutex::new(HashSet::new()), + } + } + + // -- M1 ----------------------------------------------------------------- + + /// Returns cached M1 chunks for the given key, or `None` if not yet loaded. + pub fn get_m1(&self, key: &FileKey) -> Option> { + self.m1.read().unwrap().get(key).cloned() + } + + /// Inserts M1 chunks into the cache and clears the loading guard. + pub fn insert_m1(&self, key: FileKey, chunks: Vec>) { + self.m1 + .write() + .unwrap() + .insert(key.clone(), Arc::new(chunks)); + self.loading.lock().unwrap().remove(&key); + } + + // -- Tick ---------------------------------------------------------------- + + /// Returns cached tick chunks for the given key, or `None` if not yet loaded. + pub fn get_tick(&self, key: &FileKey) -> Option> { + self.tick.read().unwrap().get(key).cloned() + } + + /// Inserts tick chunks into the cache and clears the loading guard. + pub fn insert_tick(&self, key: FileKey, chunks: Vec>) { + self.tick + .write() + .unwrap() + .insert(key.clone(), Arc::new(chunks)); + self.loading.lock().unwrap().remove(&key); + } + + // -- Loading guard ------------------------------------------------------- + + /// Attempts to claim the loading slot for `key`. Returns `true` if this + /// thread should load the file (first caller wins), `false` if another + /// thread is already loading it or it is already cached. + pub fn try_claim_loading(&self, key: &FileKey) -> bool { + // Fast path: already cached? + if key.format == DataFormat::M1 { + if self.m1.read().unwrap().contains_key(key) { + return false; + } + } else if self.tick.read().unwrap().contains_key(key) { + return false; + } + // Slow path: try to claim the loading slot + self.loading.lock().unwrap().insert(key.clone()) + } + + /// Checks whether the given key is currently being loaded by another thread. + pub fn is_loading(&self, key: &FileKey) -> bool { + self.loading.lock().unwrap().contains(key) + } + + /// Checks whether the given key is available in the cache (already loaded). + pub fn is_cached(&self, key: &FileKey) -> bool { + match key.format { + DataFormat::M1 => self.m1.read().unwrap().contains_key(key), + DataFormat::Tick => self.tick.read().unwrap().contains_key(key), + } + } + + /// Spins briefly until the key is no longer in the loading set, i.e. + /// another thread has finished loading it. Returns `true` if the key + /// is now cached, `false` on timeout. + pub fn wait_for_load(&self, key: &FileKey) -> bool { + // Simple spin-wait with yield. In practice, file loads take + // milliseconds and this is only hit during prefetch races. + for _ in 0..10_000 { + if self.is_cached(key) { + return true; + } + std::thread::yield_now(); + } + // Fallback: still not cached, caller should load it themselves. + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_key(symbol: &str, year: u16, month: u8, format: DataFormat) -> FileKey { + FileKey { + symbol: Arc::from(symbol), + year, + month, + format, + } + } + + #[test] + fn test_insert_and_get_m1() { + let cache = FileCache::new(); + let key = test_key("EURUSD", 2017, 3, DataFormat::M1); + + assert!(cache.get_m1(&key).is_none()); + + let chunk: Arc<[M1Parsed]> = Arc::from(vec![M1Parsed { + time_ms: 0, + open: 1.0, + high: 1.1, + low: 0.9, + close: 1.05, + spread: 0.1, + volume: 100, + }]); + cache.insert_m1(key.clone(), vec![chunk]); + + let result = cache.get_m1(&key).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].len(), 1); + assert_eq!(result[0][0].open, 1.0); + } + + #[test] + fn test_loading_guard() { + let cache = FileCache::new(); + let key = test_key("EURUSD", 2017, 3, DataFormat::M1); + + // First claim succeeds + assert!(cache.try_claim_loading(&key)); + // Second claim fails (already loading) + assert!(!cache.try_claim_loading(&key)); + + assert!(cache.is_loading(&key)); + + // After insert, loading guard is cleared + cache.insert_m1(key.clone(), vec![]); + assert!(!cache.is_loading(&key)); + + // After caching, claim also returns false + assert!(!cache.try_claim_loading(&key)); + } + + #[test] + fn test_is_cached() { + let cache = FileCache::new(); + let m1_key = test_key("EURUSD", 2017, 3, DataFormat::M1); + let tick_key = test_key("EURUSD", 2017, 3, DataFormat::Tick); + + assert!(!cache.is_cached(&m1_key)); + assert!(!cache.is_cached(&tick_key)); + + cache.insert_m1(m1_key.clone(), vec![]); + assert!(cache.is_cached(&m1_key)); + assert!(!cache.is_cached(&tick_key)); + + cache.insert_tick(tick_key.clone(), vec![]); + assert!(cache.is_cached(&tick_key)); + } +} diff --git a/src/ast/data_server/loader.rs b/src/ast/data_server/loader.rs new file mode 100644 index 0000000..409b5c8 --- /dev/null +++ b/src/ast/data_server/loader.rs @@ -0,0 +1,220 @@ +//! ZIP decompression and binary record parsing for tick data files. +//! +//! Each data file is a ZIP archive containing a single `.bin` entry with +//! tightly packed binary records. This module reads and parses them into +//! `Arc`-shared chunks of 1024 records, mirroring the Delphi +//! `TDataServer.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 new file mode 100644 index 0000000..9d8f544 --- /dev/null +++ b/src/ast/data_server/mod.rs @@ -0,0 +1,518 @@ +//! High-performance data server for binary market data files. +//! +//! Provides thread-safe (`Arc`-based) loading and caching of tick data, +//! designed to serve hundreds of single-threaded VMs in parallel without +//! redundant I/O. Each file is read and parsed once, then shared across +//! all consumers via `Arc<[T]>` chunk references. +//! +//! # Architecture +//! +//! ```text +//! Arc +//! ┌──────────────────────────┐ +//! │ SymbolIndex FileCache │ +//! │ (HashMap) (RwLock) │ +//! └─────┬────────────┬───────┘ +//! │ │ +//! VM Thread 1 │ VM Thread N +//! Generator ──┘ Generator ──┘ +//! ``` + +pub mod cache; +pub mod loader; +pub mod records; + +use cache::{ChunkVec, FileCache, FileKey}; +use records::{DataFormat, M1Parsed, TickParsed}; + +use regex::Regex; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +/// Global data server instance, initialized once before any VM threads start. +static DATA_SERVER: OnceLock> = 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 +} + +/// 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 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> { + let files: Vec = self + .index + .symbols + .get(symbol)? + .iter() + .filter(|f| f.format == DataFormat::M1) + .cloned() + .collect(); + + if files.is_empty() { + return None; + } + + // Eagerly load the first file and prefetch the second + self.ensure_m1_loaded(&files[0]); + if files.len() > 1 { + self.prefetch_m1(&files[1]); + } + + Some(SymbolChunkIter { + server: Arc::clone(self), + files, + file_idx: 0, + chunk_idx: 0, + current_file_chunks: None, + }) + } + + /// Returns a chunk iterator over all tick data for a symbol. + pub fn stream_tick(self: &Arc, symbol: &str) -> Option> { + let files: Vec = self + .index + .symbols + .get(symbol)? + .iter() + .filter(|f| f.format == DataFormat::Tick) + .cloned() + .collect(); + + if files.is_empty() { + return None; + } + + self.ensure_tick_loaded(&files[0]); + if files.len() > 1 { + self.prefetch_tick(&files[1]); + } + + Some(SymbolChunkIter { + server: Arc::clone(self), + files, + file_idx: 0, + chunk_idx: 0, + current_file_chunks: None, + }) + } + + // -- M1 loading ---------------------------------------------------------- + + /// Ensures the given M1 file is loaded into the cache (blocking). + fn ensure_m1_loaded(&self, key: &FileKey) { + if self.cache.is_cached(key) { + return; + } + if self.cache.is_loading(key) { + self.cache.wait_for_load(key); + if self.cache.is_cached(key) { + return; + } + } + self.load_m1_sync(key); + } + + /// Synchronously loads an M1 file into the cache. + fn load_m1_sync(&self, key: &FileKey) { + if !self.cache.try_claim_loading(key) { + // Another thread is loading or already cached — wait for it + self.cache.wait_for_load(key); + return; + } + let path = self.index.file_path(key); + match loader::load_m1_file(&path) { + Ok(chunks) => self.cache.insert_m1(key.clone(), chunks), + Err(e) => { + eprintln!("Warning: failed to load {}: {}", path.display(), e); + // Insert empty to unblock waiters and prevent retries + self.cache.insert_m1(key.clone(), vec![]); + } + } + } + + /// Prefetches an M1 file in a background thread. + /// + /// Requires `Arc` 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.is_loading(key) { + self.cache.wait_for_load(key); + if self.cache.is_cached(key) { + return; + } + } + self.load_tick_sync(key); + } + + /// Synchronously loads a tick file into the cache. + fn load_tick_sync(&self, key: &FileKey) { + if !self.cache.try_claim_loading(key) { + self.cache.wait_for_load(key); + return; + } + let path = self.index.file_path(key); + match loader::load_tick_file(&path) { + Ok(chunks) => self.cache.insert_tick(key.clone(), chunks), + Err(e) => { + eprintln!("Warning: failed to load {}: {}", path.display(), e); + self.cache.insert_tick(key.clone(), vec![]); + } + } + } + + /// Prefetches a tick file in a background thread. + fn prefetch_tick(self: &Arc, 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, + 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>, +} + +impl SymbolChunkIter { + /// Returns the next chunk of M1 records, or `None` when exhausted. + pub fn next_chunk(&mut self) -> Option> { + loop { + // Try to get chunks for the current file + if self.current_file_chunks.is_none() && self.file_idx < self.files.len() { + let key = &self.files[self.file_idx]; + self.server.ensure_m1_loaded(key); + self.current_file_chunks = self.server.cache.get_m1(key); + self.chunk_idx = 0; + } + + let chunks = self.current_file_chunks.as_ref()?; + + if self.chunk_idx < chunks.len() { + let chunk = chunks[self.chunk_idx].clone(); + self.chunk_idx += 1; + return Some(chunk); + } + + // Current file exhausted — advance to next file + self.file_idx += 1; + self.current_file_chunks = None; + + if self.file_idx >= self.files.len() { + return None; // All files exhausted + } + + // Prefetch the file AFTER the next one (N+2), since N+1 was + // already prefetched when we started N. + if self.file_idx + 1 < self.files.len() { + self.server.prefetch_m1(&self.files[self.file_idx + 1]); + } + } + } +} + +impl SymbolChunkIter { + /// Returns the next chunk of tick records, or `None` when exhausted. + pub fn next_chunk(&mut self) -> Option> { + loop { + if self.current_file_chunks.is_none() && self.file_idx < self.files.len() { + let key = &self.files[self.file_idx]; + self.server.ensure_tick_loaded(key); + self.current_file_chunks = self.server.cache.get_tick(key); + self.chunk_idx = 0; + } + + let chunks = self.current_file_chunks.as_ref()?; + + if self.chunk_idx < chunks.len() { + let chunk = chunks[self.chunk_idx].clone(); + self.chunk_idx += 1; + return Some(chunk); + } + + self.file_idx += 1; + self.current_file_chunks = None; + + if self.file_idx >= self.files.len() { + return None; + } + + if self.file_idx + 1 < self.files.len() { + self.server.prefetch_tick(&self.files[self.file_idx + 1]); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_symbol_index_scan() { + // Create a temp directory with mock files + let dir = tempfile::tempdir().unwrap(); + let names = [ + "EURUSD_2017_01.m1", + "EURUSD_2017_02.m1", + "EURUSD_2017_03.m1", + "EURUSD_2017_01.tick", + "GBPUSD_2020_06.m1", + "invalid_file.txt", + "AAPL.US_2006_08.m1", + ]; + for name in &names { + std::fs::write(dir.path().join(name), b"").unwrap(); + } + + let index = SymbolIndex::scan(dir.path()); + + // EURUSD has both m1 and tick files + let eur = index.symbols.get("EURUSD").unwrap(); + assert_eq!(eur.len(), 4); // 3 m1 + 1 tick + // Should be sorted chronologically, M1 before Tick within same month + assert_eq!(eur[0].month, 1); + assert_eq!(eur[0].format, DataFormat::M1); + // The tick file for month 1 comes right after the m1 file for month 1 + assert_eq!(eur[1].month, 1); + assert_eq!(eur[1].format, DataFormat::Tick); + + // AAPL.US with dot in symbol name + let aapl = index.symbols.get("AAPL.US").unwrap(); + assert_eq!(aapl.len(), 1); + assert_eq!(aapl[0].year, 2006); + + // GBPUSD + assert!(index.symbols.contains_key("GBPUSD")); + + // Invalid file should not appear + assert!(!index.symbols.contains_key("invalid_file")); + } + + #[test] + fn test_file_path_construction() { + let index = SymbolIndex { + base_path: PathBuf::from("/mnt/tickdata/Pepperstone"), + symbols: HashMap::new(), + }; + + let key = FileKey { + symbol: Arc::from("EURUSD"), + year: 2017, + month: 3, + format: DataFormat::M1, + }; + + assert_eq!( + index.file_path(&key), + PathBuf::from("/mnt/tickdata/Pepperstone/EURUSD_2017_03.m1") + ); + + let tick_key = FileKey { + symbol: Arc::from("AAPL.US"), + year: 2006, + month: 8, + format: DataFormat::Tick, + }; + + assert_eq!( + index.file_path(&tick_key), + PathBuf::from("/mnt/tickdata/Pepperstone/AAPL.US_2006_08.tick") + ); + } + + #[test] + fn test_data_server_symbols() { + let dir = tempfile::tempdir().unwrap(); + for name in ["EURUSD_2017_01.m1", "GBPUSD_2020_06.m1"] { + std::fs::write(dir.path().join(name), b"").unwrap(); + } + + let server = DataServer::new(dir.path()); + let syms = server.symbols(); + assert_eq!(syms.len(), 2); + assert_eq!(syms[0].as_ref(), "EURUSD"); + assert_eq!(syms[1].as_ref(), "GBPUSD"); + } +} diff --git a/src/ast/data_server/records.rs b/src/ast/data_server/records.rs new file mode 100644 index 0000000..8c10f9b --- /dev/null +++ b/src/ast/data_server/records.rs @@ -0,0 +1,189 @@ +//! Binary record types for Pepperstone tick data files. +//! +//! The raw types (`RawM1Record`, `RawTickRecord`) mirror the Delphi `packed record` +//! layout used by the C# data exporter. The parsed types (`M1Parsed`, `TickParsed`) +//! hold converted values ready for consumption by the VM. + +/// Delphi `TDateTime` epoch offset: days between 1899-12-30 and 1970-01-01. +const DELPHI_EPOCH_OFFSET_DAYS: f64 = 25569.0; +/// Milliseconds per day. +const MS_PER_DAY: f64 = 86_400_000.0; + +/// Converts a Delphi `TDateTime` (f64 days since 1899-12-30) to Unix milliseconds. +/// +/// Compatible with the existing `Value::DateTime(i64)` representation. +#[inline] +pub fn delphi_to_unix_ms(dt: f64) -> i64 { + ((dt - DELPHI_EPOCH_OFFSET_DAYS) * MS_PER_DAY) as i64 +} + +/// Raw M1 (minute) record as stored on disk. 48 bytes, packed. +/// +/// Matches the Delphi `TM1Record = packed record` layout exactly: +/// `Time: Double; Open: Double; High: Double; Low: Double; Close: Double; Spread: Single; Volume: Integer;` +#[repr(C, packed)] +#[derive(Clone, Copy)] +pub struct RawM1Record { + pub time: f64, + pub open: f64, + pub high: f64, + pub low: f64, + pub close: f64, + pub spread: f32, + pub volume: i32, +} + +/// Raw tick record as stored on disk. 24 bytes, packed. +/// +/// Matches the Delphi `TTickRecord = packed record` layout: +/// `Time: Double; Ask: Double; Bid: Double;` +#[repr(C, packed)] +#[derive(Clone, Copy)] +pub struct RawTickRecord { + pub time: f64, + pub ask: f64, + pub bid: f64, +} + +/// Parsed M1 record with converted field types. +/// +/// All fields are native-width (`f64`/`i64`) for direct mapping to `Value` variants. +/// `time_ms` is Unix milliseconds (converted from Delphi TDateTime). +#[derive(Debug, Clone, Copy)] +pub struct M1Parsed { + pub time_ms: i64, + pub open: f64, + pub high: f64, + pub low: f64, + pub close: f64, + pub spread: f64, + pub volume: i64, +} + +/// Parsed tick record with converted field types. +#[derive(Debug, Clone, Copy)] +pub struct TickParsed { + pub time_ms: i64, + pub ask: f64, + pub bid: f64, +} + +/// Distinguishes the two supported data file formats. +#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)] +pub enum DataFormat { + M1, + Tick, +} + +impl M1Parsed { + /// Converts a raw on-disk record to the parsed representation. + /// + /// Uses `read_unaligned` because `RawM1Record` is `packed` and field + /// access would be unaligned on architectures that require alignment. + #[inline] + pub fn from_raw(raw: &RawM1Record) -> Self { + // SAFETY: `raw` points to a valid `RawM1Record`. We use `read_unaligned` + // to safely read potentially unaligned fields from the packed struct. + unsafe { + Self { + time_ms: delphi_to_unix_ms(std::ptr::addr_of!(raw.time).read_unaligned()), + open: std::ptr::addr_of!(raw.open).read_unaligned(), + high: std::ptr::addr_of!(raw.high).read_unaligned(), + low: std::ptr::addr_of!(raw.low).read_unaligned(), + close: std::ptr::addr_of!(raw.close).read_unaligned(), + spread: std::ptr::addr_of!(raw.spread).read_unaligned() as f64, + volume: std::ptr::addr_of!(raw.volume).read_unaligned() as i64, + } + } + } +} + +impl TickParsed { + /// Converts a raw on-disk tick record to the parsed representation. + #[inline] + pub fn from_raw(raw: &RawTickRecord) -> Self { + unsafe { + Self { + time_ms: delphi_to_unix_ms(std::ptr::addr_of!(raw.time).read_unaligned()), + ask: std::ptr::addr_of!(raw.ask).read_unaligned(), + bid: std::ptr::addr_of!(raw.bid).read_unaligned(), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_delphi_datetime_conversion() { + // 2017-03-01 00:00:00 UTC = Unix timestamp 1488326400000 ms + // Delphi TDateTime for 2017-03-01 = 42795.0 + let delphi_dt = 42795.0; + let unix_ms = delphi_to_unix_ms(delphi_dt); + // Allow 1ms tolerance for floating-point rounding + assert!( + (unix_ms - 1_488_326_400_000).abs() <= 1, + "Expected ~1488326400000, got {unix_ms}" + ); + } + + #[test] + fn test_delphi_epoch() { + // The Unix epoch (1970-01-01) in Delphi TDateTime is exactly 25569.0 + let unix_ms = delphi_to_unix_ms(25569.0); + assert_eq!(unix_ms, 0); + } + + #[test] + fn test_raw_m1_record_size() { + assert_eq!(std::mem::size_of::(), 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 39ddc02..85f2507 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -1,5 +1,6 @@ pub mod closure; pub mod compiler; +pub mod data_server; pub mod diagnostics; pub mod environment; pub mod lexer;