diff --git a/.gitignore b/.gitignore index 42c249b..13978fb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,9 @@ # test outputs test_output.csv +test_panel_*.csv +examples/panel_output.csv +test_parquet_*.parquet +bench_parquet_*.parquet +examples/parquet_output.parquet +examples/parquet_special.parquet diff --git a/Cargo.lock b/Cargo.lock index da9b182..0a88c21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,26 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +31,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "alloca" version = "0.4.0" @@ -41,18 +76,134 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "arrow-array" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9486151b2f0785bafc6fa04fc5c99fcb4495455662e58787ea32eaaed33c4192" +dependencies = [ + "ahash", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4776577a87794bfdf0b4e90e2ea12454fa7738ea2823c4be5b9d1851da7b434" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-data" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38fe43e2e8704360f1464e6e8cc4fc381ef02cc4fb0192afa8df1aaa0115c66" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ipc" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29dac499fcbc6ba74ee0324057821d381929a48526a3966bd9dffb44aa06d98c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "flatbuffers", +] + +[[package]] +name = "arrow-schema" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9e4969dc350d571766247143ab36a5187d095d3d3690970408bc630d47c69e5" + +[[package]] +name = "arrow-select" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402770dba90865359d98d1ef92ef16e23d75c0cca9c2c880c8a05468b7743bf9" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cast" version = "0.3.0" @@ -66,6 +217,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -140,6 +293,26 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -224,6 +397,26 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +dependencies = [ + "bitflags", + "rustc_version", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -248,6 +441,40 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "half" version = "2.7.1" @@ -256,9 +483,16 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -298,6 +532,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -316,18 +560,71 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "log" version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "lz4_flex" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" +dependencies = [ + "twox-hash", +] + [[package]] name = "memchr" version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -335,6 +632,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -359,12 +657,56 @@ dependencies = [ "winapi", ] +[[package]] +name = "parquet" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5302d4da74d6596a1f11f9928767995b53bca657cbeea1e4e8c5074f8a1157dd" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ipc", + "arrow-schema", + "arrow-select", + "base64", + "brotli", + "bytes", + "chrono", + "flate2", + "half", + "hashbrown", + "lz4_flex", + "num-bigint", + "num-integer", + "num-traits", + "paste", + "seq-macro", + "simdutf8", + "snap", + "twox-hash", + "zstd", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "plotters" version = "0.3.7" @@ -411,6 +753,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rayon" version = "1.12.0" @@ -460,6 +814,15 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -475,6 +838,18 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.228" @@ -524,12 +899,30 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "snap" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" + [[package]] name = "syn" version = "2.0.117" @@ -543,10 +936,22 @@ dependencies = [ [[package]] name = "temporalseries" -version = "0.1.2" +version = "0.1.3" dependencies = [ "chrono", "criterion", + "parquet", + "serde", + "serde_json", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", ] [[package]] @@ -559,12 +964,24 @@ dependencies = [ "serde_json", ] +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -575,6 +992,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.122" @@ -729,6 +1161,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "zerocopy" version = "0.8.49" @@ -749,8 +1187,42 @@ dependencies = [ "syn", ] +[[package]] +name = "zlib-rs" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[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", +] diff --git a/Cargo.toml b/Cargo.toml index 3462a48..e81944c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalseries" -version = "0.1.2" +version = "0.1.3" edition = "2024" description = "High-performance Rust library for quantitative time-series analysis" license = "MIT" @@ -29,17 +29,43 @@ name = "panel" path = "benchmark/panel.rs" harness = false +[[bench]] +name = "temporal_series" +path = "benchmark/temporal_series.rs" +harness = false + +[[bench]] +name = "io" +path = "benchmark/io.rs" +harness = false +required-features = ["parquet"] + [dependencies] chrono = { version = "0.4", optional = true } +parquet = { version = "59", optional = true } +serde = { version = "1.0", features = ["derive"], optional = true } [features] chrono = ["dep:chrono"] +serde = ["dep:serde"] +parquet = ["dep:parquet"] [[example]] name = "temporal_series_with_chrono" path = "examples/temporal_series_with_chrono.rs" required-features = ["chrono"] +[[example]] +name = "serde_support" +path = "examples/serde_support.rs" +required-features = ["serde"] + +[[example]] +name = "parquet_io" +path = "examples/parquet_io.rs" +required-features = ["parquet"] + [dev-dependencies] criterion = { version = "0.8.2", features = ["html_reports"] } chrono = { version = "0.4" } +serde_json = { version = "1.0" } diff --git a/README.md b/README.md index 4a59b01..196d571 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,40 @@ let series = TemporalSeries::new(index, backend).unwrap(); println!("{:?}", series.get(2)); // Some(30.0) ``` +### Chunked + +Values are stored in a `Vec>` of fixed-size chunks. A new chunk is +allocated only when the current one is full, so no existing data is ever copied +on append. This makes `ChunkedBackend` the best choice when you build a series +incrementally — streaming records from a queue or cursor — and want to avoid +the periodic full-buffer reallocation that a growing `Vec` incurs. + +```rust +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ChunkedBackend; + +// 5 values split into chunks of 2: [[10, 20], [30, 40], [50]] +let backend = ChunkedBackend::new(vec![10.0_f64, 20.0, 30.0, 40.0, 50.0], 2); + +println!("n_chunks: {}", backend.n_chunks()); // 3 + +let series = TemporalSeries::new(vec![1_i64, 2, 3, 4, 5], backend).unwrap(); +println!("{:?}", series.get(2)); // Some(30.0) +``` + +Incremental appends — a new chunk is only allocated when the previous one is full: + +```rust +use temporalseries::storage::{ChunkedBackend, StorageBackend}; + +let mut backend: ChunkedBackend = ChunkedBackend::new(vec![], 3); +for v in [1.0, 2.0, 3.0, 4.0] { + backend.push(v); +} +// chunks: [[1, 2, 3], [4]] +assert_eq!(backend.n_chunks(), 2); +``` + ### Row Each observation is stored as a `RowRecord { timestamp, value }`. This layout @@ -125,11 +159,12 @@ let series = TemporalSeries::new(index, backend).unwrap(); println!("{:?}", series.get(2)); // Some(30.0) ``` -| | Columnar | Row | -|---|---|---| -| Memory layout | values packed, index separate | timestamp + value interleaved per record | -| Best for | column scans, aggregations | record-at-a-time ingestion | -| Extra allocation | separate index `Vec` | none beyond the records themselves | +| | Columnar | Row | Chunked | +|---|---|---|---| +| Memory layout | values packed, index separate | timestamp + value interleaved per record | fixed-size `Vec>` chunks | +| Best for | column scans, aggregations | record-at-a-time ingestion | high-frequency appends | +| Append cost | amortised O(1), may copy full buffer | amortised O(1), may copy full buffer | amortised O(1), never copies existing chunks | +| Random access | O(1) | O(1) | O(1) | ## Timestamp units (`chrono` feature) @@ -292,6 +327,7 @@ output length equal to the input length and preserves index alignment. | `input_output` | `cargo run --example input_output` | Reads a series from `examples/input.csv`, writes it to `examples/output.csv`, and reads it back | | `temporal_series_with_columnar_backend` | `cargo run --example temporal_series_with_columnar_backend` | Builds a `TemporalSeries` with a `ColumnarBackend` and demonstrates access and iteration | | `temporal_series_with_row_backend` | `cargo run --example temporal_series_with_row_backend` | Builds a `TemporalSeries` with a `RowBackend` and demonstrates access and iteration | +| `temporal_series_with_chunked_backend` | `cargo run --example temporal_series_with_chunked_backend` | Builds a `TemporalSeries` with a `ChunkedBackend`, demonstrates chunk layout, incremental appends, and slicing | | `panel` | `cargo run --example panel` | Builds a `Panel` of named series on a shared index and demonstrates all analytical methods (statistics, returns, moving averages, volatility, autocorrelation, stationarity, and normality) applied column-by-column | | `temporal_series_with_chrono` | `cargo run --example temporal_series_with_chrono --features chrono` | Builds a `TemporalSeries` from `DateTime` values and round-trips the index back to calendar dates | | `dickey_fuller_test` | `cargo run --example dickey_fuller_test` | Contrasts a trending vs. alternating series with the Dickey-Fuller test, shown for both `TimeSeries` and `TemporalSeries` | diff --git a/ROADMAP.md b/ROADMAP.md index 437766d..d5c3f74 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1 +1,17 @@ # Roadmap + +## v0.1.3 + +* [x] Expand benchmark surface — add benchmarks for Panel analytical methods and the `TemporalSeries` generic path; decide coverage for storage backends. +* [x] Parquet I/O — `read_parquet` / `write_parquet` behind an optional `parquet` feature flag, with tests, benchmarks, and an example. +* [x] `RollingSeries` method expansion — add `.sum()`, `.min()`, `.max()`, `.std()` to complement the existing `.mean()`; keep the same lazy-handle design. +* [x] `ChunkedBackend` — implement the stub in `crates/storage/chunked.rs`; fixed-size chunks suited for append-heavy workloads; full test coverage mirroring `ColumnarBackend` and `RowBackend`. +* [x] Panel I/O — wide-format CSV reader/writer for `Panel` (one column per symbol, shared index column), so multi-asset data can be loaded without constructing each series by hand. +* [x] NaN handling utilities — `forward_fill`, `backward_fill`, `fill_with(value)` on `TimeSeries`. +* [x] Serde support — optional `serde` feature flag; derive `Serialize`/`Deserialize` for `TimeSeries` and `Panel`. + +## v0.2+ + +* [ ] Forecasting module (`crates/forecasting/`) — ARIMA, exponential smoothing; large enough scope to warrant its own crate boundary. +* [ ] Index-based slicing and resampling — `slice(from, to)`, frequency conversion; requires a design decision on index-sorted invariant. + diff --git a/benchmark/io.rs b/benchmark/io.rs new file mode 100644 index 0000000..46f56ee --- /dev/null +++ b/benchmark/io.rs @@ -0,0 +1,43 @@ +use criterion::{Criterion, criterion_group, criterion_main}; +use std::hint::black_box; +use temporalseries::io::{read_parquet, write_parquet}; +use temporalseries::series::TimeSeries; + +const N: usize = 1_000; + +fn make_ts(n: usize) -> TimeSeries { + let index: Vec = (0..n as i64).collect(); + let values: Vec = (0..n).map(|i| 100.0 + i as f64 * 0.1).collect(); + TimeSeries::new(index, values).unwrap() +} + +// ----------------------------------------------------------------------- +// write_parquet — measures file creation + column serialization cost +// ----------------------------------------------------------------------- + +fn bench_write_parquet(c: &mut Criterion) { + let ts = make_ts(N); + c.bench_function("write_parquet 1000", |b| { + b.iter(|| { + write_parquet(black_box(&ts), "bench_parquet_write.parquet").unwrap(); + }) + }); +} + +// ----------------------------------------------------------------------- +// read_parquet — measures file open + column deserialization cost +// ----------------------------------------------------------------------- + +fn bench_read_parquet(c: &mut Criterion) { + let ts = make_ts(N); + write_parquet(&ts, "bench_parquet_read.parquet").expect("bench setup failed"); + + c.bench_function("read_parquet 1000", |b| { + b.iter(|| { + read_parquet(black_box("bench_parquet_read.parquet")).unwrap(); + }) + }); +} + +criterion_group!(benches, bench_write_parquet, bench_read_parquet); +criterion_main!(benches); diff --git a/benchmark/panel.rs b/benchmark/panel.rs index cc7b451..f2236f5 100644 --- a/benchmark/panel.rs +++ b/benchmark/panel.rs @@ -52,10 +52,62 @@ fn bench_panel_shape(c: &mut Criterion) { }); } +// ----------------------------------------------------------------------- +// Analytical methods — one representative per category +// ----------------------------------------------------------------------- + +fn bench_panel_mean(c: &mut Criterion) { + let panel = make_panel(); + c.bench_function("Panel::mean 1000x10", |b| { + b.iter(|| black_box(&panel).mean()) + }); +} + +fn bench_panel_std_deviation(c: &mut Criterion) { + let panel = make_panel(); + c.bench_function("Panel::std_deviation 1000x10", |b| { + b.iter(|| black_box(&panel).std_deviation()) + }); +} + +fn bench_panel_simple_return(c: &mut Criterion) { + let panel = make_panel(); + c.bench_function("Panel::simple_return 1000x10", |b| { + b.iter(|| black_box(&panel).simple_return().unwrap()) + }); +} + +fn bench_panel_pct_change(c: &mut Criterion) { + let panel = make_panel(); + c.bench_function("Panel::pct_change 1000x10", |b| { + b.iter(|| black_box(&panel).pct_change().unwrap()) + }); +} + +fn bench_panel_moving_average(c: &mut Criterion) { + let panel = make_panel(); + c.bench_function("Panel::moving_average window=20 1000x10", |b| { + b.iter(|| black_box(&panel).moving_average(20).unwrap()) + }); +} + +fn bench_panel_rolling_standard_deviation(c: &mut Criterion) { + let panel = make_panel(); + c.bench_function("Panel::rolling_standard_deviation window=20 1000x10", |b| { + b.iter(|| black_box(&panel).rolling_standard_deviation(20).unwrap()) + }); +} + criterion_group!( benches, bench_panel_new, bench_panel_get_series, - bench_panel_shape + bench_panel_shape, + bench_panel_mean, + bench_panel_std_deviation, + bench_panel_simple_return, + bench_panel_pct_change, + bench_panel_moving_average, + bench_panel_rolling_standard_deviation, ); criterion_main!(benches); diff --git a/benchmark/temporal_series.rs b/benchmark/temporal_series.rs new file mode 100644 index 0000000..7daea4e --- /dev/null +++ b/benchmark/temporal_series.rs @@ -0,0 +1,118 @@ +use criterion::{Criterion, criterion_group, criterion_main}; +use std::hint::black_box; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::{ColumnarBackend, RowBackend, RowRecord}; + +// Concrete type aliases for brevity. +type ColSeries = TemporalSeries>; +type RowSeries = TemporalSeries>; + +const N: usize = 1000; +const WINDOW: usize = 20; + +fn make_columnar() -> ColSeries { + let index: Vec = (0..N as i64).collect(); + let values: Vec = (0..N).map(|i| 100.0 + i as f64).collect(); + TemporalSeries::new(index, ColumnarBackend::new(values)).unwrap() +} + +fn make_row() -> RowSeries { + let index: Vec = (0..N as i64).collect(); + let records: Vec> = (0..N) + .map(|i| RowRecord { + timestamp: i as i64, + value: 100.0 + i as f64, + }) + .collect(); + TemporalSeries::new(index, RowBackend::new(records)).unwrap() +} + +// ----------------------------------------------------------------------- +// mean — pure iteration sum; shows raw read cost per backend +// ----------------------------------------------------------------------- + +fn bench_columnar_mean(c: &mut Criterion) { + let ts = make_columnar(); + c.bench_function("TemporalSeries::mean 1000", |b| { + b.iter(|| black_box(&ts).mean()) + }); +} + +fn bench_row_mean(c: &mut Criterion) { + let ts = make_row(); + c.bench_function("TemporalSeries::mean 1000", |b| { + b.iter(|| black_box(&ts).mean()) + }); +} + +// ----------------------------------------------------------------------- +// simple_return — collects values_vec then produces a new ColSeries; +// shows backend-to-vec collection cost + diff computation +// ----------------------------------------------------------------------- + +fn bench_columnar_simple_return(c: &mut Criterion) { + let ts = make_columnar(); + c.bench_function("TemporalSeries::simple_return 1000", |b| { + b.iter(|| black_box(&ts).simple_return().unwrap()) + }); +} + +fn bench_row_simple_return(c: &mut Criterion) { + let ts = make_row(); + c.bench_function("TemporalSeries::simple_return 1000", |b| { + b.iter(|| black_box(&ts).simple_return().unwrap()) + }); +} + +// ----------------------------------------------------------------------- +// moving_average — window scan over values_vec; shows window-pass cost +// ----------------------------------------------------------------------- + +fn bench_columnar_moving_average(c: &mut Criterion) { + let ts = make_columnar(); + c.bench_function( + &format!("TemporalSeries::moving_average window={WINDOW} 1000"), + |b| b.iter(|| black_box(&ts).moving_average(WINDOW).unwrap()), + ); +} + +fn bench_row_moving_average(c: &mut Criterion) { + let ts = make_row(); + c.bench_function( + &format!("TemporalSeries::moving_average window={WINDOW} 1000"), + |b| b.iter(|| black_box(&ts).moving_average(WINDOW).unwrap()), + ); +} + +// ----------------------------------------------------------------------- +// rolling_standard_deviation — heaviest analytical pass (sqrt per window) +// ----------------------------------------------------------------------- + +fn bench_columnar_rolling_std(c: &mut Criterion) { + let ts = make_columnar(); + c.bench_function( + &format!("TemporalSeries::rolling_std window={WINDOW} 1000"), + |b| b.iter(|| black_box(&ts).rolling_standard_deviation(WINDOW).unwrap()), + ); +} + +fn bench_row_rolling_std(c: &mut Criterion) { + let ts = make_row(); + c.bench_function( + &format!("TemporalSeries::rolling_std window={WINDOW} 1000"), + |b| b.iter(|| black_box(&ts).rolling_standard_deviation(WINDOW).unwrap()), + ); +} + +criterion_group!( + benches, + bench_columnar_mean, + bench_row_mean, + bench_columnar_simple_return, + bench_row_simple_return, + bench_columnar_moving_average, + bench_row_moving_average, + bench_columnar_rolling_std, + bench_row_rolling_std, +); +criterion_main!(benches); diff --git a/benchmark/time_series.rs b/benchmark/time_series.rs index cab90ad..4c26467 100644 --- a/benchmark/time_series.rs +++ b/benchmark/time_series.rs @@ -2,25 +2,61 @@ use criterion::{Criterion, criterion_group, criterion_main}; use std::hint::black_box; use temporalseries::series::TimeSeries; -fn bench_pct_change(c: &mut Criterion) { - let index: Vec = (0..1000).collect(); - let values: Vec = (0..1000).map(|i| 100.0 + i as f64).collect(); - let ts = TimeSeries::new(index, values).unwrap(); +fn make_ts(n: usize) -> TimeSeries { + let index: Vec = (0..n as i64).collect(); + let values: Vec = (0..n).map(|i| 100.0 + i as f64).collect(); + TimeSeries::new(index, values).unwrap() +} +fn bench_pct_change(c: &mut Criterion) { + let ts = make_ts(1000); c.bench_function("pct_change 1000", |b| { b.iter(|| black_box(&ts).pct_change().unwrap()) }); } fn bench_rolling_mean(c: &mut Criterion) { - let index: Vec = (0..1000).collect(); - let values: Vec = (0..1000).map(|i| 100.0 + i as f64).collect(); - let ts = TimeSeries::new(index, values).unwrap(); - + let ts = make_ts(1000); c.bench_function("rolling mean window=20 1000", |b| { b.iter(|| black_box(&ts).rolling(20).mean().unwrap()) }); } -criterion_group!(benches, bench_pct_change, bench_rolling_mean); +fn bench_rolling_sum(c: &mut Criterion) { + let ts = make_ts(1000); + c.bench_function("rolling sum window=20 1000", |b| { + b.iter(|| black_box(&ts).rolling(20).sum().unwrap()) + }); +} + +fn bench_rolling_min(c: &mut Criterion) { + let ts = make_ts(1000); + c.bench_function("rolling min window=20 1000", |b| { + b.iter(|| black_box(&ts).rolling(20).min().unwrap()) + }); +} + +fn bench_rolling_max(c: &mut Criterion) { + let ts = make_ts(1000); + c.bench_function("rolling max window=20 1000", |b| { + b.iter(|| black_box(&ts).rolling(20).max().unwrap()) + }); +} + +fn bench_rolling_std(c: &mut Criterion) { + let ts = make_ts(1000); + c.bench_function("rolling std window=20 1000", |b| { + b.iter(|| black_box(&ts).rolling(20).std().unwrap()) + }); +} + +criterion_group!( + benches, + bench_pct_change, + bench_rolling_mean, + bench_rolling_sum, + bench_rolling_min, + bench_rolling_max, + bench_rolling_std, +); criterion_main!(benches); diff --git a/crates/io/csv.rs b/crates/io/csv.rs index cc69945..85c5c76 100644 --- a/crates/io/csv.rs +++ b/crates/io/csv.rs @@ -1,4 +1,5 @@ use crate::errors::TemporalSeriesError; +use crate::panel::Panel; use crate::series::TimeSeries; use std::fs::File; use std::io::{BufRead, BufReader, Write}; @@ -84,6 +85,135 @@ pub fn read_csv(path: &str) -> Result { TimeSeries::new(index, values) } +/// Reads a wide-format CSV file into a [`Panel`]. +/// +/// The first row must be a header with `index` as the first field, followed by +/// one symbol name per column. Each subsequent row contains the timestamp +/// followed by one `f64` value per symbol column. +/// +/// ```text +/// index,AAPL,MSFT +/// 1,150.0,300.0 +/// 2,152.0,305.0 +/// 3,149.0,298.0 +/// ``` +/// +/// Symbol order in the resulting [`Panel`] matches the column order in the +/// file. Blank lines between data rows are silently skipped. +/// +/// # Errors +/// +/// - [`TemporalSeriesError::IoError`] if the file cannot be opened or read. +/// - [`TemporalSeriesError::ParseError`] if the file is empty, the header has +/// no symbol columns, a row has the wrong number of fields, or any field +/// cannot be parsed as the expected type. +pub fn read_panel_csv(path: &str) -> Result { + let file = File::open(path)?; + let reader = BufReader::new(file); + let mut lines = reader.lines(); + + let header = lines + .next() + .ok_or_else(|| TemporalSeriesError::ParseError("empty file: missing header".into()))??; + + let mut header_parts = header.split(','); + header_parts.next(); // skip "index" label + let symbols: Vec = header_parts.map(|s| s.trim().to_string()).collect(); + let n_cols = symbols.len(); + + if n_cols == 0 { + return Err(TemporalSeriesError::ParseError( + "header has no symbol columns".into(), + )); + } + + let mut index: Vec = Vec::new(); + let mut values: Vec> = vec![Vec::new(); n_cols]; + + for (line_no, line) in lines.enumerate() { + let line = line?; + let line = line.trim(); + if line.is_empty() { + continue; + } + + let parts: Vec<&str> = line.split(',').collect(); + if parts.len() != n_cols + 1 { + return Err(TemporalSeriesError::ParseError(format!( + "line {}: expected {} fields, found {}", + line_no + 2, + n_cols + 1, + parts.len() + ))); + } + + let idx = parts[0].trim().parse::().map_err(|e| { + TemporalSeriesError::ParseError(format!("line {}: invalid index — {}", line_no + 2, e)) + })?; + index.push(idx); + + for col in 0..n_cols { + let val = parts[col + 1].trim().parse::().map_err(|e| { + TemporalSeriesError::ParseError(format!( + "line {}: invalid value for column '{}' — {}", + line_no + 2, + symbols[col], + e + )) + })?; + values[col].push(val); + } + } + + Panel::new(index, symbols, values) +} + +/// Writes a [`Panel`] to a wide-format CSV file. +/// +/// The first row is `index` followed by the symbol names. Each subsequent row +/// contains the timestamp followed by the value of each symbol at that +/// timestamp, in the same column order as [`Panel::symbols`]. +/// +/// ```text +/// index,AAPL,MSFT +/// 1,150.0,300.0 +/// 2,152.0,305.0 +/// 3,149.0,298.0 +/// ``` +/// +/// The written file can be read back with [`read_panel_csv`] without any loss +/// of data for all `f64` values that have an exact decimal representation. +/// +/// # Errors +/// +/// Returns [`TemporalSeriesError::IoError`] if the file cannot be created or +/// written. +pub fn write_panel_csv(panel: &Panel, path: &str) -> Result<(), TemporalSeriesError> { + let mut file = File::create(path)?; + let symbols = panel.symbols(); + + write!(file, "index")?; + for sym in symbols { + write!(file, ",{sym}")?; + } + writeln!(file)?; + + let col_series: Vec<_> = symbols + .iter() + .map(|s| panel.get_series(s).unwrap()) + .collect(); + + for (row, &ts) in panel.index().iter().enumerate() { + write!(file, "{ts}")?; + for col in &col_series { + write!(file, ",{}", col.values[row])?; + } + writeln!(file)?; + } + + Ok(()) +} + /// Writes a [`TimeSeries`] to a CSV file. /// /// The output has a header row (`index,value`) followed by one row per diff --git a/crates/io/mod.rs b/crates/io/mod.rs index 493a2e3..46e2067 100644 --- a/crates/io/mod.rs +++ b/crates/io/mod.rs @@ -1,3 +1,7 @@ pub mod csv; +#[cfg(feature = "parquet")] +pub mod parquet; -pub use csv::{read_csv, write_csv}; +pub use csv::{read_csv, read_panel_csv, write_csv, write_panel_csv}; +#[cfg(feature = "parquet")] +pub use parquet::{read_parquet, write_parquet}; diff --git a/crates/io/parquet.rs b/crates/io/parquet.rs new file mode 100644 index 0000000..d7e7032 --- /dev/null +++ b/crates/io/parquet.rs @@ -0,0 +1,128 @@ +use crate::errors::TemporalSeriesError; +use crate::series::TimeSeries; +use parquet::column::reader::ColumnReader; +use parquet::data_type::{DoubleType, Int64Type}; +use parquet::file::properties::WriterProperties; +use parquet::file::reader::{FileReader, SerializedFileReader}; +use parquet::file::writer::SerializedFileWriter; +use parquet::schema::parser::parse_message_type; +use std::fs::File; +use std::sync::Arc; + +const SCHEMA: &str = " +message time_series { + REQUIRED INT64 index; + REQUIRED DOUBLE value; +} +"; + +fn map_parquet_err(e: parquet::errors::ParquetError) -> TemporalSeriesError { + TemporalSeriesError::IoError(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + )) +} + +/// Writes a [`TimeSeries`] to a Parquet file. +/// +/// The file contains a single row group with two columns: +/// - `index` (INT64) — the timestamp index +/// - `value` (DOUBLE) — the observed values +/// +/// The resulting file can be read back without data loss via [`read_parquet`]. +/// +/// # Feature +/// +/// Requires the `parquet` feature flag. +/// +/// # Errors +/// +/// - [`TemporalSeriesError::IoError`] if the file cannot be created, written, +/// or if a Parquet serialization error occurs. +pub fn write_parquet(ts: &TimeSeries, path: &str) -> Result<(), TemporalSeriesError> { + let schema = Arc::new(parse_message_type(SCHEMA).map_err(map_parquet_err)?); + let props = Arc::new(WriterProperties::builder().build()); + let file = File::create(path)?; + let mut writer = SerializedFileWriter::new(file, schema, props).map_err(map_parquet_err)?; + + let mut rg = writer.next_row_group().map_err(map_parquet_err)?; + + // index column (INT64) + let mut col = rg.next_column().map_err(map_parquet_err)?.unwrap(); + col.typed::() + .write_batch(&ts.index, None, None) + .map_err(map_parquet_err)?; + col.close().map_err(map_parquet_err)?; + + // value column (DOUBLE) + let mut col = rg.next_column().map_err(map_parquet_err)?.unwrap(); + col.typed::() + .write_batch(&ts.values, None, None) + .map_err(map_parquet_err)?; + col.close().map_err(map_parquet_err)?; + + rg.close().map_err(map_parquet_err)?; + writer.close().map_err(map_parquet_err)?; + Ok(()) +} + +/// Reads a Parquet file into a [`TimeSeries`]. +/// +/// Expects the file to follow the schema produced by [`write_parquet`]: two +/// columns named `index` (INT64) and `value` (DOUBLE). Multiple row groups +/// are supported and merged in order. +/// +/// # Feature +/// +/// Requires the `parquet` feature flag. +/// +/// # Errors +/// +/// - [`TemporalSeriesError::IoError`] if the file cannot be opened or if a +/// Parquet deserialization error occurs. +/// - [`TemporalSeriesError::ParseError`] if a column has an unexpected +/// physical type. +/// - [`TemporalSeriesError::LengthMismatch`] if the two columns have +/// different lengths. +pub fn read_parquet(path: &str) -> Result { + let file = File::open(path)?; + let reader = SerializedFileReader::new(file).map_err(map_parquet_err)?; + + let mut index: Vec = Vec::new(); + let mut values: Vec = Vec::new(); + + for i in 0..reader.num_row_groups() { + let rg = reader.get_row_group(i).map_err(map_parquet_err)?; + let n = rg.metadata().num_rows() as usize; + + // Read index column (column 0 — INT64) + match rg.get_column_reader(0).map_err(map_parquet_err)? { + ColumnReader::Int64ColumnReader(mut typed) => { + typed + .read_records(n, None, None, &mut index) + .map_err(map_parquet_err)?; + } + _ => { + return Err(TemporalSeriesError::ParseError( + "column 0: expected INT64 (index)".into(), + )); + } + } + + // Read value column (column 1 — DOUBLE) + match rg.get_column_reader(1).map_err(map_parquet_err)? { + ColumnReader::DoubleColumnReader(mut typed) => { + typed + .read_records(n, None, None, &mut values) + .map_err(map_parquet_err)?; + } + _ => { + return Err(TemporalSeriesError::ParseError( + "column 1: expected DOUBLE (value)".into(), + )); + } + } + } + + TimeSeries::new(index, values) +} diff --git a/crates/panel/core.rs b/crates/panel/core.rs index 2387eee..e6ad4a3 100644 --- a/crates/panel/core.rs +++ b/crates/panel/core.rs @@ -21,6 +21,25 @@ pub type Timestamp = i64; /// /// Both invariants are enforced at construction time by [`Panel::new`]. /// +/// # Serialization +/// +/// With the `serde` feature enabled, `Panel` derives [`serde::Serialize`] +/// and [`serde::Deserialize`]. The JSON representation exposes three fields: +/// +/// ```json +/// { +/// "index": [1, 2, 3], +/// "symbols": ["AAPL", "MSFT"], +/// "values": [[150.0, 152.0, 149.0], [300.0, 305.0, 298.0]] +/// } +/// ``` +/// +/// **Invariants on deserialization:** the derived `Deserialize` reconstructs +/// the struct directly without calling [`Panel::new`], so the length invariants +/// are not re-checked. A `Panel` deserialized from a valid serialized `Panel` +/// is always valid; deserializing hand-crafted or untrusted JSON with +/// mismatched lengths will produce an inconsistent `Panel`. +/// /// # Analytical methods /// /// Every analytical method available on [`TimeSeries`] is also available on @@ -48,6 +67,7 @@ pub type Timestamp = i64; /// /// assert_eq!(panel.shape(), (3, 2)); /// ``` +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Panel { index: Vec, symbols: Vec, @@ -138,6 +158,25 @@ impl Panel { self.index.is_empty() } + /// Returns the shared time index as a slice. + /// + /// # Example + /// + /// ```rust + /// use temporalseries::panel::Panel; + /// + /// let panel = Panel::new( + /// vec![10, 20, 30], + /// vec!["A".into()], + /// vec![vec![1.0, 2.0, 3.0]], + /// ).unwrap(); + /// + /// assert_eq!(panel.index(), &[10, 20, 30]); + /// ``` + pub fn index(&self) -> &[Timestamp] { + &self.index + } + /// Returns the number of series (columns) in the panel. /// /// # Example diff --git a/crates/rolling/core.rs b/crates/rolling/core.rs index 6916573..ed533c7 100644 --- a/crates/rolling/core.rs +++ b/crates/rolling/core.rs @@ -4,14 +4,20 @@ use crate::series::TimeSeries; /// A sliding-window view over a [`TimeSeries`]. /// /// `RollingSeries` is a lazy handle — it holds a reference to the original -/// series and a window size, but performs no computation until a method such -/// as [`RollingSeries::mean`] is called. +/// series and a window size, but performs no computation until a terminal +/// method (`mean`, `sum`, `min`, `max`, `std`) is called. /// /// The lifetime parameter `'a` ties the `RollingSeries` to the [`TimeSeries`] /// it was created from: the view cannot outlive the original series. /// /// Construct a `RollingSeries` via [`TimeSeries::rolling`] rather than directly. /// +/// # NaN convention +/// +/// All methods fill the first `window - 1` positions with `NaN` because there +/// are not enough preceding observations to fill the window. This keeps the +/// output length equal to the input length and preserves index alignment. +/// /// # Example /// /// ```rust @@ -24,6 +30,7 @@ use crate::series::TimeSeries; /// /// let rolling = ts.rolling(3); /// let mean = rolling.mean().unwrap(); +/// let sum = rolling.sum().unwrap(); /// ``` #[derive(Debug, Clone)] pub struct RollingSeries<'a> { @@ -51,10 +58,7 @@ impl<'a> RollingSeries<'a> { /// Computes the rolling mean over a sliding window. /// - /// For each position `t`, the result is the arithmetic mean of - /// `values[t - window + 1..=t]`. The first `window - 1` elements - /// are set to `NaN` because there are not enough preceding observations - /// to fill the window. + /// $$MA_t^{(w)} = \frac{1}{w} \sum_{i=0}^{w-1} x_{t-i}$$ /// /// # Example /// @@ -83,8 +87,176 @@ impl<'a> RollingSeries<'a> { for (i, item) in result.iter_mut().enumerate().skip(self.window - 1) { let slice: &[f64] = &self.series.values[i + 1 - self.window..=i]; - let mean: f64 = slice.iter().sum::() / self.window as f64; - *item = mean; + *item = slice.iter().sum::() / self.window as f64; + } + + TimeSeries::new(self.series.index.clone(), result) + } + + /// Computes the rolling sum over a sliding window. + /// + /// $$S_t^{(w)} = \sum_{i=0}^{w-1} x_{t-i}$$ + /// + /// # Example + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3, 4, 5], + /// vec![1.0, 2.0, 3.0, 4.0, 5.0], + /// ).unwrap(); + /// + /// let result = ts.rolling(3).sum().unwrap(); + /// + /// assert!(result.values[0].is_nan()); + /// assert!(result.values[1].is_nan()); + /// assert!((result.values[2] - 6.0).abs() < 1e-6); // 1 + 2 + 3 + /// assert!((result.values[4] - 12.0).abs() < 1e-6); // 3 + 4 + 5 + /// ``` + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError`] if the resulting series cannot be constructed. + pub fn sum(&self) -> Result { + let n = self.series.len(); + let mut result = vec![f64::NAN; n]; + + for (i, val) in result.iter_mut().enumerate().skip(self.window - 1) { + *val = self.series.values[i + 1 - self.window..=i] + .iter() + .sum::(); + } + + TimeSeries::new(self.series.index.clone(), result) + } + + /// Computes the rolling minimum over a sliding window. + /// + /// $$\min_t^{(w)} = \min_{i=0}^{w-1} x_{t-i}$$ + /// + /// # Example + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3, 4, 5], + /// vec![3.0, 1.0, 4.0, 1.0, 5.0], + /// ).unwrap(); + /// + /// let result = ts.rolling(3).min().unwrap(); + /// + /// assert!(result.values[0].is_nan()); + /// assert!(result.values[1].is_nan()); + /// assert!((result.values[2] - 1.0).abs() < 1e-6); // min(3, 1, 4) + /// assert!((result.values[3] - 1.0).abs() < 1e-6); // min(1, 4, 1) + /// assert!((result.values[4] - 1.0).abs() < 1e-6); // min(4, 1, 5) + /// ``` + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError`] if the resulting series cannot be constructed. + pub fn min(&self) -> Result { + let n = self.series.len(); + let mut result = vec![f64::NAN; n]; + + for (i, val) in result.iter_mut().enumerate().skip(self.window - 1) { + *val = self.series.values[i + 1 - self.window..=i] + .iter() + .cloned() + .fold(f64::INFINITY, f64::min); + } + + TimeSeries::new(self.series.index.clone(), result) + } + + /// Computes the rolling maximum over a sliding window. + /// + /// $$\max_t^{(w)} = \max_{i=0}^{w-1} x_{t-i}$$ + /// + /// # Example + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3, 4, 5], + /// vec![3.0, 1.0, 4.0, 1.0, 5.0], + /// ).unwrap(); + /// + /// let result = ts.rolling(3).max().unwrap(); + /// + /// assert!(result.values[0].is_nan()); + /// assert!(result.values[1].is_nan()); + /// assert!((result.values[2] - 4.0).abs() < 1e-6); // max(3, 1, 4) + /// assert!((result.values[3] - 4.0).abs() < 1e-6); // max(1, 4, 1) + /// assert!((result.values[4] - 5.0).abs() < 1e-6); // max(4, 1, 5) + /// ``` + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError`] if the resulting series cannot be constructed. + pub fn max(&self) -> Result { + let n = self.series.len(); + let mut result = vec![f64::NAN; n]; + + for (i, val) in result.iter_mut().enumerate().skip(self.window - 1) { + *val = self.series.values[i + 1 - self.window..=i] + .iter() + .cloned() + .fold(f64::NEG_INFINITY, f64::max); + } + + TimeSeries::new(self.series.index.clone(), result) + } + + /// Computes the rolling sample standard deviation (Bessel-corrected) over a + /// sliding window. + /// + /// $$\sigma_t^{(w)} = \sqrt{\frac{1}{w-1} \sum_{i=0}^{w-1} \left(x_{t-i} - \bar{x}_t^{(w)}\right)^2}$$ + /// + /// Requires `window >= 2` because Bessel's correction divides by `w - 1`. + /// + /// # Example + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3, 4, 5], + /// vec![1.0, 2.0, 3.0, 4.0, 5.0], + /// ).unwrap(); + /// + /// let result = ts.rolling(3).std().unwrap(); + /// + /// assert!(result.values[0].is_nan()); + /// assert!(result.values[1].is_nan()); + /// assert!((result.values[2] - 1.0).abs() < 1e-6); // std(1, 2, 3) = 1.0 + /// assert!((result.values[4] - 1.0).abs() < 1e-6); // std(3, 4, 5) = 1.0 + /// ``` + /// + /// # Errors + /// + /// - [`TemporalSeriesError::InvalidWindow`] if `window < 2`. + /// - [`TemporalSeriesError`] if the resulting series cannot be constructed. + pub fn std(&self) -> Result { + if self.window < 2 { + return Err(TemporalSeriesError::InvalidWindow { + window: self.window, + series_len: self.series.len(), + }); + } + + let n = self.series.len(); + let mut result = vec![f64::NAN; n]; + + for (i, val) in result.iter_mut().enumerate().skip(self.window - 1) { + let slice = &self.series.values[i + 1 - self.window..=i]; + let mean = slice.iter().sum::() / self.window as f64; + let variance = + slice.iter().map(|x| (x - mean).powi(2)).sum::() / (self.window - 1) as f64; + *val = variance.sqrt(); } TimeSeries::new(self.series.index.clone(), result) diff --git a/crates/series/time_series.rs b/crates/series/time_series.rs index 8af86ad..56d76fe 100644 --- a/crates/series/time_series.rs +++ b/crates/series/time_series.rs @@ -15,6 +15,22 @@ use crate::rolling::RollingSeries; /// sentinel for missing or undefined observations (e.g. the first element /// after a [`TimeSeries::diff`] or [`TimeSeries::shift`]). /// +/// # Serialization +/// +/// With the `serde` feature enabled, `TimeSeries` derives [`serde::Serialize`] +/// and [`serde::Deserialize`]. The JSON representation uses two fields, +/// `"index"` and `"values"`: +/// +/// ```json +/// {"index":[1,2,3],"values":[100.0,110.0,121.0]} +/// ``` +/// +/// **Note on `NaN`:** JSON has no native representation for `NaN`. When +/// serializing with `serde_json`, `NaN` values are mapped to `null`, which +/// means the round-trip is lossy — `null` cannot be deserialized back as an +/// `f64`. Formats that preserve IEEE 754 special values (e.g. MessagePack, +/// bincode) do not have this limitation. +/// /// # Example /// /// ```rust @@ -28,6 +44,7 @@ use crate::rolling::RollingSeries; /// assert_eq!(ts.len(), 3); /// ``` #[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct TimeSeries { pub index: Vec, pub values: Vec, @@ -1103,6 +1120,157 @@ impl TimeSeries { Self::new(self.index.clone(), values) } + // NAN HANDLING ----------------------------------------------------------- + + /// Returns a new series with `NaN` values replaced by the last observed + /// non-`NaN` value (last-observation-carried-forward). + /// + /// Gaps are filled by propagating the preceding valid value to the right. + /// Leading `NaN` values — those that appear before any valid observation — + /// are left as `NaN` because there is no prior value to carry forward. + /// + /// # Examples + /// + /// Interior and trailing gaps are filled: + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3, 4, 5], + /// vec![1.0, f64::NAN, f64::NAN, 4.0, f64::NAN], + /// ).unwrap(); + /// + /// let filled = ts.forward_fill().unwrap(); + /// + /// assert_eq!(filled.values, vec![1.0, 1.0, 1.0, 4.0, 4.0]); + /// ``` + /// + /// Leading `NaN` values have no prior observation and are preserved: + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3], + /// vec![f64::NAN, f64::NAN, 3.0], + /// ).unwrap(); + /// + /// let filled = ts.forward_fill().unwrap(); + /// + /// assert!(filled.values[0].is_nan()); + /// assert!(filled.values[1].is_nan()); + /// assert_eq!(filled.values[2], 3.0); + /// ``` + pub fn forward_fill(&self) -> Result { + let mut result = self.values.clone(); + let mut last = f64::NAN; + for v in result.iter_mut() { + if !v.is_nan() { + last = *v; + } else if !last.is_nan() { + *v = last; + } + } + Self::new(self.index.clone(), result) + } + + /// Returns a new series with `NaN` values replaced by the next observed + /// non-`NaN` value (next-observation-carried-backward). + /// + /// Gaps are filled by propagating the following valid value to the left. + /// Trailing `NaN` values — those that appear after the last valid + /// observation — are left as `NaN` because there is no future value to + /// carry backward. + /// + /// # Examples + /// + /// Interior and leading gaps are filled: + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3, 4, 5], + /// vec![f64::NAN, f64::NAN, 3.0, f64::NAN, 5.0], + /// ).unwrap(); + /// + /// let filled = ts.backward_fill().unwrap(); + /// + /// assert_eq!(filled.values, vec![3.0, 3.0, 3.0, 5.0, 5.0]); + /// ``` + /// + /// Trailing `NaN` values have no following observation and are preserved: + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3], + /// vec![1.0, f64::NAN, f64::NAN], + /// ).unwrap(); + /// + /// let filled = ts.backward_fill().unwrap(); + /// + /// assert_eq!(filled.values[0], 1.0); + /// assert!(filled.values[1].is_nan()); + /// assert!(filled.values[2].is_nan()); + /// ``` + pub fn backward_fill(&self) -> Result { + let mut result = self.values.clone(); + let mut next = f64::NAN; + for v in result.iter_mut().rev() { + if !v.is_nan() { + next = *v; + } else if !next.is_nan() { + *v = next; + } + } + Self::new(self.index.clone(), result) + } + + /// Returns a new series with every `NaN` replaced by a fixed scalar. + /// + /// Non-`NaN` values are passed through unchanged. Passing `f64::NAN` as + /// `fill_value` is a valid no-op — all `NaN` positions remain `NaN`. + /// + /// # Examples + /// + /// Replace missing values with zero: + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3, 4], + /// vec![1.0, f64::NAN, 3.0, f64::NAN], + /// ).unwrap(); + /// + /// let filled = ts.fill_with(0.0).unwrap(); + /// + /// assert_eq!(filled.values, vec![1.0, 0.0, 3.0, 0.0]); + /// ``` + /// + /// A series with no `NaN` values is returned unchanged: + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); + /// + /// let filled = ts.fill_with(-1.0).unwrap(); + /// + /// assert_eq!(filled.values, vec![1.0, 2.0, 3.0]); + /// ``` + pub fn fill_with(&self, fill_value: f64) -> Result { + let result: Vec = self + .values + .iter() + .map(|&v| if v.is_nan() { fill_value } else { v }) + .collect(); + Self::new(self.index.clone(), result) + } + /// Creates a [`RollingSeries`] view over this series with the given window size. /// /// The returned value is a lazy handle — no computation happens until you call diff --git a/crates/storage/backend.rs b/crates/storage/backend.rs index 5a83923..7f57671 100644 --- a/crates/storage/backend.rs +++ b/crates/storage/backend.rs @@ -14,6 +14,7 @@ use std::ops::Range; /// |---|---|---| /// | [`ColumnarBackend`](crate::storage::ColumnarBackend) | Contiguous `Vec` | Column scans, aggregations | /// | [`RowBackend`](crate::storage::RowBackend) | `Vec>` (timestamp + value per record) | Record-at-a-time ingestion | +/// | [`ChunkedBackend`](crate::storage::ChunkedBackend) | `Vec>` of fixed-size chunks | High-frequency appends without full-buffer reallocation | /// /// # Implementing a custom backend /// diff --git a/crates/storage/chunked.rs b/crates/storage/chunked.rs index 8b13789..573348a 100644 --- a/crates/storage/chunked.rs +++ b/crates/storage/chunked.rs @@ -1 +1,172 @@ +use std::ops::Range; +use super::StorageBackend; + +/// A [`StorageBackend`] that stores values in fixed-size contiguous chunks. +/// +/// `ChunkedBackend` partitions the value sequence into a `Vec>` where +/// every inner vector (chunk) holds at most `chunk_size` elements. Only the +/// final chunk may be shorter. +/// +/// ## When to use this backend +/// +/// | Property | Details | +/// |---|---| +/// | Append cost | Amortised O(1) — a new chunk is allocated only when the current one is full, so existing data is never reallocated | +/// | Random access | O(1) — `idx / chunk_size` gives the chunk, `idx % chunk_size` gives the offset | +/// | Iteration | Slightly more pointer-chasing than [`ColumnarBackend`](crate::storage::ColumnarBackend) because chunks are not contiguous | +/// | Best for | High-frequency append workloads where growing a single `Vec` would cause repeated reallocation | +/// +/// ## Compared to the other backends +/// +/// - [`ColumnarBackend`](crate::storage::ColumnarBackend) — single contiguous +/// `Vec`; best cache locality for scans, but reallocation on growth copies +/// the entire buffer. +/// - [`RowBackend`](crate::storage::RowBackend) — values co-located with +/// timestamps; natural for record-at-a-time ingestion. +/// - `ChunkedBackend` — fixed-size chunks; avoids reallocation on append at +/// the cost of slightly higher scan overhead. +/// +/// # Example +/// +/// ```rust +/// use temporalseries::storage::{ChunkedBackend, StorageBackend}; +/// +/// // Store 5 values in chunks of 2. +/// // Layout: [[1.0, 2.0], [3.0, 4.0], [5.0]] +/// let backend = ChunkedBackend::new(vec![1.0_f64, 2.0, 3.0, 4.0, 5.0], 2); +/// +/// assert_eq!(backend.len(), 5); +/// assert_eq!(backend.n_chunks(), 3); +/// assert_eq!(backend.get(2), Some(&3.0)); +/// ``` +pub struct ChunkedBackend { + chunks: Vec>, + chunk_size: usize, + len: usize, +} + +impl ChunkedBackend { + /// Creates a new `ChunkedBackend` by splitting `data` into fixed-size chunks. + /// + /// The last chunk may contain fewer than `chunk_size` elements if + /// `data.len()` is not a multiple of `chunk_size`. + /// + /// # Panics + /// + /// Panics if `chunk_size` is zero. + /// + /// # Example + /// + /// ```rust + /// use temporalseries::storage::{ChunkedBackend, StorageBackend}; + /// + /// let backend = ChunkedBackend::new(vec![1.0_f64, 2.0, 3.0, 4.0, 5.0], 2); + /// + /// // 5 values in chunks of 2 → 3 chunks: [1,2], [3,4], [5] + /// assert_eq!(backend.n_chunks(), 3); + /// assert_eq!(backend.len(), 5); + /// ``` + pub fn new(data: Vec, chunk_size: usize) -> Self { + assert!(chunk_size > 0, "chunk_size must be greater than zero"); + let len = data.len(); + let chunks = data.chunks(chunk_size).map(<[T]>::to_vec).collect(); + Self { + chunks, + chunk_size, + len, + } + } + + /// Returns the configured maximum size of each chunk. + /// + /// # Example + /// + /// ```rust + /// use temporalseries::storage::ChunkedBackend; + /// + /// let backend = ChunkedBackend::new(vec![1.0_f64, 2.0, 3.0], 2); + /// assert_eq!(backend.chunk_size(), 2); + /// ``` + pub fn chunk_size(&self) -> usize { + self.chunk_size + } + + /// Returns the number of chunks currently allocated. + /// + /// # Example + /// + /// ```rust + /// use temporalseries::storage::ChunkedBackend; + /// + /// // 6 values in chunks of 2 → 3 full chunks + /// let backend = ChunkedBackend::new(vec![1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0], 2); + /// assert_eq!(backend.n_chunks(), 3); + /// + /// // 5 values in chunks of 2 → 2 full + 1 partial chunk + /// let backend = ChunkedBackend::new(vec![1.0_f64, 2.0, 3.0, 4.0, 5.0], 2); + /// assert_eq!(backend.n_chunks(), 3); + /// ``` + pub fn n_chunks(&self) -> usize { + self.chunks.len() + } +} + +impl StorageBackend for ChunkedBackend { + /// Returns the total number of values across all chunks. + fn len(&self) -> usize { + self.len + } + + /// Returns `true` if the backend contains no values. + fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Returns a reference to the value at position `idx`, or `None` if out of bounds. + /// + /// Access is O(1): `chunk = idx / chunk_size`, `offset = idx % chunk_size`. + fn get(&self, idx: usize) -> Option<&T> { + if idx >= self.len { + return None; + } + self.chunks + .get(idx / self.chunk_size)? + .get(idx % self.chunk_size) + } + + /// Appends `value` to the end of the backend. + /// + /// A new chunk is allocated only when the current last chunk is full, + /// so existing chunk allocations are never disturbed. + fn push(&mut self, value: T) { + if self.chunks.is_empty() + || self.chunks.last().map(Vec::len).unwrap_or(0) == self.chunk_size + { + self.chunks.push(Vec::with_capacity(self.chunk_size)); + } + self.chunks.last_mut().unwrap().push(value); + self.len += 1; + } + + /// Returns a new `ChunkedBackend` containing only the values in `range`. + /// + /// The result uses the same `chunk_size` as the source. The range is + /// re-chunked from scratch, so the chunk boundaries in the result may + /// differ from those in the source. + fn slice(&self, range: Range) -> Self { + let data: Vec = self + .iter() + .skip(range.start) + .take(range.end.saturating_sub(range.start)) + .cloned() + .collect(); + Self::new(data, self.chunk_size) + } + + /// Returns an iterator over references to each value in order, + /// flattening across chunk boundaries. + fn iter(&self) -> Box + '_> { + Box::new(self.chunks.iter().flat_map(|c| c.iter())) + } +} diff --git a/crates/storage/mod.rs b/crates/storage/mod.rs index 5c6b442..ffc010f 100644 --- a/crates/storage/mod.rs +++ b/crates/storage/mod.rs @@ -4,5 +4,6 @@ pub mod columnar; pub mod row; pub use backend::StorageBackend; +pub use chunked::ChunkedBackend; pub use columnar::ColumnarBackend; pub use row::{RowBackend, RowRecord}; diff --git a/examples/panel_input.csv b/examples/panel_input.csv new file mode 100644 index 0000000..da02d21 --- /dev/null +++ b/examples/panel_input.csv @@ -0,0 +1,6 @@ +index,AAPL,MSFT +1,150.0,300.0 +2,152.0,305.0 +3,149.0,298.0 +4,153.0,310.0 +5,155.0,308.0 diff --git a/examples/panel_io.rs b/examples/panel_io.rs new file mode 100644 index 0000000..88ef484 --- /dev/null +++ b/examples/panel_io.rs @@ -0,0 +1,92 @@ +//! Demonstrates reading and writing a [`Panel`] via wide-format CSV. +//! +//! The wide-format used by this library places one symbol per column, with a +//! shared `index` column as the first field: +//! +//! ```text +//! index,AAPL,MSFT +//! 1,150.0,300.0 +//! 2,152.0,305.0 +//! 3,149.0,298.0 +//! ``` +//! +//! This example exercises the full round-trip: +//! +//! 1. Build a `Panel` in memory with two symbols over five timestamps. +//! 2. [`write_panel_csv`] — serialize it to `examples/panel_output.csv`. +//! 3. [`read_panel_csv`] — parse the file back into a `Panel`. +//! 4. Verify that shape, symbols, index, and values are all preserved. +//! +//! # Files +//! +//! - **`examples/panel_input.csv`** — pre-existing fixture (five observations, +//! two symbols) used by integration tests. +//! - **`examples/panel_output.csv`** — created by this example at runtime; +//! safe to delete. +//! +//! # Run +//! +//! ```bash +//! cargo run --example panel_io +//! ``` + +use temporalseries::io::{read_panel_csv, write_panel_csv}; +use temporalseries::panel::Panel; + +fn main() { + // ----------------------------------------------------------------------- + // Build a panel in memory + // ----------------------------------------------------------------------- + + let panel = Panel::new( + vec![1, 2, 3, 4, 5], + vec!["AAPL".into(), "MSFT".into()], + vec![ + vec![150.0, 152.0, 149.0, 153.0, 155.0], + vec![300.0, 305.0, 298.0, 310.0, 308.0], + ], + ) + .unwrap(); + + println!("Original panel"); + println!(" shape: {:?}", panel.shape()); + println!(" symbols: {:?}", panel.symbols()); + println!(" index: {:?}", panel.index()); + + // ----------------------------------------------------------------------- + // Write to CSV + // ----------------------------------------------------------------------- + + let out_path = "examples/panel_output.csv"; + write_panel_csv(&panel, out_path).unwrap(); + println!("\nWrote panel to '{out_path}'"); + + // ----------------------------------------------------------------------- + // Read back and verify + // ----------------------------------------------------------------------- + + let loaded = read_panel_csv(out_path).unwrap(); + println!("\nLoaded panel from '{out_path}'"); + println!(" shape: {:?}", loaded.shape()); + println!(" symbols: {:?}", loaded.symbols()); + println!(" index: {:?}", loaded.index()); + + for sym in loaded.symbols() { + let orig = panel.get_series(sym).unwrap(); + let back = loaded.get_series(sym).unwrap(); + assert_eq!(orig.values, back.values, "round-trip mismatch for {sym}"); + println!(" {sym}: {:?}", back.values); + } + + println!("\nRound-trip OK — all values match."); + + // ----------------------------------------------------------------------- + // Read from the committed fixture + // ----------------------------------------------------------------------- + + let fixture = read_panel_csv("examples/panel_input.csv").unwrap(); + println!("\nLoaded fixture 'examples/panel_input.csv'"); + println!(" shape: {:?}", fixture.shape()); + println!(" AAPL: {:?}", fixture.get_series("AAPL").unwrap().values); + println!(" MSFT: {:?}", fixture.get_series("MSFT").unwrap().values); +} diff --git a/examples/parquet_io.rs b/examples/parquet_io.rs new file mode 100644 index 0000000..89f041b --- /dev/null +++ b/examples/parquet_io.rs @@ -0,0 +1,79 @@ +//! Demonstrates writing and reading a [`TimeSeries`] using Parquet I/O. +//! +//! The `parquet` feature flag exposes [`write_parquet`] and [`read_parquet`], +//! which store a `TimeSeries` as a two-column Parquet file: +//! +//! | column | physical type | content | +//! |---------|---------------|----------------------| +//! | `index` | INT64 | timestamp index | +//! | `value` | DOUBLE | observed float value | +//! +//! Parquet is a columnar binary format that compresses well and preserves +//! full IEEE 754 double precision — NaN and infinite values round-trip +//! exactly, unlike JSON or CSV. +//! +//! # Run +//! +//! ```bash +//! cargo run --example parquet_io --features parquet +//! ``` + +use temporalseries::io::{read_parquet, write_parquet}; +use temporalseries::series::TimeSeries; + +fn main() { + // ----------------------------------------------------------------------- + // Build a TimeSeries in memory + // ----------------------------------------------------------------------- + + let ts = TimeSeries::new( + vec![1_000, 2_000, 3_000, 4_000, 5_000], + vec![150.25, 151.80, 149.60, 153.10, 154.50], + ) + .unwrap(); + + println!("Original TimeSeries:"); + println!(" index = {:?}", ts.index); + println!(" values = {:?}", ts.values); + + // ----------------------------------------------------------------------- + // Write to Parquet + // ----------------------------------------------------------------------- + + let path = "examples/parquet_output.parquet"; + write_parquet(&ts, path).expect("failed to write parquet file"); + println!("\nWritten to {path}"); + + // ----------------------------------------------------------------------- + // Read back and verify round-trip + // ----------------------------------------------------------------------- + + let loaded = read_parquet(path).expect("failed to read parquet file"); + + println!("\nLoaded TimeSeries:"); + println!(" index = {:?}", loaded.index); + println!(" values = {:?}", loaded.values); + + assert_eq!(loaded.index, ts.index, "index mismatch after round-trip"); + assert_eq!(loaded.values, ts.values, "values mismatch after round-trip"); + println!("\nRound-trip OK — index and values match exactly."); + + // ----------------------------------------------------------------------- + // Parquet preserves IEEE 754 special values exactly + // ----------------------------------------------------------------------- + + let ts_special = TimeSeries::new( + vec![1, 2, 3], + vec![f64::NAN, f64::INFINITY, f64::NEG_INFINITY], + ) + .unwrap(); + + let special_path = "examples/parquet_special.parquet"; + write_parquet(&ts_special, special_path).expect("failed to write special parquet"); + let loaded_special = read_parquet(special_path).expect("failed to read special parquet"); + + assert!(loaded_special.values[0].is_nan(), "NaN preserved"); + assert!(loaded_special.values[1].is_infinite() && loaded_special.values[1].is_sign_positive()); + assert!(loaded_special.values[2].is_infinite() && loaded_special.values[2].is_sign_negative()); + println!("IEEE 754 special values (NaN, +Inf, -Inf) preserved exactly."); +} diff --git a/examples/serde_support.rs b/examples/serde_support.rs new file mode 100644 index 0000000..3704ac7 --- /dev/null +++ b/examples/serde_support.rs @@ -0,0 +1,110 @@ +//! Demonstrates JSON serialization and deserialization of [`TimeSeries`] and [`Panel`]. +//! +//! The `serde` feature flag adds [`serde::Serialize`] and [`serde::Deserialize`] +//! to both types via `#[derive]`, enabling integration with any serde-compatible +//! format (JSON, MessagePack, CBOR, TOML, …). +//! +//! This example uses [`serde_json`] to show the full round-trip: +//! +//! 1. Build a [`TimeSeries`] and a [`Panel`] in memory. +//! 2. Serialize them to pretty-printed JSON strings. +//! 3. Deserialize them back and verify that index, symbols, and values are all +//! preserved. +//! 4. Demonstrate deserialization from hand-crafted JSON. +//! +//! ## JSON layout +//! +//! ```json +//! // TimeSeries +//! {"index":[1,2,3],"values":[100.0,110.0,121.0]} +//! +//! // Panel +//! {"index":[1,2,3],"symbols":["AAPL","MSFT"],"values":[[150.0,152.0,149.0],[300.0,305.0,298.0]]} +//! ``` +//! +//! ## Note on `NaN` +//! +//! JSON has no native representation for `NaN`. When serializing with +//! `serde_json`, `NaN` values are mapped to `null`, making the round-trip +//! lossy — `null` cannot be deserialized back as `f64`. Use a format that +//! preserves IEEE 754 special values — such as MessagePack or bincode — when +//! the series may contain missing-value sentinels. +//! +//! # Run +//! +//! ```bash +//! cargo run --example serde_support --features serde +//! ``` + +use temporalseries::panel::Panel; +use temporalseries::series::TimeSeries; + +fn main() { + // ----------------------------------------------------------------------- + // TimeSeries round-trip + // ----------------------------------------------------------------------- + + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![100.0, 102.5, 99.0, 104.0, 107.5]).unwrap(); + + let ts_json = serde_json::to_string_pretty(&ts).unwrap(); + println!("TimeSeries as JSON:\n{ts_json}\n"); + + let ts_back: TimeSeries = serde_json::from_str(&ts_json).unwrap(); + assert_eq!(ts_back.index, ts.index); + assert_eq!(ts_back.values, ts.values); + println!("TimeSeries round-trip OK\n"); + + // ----------------------------------------------------------------------- + // Panel round-trip + // ----------------------------------------------------------------------- + + let panel = Panel::new( + vec![1, 2, 3], + vec!["AAPL".into(), "MSFT".into()], + vec![vec![150.0, 152.0, 149.0], vec![300.0, 305.0, 298.0]], + ) + .unwrap(); + + let panel_json = serde_json::to_string_pretty(&panel).unwrap(); + println!("Panel as JSON:\n{panel_json}\n"); + + let panel_back: Panel = serde_json::from_str(&panel_json).unwrap(); + assert_eq!(panel_back.shape(), panel.shape()); + assert_eq!(panel_back.symbols(), panel.symbols()); + assert_eq!(panel_back.index(), panel.index()); + for sym in panel.symbols() { + let orig = panel.get_series(sym).unwrap(); + let back = panel_back.get_series(sym).unwrap(); + assert_eq!(orig.values, back.values, "value mismatch for {sym}"); + } + println!("Panel round-trip OK\n"); + + // ----------------------------------------------------------------------- + // Deserialize from hand-crafted JSON + // ----------------------------------------------------------------------- + + let raw_ts = r#"{"index":[10,20,30],"values":[1.0,2.0,3.0]}"#; + let loaded_ts: TimeSeries = serde_json::from_str(raw_ts).unwrap(); + println!( + "Loaded TimeSeries from raw JSON: index={:?} values={:?}", + loaded_ts.index, loaded_ts.values + ); + + let raw_panel = r#"{"index":[1,2],"symbols":["SPY"],"values":[[430.0,432.5]]}"#; + let loaded_panel: Panel = serde_json::from_str(raw_panel).unwrap(); + println!( + "Loaded Panel from raw JSON: shape={:?} symbols={:?}", + loaded_panel.shape(), + loaded_panel.symbols() + ); + + // ----------------------------------------------------------------------- + // NaN becomes null in JSON (lossy) + // ----------------------------------------------------------------------- + + let ts_with_nan = TimeSeries::new(vec![1, 2, 3], vec![1.0, f64::NAN, 3.0]).unwrap(); + let json_with_null = serde_json::to_string(&ts_with_nan).unwrap(); + println!("\nTimeSeries with NaN serialized as JSON:"); + println!(" {json_with_null}"); + println!(" (NaN → null; round-trip is lossy — null cannot deserialize back as f64)"); +} diff --git a/examples/temporal_series_with_chunked_backend.rs b/examples/temporal_series_with_chunked_backend.rs new file mode 100644 index 0000000..34db702 --- /dev/null +++ b/examples/temporal_series_with_chunked_backend.rs @@ -0,0 +1,83 @@ +//! Demonstrates building a [`TemporalSeries`] with a [`ChunkedBackend`]. +//! +//! A `ChunkedBackend` stores values in fixed-size chunks (`Vec>`) rather +//! than a single contiguous buffer. The key trade-off versus [`ColumnarBackend`] +//! is: +//! +//! - **Append cost** — a new chunk is allocated only when the current one is +//! full, so no existing data is ever copied on growth. +//! - **Access cost** — O(1) via `chunk = idx / chunk_size`, `offset = idx % chunk_size`. +//! - **Scan cost** — slightly more pointer-chasing than columnar because chunks +//! are not contiguous in memory. +//! +//! Use `ChunkedBackend` when you build the series incrementally (record-by-record +//! from a stream or cursor) and want to avoid the periodic full-buffer +//! reallocation that a single `Vec` incurs as it grows. +//! +//! # Layout +//! +//! ```text +//! values: [10.0, 20.0, 30.0, 40.0, 50.0] chunk_size = 2 +//! chunks: [[10.0, 20.0], [30.0, 40.0], [50.0]] +//! ``` +//! +//! # Run +//! +//! ```bash +//! cargo run --example temporal_series_with_chunked_backend +//! ``` + +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ChunkedBackend; + +fn main() { + // ----------------------------------------------------------------------- + // Construction from existing data + // ----------------------------------------------------------------------- + + let index = vec![1_i64, 2, 3, 4, 5]; + let values = vec![10.0_f64, 20.0, 30.0, 40.0, 50.0]; + let backend = ChunkedBackend::new(values, 2); + + println!("chunk_size : {}", backend.chunk_size()); + println!("n_chunks : {}", backend.n_chunks()); // 3: [10,20], [30,40], [50] + + let series = TemporalSeries::new(index, backend).unwrap(); + + println!("len : {}", series.len()); + println!("get(0) : {:?}", series.get(0)); // Some(10.0) + println!("get(2) : {:?}", series.get(2)); // Some(30.0) — first element of second chunk + println!("get(4) : {:?}", series.get(4)); // Some(50.0) — partial last chunk + println!("get(99) : {:?}", series.get(99)); // None + + // ----------------------------------------------------------------------- + // Iteration — flattens across chunk boundaries + // ----------------------------------------------------------------------- + + let values_collected: Vec = series.iter().copied().collect(); + println!("\nall values : {:?}", values_collected); + + // ----------------------------------------------------------------------- + // Incremental appends — core strength of ChunkedBackend + // ----------------------------------------------------------------------- + + let mut growing_backend: ChunkedBackend = ChunkedBackend::new(vec![], 3); + for v in [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0] { + growing_backend.push(v); + } + println!("\nafter 7 pushes with chunk_size=3:"); + println!(" n_chunks : {}", growing_backend.n_chunks()); // 3: [1,2,3],[4,5,6],[7] + println!(" len : {}", growing_backend.len()); + println!(" get(6) : {:?}", growing_backend.get(6)); // Some(7.0) + + // ----------------------------------------------------------------------- + // Slice — returns a new ChunkedBackend with the same chunk_size + // ----------------------------------------------------------------------- + + use temporalseries::storage::StorageBackend; + let sliced: ChunkedBackend = growing_backend.slice(2..5); + println!("\nslice(2..5):"); + println!(" len : {}", sliced.len()); // 3 + let sliced_values: Vec = sliced.iter().copied().collect(); + println!(" values : {:?}", sliced_values); // [3.0, 4.0, 5.0] +} diff --git a/tests/integration.rs b/tests/integration.rs index e07e216..50be330 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -2,6 +2,8 @@ mod errors; mod io; mod panel; mod rolling; +#[cfg(feature = "serde")] +mod serde_support; mod series; mod storage; #[cfg(feature = "chrono")] diff --git a/tests/io/mod.rs b/tests/io/mod.rs index 0edad95..6ac7cd2 100644 --- a/tests/io/mod.rs +++ b/tests/io/mod.rs @@ -1,2 +1,8 @@ mod test_read_csv; +mod test_read_panel_csv; +#[cfg(feature = "parquet")] +mod test_read_parquet; mod test_write_csv; +mod test_write_panel_csv; +#[cfg(feature = "parquet")] +mod test_write_parquet; diff --git a/tests/io/test_read_panel_csv.rs b/tests/io/test_read_panel_csv.rs new file mode 100644 index 0000000..0f6bad4 --- /dev/null +++ b/tests/io/test_read_panel_csv.rs @@ -0,0 +1,116 @@ +use std::fs; +use temporalseries::errors::TemporalSeriesError; +use temporalseries::io::read_panel_csv; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_panel_csv__when_read_panel_csv__then_returns_correct_shape_and_symbols() { + // Given + let path = "examples/panel_input.csv"; + // When + let panel = read_panel_csv(path).unwrap(); + // Then + assert_eq!(panel.shape(), (5, 2)); + assert_eq!(panel.symbols(), &["AAPL", "MSFT"]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_panel_csv__when_read_panel_csv__then_values_are_correct() { + // Given + let path = "examples/panel_input.csv"; + // When + let panel = read_panel_csv(path).unwrap(); + // Then + let aapl = panel.get_series("AAPL").unwrap(); + assert_eq!(aapl.values, vec![150.0, 152.0, 149.0, 153.0, 155.0]); + let msft = panel.get_series("MSFT").unwrap(); + assert_eq!(msft.values, vec![300.0, 305.0, 298.0, 310.0, 308.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_panel_csv__when_read_panel_csv__then_index_is_preserved() { + // Given + let path = "examples/panel_input.csv"; + // When + let panel = read_panel_csv(path).unwrap(); + // Then + assert_eq!(panel.index(), &[1, 2, 3, 4, 5]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_non_existent_file__when_read_panel_csv__then_returns_io_error() { + // Given + let path = "non_existent_panel.csv"; + // When + let result = read_panel_csv(path); + // Then + assert!(matches!(result, Err(TemporalSeriesError::IoError(_)))); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_single_symbol_csv__when_read_panel_csv__then_returns_one_column_panel() { + // Given + let path = "test_panel_single_symbol.csv"; + fs::write(path, "index,SPY\n1,430.0\n2,432.5\n3,428.0\n").unwrap(); + // When + let panel = read_panel_csv(path).unwrap(); + // Then + assert_eq!(panel.shape(), (3, 1)); + assert_eq!(panel.symbols(), &["SPY"]); + assert_eq!( + panel.get_series("SPY").unwrap().values, + vec![430.0, 432.5, 428.0] + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_malformed_value__when_read_panel_csv__then_returns_parse_error() { + // Given — second row has "not_a_number" where a f64 is expected + let path = "test_panel_malformed.csv"; + fs::write(path, "index,AAPL\n1,150.0\n2,not_a_number\n").unwrap(); + // When + let result = read_panel_csv(path); + // Then + assert!(matches!(result, Err(TemporalSeriesError::ParseError(_)))); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_file__when_read_panel_csv__then_returns_parse_error() { + // Given + let path = "test_panel_empty.csv"; + fs::write(path, "").unwrap(); + // When + let result = read_panel_csv(path); + // Then + assert!(matches!(result, Err(TemporalSeriesError::ParseError(_)))); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_header_only_no_symbols__when_read_panel_csv__then_returns_parse_error() { + // Given — header row with only the index label, no symbol columns + let path = "test_panel_no_symbols.csv"; + fs::write(path, "index\n1\n2\n").unwrap(); + // When + let result = read_panel_csv(path); + // Then + assert!(matches!(result, Err(TemporalSeriesError::ParseError(_)))); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_row_with_wrong_field_count__when_read_panel_csv__then_returns_parse_error() { + // Given — data row has one more field than the header declares + let path = "test_panel_extra_field.csv"; + fs::write(path, "index,AAPL\n1,150.0,999.0\n").unwrap(); + // When + let result = read_panel_csv(path); + // Then + assert!(matches!(result, Err(TemporalSeriesError::ParseError(_)))); +} diff --git a/tests/io/test_read_parquet.rs b/tests/io/test_read_parquet.rs new file mode 100644 index 0000000..43260db --- /dev/null +++ b/tests/io/test_read_parquet.rs @@ -0,0 +1,99 @@ +#![cfg(feature = "parquet")] + +use temporalseries::errors::TemporalSeriesError; +use temporalseries::io::{read_parquet, write_parquet}; +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_non_existent_file__when_read_parquet__then_returns_io_error() { + // Given + let path = "this_file_does_not_exist_at_all.parquet"; + // When + let result = read_parquet(path); + // Then + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + TemporalSeriesError::IoError(_) + )); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_written_parquet_file__when_read_parquet__then_returns_correct_index() { + // Given + let ts = TimeSeries::new(vec![100, 200, 300], vec![1.0, 2.0, 3.0]).unwrap(); + let path = "test_parquet_read_index.parquet"; + write_parquet(&ts, path).unwrap(); + // When + let loaded = read_parquet(path).unwrap(); + // Then + assert_eq!(loaded.index, vec![100, 200, 300]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_written_parquet_file__when_read_parquet__then_returns_correct_values() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![11.1, 22.2, 33.3]).unwrap(); + let path = "test_parquet_read_values.parquet"; + write_parquet(&ts, path).unwrap(); + // When + let loaded = read_parquet(path).unwrap(); + // Then + assert_eq!(loaded.values, vec![11.1, 22.2, 33.3]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_written_parquet_file__when_read_parquet__then_returns_correct_length() { + // Given + let n = 50_usize; + let index: Vec = (0..n as i64).collect(); + let values: Vec = (0..n).map(|i| i as f64 * 0.5).collect(); + let ts = TimeSeries::new(index.clone(), values.clone()).unwrap(); + let path = "test_parquet_read_length.parquet"; + write_parquet(&ts, path).unwrap(); + // When + let loaded = read_parquet(path).unwrap(); + // Then + assert_eq!(loaded.index.len(), n); + assert_eq!(loaded.values.len(), n); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_written_parquet_file__when_read_parquet__then_index_and_values_same_length() { + // Given + let ts = TimeSeries::new(vec![10, 20, 30, 40], vec![0.1, 0.2, 0.3, 0.4]).unwrap(); + let path = "test_parquet_read_lengths_match.parquet"; + write_parquet(&ts, path).unwrap(); + // When + let loaded = read_parquet(path).unwrap(); + // Then + assert_eq!(loaded.index.len(), loaded.values.len()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series_with_float_precision__when_write_then_read__then_values_are_exact() { + // Given — IEEE 754 doubles round-trip exactly through Parquet DOUBLE + let ts = TimeSeries::new( + vec![1, 2, 3, 4, 5], + vec![1.0 / 3.0, f64::MAX, f64::MIN_POSITIVE, 0.0, -1.5], + ) + .unwrap(); + let path = "test_parquet_read_precision.parquet"; + write_parquet(&ts, path).unwrap(); + // When + let loaded = read_parquet(path).unwrap(); + // Then + for (orig, got) in ts.values.iter().zip(loaded.values.iter()) { + assert_eq!( + orig.to_bits(), + got.to_bits(), + "value mismatch: {orig} vs {got}" + ); + } +} diff --git a/tests/io/test_write_panel_csv.rs b/tests/io/test_write_panel_csv.rs new file mode 100644 index 0000000..2cf2676 --- /dev/null +++ b/tests/io/test_write_panel_csv.rs @@ -0,0 +1,108 @@ +use std::fs; +use temporalseries::errors::TemporalSeriesError; +use temporalseries::io::{read_panel_csv, write_panel_csv}; +use temporalseries::panel::Panel; + +fn sample_panel() -> Panel { + Panel::new( + vec![1, 2, 3], + vec!["AAPL".into(), "MSFT".into()], + vec![vec![150.0, 152.0, 149.0], vec![300.0, 305.0, 298.0]], + ) + .unwrap() +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_panel__when_write_panel_csv__then_writes_successfully() { + // Given + let panel = sample_panel(); + let path = "test_panel_output.csv"; + // When + let result = write_panel_csv(&panel, path); + // Then + assert!(result.is_ok()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_invalid_path__when_write_panel_csv__then_returns_io_error() { + // Given + let panel = sample_panel(); + let path = "/invalid_path/test_panel_output.csv"; + // When + let result = write_panel_csv(&panel, path); + // Then + assert!(matches!(result, Err(TemporalSeriesError::IoError(_)))); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_write_then_read__then_round_trip_preserves_values() { + // Given + let original = sample_panel(); + let path = "test_panel_round_trip.csv"; + write_panel_csv(&original, path).unwrap(); + // When + let loaded = read_panel_csv(path).unwrap(); + // Then — shape, symbols, index, and values are all preserved + assert_eq!(loaded.shape(), original.shape()); + assert_eq!(loaded.symbols(), original.symbols()); + assert_eq!(loaded.index(), original.index()); + for sym in original.symbols() { + let orig_ts = original.get_series(sym).unwrap(); + let load_ts = loaded.get_series(sym).unwrap(); + assert_eq!(orig_ts.values, load_ts.values, "values mismatch for {sym}"); + } +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_write_panel_csv__then_header_matches_symbols() { + // Given + let panel = sample_panel(); + let path = "test_panel_header_check.csv"; + write_panel_csv(&panel, path).unwrap(); + // When + let content = fs::read_to_string(path).unwrap(); + let header = content.lines().next().unwrap(); + // Then + assert_eq!(header, "index,AAPL,MSFT"); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_write_panel_csv__then_row_count_equals_index_len() { + // Given + let panel = sample_panel(); + let path = "test_panel_row_count.csv"; + write_panel_csv(&panel, path).unwrap(); + // When + let content = fs::read_to_string(path).unwrap(); + let data_rows = content.lines().skip(1).filter(|l| !l.is_empty()).count(); + // Then — one data row per timestamp (3 rows for a 3-point panel) + assert_eq!(data_rows, panel.len()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_single_symbol_panel__when_write_then_read__then_round_trip_is_correct() { + // Given + let panel = Panel::new( + vec![10, 20, 30], + vec!["SPY".into()], + vec![vec![430.0, 432.5, 428.0]], + ) + .unwrap(); + let path = "test_panel_single_sym_rt.csv"; + write_panel_csv(&panel, path).unwrap(); + // When + let loaded = read_panel_csv(path).unwrap(); + // Then + assert_eq!(loaded.shape(), (3, 1)); + assert_eq!(loaded.symbols(), &["SPY"]); + assert_eq!( + loaded.get_series("SPY").unwrap().values, + vec![430.0, 432.5, 428.0] + ); +} diff --git a/tests/io/test_write_parquet.rs b/tests/io/test_write_parquet.rs new file mode 100644 index 0000000..c65f850 --- /dev/null +++ b/tests/io/test_write_parquet.rs @@ -0,0 +1,106 @@ +#![cfg(feature = "parquet")] + +use temporalseries::errors::TemporalSeriesError; +use temporalseries::io::{read_parquet, write_parquet}; +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series__when_write_parquet__then_creates_file_successfully() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![10.0, 20.0, 30.0]).unwrap(); + let path = "test_parquet_write_ok.parquet"; + // When + let result: Result<(), TemporalSeriesError> = write_parquet(&ts, path); + // Then + assert!(result.is_ok()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_invalid_path__when_write_parquet__then_returns_io_error() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![10.0, 20.0, 30.0]).unwrap(); + let path = "/invalid_path_xyz/test.parquet"; + // When + let result = write_parquet(&ts, path); + // Then + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + TemporalSeriesError::IoError(_) + )); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series__when_write_then_read__then_round_trip_preserves_index() { + // Given + let ts = TimeSeries::new(vec![10, 20, 30, 40, 50], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + let path = "test_parquet_round_trip_index.parquet"; + // When + write_parquet(&ts, path).unwrap(); + let loaded = read_parquet(path).unwrap(); + // Then + assert_eq!(loaded.index, ts.index); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series__when_write_then_read__then_round_trip_preserves_values() { + // Given + let ts = TimeSeries::new(vec![10, 20, 30, 40, 50], vec![1.5, 2.5, 3.5, 4.5, 5.5]).unwrap(); + let path = "test_parquet_round_trip_values.parquet"; + // When + write_parquet(&ts, path).unwrap(); + let loaded = read_parquet(path).unwrap(); + // Then + assert_eq!(loaded.values, ts.values); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series__when_write_then_read__then_round_trip_preserves_length() { + // Given + let ts = TimeSeries::new( + vec![1, 2, 3, 4, 5, 6, 7], + vec![7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0], + ) + .unwrap(); + let path = "test_parquet_round_trip_len.parquet"; + // When + write_parquet(&ts, path).unwrap(); + let loaded = read_parquet(path).unwrap(); + // Then + assert_eq!(loaded.index.len(), ts.index.len()); + assert_eq!(loaded.values.len(), ts.values.len()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_single_element_series__when_write_then_read__then_round_trip_preserves_data() { + // Given + let ts = TimeSeries::new(vec![42], vec![99.9]).unwrap(); + let path = "test_parquet_round_trip_single.parquet"; + // When + write_parquet(&ts, path).unwrap(); + let loaded = read_parquet(path).unwrap(); + // Then + assert_eq!(loaded.index, vec![42]); + assert_eq!(loaded.values, vec![99.9]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series_with_negative_indices__when_write_then_read__then_round_trip_preserves_data() + { + // Given + let ts = TimeSeries::new(vec![-3, -2, -1, 0, 1], vec![10.0, 20.0, 30.0, 40.0, 50.0]).unwrap(); + let path = "test_parquet_round_trip_neg.parquet"; + // When + write_parquet(&ts, path).unwrap(); + let loaded = read_parquet(path).unwrap(); + // Then + assert_eq!(loaded.index, ts.index); + assert_eq!(loaded.values, ts.values); +} diff --git a/tests/rolling/mod.rs b/tests/rolling/mod.rs index 41008c3..8fc2ce2 100644 --- a/tests/rolling/mod.rs +++ b/tests/rolling/mod.rs @@ -1 +1,5 @@ +mod test_max; mod test_mean; +mod test_min; +mod test_std; +mod test_sum; diff --git a/tests/rolling/test_max.rs b/tests/rolling/test_max.rs new file mode 100644 index 0000000..fb4063f --- /dev/null +++ b/tests/rolling/test_max.rs @@ -0,0 +1,76 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_max_window_3__then_first_two_are_nan() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![3.0, 1.0, 4.0, 1.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).max().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!(result.values[1].is_nan()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_max_window_3__then_computes_correctly() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![3.0, 1.0, 4.0, 1.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).max().unwrap(); + // Then + assert!((result.values[2] - 4.0).abs() < 1e-6); // max(3, 1, 4) + assert!((result.values[3] - 4.0).abs() < 1e-6); // max(1, 4, 1) + assert!((result.values[4] - 5.0).abs() < 1e-6); // max(4, 1, 5) +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_max_window_1__then_equals_original() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![5.0, 2.0, 8.0]).unwrap(); + // When + let result = ts.rolling(1).max().unwrap(); + // Then + assert!((result.values[0] - 5.0).abs() < 1e-6); + assert!((result.values[1] - 2.0).abs() < 1e-6); + assert!((result.values[2] - 8.0).abs() < 1e-6); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_max__then_output_length_equals_input() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).max().unwrap(); + // Then + assert_eq!(result.len(), ts.len()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_max__then_preserves_index() { + // Given + let index = vec![10_i64, 20, 30, 40, 50]; + let ts = TimeSeries::new(index.clone(), vec![5.0, 3.0, 8.0, 1.0, 6.0]).unwrap(); + // When + let result = ts.rolling(2).max().unwrap(); + // Then + assert_eq!(result.index, index); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_ascending_series__when_rolling_max_window_2__then_always_second_value() { + // Given — strictly ascending: each new value is always the maximum + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 3.0, 6.0, 10.0]).unwrap(); + // When + let result = ts.rolling(2).max().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!((result.values[1] - 3.0).abs() < 1e-6); + assert!((result.values[2] - 6.0).abs() < 1e-6); + assert!((result.values[3] - 10.0).abs() < 1e-6); +} diff --git a/tests/rolling/test_min.rs b/tests/rolling/test_min.rs new file mode 100644 index 0000000..63c45ef --- /dev/null +++ b/tests/rolling/test_min.rs @@ -0,0 +1,76 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_min_window_3__then_first_two_are_nan() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![3.0, 1.0, 4.0, 1.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).min().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!(result.values[1].is_nan()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_min_window_3__then_computes_correctly() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![3.0, 1.0, 4.0, 1.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).min().unwrap(); + // Then + assert!((result.values[2] - 1.0).abs() < 1e-6); // min(3, 1, 4) + assert!((result.values[3] - 1.0).abs() < 1e-6); // min(1, 4, 1) + assert!((result.values[4] - 1.0).abs() < 1e-6); // min(4, 1, 5) +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_min_window_1__then_equals_original() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![5.0, 2.0, 8.0]).unwrap(); + // When + let result = ts.rolling(1).min().unwrap(); + // Then + assert!((result.values[0] - 5.0).abs() < 1e-6); + assert!((result.values[1] - 2.0).abs() < 1e-6); + assert!((result.values[2] - 8.0).abs() < 1e-6); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_min__then_output_length_equals_input() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).min().unwrap(); + // Then + assert_eq!(result.len(), ts.len()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_min__then_preserves_index() { + // Given + let index = vec![10_i64, 20, 30, 40, 50]; + let ts = TimeSeries::new(index.clone(), vec![5.0, 3.0, 8.0, 1.0, 6.0]).unwrap(); + // When + let result = ts.rolling(2).min().unwrap(); + // Then + assert_eq!(result.index, index); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_descending_series__when_rolling_min_window_2__then_always_second_value() { + // Given — strictly descending: each new value is always the minimum + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![10.0, 8.0, 6.0, 4.0]).unwrap(); + // When + let result = ts.rolling(2).min().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!((result.values[1] - 8.0).abs() < 1e-6); + assert!((result.values[2] - 6.0).abs() < 1e-6); + assert!((result.values[3] - 4.0).abs() < 1e-6); +} diff --git a/tests/rolling/test_std.rs b/tests/rolling/test_std.rs new file mode 100644 index 0000000..7045c5c --- /dev/null +++ b/tests/rolling/test_std.rs @@ -0,0 +1,90 @@ +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_std_window_3__then_first_two_are_nan() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).std().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!(result.values[1].is_nan()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_linear_series__when_rolling_std_window_3__then_computes_correctly() { + // Given — equally spaced values: std of any window of 3 is 1.0 + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).std().unwrap(); + // Then + assert!((result.values[2] - 1.0).abs() < 1e-6); // std(1, 2, 3) = 1.0 + assert!((result.values[3] - 1.0).abs() < 1e-6); // std(2, 3, 4) = 1.0 + assert!((result.values[4] - 1.0).abs() < 1e-6); // std(3, 4, 5) = 1.0 +} + +#[test] +#[allow(non_snake_case)] +fn test__given_constant_series__when_rolling_std_window_2__then_returns_zero() { + // Given — constant series has zero deviation + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![5.0, 5.0, 5.0, 5.0]).unwrap(); + // When + let result = ts.rolling(2).std().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!((result.values[1] - 0.0).abs() < 1e-10); + assert!((result.values[2] - 0.0).abs() < 1e-10); + assert!((result.values[3] - 0.0).abs() < 1e-10); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_window_of_2__when_rolling_std__then_computes_correctly() { + // Given — std([1, 3]) = sqrt(((1-2)^2 + (3-2)^2) / 1) = sqrt(2) ≈ 1.4142 + let ts = TimeSeries::new(vec![1, 2, 3], vec![1.0, 3.0, 5.0]).unwrap(); + // When + let result = ts.rolling(2).std().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!((result.values[1] - 2_f64.sqrt()).abs() < 1e-6); // std(1, 3) + assert!((result.values[2] - 2_f64.sqrt()).abs() < 1e-6); // std(3, 5) +} + +#[test] +#[allow(non_snake_case)] +fn test__given_window_1__when_rolling_std__then_returns_invalid_window_error() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); + // When + let result = ts.rolling(1).std(); + // Then — window=1 gives 0 degrees of freedom; must be rejected + assert!(matches!( + result, + Err(TemporalSeriesError::InvalidWindow { .. }) + )); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_std__then_output_length_equals_input() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).std().unwrap(); + // Then + assert_eq!(result.len(), ts.len()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_std__then_preserves_index() { + // Given + let index = vec![10_i64, 20, 30, 40, 50]; + let ts = TimeSeries::new(index.clone(), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(2).std().unwrap(); + // Then + assert_eq!(result.index, index); +} diff --git a/tests/rolling/test_sum.rs b/tests/rolling/test_sum.rs new file mode 100644 index 0000000..9cc6d88 --- /dev/null +++ b/tests/rolling/test_sum.rs @@ -0,0 +1,76 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_sum_window_3__then_first_two_are_nan() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).sum().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!(result.values[1].is_nan()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_sum_window_3__then_computes_correctly() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).sum().unwrap(); + // Then + assert!((result.values[2] - 6.0).abs() < 1e-6); // 1 + 2 + 3 + assert!((result.values[3] - 9.0).abs() < 1e-6); // 2 + 3 + 4 + assert!((result.values[4] - 12.0).abs() < 1e-6); // 3 + 4 + 5 +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_sum_window_1__then_equals_original() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![10.0, 20.0, 30.0]).unwrap(); + // When + let result = ts.rolling(1).sum().unwrap(); + // Then + assert!((result.values[0] - 10.0).abs() < 1e-6); + assert!((result.values[1] - 20.0).abs() < 1e-6); + assert!((result.values[2] - 30.0).abs() < 1e-6); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_sum__then_output_length_equals_input() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).sum().unwrap(); + // Then + assert_eq!(result.len(), ts.len()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_sum__then_preserves_index() { + // Given + let index = vec![10_i64, 20, 30, 40, 50]; + let ts = TimeSeries::new(index.clone(), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(2).sum().unwrap(); + // Then + assert_eq!(result.index, index); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_sum_window_2__then_computes_pairwise_sums() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 3.0, 6.0, 10.0]).unwrap(); + // When + let result = ts.rolling(2).sum().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!((result.values[1] - 4.0).abs() < 1e-6); // 1 + 3 + assert!((result.values[2] - 9.0).abs() < 1e-6); // 3 + 6 + assert!((result.values[3] - 16.0).abs() < 1e-6); // 6 + 10 +} diff --git a/tests/serde_support/mod.rs b/tests/serde_support/mod.rs new file mode 100644 index 0000000..5f73c49 --- /dev/null +++ b/tests/serde_support/mod.rs @@ -0,0 +1,2 @@ +mod test_panel_serde; +mod test_time_series_serde; diff --git a/tests/serde_support/test_panel_serde.rs b/tests/serde_support/test_panel_serde.rs new file mode 100644 index 0000000..80fcd72 --- /dev/null +++ b/tests/serde_support/test_panel_serde.rs @@ -0,0 +1,99 @@ +use temporalseries::panel::Panel; + +fn sample_panel() -> Panel { + Panel::new( + vec![1, 2, 3], + vec!["AAPL".into(), "MSFT".into()], + vec![vec![150.0, 152.0, 149.0], vec![300.0, 305.0, 298.0]], + ) + .unwrap() +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_serialized_to_json__then_contains_expected_fields() { + // Given + let panel = sample_panel(); + // When + let json = serde_json::to_string(&panel).unwrap(); + // Then + assert!(json.contains("\"index\"")); + assert!(json.contains("\"symbols\"")); + assert!(json.contains("\"values\"")); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_round_trip_via_json__then_shape_is_preserved() { + // Given + let panel = sample_panel(); + // When + let json = serde_json::to_string(&panel).unwrap(); + let loaded: Panel = serde_json::from_str(&json).unwrap(); + // Then + assert_eq!(loaded.shape(), panel.shape()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_round_trip_via_json__then_symbols_are_preserved() { + // Given + let panel = Panel::new( + vec![1, 2], + vec!["X".into(), "Y".into(), "Z".into()], + vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![5.0, 6.0]], + ) + .unwrap(); + // When + let json = serde_json::to_string(&panel).unwrap(); + let loaded: Panel = serde_json::from_str(&json).unwrap(); + // Then + assert_eq!(loaded.symbols(), panel.symbols()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_round_trip_via_json__then_values_are_preserved() { + // Given + let panel = sample_panel(); + // When + let json = serde_json::to_string(&panel).unwrap(); + let loaded: Panel = serde_json::from_str(&json).unwrap(); + // Then + for sym in panel.symbols() { + let orig = panel.get_series(sym).unwrap(); + let back = loaded.get_series(sym).unwrap(); + assert_eq!(orig.values, back.values, "value mismatch for {sym}"); + } +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_round_trip_via_json__then_index_is_preserved() { + // Given + let panel = Panel::new( + vec![100, 200, 300], + vec!["A".into()], + vec![vec![1.0, 2.0, 3.0]], + ) + .unwrap(); + // When + let json = serde_json::to_string(&panel).unwrap(); + let loaded: Panel = serde_json::from_str(&json).unwrap(); + // Then + assert_eq!(loaded.index(), panel.index()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_raw_json__when_deserialize_panel__then_produces_correct_panel() { + // Given — compact JSON as a downstream consumer might produce it + let json = r#"{"index":[1,2],"symbols":["SPY"],"values":[[430.0,432.5]]}"#; + // When + let panel: Panel = serde_json::from_str(json).unwrap(); + // Then + assert_eq!(panel.shape(), (2, 1)); + assert_eq!(panel.symbols(), &["SPY"]); + assert_eq!(panel.index(), &[1, 2]); + assert_eq!(panel.get_series("SPY").unwrap().values, vec![430.0, 432.5]); +} diff --git a/tests/serde_support/test_time_series_serde.rs b/tests/serde_support/test_time_series_serde.rs new file mode 100644 index 0000000..ae8bdd4 --- /dev/null +++ b/tests/serde_support/test_time_series_serde.rs @@ -0,0 +1,72 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series__when_serialized_to_json__then_contains_index_and_values_fields() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![10.0, 20.0, 30.0]).unwrap(); + // When + let json = serde_json::to_string(&ts).unwrap(); + // Then + assert!(json.contains("\"index\"")); + assert!(json.contains("\"values\"")); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series__when_round_trip_via_json__then_index_is_preserved() { + // Given + let ts = TimeSeries::new(vec![10, 20, 30], vec![1.0, 2.0, 3.0]).unwrap(); + // When + let json = serde_json::to_string(&ts).unwrap(); + let loaded: TimeSeries = serde_json::from_str(&json).unwrap(); + // Then + assert_eq!(loaded.index, ts.index); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series__when_round_trip_via_json__then_values_are_preserved() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.5, 2.5, 3.5, 4.5, 5.5]).unwrap(); + // When + let json = serde_json::to_string(&ts).unwrap(); + let loaded: TimeSeries = serde_json::from_str(&json).unwrap(); + // Then + assert_eq!(loaded.values, ts.values); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_raw_json__when_deserialize_time_series__then_produces_correct_series() { + // Given — JSON as a downstream consumer might produce it + let json = r#"{"index":[1,2,3],"values":[100.0,110.0,121.0]}"#; + // When + let ts: TimeSeries = serde_json::from_str(json).unwrap(); + // Then + assert_eq!(ts.index, vec![1, 2, 3]); + assert_eq!(ts.values, vec![100.0, 110.0, 121.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series_with_nan__when_serialize_to_json__then_nan_becomes_null() { + // Given — JSON has no native NaN; serde_json maps NaN → null + let ts = TimeSeries::new(vec![1, 2], vec![1.0, f64::NAN]).unwrap(); + // When + let json = serde_json::to_string(&ts).unwrap(); + // Then — the NaN position is represented as null in the output + assert!(json.contains("null")); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series__when_round_trip_via_json__then_length_is_preserved() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![10.0, 20.0, 30.0, 40.0]).unwrap(); + // When + let json = serde_json::to_string(&ts).unwrap(); + let loaded: TimeSeries = serde_json::from_str(&json).unwrap(); + // Then + assert_eq!(loaded.len(), ts.len()); +} diff --git a/tests/series/time_series/mod.rs b/tests/series/time_series/mod.rs index e5b180d..404738d 100644 --- a/tests/series/time_series/mod.rs +++ b/tests/series/time_series/mod.rs @@ -1,11 +1,14 @@ mod test_autocorrelation_function; mod test_average_true_range; +mod test_backward_fill; mod test_borillenger_bads; mod test_crossover_signal; mod test_cumulative_return; mod test_dickey_fuller_test; mod test_excess_kurtosis; mod test_exponential_moving_average; +mod test_fill_with; +mod test_forward_fill; mod test_iqr; mod test_is_empty; mod test_jacque_bera_test; diff --git a/tests/series/time_series/test_backward_fill.rs b/tests/series/time_series/test_backward_fill.rs new file mode 100644 index 0000000..3c6e448 --- /dev/null +++ b/tests/series/time_series/test_backward_fill.rs @@ -0,0 +1,72 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_interior_nans__when_backward_fill__then_propagates_next_value() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, f64::NAN, f64::NAN, 4.0]).unwrap(); + // When + let result = ts.backward_fill().unwrap(); + // Then + assert_eq!(result.values, vec![1.0, 4.0, 4.0, 4.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_leading_nans__when_backward_fill__then_propagates_to_start() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![f64::NAN, f64::NAN, 3.0, 4.0]).unwrap(); + // When + let result = ts.backward_fill().unwrap(); + // Then + assert_eq!(result.values, vec![3.0, 3.0, 3.0, 4.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_trailing_nans__when_backward_fill__then_trailing_nans_are_preserved() { + // Given — no following value exists after the last non-NaN + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 2.0, f64::NAN, f64::NAN]).unwrap(); + // When + let result = ts.backward_fill().unwrap(); + // Then + assert_eq!(result.values[0], 1.0); + assert_eq!(result.values[1], 2.0); + assert!(result.values[2].is_nan()); + assert!(result.values[3].is_nan()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_no_nans__when_backward_fill__then_values_are_unchanged() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![10.0, 20.0, 30.0]).unwrap(); + // When + let result = ts.backward_fill().unwrap(); + // Then + assert_eq!(result.values, vec![10.0, 20.0, 30.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_all_nans__when_backward_fill__then_all_remain_nan() { + // Given — no valid value to propagate in any direction + let ts = TimeSeries::new(vec![1, 2, 3], vec![f64::NAN, f64::NAN, f64::NAN]).unwrap(); + // When + let result = ts.backward_fill().unwrap(); + // Then + assert!(result.values.iter().all(|v| v.is_nan())); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_backward_fill__then_output_length_and_index_preserved() { + // Given + let index = vec![10_i64, 20, 30, 40]; + let ts = TimeSeries::new(index.clone(), vec![f64::NAN, 2.0, f64::NAN, 4.0]).unwrap(); + // When + let result = ts.backward_fill().unwrap(); + // Then + assert_eq!(result.len(), ts.len()); + assert_eq!(result.index, index); +} diff --git a/tests/series/time_series/test_fill_with.rs b/tests/series/time_series/test_fill_with.rs new file mode 100644 index 0000000..b163f28 --- /dev/null +++ b/tests/series/time_series/test_fill_with.rs @@ -0,0 +1,73 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_nans__when_fill_with_zero__then_nans_become_zero() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, f64::NAN, 3.0, f64::NAN]).unwrap(); + // When + let result = ts.fill_with(0.0).unwrap(); + // Then + assert_eq!(result.values, vec![1.0, 0.0, 3.0, 0.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_nans__when_fill_with_custom_value__then_nans_become_that_value() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![f64::NAN, 2.0, f64::NAN]).unwrap(); + // When + let result = ts.fill_with(-99.0).unwrap(); + // Then + assert_eq!(result.values[0], -99.0); + assert_eq!(result.values[1], 2.0); + assert_eq!(result.values[2], -99.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_no_nans__when_fill_with__then_values_are_unchanged() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); + // When + let result = ts.fill_with(0.0).unwrap(); + // Then + assert_eq!(result.values, vec![1.0, 2.0, 3.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_all_nans__when_fill_with__then_all_become_fill_value() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![f64::NAN, f64::NAN, f64::NAN]).unwrap(); + // When + let result = ts.fill_with(5.0).unwrap(); + // Then + assert_eq!(result.values, vec![5.0, 5.0, 5.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_nans__when_fill_with_nan__then_nans_are_unchanged() { + // Given — filling with NaN is a valid no-op + let ts = TimeSeries::new(vec![1, 2, 3], vec![1.0, f64::NAN, 3.0]).unwrap(); + // When + let result = ts.fill_with(f64::NAN).unwrap(); + // Then + assert_eq!(result.values[0], 1.0); + assert!(result.values[1].is_nan()); + assert_eq!(result.values[2], 3.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_fill_with__then_output_length_and_index_preserved() { + // Given + let index = vec![10_i64, 20, 30]; + let ts = TimeSeries::new(index.clone(), vec![f64::NAN, 2.0, f64::NAN]).unwrap(); + // When + let result = ts.fill_with(0.0).unwrap(); + // Then + assert_eq!(result.len(), ts.len()); + assert_eq!(result.index, index); +} diff --git a/tests/series/time_series/test_forward_fill.rs b/tests/series/time_series/test_forward_fill.rs new file mode 100644 index 0000000..83d31bf --- /dev/null +++ b/tests/series/time_series/test_forward_fill.rs @@ -0,0 +1,72 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_interior_nans__when_forward_fill__then_propagates_last_value() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, f64::NAN, f64::NAN, 4.0]).unwrap(); + // When + let result = ts.forward_fill().unwrap(); + // Then + assert_eq!(result.values, vec![1.0, 1.0, 1.0, 4.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_trailing_nans__when_forward_fill__then_propagates_to_end() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 2.0, f64::NAN, f64::NAN]).unwrap(); + // When + let result = ts.forward_fill().unwrap(); + // Then + assert_eq!(result.values, vec![1.0, 2.0, 2.0, 2.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_leading_nans__when_forward_fill__then_leading_nans_are_preserved() { + // Given — no prior value exists before the first non-NaN + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![f64::NAN, f64::NAN, 3.0, 4.0]).unwrap(); + // When + let result = ts.forward_fill().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!(result.values[1].is_nan()); + assert_eq!(result.values[2], 3.0); + assert_eq!(result.values[3], 4.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_no_nans__when_forward_fill__then_values_are_unchanged() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![10.0, 20.0, 30.0]).unwrap(); + // When + let result = ts.forward_fill().unwrap(); + // Then + assert_eq!(result.values, vec![10.0, 20.0, 30.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_all_nans__when_forward_fill__then_all_remain_nan() { + // Given — no valid value to propagate + let ts = TimeSeries::new(vec![1, 2, 3], vec![f64::NAN, f64::NAN, f64::NAN]).unwrap(); + // When + let result = ts.forward_fill().unwrap(); + // Then + assert!(result.values.iter().all(|v| v.is_nan())); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_forward_fill__then_output_length_and_index_preserved() { + // Given + let index = vec![10_i64, 20, 30, 40]; + let ts = TimeSeries::new(index.clone(), vec![1.0, f64::NAN, 3.0, f64::NAN]).unwrap(); + // When + let result = ts.forward_fill().unwrap(); + // Then + assert_eq!(result.len(), ts.len()); + assert_eq!(result.index, index); +} diff --git a/tests/storage/mod.rs b/tests/storage/mod.rs index c999f13..1e89a03 100644 --- a/tests/storage/mod.rs +++ b/tests/storage/mod.rs @@ -1,2 +1,3 @@ +mod test_chunked; mod test_columnar; mod test_row; diff --git a/tests/storage/test_chunked.rs b/tests/storage/test_chunked.rs new file mode 100644 index 0000000..4751059 --- /dev/null +++ b/tests/storage/test_chunked.rs @@ -0,0 +1,240 @@ +use temporalseries::storage::{ChunkedBackend, StorageBackend}; + +// --------------------------------------------------------------------------- +// Construction +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_values__when_new__then_len_is_correct() { + // Given & When + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0], 2); + // Then + assert_eq!(backend.len(), 5); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_values_divisible_by_chunk_size__when_new__then_n_chunks_is_exact() { + // Given & When + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0, 3.0, 4.0], 2); + // Then — 4 values / chunk_size 2 → exactly 2 full chunks + assert_eq!(backend.n_chunks(), 2); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_values_not_divisible_by_chunk_size__when_new__then_last_chunk_is_partial() { + // Given & When + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0], 2); + // Then — 5 values / chunk_size 2 → 2 full + 1 partial chunk + assert_eq!(backend.n_chunks(), 3); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_values__when_new__then_is_empty() { + // Given & When + let backend: ChunkedBackend = ChunkedBackend::new(vec![], 4); + // Then + assert!(backend.is_empty()); + assert_eq!(backend.len(), 0); + assert_eq!(backend.n_chunks(), 0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_chunk_size_equals_len__when_new__then_single_chunk() { + // Given & When + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0, 3.0], 3); + // Then + assert_eq!(backend.n_chunks(), 1); + assert_eq!(backend.len(), 3); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_chunk_size_larger_than_len__when_new__then_single_partial_chunk() { + // Given & When + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0], 10); + // Then + assert_eq!(backend.n_chunks(), 1); + assert_eq!(backend.len(), 2); +} + +// --------------------------------------------------------------------------- +// chunk_size accessor +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_chunk_size__then_returns_configured_size() { + // Given & When + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0, 3.0], 2); + // Then + assert_eq!(backend.chunk_size(), 2); +} + +// --------------------------------------------------------------------------- +// get +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_get_first_element__then_returns_correct_value() { + // Given + let backend: ChunkedBackend = ChunkedBackend::new(vec![10.0, 20.0, 30.0], 2); + // When & Then + assert_eq!(backend.get(0), Some(&10.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_get_last_element__then_returns_correct_value() { + // Given + let backend: ChunkedBackend = ChunkedBackend::new(vec![10.0, 20.0, 30.0], 2); + // When & Then + assert_eq!(backend.get(2), Some(&30.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_get_element_at_chunk_boundary__then_returns_correct_value() { + // Given — chunk_size 2: chunks are [10, 20] and [30, 40] + let backend: ChunkedBackend = ChunkedBackend::new(vec![10.0, 20.0, 30.0, 40.0], 2); + // When — index 2 is the first element of the second chunk + // Then + assert_eq!(backend.get(2), Some(&30.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_get_out_of_bounds__then_returns_none() { + // Given + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0], 2); + // When & Then + assert!(backend.get(99).is_none()); +} + +// --------------------------------------------------------------------------- +// push +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_push__then_len_increases() { + // Given + let mut backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0], 2); + // When + backend.push(3.0); + // Then + assert_eq!(backend.len(), 3); + assert_eq!(backend.get(2), Some(&3.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_full_last_chunk__when_push__then_new_chunk_is_allocated() { + // Given — 2 values in chunks of 2 → last chunk is full + let mut backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0], 2); + assert_eq!(backend.n_chunks(), 1); + // When + backend.push(3.0); + // Then — a new chunk must have been allocated + assert_eq!(backend.n_chunks(), 2); + assert_eq!(backend.get(2), Some(&3.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_partial_last_chunk__when_push__then_no_new_chunk_allocated() { + // Given — 3 values in chunks of 2 → last chunk has 1 slot free + let mut backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0, 3.0], 2); + let chunks_before = backend.n_chunks(); + // When + backend.push(4.0); + // Then — same number of chunks, last chunk now full + assert_eq!(backend.n_chunks(), chunks_before); + assert_eq!(backend.len(), 4); + assert_eq!(backend.get(3), Some(&4.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_backend__when_push__then_first_chunk_is_created() { + // Given + let mut backend: ChunkedBackend = ChunkedBackend::new(vec![], 3); + assert_eq!(backend.n_chunks(), 0); + // When + backend.push(42.0); + // Then + assert_eq!(backend.n_chunks(), 1); + assert_eq!(backend.len(), 1); + assert_eq!(backend.get(0), Some(&42.0)); +} + +// --------------------------------------------------------------------------- +// slice +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_slice__then_returns_correct_subset() { + // Given + let backend: ChunkedBackend = ChunkedBackend::new(vec![10.0, 20.0, 30.0, 40.0], 2); + // When + let sliced: ChunkedBackend = backend.slice(1..3); + // Then + assert_eq!(sliced.len(), 2); + assert_eq!(sliced.get(0), Some(&20.0)); + assert_eq!(sliced.get(1), Some(&30.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_slice_preserves_chunk_size() { + // Given + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0], 3); + // When + let sliced: ChunkedBackend = backend.slice(0..4); + // Then — same chunk_size, re-chunked + assert_eq!(sliced.chunk_size(), 3); + assert_eq!(sliced.len(), 4); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_slice_does_not_mutate_source() { + // Given + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0, 3.0, 4.0], 2); + // When + let _sliced = backend.slice(0..2); + // Then — source is unchanged + assert_eq!(backend.len(), 4); +} + +// --------------------------------------------------------------------------- +// iter +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_iter__then_yields_all_values_in_order() { + // Given + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0], 2); + // When + let collected: Vec<&f64> = backend.iter().collect(); + // Then + assert_eq!(collected, vec![&1.0, &2.0, &3.0, &4.0, &5.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_backend__when_iter__then_yields_nothing() { + // Given + let backend: ChunkedBackend = ChunkedBackend::new(vec![], 4); + // When + let collected: Vec<&f64> = backend.iter().collect(); + // Then + assert!(collected.is_empty()); +}