feat: Add data server for market data loading
This commit introduces a new data server module (`src/ast/data_server`) designed for high-performance, thread-safe loading and caching of market data. Key components: - `DataServer`: Manages symbol indexing and delegates loading/caching. - `SymbolIndex`: Scans the data directory and maintains a sorted index of data files per symbol. - `FileCache`: Implements a thread-safe cache using `RwLock` and a loading guard to prevent duplicate work. - `loader`: Handles ZIP decompression and binary record parsing from `.bin` files. - `records`: Defines raw and parsed data structures for M1 and tick data. - `SymbolChunkIter`: Provides an iterator over pre-parsed data chunks, prefetching subsequent files. This architecture allows multiple VM threads to access market data concurrently without redundant I/O, mirroring the strategy used in the original Delphi `TDataServer`.
This commit is contained in:
Generated
+356
@@ -114,6 +114,17 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
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]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@@ -228,6 +239,15 @@ version = "1.0.101"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
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]]
|
[[package]]
|
||||||
name = "arboard"
|
name = "arboard"
|
||||||
version = "3.6.1"
|
version = "3.6.1"
|
||||||
@@ -507,6 +527,15 @@ version = "0.1.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
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]]
|
[[package]]
|
||||||
name = "block2"
|
name = "block2"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -555,6 +584,12 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder-lite"
|
name = "byteorder-lite"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -567,6 +602,25 @@ version = "1.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
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]]
|
[[package]]
|
||||||
name = "calloop"
|
name = "calloop"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@@ -671,6 +725,16 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.59"
|
version = "4.5.59"
|
||||||
@@ -768,6 +832,12 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"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]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -829,6 +899,30 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -850,6 +944,16 @@ version = "0.2.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
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]]
|
[[package]]
|
||||||
name = "cursor-icon"
|
name = "cursor-icon"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -890,6 +994,43 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "dispatch"
|
name = "dispatch"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1384,6 +1525,16 @@ dependencies = [
|
|||||||
"slab",
|
"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]]
|
[[package]]
|
||||||
name = "gethostname"
|
name = "gethostname"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -1401,9 +1552,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1613,6 +1766,15 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iana-time-zone"
|
name = "iana-time-zone"
|
||||||
version = "0.1.65"
|
version = "0.1.65"
|
||||||
@@ -1777,6 +1939,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "insta"
|
name = "insta"
|
||||||
version = "1.46.3"
|
version = "1.46.3"
|
||||||
@@ -1939,6 +2110,27 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
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]]
|
[[package]]
|
||||||
name = "malloc_buf"
|
name = "malloc_buf"
|
||||||
version = "0.0.6"
|
version = "0.0.6"
|
||||||
@@ -2021,6 +2213,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2085,6 +2278,12 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -2488,6 +2687,16 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec"
|
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]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -2588,6 +2797,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "presser"
|
name = "presser"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -2972,6 +3187,17 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -3126,6 +3352,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.116"
|
version = "2.0.116"
|
||||||
@@ -3224,6 +3456,25 @@ dependencies = [
|
|||||||
"zune-jpeg",
|
"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]]
|
[[package]]
|
||||||
name = "tiny-skia"
|
name = "tiny-skia"
|
||||||
version = "0.11.4"
|
version = "0.11.4"
|
||||||
@@ -3371,6 +3622,12 @@ dependencies = [
|
|||||||
"rustc-hash 2.1.1",
|
"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]]
|
[[package]]
|
||||||
name = "uds_windows"
|
name = "uds_windows"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -4557,6 +4814,15 @@ version = "0.8.28"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
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]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -4718,6 +4984,26 @@ dependencies = [
|
|||||||
"synstructure",
|
"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]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@@ -4751,12 +5037,82 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
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]]
|
[[package]]
|
||||||
name = "zune-core"
|
name = "zune-core"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ fastrand = "2.3"
|
|||||||
rmcp = { version = "1.2", features = ["server", "transport-io"] }
|
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"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
insta = { version = "1.39", features = ["yaml"] }
|
insta = { version = "1.39", features = ["yaml"] }
|
||||||
|
|||||||
Executable
+108
@@ -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<M1Record> _m1Buffer = new List<M1Record>();
|
||||||
|
private List<TickRecord> _tickBuffer = new List<TickRecord>();
|
||||||
|
|
||||||
|
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<M1Record> span = CollectionsMarshal.AsSpan(_m1Buffer);
|
||||||
|
ReadOnlySpan<byte> byteSpan = MemoryMarshal.Cast<M1Record, byte>(span);
|
||||||
|
if (!byteSpan.IsEmpty) entryStream.Write(byteSpan);
|
||||||
|
} else {
|
||||||
|
ReadOnlySpan<TickRecord> span = CollectionsMarshal.AsSpan(_tickBuffer);
|
||||||
|
ReadOnlySpan<byte> byteSpan = MemoryMarshal.Cast<TickRecord, byte>(span);
|
||||||
|
if (!byteSpan.IsEmpty) entryStream.Write(byteSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
Print("EXPORT_SUCCESS");
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Print("ERROR: {0}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
//! Thread-safe file cache for parsed market data chunks.
|
||||||
|
//!
|
||||||
|
//! Designed for a many-readers / rare-writer pattern: hundreds of VM threads
|
||||||
|
//! read concurrently via `RwLock`, while the loader writes each file exactly
|
||||||
|
//! once. Duplicate loads are prevented by a `loading` guard set.
|
||||||
|
|
||||||
|
use super::records::{DataFormat, M1Parsed, TickParsed};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
|
|
||||||
|
/// Uniquely identifies one data file on disk.
|
||||||
|
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
|
||||||
|
pub struct FileKey {
|
||||||
|
pub symbol: Arc<str>,
|
||||||
|
pub year: u16,
|
||||||
|
pub month: u8,
|
||||||
|
pub format: DataFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file's worth of pre-parsed chunks, shared across threads.
|
||||||
|
pub type ChunkVec<T> = Arc<Vec<Arc<[T]>>>;
|
||||||
|
/// Cache map: file key → shared chunk vector.
|
||||||
|
type CacheMap<T> = RwLock<HashMap<FileKey, ChunkVec<T>>>;
|
||||||
|
|
||||||
|
/// Thread-safe cache storing pre-parsed chunks of market data.
|
||||||
|
///
|
||||||
|
/// Each file is stored as a `Vec` of `Arc<[T]>` chunks (1024 records each).
|
||||||
|
/// The outer `Arc` allows the entire file to be shared without copying,
|
||||||
|
/// and individual chunk `Arc`s let iterators hold references to just the
|
||||||
|
/// chunk they are consuming.
|
||||||
|
pub struct FileCache {
|
||||||
|
m1: CacheMap<M1Parsed>,
|
||||||
|
tick: CacheMap<TickParsed>,
|
||||||
|
/// Tracks files currently being loaded to prevent duplicate work.
|
||||||
|
/// A key is inserted before loading starts and removed after the
|
||||||
|
/// result is written to the cache.
|
||||||
|
loading: Mutex<HashSet<FileKey>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FileCache {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileCache {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
m1: RwLock::new(HashMap::new()),
|
||||||
|
tick: RwLock::new(HashMap::new()),
|
||||||
|
loading: Mutex::new(HashSet::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- M1 -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Returns cached M1 chunks for the given key, or `None` if not yet loaded.
|
||||||
|
pub fn get_m1(&self, key: &FileKey) -> Option<ChunkVec<M1Parsed>> {
|
||||||
|
self.m1.read().unwrap().get(key).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts M1 chunks into the cache and clears the loading guard.
|
||||||
|
pub fn insert_m1(&self, key: FileKey, chunks: Vec<Arc<[M1Parsed]>>) {
|
||||||
|
self.m1
|
||||||
|
.write()
|
||||||
|
.unwrap()
|
||||||
|
.insert(key.clone(), Arc::new(chunks));
|
||||||
|
self.loading.lock().unwrap().remove(&key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Tick ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Returns cached tick chunks for the given key, or `None` if not yet loaded.
|
||||||
|
pub fn get_tick(&self, key: &FileKey) -> Option<ChunkVec<TickParsed>> {
|
||||||
|
self.tick.read().unwrap().get(key).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts tick chunks into the cache and clears the loading guard.
|
||||||
|
pub fn insert_tick(&self, key: FileKey, chunks: Vec<Arc<[TickParsed]>>) {
|
||||||
|
self.tick
|
||||||
|
.write()
|
||||||
|
.unwrap()
|
||||||
|
.insert(key.clone(), Arc::new(chunks));
|
||||||
|
self.loading.lock().unwrap().remove(&key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Loading guard -------------------------------------------------------
|
||||||
|
|
||||||
|
/// Attempts to claim the loading slot for `key`. Returns `true` if this
|
||||||
|
/// thread should load the file (first caller wins), `false` if another
|
||||||
|
/// thread is already loading it or it is already cached.
|
||||||
|
pub fn try_claim_loading(&self, key: &FileKey) -> bool {
|
||||||
|
// Fast path: already cached?
|
||||||
|
if key.format == DataFormat::M1 {
|
||||||
|
if self.m1.read().unwrap().contains_key(key) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if self.tick.read().unwrap().contains_key(key) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Slow path: try to claim the loading slot
|
||||||
|
self.loading.lock().unwrap().insert(key.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks whether the given key is currently being loaded by another thread.
|
||||||
|
pub fn is_loading(&self, key: &FileKey) -> bool {
|
||||||
|
self.loading.lock().unwrap().contains(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks whether the given key is available in the cache (already loaded).
|
||||||
|
pub fn is_cached(&self, key: &FileKey) -> bool {
|
||||||
|
match key.format {
|
||||||
|
DataFormat::M1 => self.m1.read().unwrap().contains_key(key),
|
||||||
|
DataFormat::Tick => self.tick.read().unwrap().contains_key(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spins briefly until the key is no longer in the loading set, i.e.
|
||||||
|
/// another thread has finished loading it. Returns `true` if the key
|
||||||
|
/// is now cached, `false` on timeout.
|
||||||
|
pub fn wait_for_load(&self, key: &FileKey) -> bool {
|
||||||
|
// Simple spin-wait with yield. In practice, file loads take
|
||||||
|
// milliseconds and this is only hit during prefetch races.
|
||||||
|
for _ in 0..10_000 {
|
||||||
|
if self.is_cached(key) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
// Fallback: still not cached, caller should load it themselves.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_key(symbol: &str, year: u16, month: u8, format: DataFormat) -> FileKey {
|
||||||
|
FileKey {
|
||||||
|
symbol: Arc::from(symbol),
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
format,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_insert_and_get_m1() {
|
||||||
|
let cache = FileCache::new();
|
||||||
|
let key = test_key("EURUSD", 2017, 3, DataFormat::M1);
|
||||||
|
|
||||||
|
assert!(cache.get_m1(&key).is_none());
|
||||||
|
|
||||||
|
let chunk: Arc<[M1Parsed]> = Arc::from(vec![M1Parsed {
|
||||||
|
time_ms: 0,
|
||||||
|
open: 1.0,
|
||||||
|
high: 1.1,
|
||||||
|
low: 0.9,
|
||||||
|
close: 1.05,
|
||||||
|
spread: 0.1,
|
||||||
|
volume: 100,
|
||||||
|
}]);
|
||||||
|
cache.insert_m1(key.clone(), vec![chunk]);
|
||||||
|
|
||||||
|
let result = cache.get_m1(&key).unwrap();
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].len(), 1);
|
||||||
|
assert_eq!(result[0][0].open, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_loading_guard() {
|
||||||
|
let cache = FileCache::new();
|
||||||
|
let key = test_key("EURUSD", 2017, 3, DataFormat::M1);
|
||||||
|
|
||||||
|
// First claim succeeds
|
||||||
|
assert!(cache.try_claim_loading(&key));
|
||||||
|
// Second claim fails (already loading)
|
||||||
|
assert!(!cache.try_claim_loading(&key));
|
||||||
|
|
||||||
|
assert!(cache.is_loading(&key));
|
||||||
|
|
||||||
|
// After insert, loading guard is cleared
|
||||||
|
cache.insert_m1(key.clone(), vec![]);
|
||||||
|
assert!(!cache.is_loading(&key));
|
||||||
|
|
||||||
|
// After caching, claim also returns false
|
||||||
|
assert!(!cache.try_claim_loading(&key));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_cached() {
|
||||||
|
let cache = FileCache::new();
|
||||||
|
let m1_key = test_key("EURUSD", 2017, 3, DataFormat::M1);
|
||||||
|
let tick_key = test_key("EURUSD", 2017, 3, DataFormat::Tick);
|
||||||
|
|
||||||
|
assert!(!cache.is_cached(&m1_key));
|
||||||
|
assert!(!cache.is_cached(&tick_key));
|
||||||
|
|
||||||
|
cache.insert_m1(m1_key.clone(), vec![]);
|
||||||
|
assert!(cache.is_cached(&m1_key));
|
||||||
|
assert!(!cache.is_cached(&tick_key));
|
||||||
|
|
||||||
|
cache.insert_tick(tick_key.clone(), vec![]);
|
||||||
|
assert!(cache.is_cached(&tick_key));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
//! ZIP decompression and binary record parsing for tick data files.
|
||||||
|
//!
|
||||||
|
//! Each data file is a ZIP archive containing a single `.bin` entry with
|
||||||
|
//! tightly packed binary records. This module reads and parses them into
|
||||||
|
//! `Arc`-shared chunks of 1024 records, mirroring the Delphi
|
||||||
|
//! `TDataServer<T,R>.ReadUncompressedData` chunking strategy.
|
||||||
|
|
||||||
|
use super::records::{M1Parsed, RawM1Record, RawTickRecord, TickParsed};
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Number of records per chunk. Matches the Delphi `ChunkSize = 1024` constant.
|
||||||
|
pub const CHUNK_SIZE: usize = 1024;
|
||||||
|
|
||||||
|
/// Loads an M1 (minute OHLCV) data file and returns pre-parsed chunks.
|
||||||
|
///
|
||||||
|
/// The file must be a ZIP archive containing a `.bin` entry with packed
|
||||||
|
/// `RawM1Record` structs (48 bytes each).
|
||||||
|
pub fn load_m1_file(path: &Path) -> std::io::Result<Vec<Arc<[M1Parsed]>>> {
|
||||||
|
let buf = read_bin_entry(path)?;
|
||||||
|
Ok(parse_chunks(&buf, std::mem::size_of::<RawM1Record>(), |ptr| {
|
||||||
|
// SAFETY: `ptr` points to `size_of::<RawM1Record>()` valid bytes.
|
||||||
|
let raw = unsafe { &*(ptr as *const RawM1Record) };
|
||||||
|
M1Parsed::from_raw(raw)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a tick data file and returns pre-parsed chunks.
|
||||||
|
///
|
||||||
|
/// The file must be a ZIP archive containing a `.bin` entry with packed
|
||||||
|
/// `RawTickRecord` structs (24 bytes each).
|
||||||
|
pub fn load_tick_file(path: &Path) -> std::io::Result<Vec<Arc<[TickParsed]>>> {
|
||||||
|
let buf = read_bin_entry(path)?;
|
||||||
|
Ok(parse_chunks(&buf, std::mem::size_of::<RawTickRecord>(), |ptr| {
|
||||||
|
let raw = unsafe { &*(ptr as *const RawTickRecord) };
|
||||||
|
TickParsed::from_raw(raw)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a ZIP archive and reads the first `.bin` entry into memory.
|
||||||
|
///
|
||||||
|
/// Mirrors Delphi's `ReadCompressedData`: opens the archive, locates the
|
||||||
|
/// `.bin` entry, decompresses it, and returns the raw bytes.
|
||||||
|
fn read_bin_entry(path: &Path) -> std::io::Result<Vec<u8>> {
|
||||||
|
let file = std::fs::File::open(path)?;
|
||||||
|
let mut archive = zip::ZipArchive::new(file)?;
|
||||||
|
|
||||||
|
// Find the .bin entry (like Delphi's search loop in ReadCompressedData)
|
||||||
|
let entry_index = (0..archive.len())
|
||||||
|
.find(|&i| {
|
||||||
|
archive
|
||||||
|
.name_for_index(i)
|
||||||
|
.is_some_and(|name| name.ends_with(".bin"))
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
format!("No .bin entry found in {}", path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut entry = archive.by_index(entry_index)?;
|
||||||
|
let mut buf = Vec::with_capacity(entry.size() as usize);
|
||||||
|
entry.read_to_end(&mut buf)?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a byte buffer into chunks of parsed records.
|
||||||
|
///
|
||||||
|
/// Generic over the output type `T` via a conversion closure that receives
|
||||||
|
/// a pointer to each raw record in the buffer. This is the Rust equivalent
|
||||||
|
/// of Delphi's `ReadUncompressedData` with its `MapRecord` virtual call.
|
||||||
|
fn parse_chunks<T: Copy>(
|
||||||
|
buf: &[u8],
|
||||||
|
record_size: usize,
|
||||||
|
convert: impl Fn(*const u8) -> T,
|
||||||
|
) -> Vec<Arc<[T]>> {
|
||||||
|
let total_records = buf.len() / record_size;
|
||||||
|
if total_records == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk_count = total_records.div_ceil(CHUNK_SIZE);
|
||||||
|
let mut chunks = Vec::with_capacity(chunk_count);
|
||||||
|
|
||||||
|
for chunk_idx in 0..chunk_count {
|
||||||
|
let start = chunk_idx * CHUNK_SIZE;
|
||||||
|
let end = (start + CHUNK_SIZE).min(total_records);
|
||||||
|
let mut parsed = Vec::with_capacity(end - start);
|
||||||
|
|
||||||
|
for i in start..end {
|
||||||
|
let ptr = buf[i * record_size..].as_ptr();
|
||||||
|
parsed.push(convert(ptr));
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(Arc::from(parsed.into_boxed_slice()));
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::ast::data_server::records::RawM1Record;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
/// Creates a minimal ZIP file containing a `.bin` entry with raw M1 records.
|
||||||
|
fn create_test_m1_zip(records: &[RawM1Record]) -> tempfile::NamedTempFile {
|
||||||
|
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
let mut zip = zip::ZipWriter::new(tmp.reopen().unwrap());
|
||||||
|
zip.start_file::<String, ()>("TEST.bin".into(), Default::default())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for rec in records {
|
||||||
|
let bytes: &[u8] = unsafe {
|
||||||
|
std::slice::from_raw_parts(rec as *const RawM1Record as *const u8, 48)
|
||||||
|
};
|
||||||
|
zip.write_all(bytes).unwrap();
|
||||||
|
}
|
||||||
|
zip.finish().unwrap();
|
||||||
|
tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_m1_single_chunk() {
|
||||||
|
// Create 3 test records
|
||||||
|
let records = vec![
|
||||||
|
RawM1Record {
|
||||||
|
time: 42795.0,
|
||||||
|
open: 1.05,
|
||||||
|
high: 1.06,
|
||||||
|
low: 1.04,
|
||||||
|
close: 1.055,
|
||||||
|
spread: 0.5,
|
||||||
|
volume: 100,
|
||||||
|
},
|
||||||
|
RawM1Record {
|
||||||
|
time: 42795.001,
|
||||||
|
open: 1.055,
|
||||||
|
high: 1.07,
|
||||||
|
low: 1.05,
|
||||||
|
close: 1.065,
|
||||||
|
spread: 0.4,
|
||||||
|
volume: 200,
|
||||||
|
},
|
||||||
|
RawM1Record {
|
||||||
|
time: 42795.002,
|
||||||
|
open: 1.065,
|
||||||
|
high: 1.08,
|
||||||
|
low: 1.06,
|
||||||
|
close: 1.075,
|
||||||
|
spread: 0.3,
|
||||||
|
volume: 300,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let zip_file = create_test_m1_zip(&records);
|
||||||
|
let chunks = load_m1_file(zip_file.path()).unwrap();
|
||||||
|
|
||||||
|
// 3 records < 1024, so only 1 chunk
|
||||||
|
assert_eq!(chunks.len(), 1);
|
||||||
|
assert_eq!(chunks[0].len(), 3);
|
||||||
|
|
||||||
|
assert_eq!(chunks[0][0].open, 1.05);
|
||||||
|
assert_eq!(chunks[0][1].volume, 200);
|
||||||
|
assert_eq!(chunks[0][2].close, 1.075);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_m1_multiple_chunks() {
|
||||||
|
// Create exactly CHUNK_SIZE + 1 records to verify chunking
|
||||||
|
let records: Vec<RawM1Record> = (0..CHUNK_SIZE + 1)
|
||||||
|
.map(|i| RawM1Record {
|
||||||
|
time: 42795.0 + i as f64 * 0.001,
|
||||||
|
open: 1.0 + i as f64 * 0.001,
|
||||||
|
high: 1.1,
|
||||||
|
low: 0.9,
|
||||||
|
close: 1.05,
|
||||||
|
spread: 0.1,
|
||||||
|
volume: i as i32,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let zip_file = create_test_m1_zip(&records);
|
||||||
|
let chunks = load_m1_file(zip_file.path()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(chunks.len(), 2);
|
||||||
|
assert_eq!(chunks[0].len(), CHUNK_SIZE);
|
||||||
|
assert_eq!(chunks[1].len(), 1);
|
||||||
|
// Last record should have volume == CHUNK_SIZE
|
||||||
|
assert_eq!(chunks[1][0].volume, CHUNK_SIZE as i64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_bin_entry() {
|
||||||
|
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
let mut zip = zip::ZipWriter::new(tmp.reopen().unwrap());
|
||||||
|
zip.start_file::<String, ()>("empty.bin".into(), Default::default())
|
||||||
|
.unwrap();
|
||||||
|
zip.finish().unwrap();
|
||||||
|
|
||||||
|
let chunks = load_m1_file(tmp.path()).unwrap();
|
||||||
|
assert!(chunks.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_bin_entry() {
|
||||||
|
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
let mut zip = zip::ZipWriter::new(tmp.reopen().unwrap());
|
||||||
|
zip.start_file::<String, ()>("data.csv".into(), Default::default())
|
||||||
|
.unwrap();
|
||||||
|
zip.write_all(b"not binary").unwrap();
|
||||||
|
zip.finish().unwrap();
|
||||||
|
|
||||||
|
let result = load_m1_file(tmp.path());
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
//! High-performance data server for binary market data files.
|
||||||
|
//!
|
||||||
|
//! Provides thread-safe (`Arc`-based) loading and caching of tick data,
|
||||||
|
//! designed to serve hundreds of single-threaded VMs in parallel without
|
||||||
|
//! redundant I/O. Each file is read and parsed once, then shared across
|
||||||
|
//! all consumers via `Arc<[T]>` chunk references.
|
||||||
|
//!
|
||||||
|
//! # Architecture
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! Arc<DataServer>
|
||||||
|
//! ┌──────────────────────────┐
|
||||||
|
//! │ SymbolIndex FileCache │
|
||||||
|
//! │ (HashMap) (RwLock) │
|
||||||
|
//! └─────┬────────────┬───────┘
|
||||||
|
//! │ │
|
||||||
|
//! VM Thread 1 │ VM Thread N
|
||||||
|
//! Generator ──┘ Generator ──┘
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod cache;
|
||||||
|
pub mod loader;
|
||||||
|
pub mod records;
|
||||||
|
|
||||||
|
use cache::{ChunkVec, FileCache, FileKey};
|
||||||
|
use records::{DataFormat, M1Parsed, TickParsed};
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
|
|
||||||
|
/// Global data server instance, initialized once before any VM threads start.
|
||||||
|
static DATA_SERVER: OnceLock<Arc<DataServer>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Initializes the global data server for the given base path.
|
||||||
|
///
|
||||||
|
/// Scans the directory for data files and builds the symbol index.
|
||||||
|
/// Must be called once before any `get_data_server()` calls (typically
|
||||||
|
/// at program startup). Returns the `Arc` for optional local use.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if called more than once (the `OnceLock` is already set).
|
||||||
|
pub fn init_data_server(base_path: impl AsRef<Path>) -> Arc<DataServer> {
|
||||||
|
let server = Arc::new(DataServer::new(base_path));
|
||||||
|
DATA_SERVER
|
||||||
|
.set(server.clone())
|
||||||
|
.expect("DataServer already initialized");
|
||||||
|
server
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the global data server, or `None` if not yet initialized.
|
||||||
|
pub fn get_data_server() -> Option<Arc<DataServer>> {
|
||||||
|
DATA_SERVER.get().cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-scanned directory index mapping symbols to their sorted data files.
|
||||||
|
struct SymbolIndex {
|
||||||
|
base_path: PathBuf,
|
||||||
|
/// Symbol name (e.g. "EURUSD") → sorted list of file keys (chronological).
|
||||||
|
symbols: HashMap<Arc<str>, Vec<FileKey>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SymbolIndex {
|
||||||
|
/// Scans `base_path` for files matching `SYMBOL_YYYY_MM.(m1|tick)` and
|
||||||
|
/// builds a grouped, chronologically sorted index.
|
||||||
|
///
|
||||||
|
/// Mirrors Delphi's `TDataServer.UpdateSymbols`.
|
||||||
|
fn scan(base_path: &Path) -> Self {
|
||||||
|
let re = Regex::new(r"^(.+)_(\d{4})_(\d{2})\.(m1|tick)$").unwrap();
|
||||||
|
let mut map: HashMap<Arc<str>, Vec<FileKey>> = HashMap::new();
|
||||||
|
|
||||||
|
if let Ok(entries) = std::fs::read_dir(base_path) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
let name = file_name.to_string_lossy();
|
||||||
|
|
||||||
|
if let Some(caps) = re.captures(&name) {
|
||||||
|
let symbol: Arc<str> = Arc::from(&caps[1]);
|
||||||
|
let year: u16 = caps[2].parse().unwrap_or(0);
|
||||||
|
let month: u8 = caps[3].parse().unwrap_or(0);
|
||||||
|
let format = match &caps[4] {
|
||||||
|
"m1" => DataFormat::M1,
|
||||||
|
"tick" => DataFormat::Tick,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if year > 0 && (1..=12).contains(&month) {
|
||||||
|
let key = FileKey {
|
||||||
|
symbol: symbol.clone(),
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
format,
|
||||||
|
};
|
||||||
|
map.entry(symbol).or_default().push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort each symbol's files chronologically (year, month, format)
|
||||||
|
for files in map.values_mut() {
|
||||||
|
files.sort_by(|a, b| {
|
||||||
|
(a.year, a.month, a.format as u8).cmp(&(b.year, b.month, b.format as u8))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
base_path: base_path.to_path_buf(),
|
||||||
|
symbols: map,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the filesystem path for a given file key.
|
||||||
|
fn file_path(&self, key: &FileKey) -> PathBuf {
|
||||||
|
let ext = match key.format {
|
||||||
|
DataFormat::M1 => "m1",
|
||||||
|
DataFormat::Tick => "tick",
|
||||||
|
};
|
||||||
|
self.base_path
|
||||||
|
.join(format!("{}_{:04}_{:02}.{}", key.symbol, key.year, key.month, ext))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// High-performance data server for binary market data files.
|
||||||
|
///
|
||||||
|
/// Thread-safe (`Send + Sync`). Designed to be wrapped in `Arc` and shared
|
||||||
|
/// across hundreds of VM threads. Each file is loaded and parsed once,
|
||||||
|
/// then served from the in-memory cache.
|
||||||
|
pub struct DataServer {
|
||||||
|
index: SymbolIndex,
|
||||||
|
cache: FileCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for DataServer {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"DataServer({:?}, {} symbols)",
|
||||||
|
self.index.base_path,
|
||||||
|
self.index.symbols.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataServer {
|
||||||
|
/// Creates a new data server, scanning the base path for data files.
|
||||||
|
pub fn new(base_path: impl AsRef<Path>) -> Self {
|
||||||
|
Self {
|
||||||
|
index: SymbolIndex::scan(base_path.as_ref()),
|
||||||
|
cache: FileCache::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a sorted list of all known symbol names.
|
||||||
|
pub fn symbols(&self) -> Vec<Arc<str>> {
|
||||||
|
let mut syms: Vec<_> = self.index.symbols.keys().cloned().collect();
|
||||||
|
syms.sort();
|
||||||
|
syms
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of data files for a given symbol and format,
|
||||||
|
/// or `None` if the symbol is unknown.
|
||||||
|
pub fn file_count(&self, symbol: &str, format: DataFormat) -> Option<usize> {
|
||||||
|
self.index.symbols.get(symbol).map(|files| {
|
||||||
|
files.iter().filter(|f| f.format == format).count()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a chunk iterator over all M1 data for a symbol.
|
||||||
|
///
|
||||||
|
/// The iterator yields `Arc<[M1Parsed]>` chunks in chronological order,
|
||||||
|
/// prefetching the next file while the current one is consumed.
|
||||||
|
pub fn stream_m1(self: &Arc<Self>, symbol: &str) -> Option<SymbolChunkIter<M1Parsed>> {
|
||||||
|
let files: Vec<FileKey> = self
|
||||||
|
.index
|
||||||
|
.symbols
|
||||||
|
.get(symbol)?
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.format == DataFormat::M1)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if files.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eagerly load the first file and prefetch the second
|
||||||
|
self.ensure_m1_loaded(&files[0]);
|
||||||
|
if files.len() > 1 {
|
||||||
|
self.prefetch_m1(&files[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(SymbolChunkIter {
|
||||||
|
server: Arc::clone(self),
|
||||||
|
files,
|
||||||
|
file_idx: 0,
|
||||||
|
chunk_idx: 0,
|
||||||
|
current_file_chunks: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a chunk iterator over all tick data for a symbol.
|
||||||
|
pub fn stream_tick(self: &Arc<Self>, symbol: &str) -> Option<SymbolChunkIter<TickParsed>> {
|
||||||
|
let files: Vec<FileKey> = self
|
||||||
|
.index
|
||||||
|
.symbols
|
||||||
|
.get(symbol)?
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.format == DataFormat::Tick)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if files.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.ensure_tick_loaded(&files[0]);
|
||||||
|
if files.len() > 1 {
|
||||||
|
self.prefetch_tick(&files[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(SymbolChunkIter {
|
||||||
|
server: Arc::clone(self),
|
||||||
|
files,
|
||||||
|
file_idx: 0,
|
||||||
|
chunk_idx: 0,
|
||||||
|
current_file_chunks: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- M1 loading ----------------------------------------------------------
|
||||||
|
|
||||||
|
/// Ensures the given M1 file is loaded into the cache (blocking).
|
||||||
|
fn ensure_m1_loaded(&self, key: &FileKey) {
|
||||||
|
if self.cache.is_cached(key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.cache.is_loading(key) {
|
||||||
|
self.cache.wait_for_load(key);
|
||||||
|
if self.cache.is_cached(key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.load_m1_sync(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronously loads an M1 file into the cache.
|
||||||
|
fn load_m1_sync(&self, key: &FileKey) {
|
||||||
|
if !self.cache.try_claim_loading(key) {
|
||||||
|
// Another thread is loading or already cached — wait for it
|
||||||
|
self.cache.wait_for_load(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let path = self.index.file_path(key);
|
||||||
|
match loader::load_m1_file(&path) {
|
||||||
|
Ok(chunks) => self.cache.insert_m1(key.clone(), chunks),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Warning: failed to load {}: {}", path.display(), e);
|
||||||
|
// Insert empty to unblock waiters and prevent retries
|
||||||
|
self.cache.insert_m1(key.clone(), vec![]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefetches an M1 file in a background thread.
|
||||||
|
///
|
||||||
|
/// Requires `Arc<Self>` to safely share the server with the spawned thread.
|
||||||
|
fn prefetch_m1(self: &Arc<Self>, key: &FileKey) {
|
||||||
|
if self.cache.is_cached(key) || !self.cache.try_claim_loading(key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let path = self.index.file_path(key);
|
||||||
|
let cache_key = key.clone();
|
||||||
|
let server = Arc::clone(self);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
match loader::load_m1_file(&path) {
|
||||||
|
Ok(chunks) => server.cache.insert_m1(cache_key, chunks),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Warning: prefetch failed for {}: {}", path.display(), e);
|
||||||
|
server.cache.insert_m1(cache_key, vec![]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Tick loading --------------------------------------------------------
|
||||||
|
|
||||||
|
/// Ensures the given tick file is loaded into the cache (blocking).
|
||||||
|
fn ensure_tick_loaded(&self, key: &FileKey) {
|
||||||
|
if self.cache.is_cached(key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.cache.is_loading(key) {
|
||||||
|
self.cache.wait_for_load(key);
|
||||||
|
if self.cache.is_cached(key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.load_tick_sync(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronously loads a tick file into the cache.
|
||||||
|
fn load_tick_sync(&self, key: &FileKey) {
|
||||||
|
if !self.cache.try_claim_loading(key) {
|
||||||
|
self.cache.wait_for_load(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let path = self.index.file_path(key);
|
||||||
|
match loader::load_tick_file(&path) {
|
||||||
|
Ok(chunks) => self.cache.insert_tick(key.clone(), chunks),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Warning: failed to load {}: {}", path.display(), e);
|
||||||
|
self.cache.insert_tick(key.clone(), vec![]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefetches a tick file in a background thread.
|
||||||
|
fn prefetch_tick(self: &Arc<Self>, key: &FileKey) {
|
||||||
|
if self.cache.is_cached(key) || !self.cache.try_claim_loading(key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let path = self.index.file_path(key);
|
||||||
|
let cache_key = key.clone();
|
||||||
|
let server = Arc::clone(self);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
match loader::load_tick_file(&path) {
|
||||||
|
Ok(chunks) => server.cache.insert_tick(cache_key, chunks),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Warning: prefetch failed for {}: {}", path.display(), e);
|
||||||
|
server.cache.insert_tick(cache_key, vec![]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterator yielding chunks of parsed records for a single symbol.
|
||||||
|
///
|
||||||
|
/// Traverses all files for the symbol in chronological order, yielding
|
||||||
|
/// each 1024-record chunk as an `Arc<[T]>`. When advancing to the next
|
||||||
|
/// file, the file after that is prefetched in the background.
|
||||||
|
///
|
||||||
|
/// This is the Rust equivalent of Delphi's `ProcessFile` chain with its
|
||||||
|
/// `LoadDataFile(nextFileInfo)` prefetch call.
|
||||||
|
pub struct SymbolChunkIter<T> {
|
||||||
|
server: Arc<DataServer>,
|
||||||
|
files: Vec<FileKey>,
|
||||||
|
file_idx: usize,
|
||||||
|
chunk_idx: usize,
|
||||||
|
/// Cached reference to the current file's chunks to avoid repeated
|
||||||
|
/// cache lookups within the same file.
|
||||||
|
current_file_chunks: Option<ChunkVec<T>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SymbolChunkIter<M1Parsed> {
|
||||||
|
/// Returns the next chunk of M1 records, or `None` when exhausted.
|
||||||
|
pub fn next_chunk(&mut self) -> Option<Arc<[M1Parsed]>> {
|
||||||
|
loop {
|
||||||
|
// Try to get chunks for the current file
|
||||||
|
if self.current_file_chunks.is_none() && self.file_idx < self.files.len() {
|
||||||
|
let key = &self.files[self.file_idx];
|
||||||
|
self.server.ensure_m1_loaded(key);
|
||||||
|
self.current_file_chunks = self.server.cache.get_m1(key);
|
||||||
|
self.chunk_idx = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunks = self.current_file_chunks.as_ref()?;
|
||||||
|
|
||||||
|
if self.chunk_idx < chunks.len() {
|
||||||
|
let chunk = chunks[self.chunk_idx].clone();
|
||||||
|
self.chunk_idx += 1;
|
||||||
|
return Some(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current file exhausted — advance to next file
|
||||||
|
self.file_idx += 1;
|
||||||
|
self.current_file_chunks = None;
|
||||||
|
|
||||||
|
if self.file_idx >= self.files.len() {
|
||||||
|
return None; // All files exhausted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefetch the file AFTER the next one (N+2), since N+1 was
|
||||||
|
// already prefetched when we started N.
|
||||||
|
if self.file_idx + 1 < self.files.len() {
|
||||||
|
self.server.prefetch_m1(&self.files[self.file_idx + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SymbolChunkIter<TickParsed> {
|
||||||
|
/// Returns the next chunk of tick records, or `None` when exhausted.
|
||||||
|
pub fn next_chunk(&mut self) -> Option<Arc<[TickParsed]>> {
|
||||||
|
loop {
|
||||||
|
if self.current_file_chunks.is_none() && self.file_idx < self.files.len() {
|
||||||
|
let key = &self.files[self.file_idx];
|
||||||
|
self.server.ensure_tick_loaded(key);
|
||||||
|
self.current_file_chunks = self.server.cache.get_tick(key);
|
||||||
|
self.chunk_idx = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunks = self.current_file_chunks.as_ref()?;
|
||||||
|
|
||||||
|
if self.chunk_idx < chunks.len() {
|
||||||
|
let chunk = chunks[self.chunk_idx].clone();
|
||||||
|
self.chunk_idx += 1;
|
||||||
|
return Some(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.file_idx += 1;
|
||||||
|
self.current_file_chunks = None;
|
||||||
|
|
||||||
|
if self.file_idx >= self.files.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.file_idx + 1 < self.files.len() {
|
||||||
|
self.server.prefetch_tick(&self.files[self.file_idx + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_symbol_index_scan() {
|
||||||
|
// Create a temp directory with mock files
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let names = [
|
||||||
|
"EURUSD_2017_01.m1",
|
||||||
|
"EURUSD_2017_02.m1",
|
||||||
|
"EURUSD_2017_03.m1",
|
||||||
|
"EURUSD_2017_01.tick",
|
||||||
|
"GBPUSD_2020_06.m1",
|
||||||
|
"invalid_file.txt",
|
||||||
|
"AAPL.US_2006_08.m1",
|
||||||
|
];
|
||||||
|
for name in &names {
|
||||||
|
std::fs::write(dir.path().join(name), b"").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = SymbolIndex::scan(dir.path());
|
||||||
|
|
||||||
|
// EURUSD has both m1 and tick files
|
||||||
|
let eur = index.symbols.get("EURUSD").unwrap();
|
||||||
|
assert_eq!(eur.len(), 4); // 3 m1 + 1 tick
|
||||||
|
// Should be sorted chronologically, M1 before Tick within same month
|
||||||
|
assert_eq!(eur[0].month, 1);
|
||||||
|
assert_eq!(eur[0].format, DataFormat::M1);
|
||||||
|
// The tick file for month 1 comes right after the m1 file for month 1
|
||||||
|
assert_eq!(eur[1].month, 1);
|
||||||
|
assert_eq!(eur[1].format, DataFormat::Tick);
|
||||||
|
|
||||||
|
// AAPL.US with dot in symbol name
|
||||||
|
let aapl = index.symbols.get("AAPL.US").unwrap();
|
||||||
|
assert_eq!(aapl.len(), 1);
|
||||||
|
assert_eq!(aapl[0].year, 2006);
|
||||||
|
|
||||||
|
// GBPUSD
|
||||||
|
assert!(index.symbols.contains_key("GBPUSD"));
|
||||||
|
|
||||||
|
// Invalid file should not appear
|
||||||
|
assert!(!index.symbols.contains_key("invalid_file"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_file_path_construction() {
|
||||||
|
let index = SymbolIndex {
|
||||||
|
base_path: PathBuf::from("/mnt/tickdata/Pepperstone"),
|
||||||
|
symbols: HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = FileKey {
|
||||||
|
symbol: Arc::from("EURUSD"),
|
||||||
|
year: 2017,
|
||||||
|
month: 3,
|
||||||
|
format: DataFormat::M1,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
index.file_path(&key),
|
||||||
|
PathBuf::from("/mnt/tickdata/Pepperstone/EURUSD_2017_03.m1")
|
||||||
|
);
|
||||||
|
|
||||||
|
let tick_key = FileKey {
|
||||||
|
symbol: Arc::from("AAPL.US"),
|
||||||
|
year: 2006,
|
||||||
|
month: 8,
|
||||||
|
format: DataFormat::Tick,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
index.file_path(&tick_key),
|
||||||
|
PathBuf::from("/mnt/tickdata/Pepperstone/AAPL.US_2006_08.tick")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_data_server_symbols() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
for name in ["EURUSD_2017_01.m1", "GBPUSD_2020_06.m1"] {
|
||||||
|
std::fs::write(dir.path().join(name), b"").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let server = DataServer::new(dir.path());
|
||||||
|
let syms = server.symbols();
|
||||||
|
assert_eq!(syms.len(), 2);
|
||||||
|
assert_eq!(syms[0].as_ref(), "EURUSD");
|
||||||
|
assert_eq!(syms[1].as_ref(), "GBPUSD");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
//! Binary record types for Pepperstone tick data files.
|
||||||
|
//!
|
||||||
|
//! The raw types (`RawM1Record`, `RawTickRecord`) mirror the Delphi `packed record`
|
||||||
|
//! layout used by the C# data exporter. The parsed types (`M1Parsed`, `TickParsed`)
|
||||||
|
//! hold converted values ready for consumption by the VM.
|
||||||
|
|
||||||
|
/// Delphi `TDateTime` epoch offset: days between 1899-12-30 and 1970-01-01.
|
||||||
|
const DELPHI_EPOCH_OFFSET_DAYS: f64 = 25569.0;
|
||||||
|
/// Milliseconds per day.
|
||||||
|
const MS_PER_DAY: f64 = 86_400_000.0;
|
||||||
|
|
||||||
|
/// Converts a Delphi `TDateTime` (f64 days since 1899-12-30) to Unix milliseconds.
|
||||||
|
///
|
||||||
|
/// Compatible with the existing `Value::DateTime(i64)` representation.
|
||||||
|
#[inline]
|
||||||
|
pub fn delphi_to_unix_ms(dt: f64) -> i64 {
|
||||||
|
((dt - DELPHI_EPOCH_OFFSET_DAYS) * MS_PER_DAY) as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw M1 (minute) record as stored on disk. 48 bytes, packed.
|
||||||
|
///
|
||||||
|
/// Matches the Delphi `TM1Record = packed record` layout exactly:
|
||||||
|
/// `Time: Double; Open: Double; High: Double; Low: Double; Close: Double; Spread: Single; Volume: Integer;`
|
||||||
|
#[repr(C, packed)]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct RawM1Record {
|
||||||
|
pub time: f64,
|
||||||
|
pub open: f64,
|
||||||
|
pub high: f64,
|
||||||
|
pub low: f64,
|
||||||
|
pub close: f64,
|
||||||
|
pub spread: f32,
|
||||||
|
pub volume: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw tick record as stored on disk. 24 bytes, packed.
|
||||||
|
///
|
||||||
|
/// Matches the Delphi `TTickRecord = packed record` layout:
|
||||||
|
/// `Time: Double; Ask: Double; Bid: Double;`
|
||||||
|
#[repr(C, packed)]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct RawTickRecord {
|
||||||
|
pub time: f64,
|
||||||
|
pub ask: f64,
|
||||||
|
pub bid: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed M1 record with converted field types.
|
||||||
|
///
|
||||||
|
/// All fields are native-width (`f64`/`i64`) for direct mapping to `Value` variants.
|
||||||
|
/// `time_ms` is Unix milliseconds (converted from Delphi TDateTime).
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct M1Parsed {
|
||||||
|
pub time_ms: i64,
|
||||||
|
pub open: f64,
|
||||||
|
pub high: f64,
|
||||||
|
pub low: f64,
|
||||||
|
pub close: f64,
|
||||||
|
pub spread: f64,
|
||||||
|
pub volume: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed tick record with converted field types.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct TickParsed {
|
||||||
|
pub time_ms: i64,
|
||||||
|
pub ask: f64,
|
||||||
|
pub bid: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distinguishes the two supported data file formats.
|
||||||
|
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
|
||||||
|
pub enum DataFormat {
|
||||||
|
M1,
|
||||||
|
Tick,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl M1Parsed {
|
||||||
|
/// Converts a raw on-disk record to the parsed representation.
|
||||||
|
///
|
||||||
|
/// Uses `read_unaligned` because `RawM1Record` is `packed` and field
|
||||||
|
/// access would be unaligned on architectures that require alignment.
|
||||||
|
#[inline]
|
||||||
|
pub fn from_raw(raw: &RawM1Record) -> Self {
|
||||||
|
// SAFETY: `raw` points to a valid `RawM1Record`. We use `read_unaligned`
|
||||||
|
// to safely read potentially unaligned fields from the packed struct.
|
||||||
|
unsafe {
|
||||||
|
Self {
|
||||||
|
time_ms: delphi_to_unix_ms(std::ptr::addr_of!(raw.time).read_unaligned()),
|
||||||
|
open: std::ptr::addr_of!(raw.open).read_unaligned(),
|
||||||
|
high: std::ptr::addr_of!(raw.high).read_unaligned(),
|
||||||
|
low: std::ptr::addr_of!(raw.low).read_unaligned(),
|
||||||
|
close: std::ptr::addr_of!(raw.close).read_unaligned(),
|
||||||
|
spread: std::ptr::addr_of!(raw.spread).read_unaligned() as f64,
|
||||||
|
volume: std::ptr::addr_of!(raw.volume).read_unaligned() as i64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TickParsed {
|
||||||
|
/// Converts a raw on-disk tick record to the parsed representation.
|
||||||
|
#[inline]
|
||||||
|
pub fn from_raw(raw: &RawTickRecord) -> Self {
|
||||||
|
unsafe {
|
||||||
|
Self {
|
||||||
|
time_ms: delphi_to_unix_ms(std::ptr::addr_of!(raw.time).read_unaligned()),
|
||||||
|
ask: std::ptr::addr_of!(raw.ask).read_unaligned(),
|
||||||
|
bid: std::ptr::addr_of!(raw.bid).read_unaligned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delphi_datetime_conversion() {
|
||||||
|
// 2017-03-01 00:00:00 UTC = Unix timestamp 1488326400000 ms
|
||||||
|
// Delphi TDateTime for 2017-03-01 = 42795.0
|
||||||
|
let delphi_dt = 42795.0;
|
||||||
|
let unix_ms = delphi_to_unix_ms(delphi_dt);
|
||||||
|
// Allow 1ms tolerance for floating-point rounding
|
||||||
|
assert!(
|
||||||
|
(unix_ms - 1_488_326_400_000).abs() <= 1,
|
||||||
|
"Expected ~1488326400000, got {unix_ms}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delphi_epoch() {
|
||||||
|
// The Unix epoch (1970-01-01) in Delphi TDateTime is exactly 25569.0
|
||||||
|
let unix_ms = delphi_to_unix_ms(25569.0);
|
||||||
|
assert_eq!(unix_ms, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_raw_m1_record_size() {
|
||||||
|
assert_eq!(std::mem::size_of::<RawM1Record>(), 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_raw_tick_record_size() {
|
||||||
|
assert_eq!(std::mem::size_of::<RawTickRecord>(), 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_m1_from_raw() {
|
||||||
|
// Construct a raw record with known values
|
||||||
|
let raw = RawM1Record {
|
||||||
|
time: 42795.0, // 2017-03-01
|
||||||
|
open: 1.05,
|
||||||
|
high: 1.06,
|
||||||
|
low: 1.04,
|
||||||
|
close: 1.055,
|
||||||
|
spread: 0.5_f32,
|
||||||
|
volume: 1000,
|
||||||
|
};
|
||||||
|
let parsed = M1Parsed::from_raw(&raw);
|
||||||
|
assert!((parsed.time_ms - 1_488_326_400_000).abs() <= 1);
|
||||||
|
assert_eq!(parsed.open, 1.05);
|
||||||
|
assert_eq!(parsed.high, 1.06);
|
||||||
|
assert_eq!(parsed.low, 1.04);
|
||||||
|
assert_eq!(parsed.close, 1.055);
|
||||||
|
assert!((parsed.spread - 0.5).abs() < f64::EPSILON);
|
||||||
|
assert_eq!(parsed.volume, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tick_from_raw() {
|
||||||
|
let raw = RawTickRecord {
|
||||||
|
time: 42795.5, // 2017-03-01 12:00:00
|
||||||
|
ask: 1.05684,
|
||||||
|
bid: 1.05689,
|
||||||
|
};
|
||||||
|
let parsed = TickParsed::from_raw(&raw);
|
||||||
|
assert_eq!(parsed.ask, 1.05684);
|
||||||
|
assert_eq!(parsed.bid, 1.05689);
|
||||||
|
// 12:00:00 = half a day = 43200000 ms offset
|
||||||
|
let expected_ms = 1_488_326_400_000 + 43_200_000;
|
||||||
|
assert!(
|
||||||
|
(parsed.time_ms - expected_ms).abs() <= 1,
|
||||||
|
"Expected ~{expected_ms}, got {}",
|
||||||
|
parsed.time_ms
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod closure;
|
pub mod closure;
|
||||||
pub mod compiler;
|
pub mod compiler;
|
||||||
|
pub mod data_server;
|
||||||
pub mod diagnostics;
|
pub mod diagnostics;
|
||||||
pub mod environment;
|
pub mod environment;
|
||||||
pub mod lexer;
|
pub mod lexer;
|
||||||
|
|||||||
Reference in New Issue
Block a user