diff --git a/.agents/skills/speckit-plan/SKILL.md b/.agents/skills/speckit-plan/SKILL.md index 21897d1..8eb7fd1 100644 --- a/.agents/skills/speckit-plan/SKILL.md +++ b/.agents/skills/speckit-plan/SKILL.md @@ -154,7 +154,7 @@ Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generate - Keep this artifact as a validation/run guide; implementation details belong in `tasks.md` and the implementation phase 4. **Agent context update**: - - Update the plan reference between the `` and `` markers in `CLAUDE.md` to point to the plan file created in step 1 (the IMPL_PLAN path) + - Update the plan reference between the `` and `` markers in `AGENTS.md` to point to the plan file created in step 1 (the IMPL_PLAN path) **Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file diff --git a/.specify/feature.json b/.specify/feature.json index b6dc4b1..8601939 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/013-fast-sbe-codec" + "feature_directory": "specs/016-okx-parity-remediation" } diff --git a/.specify/integration.json b/.specify/integration.json index 2e0fa37..3f77c71 100644 --- a/.specify/integration.json +++ b/.specify/integration.json @@ -7,10 +7,6 @@ "integration_settings": { "codex": { "script": "sh", - "raw_options": "--skills", - "parsed_options": { - "skills": true - }, "invoke_separator": "-" } }, diff --git a/.specify/integrations/codex.manifest.json b/.specify/integrations/codex.manifest.json index f3994a8..26a5975 100644 --- a/.specify/integrations/codex.manifest.json +++ b/.specify/integrations/codex.manifest.json @@ -1,14 +1,14 @@ { "integration": "codex", "version": "0.11.9", - "installed_at": "2026-07-06T03:25:14.208320+00:00", + "installed_at": "2026-07-10T07:11:14.011122+00:00", "files": { ".agents/skills/speckit-analyze/SKILL.md": "2e89ffed73764eb9d3ee2d9fba7e245a5e73229b809ddec52409ca74c44e585d", ".agents/skills/speckit-clarify/SKILL.md": "5095627c89a249d402ad2d0fad425ea0c0bd5e8559e9d6f5b682dea4f513ed54", ".agents/skills/speckit-constitution/SKILL.md": "87f247a2e947b44f9248491dcbedbfc650d095dde6498938b6be7a413b418d5e", ".agents/skills/speckit-implement/SKILL.md": "d31986feebd257c7ebbca443d432c6484046a951149f831281fbba61adcd5889", ".agents/skills/speckit-converge/SKILL.md": "1355b748919c7750cc87be94534192e5ff227229c14cbd34ca993a5f977a4ce2", - ".agents/skills/speckit-plan/SKILL.md": "84c76b584d729a0c50ef3a27844383b597aef36b4e1b5cf4df47060a0b058ab0", + ".agents/skills/speckit-plan/SKILL.md": "95445751fa56bb409714f6804aa4b79efbbeea5cf36f30363aff1871caf8b8aa", ".agents/skills/speckit-checklist/SKILL.md": "4eb7e030d04014e6bbf5177f84aeef19b11adfa8470a14dd18506f65c7cf9dcb", ".agents/skills/speckit-specify/SKILL.md": "73518d4f228a59621e96c7787051057afd389d0978b73ac7e7721991df6b3f87", ".agents/skills/speckit-tasks/SKILL.md": "e08c8def433c9964e3e4446cb5ebbd97a1a85cb046ffd4c85c3405f58593f503", diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..710c8f9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,4 @@ + +For the active feature's technologies, layout, and validation commands, read +`specs/016-okx-parity-remediation/plan.md`. + diff --git a/Cargo.lock b/Cargo.lock index 4c15fa3..2e8366d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # 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 = "aes" version = "0.8.4" @@ -52,6 +58,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.103" @@ -97,6 +112,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" @@ -149,6 +170,47 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "binance-sdk" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "228c20d4551624875cf56826876e04ab962e06cbd477aa366ba6b5849cb6518b" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "derive_builder", + "ed25519-dalek", + "flate2", + "futures", + "hex", + "hmac 0.12.1", + "http", + "once_cell", + "openssl", + "rand 0.8.6", + "regex", + "reqwest 0.12.28", + "reqwest-middleware", + "rust_decimal", + "rustc_version", + "serde", + "serde_json", + "serde_repr", + "serde_with", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tokio-native-tls", + "tokio-stream", + "tokio-tungstenite 0.26.2", + "tokio-util", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + [[package]] name = "bitflags" version = "2.13.0" @@ -221,6 +283,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bson" version = "2.15.0" @@ -233,7 +304,7 @@ dependencies = [ "getrandom 0.2.17", "getrandom 0.3.4", "hex", - "indexmap", + "indexmap 2.14.0", "js-sys", "once_cell", "rand 0.9.4", @@ -328,6 +399,18 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" @@ -484,6 +567,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -608,14 +700,38 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "darling" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2c43f534ea4b0b049015d00269734195e6d3f0f6635cb692251aca6f9f8b3c" +dependencies = [ + "darling_core 0.12.4", + "darling_macro 0.12.4", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e91455b86830a1c21799d94524df0845183fa55bafd9aa137b01c7d1065fa36" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", ] [[package]] @@ -627,17 +743,28 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.118", ] +[[package]] +name = "darling_macro" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29b5acf0dea37a7f66f7b25d2c5e93fd46f8f6968b1a5d7a3e02e97768afc95a" +dependencies = [ + "darling_core 0.12.4", + "quote", + "syn 1.0.109", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.118", ] @@ -675,6 +802,9 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] [[package]] name = "derive-syn-parse" @@ -698,6 +828,37 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "derive_builder" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d13202debe11181040ae9063d739fa32cfcaaebe2275fe387703460ae2365b30" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e616858f6187ed828df7c64a6d71720d83767a7f19740b2d1b6fe6327b36e5" +dependencies = [ + "darling 0.12.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58a94ace95092c5acb1e97a7e846b310cfbd499652f72297da7493f618a98d73" +dependencies = [ + "derive_builder_core", + "syn 1.0.109", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -768,6 +929,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ed25519" version = "2.2.3" @@ -892,6 +1059,16 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.12.0" @@ -903,6 +1080,12 @@ dependencies = [ "spin", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -915,6 +1098,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -936,6 +1134,21 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1009,6 +1222,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1063,9 +1277,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] @@ -1221,6 +1456,45 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "hybrid-array" version = "0.4.13" @@ -1230,6 +1504,107 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls 0.23.41", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.5.10", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1339,6 +1714,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1347,6 +1733,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -1525,6 +1913,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "macro_magic" version = "0.5.1" @@ -1573,6 +1967,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "md-5" version = "0.10.6" @@ -1618,7 +2021,7 @@ dependencies = [ "crossbeam-epoch", "crossbeam-utils", "hashbrown 0.16.1", - "indexmap", + "indexmap 2.14.0", "metrics", "ordered-float", "rand 0.9.4", @@ -1627,6 +2030,32 @@ dependencies = [ "sketches-ddsketch", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[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 = "mio" version = "1.2.1" @@ -1708,7 +2137,7 @@ dependencies = [ "sha2 0.10.9", "socket2 0.6.4", "stringprep", - "strsim", + "strsim 0.11.1", "take_mut", "thiserror 2.0.18", "tokio", @@ -1737,6 +2166,23 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk-context" version = "0.1.1" @@ -1813,6 +2259,31 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "openssl-probe" version = "0.1.6" @@ -1825,6 +2296,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "ordered-float" version = "5.3.0" @@ -1923,7 +2406,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", ] [[package]] @@ -2181,6 +2664,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.41", + "socket2 0.5.10", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.1", + "rand_pcg", + "ring", + "rustc-hash", + "rustls 0.23.41", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.46" @@ -2284,6 +2824,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xoshiro" version = "0.7.0" @@ -2333,6 +2882,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "regex" version = "1.12.4" @@ -2371,6 +2940,102 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.41", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "reqwest-middleware" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562ceb5a604d3f7c885a792d42c199fd8af239d0a51b2fa6a78aafa092452b04" +dependencies = [ + "anyhow", + "async-trait", + "http", + "reqwest 0.12.28", + "serde", + "thiserror 1.0.69", + "tower-service", +] + [[package]] name = "resolv-conf" version = "0.7.6" @@ -2470,11 +3135,28 @@ dependencies = [ "num-traits", "rand 0.8.6", "rkyv", + "rust_decimal_macros", "serde", "serde_json", "wasm-bindgen", ] +[[package]] +name = "rust_decimal_macros" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74a5a6f027e892c7a035c6fddb50435a1fbf5a734ffc0c2a9fed4d0221440519" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2565,17 +3247,45 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64 0.21.7", + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls 0.23.41", + "rustls-native-certs 0.8.4", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.13", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", ] [[package]] -name = "rustls-pki-types" -version = "1.14.1" +name = "rustls-platform-verifier-android" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "zeroize", -] +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" @@ -2605,6 +3315,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2623,6 +3339,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2745,7 +3485,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -2753,14 +3493,47 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", "serde_core", + "serde_json", "serde_with_macros", + "time", ] [[package]] @@ -2769,7 +3542,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.118", @@ -2874,6 +3647,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simd_cesu8" version = "1.1.1" @@ -2998,7 +3777,7 @@ dependencies = [ "futures-util", "hashbrown 0.16.1", "hashlink", - "indexmap", + "indexmap 2.14.0", "log", "memchr", "percent-encoding", @@ -3155,6 +3934,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" @@ -3189,6 +3974,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -3246,6 +4040,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3436,6 +4231,16 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.24.1" @@ -3479,6 +4284,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite 0.26.2", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "rustls 0.23.41", + "rustls-native-certs 0.8.4", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tungstenite 0.29.0", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3491,6 +4326,7 @@ dependencies = [ "futures-sink", "futures-util", "pin-project-lite", + "slab", "tokio", ] @@ -3509,7 +4345,7 @@ version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime", "toml_parser", "winnow", @@ -3524,6 +4360,51 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -3568,18 +4449,35 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -3589,11 +4487,14 @@ dependencies = [ "anyhow", "async-trait", "base64 0.22.1", + "binance-sdk", "ed25519-dalek", + "http", "pkcs8 0.10.2", "rcgen", "redb", "rustls 0.23.41", + "serde_json", "thiserror 2.0.18", "time", "tokio", @@ -3705,6 +4606,27 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "truefix-okx-client" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "futures-util", + "hmac 0.12.1", + "reqwest 0.13.4", + "rust_decimal", + "serde", + "serde_json", + "serde_urlencoded", + "sha2 0.10.9", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-tungstenite 0.29.0", + "tracing", + "url", +] + [[package]] name = "truefix-session" version = "0.1.0" @@ -3775,6 +4697,48 @@ dependencies = [ "tracing", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.9.4", + "sha1 0.10.6", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "rustls 0.23.41", + "rustls-pki-types", + "sha1 0.10.6", + "thiserror 2.0.18", +] + [[package]] name = "typed-builder" version = "0.22.0" @@ -3801,6 +4765,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -3858,6 +4828,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3904,6 +4880,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3933,6 +4918,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.126" @@ -3965,6 +4960,35 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -4004,6 +5028,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 9dcfcfb..cdb1bf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ time = { version = "0.3", features = ["formatting", "parsing", "macros"] } time-tz = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_urlencoded = "0.7" sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio"] } tracing = "0.1" metrics = "0.24" @@ -45,6 +46,13 @@ ppp = "2" tokio-socks = "0.5" tiberius = { version = "0.12", default-features = false, features = ["rustls", "tds73"] } tokio-util = { version = "0.7", features = ["compat"] } +reqwest = { version = "0.13", default-features = false, features = ["http2", "json", "rustls", "socks"] } +tokio-tungstenite = { version = "0.29", default-features = false, features = ["connect", "rustls-tls-native-roots"] } +futures-util = "0.3" +hmac = "0.12" +sha2 = "0.10" +base64 = "0.22" +url = "2" redb = "4" mongodb = "3" # T177/T178 (feature 009, NEW-35/36): already present transitively (see Cargo.lock) — promoted to @@ -65,6 +73,7 @@ truefix-log = { path = "crates/truefix-log" } truefix-binary = { path = "crates/truefix-binary" } truefix-twsapi-client = { path = "crates/truefix-twsapi-client" } truefix-futu-client = { path = "crates/truefix-futu-client" } +truefix-okx-client = { path = "crates/truefix-okx-client" } [profile.release] # Production builds: keep debug info off, optimize. Benchmarks live in benches/. diff --git a/crates/truefix-log/tests/mssql_log.rs b/crates/truefix-log/tests/mssql_log.rs index 0507d28..e8c21db 100644 --- a/crates/truefix-log/tests/mssql_log.rs +++ b/crates/truefix-log/tests/mssql_log.rs @@ -8,8 +8,6 @@ #![cfg(feature = "mssql")] -use std::time::Duration; - use tiberius::{AuthMethod, Client, Config}; use tokio::net::TcpStream; use tokio_util::compat::TokioAsyncWriteCompatExt; @@ -54,8 +52,9 @@ async fn mssql_log_persists_messages_and_events_if_available() { log.on_outgoing("8=FIX.4.4|35=0"); log.on_event("logged on"); - // Allow the background writer to flush. - tokio::time::sleep(Duration::from_millis(300)).await; + // `MssqlLog` writes on a single background connection. Wait for its explicit drain + // boundary instead of racing a fixed delay against the three queued inserts. + log.shutdown().await; let tiberius_config = parse_url(&url); let addr = tokio::net::lookup_host(tiberius_config.get_addr()) @@ -121,7 +120,7 @@ async fn mssql_log_rows_carry_logged_at_and_session_id_if_available() { }; let log = MssqlLog::connect_with_config(config).await.unwrap(); log.on_event("logged on"); - tokio::time::sleep(Duration::from_millis(300)).await; + log.shutdown().await; let tiberius_config = parse_url(&url); let addr = tokio::net::lookup_host(tiberius_config.get_addr()) diff --git a/crates/truefix-okx-client/Cargo.toml b/crates/truefix-okx-client/Cargo.toml new file mode 100644 index 0000000..6068312 --- /dev/null +++ b/crates/truefix-okx-client/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "truefix-okx-client" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Native, typed OKX V5 REST and WebSocket client for TrueFix." +publish = false + +[dependencies] +base64.workspace = true +futures-util.workspace = true +hmac.workspace = true +reqwest.workspace = true +rust_decimal.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_urlencoded.workspace = true +sha2.workspace = true +thiserror.workspace = true +time.workspace = true +tokio.workspace = true +tokio-tungstenite.workspace = true +tracing.workspace = true +url.workspace = true + +[lints] +workspace = true diff --git a/crates/truefix-okx-client/README.md b/crates/truefix-okx-client/README.md new file mode 100644 index 0000000..dbc51e4 --- /dev/null +++ b/crates/truefix-okx-client/README.md @@ -0,0 +1,8 @@ +# TrueFix OKX Client + +Maintain the operation manifest whenever upstream V5 changes: add the source identity, Rust +entrypoint, auth/replay class and fixture evidence together. Compare domain counts against the +recorded `python-okx@fa8d738` baseline; never copy upstream source or tests. + +All write operations are non-replaying. Demo is the default; credentials belong in a secret +provider, never this repository. diff --git a/crates/truefix-okx-client/THIRD_PARTY.md b/crates/truefix-okx-client/THIRD_PARTY.md new file mode 100644 index 0000000..55a205e --- /dev/null +++ b/crates/truefix-okx-client/THIRD_PARTY.md @@ -0,0 +1,14 @@ +# Third-party and provenance record + +This crate is independently implemented. `thrdpty/clientapi/python-okx` at commit +`fa8d738249286b9b7ff8fed678218701f87bbb86` is used only as a capability inventory and external +behaviour reference; no source, comments, or tests are copied. + +| Dependency | License | Purpose | +|---|---|---| +| reqwest | MIT OR Apache-2.0 | Async HTTP client, HTTP/2, proxy and rustls TLS | +| tokio-tungstenite | MIT | Tokio WebSocket transport | +| hmac, sha2, base64 | MIT OR Apache-2.0 | OKX V5 HMAC-SHA256/Base64 signing | +| futures-util, url | MIT OR Apache-2.0 | Stream and URL support | + +The final dependency lockfile review is required by task T067 before release. diff --git a/crates/truefix-okx-client/examples/demo_order_lifecycle.rs b/crates/truefix-okx-client/examples/demo_order_lifecycle.rs new file mode 100644 index 0000000..c4f3b39 --- /dev/null +++ b/crates/truefix-okx-client/examples/demo_order_lifecycle.rs @@ -0,0 +1,46 @@ +//! Demo-only lifecycle. Credentials are loaded from process environment, never source control. +//! +//! Set `OKX_DEMO_API_KEY`, `OKX_DEMO_SECRET`, `OKX_DEMO_PASSPHRASE`, and explicitly set +//! `OKX_DEMO_PLACE_ORDER=1` before the write path is enabled. + +use truefix_okx_client::{ + ClientConfig, Credentials, OkxClient, + types::{common::DecimalValue, order::PlaceOrder}, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let key = match std::env::var("OKX_DEMO_API_KEY") { + Ok(value) => value, + Err(_) => { + println!("Demo credentials are not configured; no request was sent."); + return Ok(()); + } + }; + let credentials = Credentials::new( + key, + std::env::var("OKX_DEMO_SECRET")?, + std::env::var("OKX_DEMO_PASSPHRASE")?, + )?; + let client = OkxClient::new(ClientConfig::demo(Some(credentials)))?; + println!( + "Demo balances: {} records", + client.account().balances().await?.len() + ); + if std::env::var("OKX_DEMO_PLACE_ORDER").ok().as_deref() != Some("1") { + println!("Order placement is disabled; set OKX_DEMO_PLACE_ORDER=1 to continue."); + return Ok(()); + } + let order = PlaceOrder::new( + "BTC-USDT", + "cash", + "buy", + "market", + "0.00001".parse::()?, + ); + println!( + "Demo order acknowledgement: {:?}", + client.trade().place_order(&order).await? + ); + Ok(()) +} diff --git a/crates/truefix-okx-client/examples/okx_cli.rs b/crates/truefix-okx-client/examples/okx_cli.rs new file mode 100644 index 0000000..7fb977a --- /dev/null +++ b/crates/truefix-okx-client/examples/okx_cli.rs @@ -0,0 +1,427 @@ +//! Interactive CLI for the complete audited OKX REST surface. +//! +//! Run `cargo run -p truefix-okx-client --example okx_cli` and type `help`. +//! `request` accepts only operations registered in the client's 264-operation baseline +//! manifest. It never prints credentials, defaults to OKX Demo, and requires +//! `--confirm-write` for every POST operation. + +use std::{ + collections::BTreeMap, + env, fs, + io::{self, Write}, + path::Path, +}; + +use serde_json::Value; +use truefix_okx_client::{ + ClientConfig, Credentials, Environment, LiveTradingConfirmation, OkxClient, + inventory::{AuthClass, BASELINE_OPERATION_MANIFEST}, +}; + +const DEFAULT_CREDENTIAL_FILE: &str = + "/Users/jiayin/workspace/dev/dev/rust/truefix-account/okx/okx"; + +type CliResult = Result>; + +struct Request { + domain: String, + operation: String, + requested_method: Option, + query: BTreeMap, + body: Option, + confirm_write: bool, +} + +#[tokio::main] +async fn main() -> CliResult<()> { + let arguments = env::args().skip(1).collect::>(); + let client = connect_client()?; + if arguments.is_empty() { + repl(&client).await + } else if matches!( + arguments.first().map(String::as_str), + Some("help" | "h" | "?") + ) { + print_help(); + Ok(()) + } else if arguments + .first() + .is_some_and(|argument| matches!(argument.as_str(), "catalog" | "list")) + { + list_operations(arguments.get(1).map(String::as_str)); + Ok(()) + } else if arguments + .first() + .is_some_and(|argument| argument == "overview") + { + overview(&client, arguments.get(1).map(String::as_str)).await + } else { + execute(&client, parse_request(&arguments)?).await + } +} + +fn connect_client() -> CliResult { + let credentials = credentials()?; + let live = env::var("OKX_ENV").is_ok_and(|value| value.eq_ignore_ascii_case("live")); + let config = if live { + if !env::var("OKX_CONFIRM_LIVE").is_ok_and(|value| value == "1") { + return Err("set OKX_CONFIRM_LIVE=1 to select the live trading environment".into()); + } + let credentials = credentials.ok_or("live mode requires OKX credentials")?; + ClientConfig::live(credentials, LiveTradingConfirmation::acknowledge_risk()) + } else { + ClientConfig::demo(credentials) + }; + let environment = if matches!(config.environment, Environment::Demo) { + "Demo" + } else { + "Live" + }; + println!("OKX CLI ({environment}); type `help` for commands."); + Ok(OkxClient::new(config)?) +} + +/// Loads credentials only when the caller supplies a passphrase or key environment variable. +/// This keeps public commands usable on hosts without local private credentials. +fn credentials() -> CliResult> { + let requested = ["OKX_API_KEY", "OKX_SECRET", "OKX_PASSPHRASE"] + .iter() + .any(|name| env::var(name).is_ok_and(|value| !value.is_empty())); + if !requested { + return Ok(None); + } + let credential_file = + env::var("OKX_CREDENTIAL_FILE").unwrap_or_else(|_| DEFAULT_CREDENTIAL_FILE.to_owned()); + let path = Path::new(&credential_file); + Ok(Some(Credentials::new( + environment_or_file("OKX_API_KEY", "apikey", path)?, + environment_or_file("OKX_SECRET", "secretkey", path)?, + env::var("OKX_PASSPHRASE")?, + )?)) +} + +fn environment_or_file(environment: &str, file_key: &str, path: &Path) -> CliResult { + match env::var(environment) { + Ok(value) if !value.is_empty() => Ok(value), + _ => credential_from_file(file_key, path), + } +} + +fn credential_from_file(name: &str, path: &Path) -> CliResult { + let contents = fs::read_to_string(path)?; + contents + .lines() + .filter_map(|line| line.split_once('=')) + .find_map(|(key, value)| (key.trim().eq_ignore_ascii_case(name)).then(|| value.trim())) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or_else(|| format!("missing {name} in {}", path.display()).into()) +} + +async fn repl(client: &OkxClient) -> CliResult<()> { + let stdin = io::stdin(); + let mut line = String::new(); + loop { + print!("okx> "); + io::stdout().flush()?; + line.clear(); + if stdin.read_line(&mut line)? == 0 { + println!(); + break; + } + let input = line.trim(); + if input.is_empty() { + continue; + } + if matches!(input, "exit" | "quit" | "q") { + break; + } + let arguments = match split_args(input) { + Ok(arguments) => arguments, + Err(error) => { + eprintln!("{error}"); + continue; + } + }; + let result = match arguments.first().map(String::as_str) { + Some("help" | "h" | "?") => { + print_help(); + Ok(()) + } + Some("catalog" | "list") => { + list_operations(arguments.get(1).map(String::as_str)); + Ok(()) + } + Some("overview") => overview(client, arguments.get(1).map(String::as_str)).await, + _ => match parse_request(&arguments) { + Ok(request) => execute(client, request).await, + Err(error) => Err(error), + }, + }; + if let Err(error) = result { + eprintln!("{error}"); + } + } + Ok(()) +} + +async fn execute(client: &OkxClient, request: Request) -> CliResult<()> { + let entry = BASELINE_OPERATION_MANIFEST + .iter() + .find(|entry| entry.domain == request.domain && entry.operation == request.operation) + .ok_or_else(|| { + format!( + "unknown operation {}/{}; use `list`", + request.domain, request.operation + ) + })?; + if entry.method == "POST" && !request.confirm_write { + return Err("POST operations require --confirm-write".into()); + } + if let Some(method) = request.requested_method.as_deref() + && method != entry.method + { + return Err(format!( + "{method} does not match {}/{}; the audited operation uses {}", + request.domain, request.operation, entry.method + ) + .into()); + } + let response = client + .execute_baseline_operation( + &request.domain, + &request.operation, + request.query, + request.body.as_ref(), + ) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + Ok(()) +} + +async fn overview(client: &OkxClient, symbol: Option<&str>) -> CliResult<()> { + let symbol = symbol.unwrap_or("BTC-USDT"); + let ticker = client.market().ticker(symbol).await?; + let books = client.market().books(symbol, Some(5)).await?; + println!("ticker:\n{ticker:#?}"); + println!("top-of-book:\n{books:#?}"); + if client.config().credentials.is_some() { + let balances = client.account().balances().await?; + let positions = client.account().positions().await?; + let orders = client.trade().orders(BTreeMap::new()).await?; + println!("balances:\n{balances:#?}"); + println!("positions:\n{positions:#?}"); + println!("open orders:\n{orders:#?}"); + } else { + println!("Set OKX_PASSPHRASE (and configure key/secret) to include private account data."); + } + Ok(()) +} + +fn parse_request(arguments: &[String]) -> CliResult { + let requested_method = match arguments.first().map(String::as_str) { + Some("get") => Some("GET".to_owned()), + Some("post") => Some("POST".to_owned()), + _ => None, + }; + let arguments = request_alias(arguments)?; + if arguments.len() < 3 || arguments.first().map(String::as_str) != Some("request") { + return Err( + "usage: request [QUERY_JSON] [BODY_JSON] [--confirm-write]".into(), + ); + } + let values = arguments[3..] + .iter() + .filter(|argument| !argument.starts_with("--")) + .collect::>(); + if values.len() > 2 { + return Err("only QUERY_JSON and BODY_JSON may be supplied".into()); + } + Ok(Request { + domain: arguments[1].clone(), + operation: arguments[2].clone(), + requested_method, + query: values + .first() + .map(|value| query_object(value)) + .transpose()? + .unwrap_or_default(), + body: values.get(1).map(|value| json_object(value)).transpose()?, + confirm_write: arguments + .iter() + .any(|argument| argument == "--confirm-write"), + }) +} + +fn request_alias(arguments: &[String]) -> CliResult> { + let Some(command) = arguments.first() else { + return Ok(Vec::new()); + }; + match command.as_str() { + "request" => Ok(arguments.to_vec()), + "get" | "post" => { + if arguments.len() < 3 { + return Err(format!( + "usage: {command} [QUERY_JSON] [BODY_JSON] [--confirm-write]" + ) + .into()); + } + Ok(std::iter::once("request".to_owned()) + .chain(arguments[1..].iter().cloned()) + .collect()) + } + _ => Ok(arguments.to_vec()), + } +} + +fn query_object(value: &str) -> CliResult> { + let object: serde_json::Map = serde_json::from_str(value)?; + object + .into_iter() + .map(|(key, value)| match value { + Value::String(value) => Ok((key, value)), + Value::Number(value) => Ok((key, value.to_string())), + Value::Bool(value) => Ok((key, value.to_string())), + _ => { + Err(format!("query value for `{key}` must be a string, number, or boolean").into()) + } + }) + .collect() +} + +fn json_object(value: &str) -> CliResult { + let value: Value = serde_json::from_str(value)?; + if value.is_object() { + Ok(value) + } else { + Err("BODY_JSON must be a JSON object".into()) + } +} + +/// Split a command line while retaining JSON quotes. Quote a JSON document with single quotes +/// when it contains whitespace. +fn split_args(input: &str) -> CliResult> { + let mut arguments = Vec::new(); + let mut current = String::new(); + let mut quote = None; + let mut escaped = false; + let mut json_depth = 0usize; + for character in input.chars() { + if escaped { + current.push(character); + escaped = false; + } else if character == '\\' && (quote.is_some() || json_depth > 0) { + current.push(character); + escaped = true; + } else if Some(character) == quote { + quote = None; + } else if quote.is_none() && json_depth == 0 && matches!(character, '\'' | '"') { + quote = Some(character); + } else if quote.is_none() && matches!(character, '{' | '[') { + json_depth += 1; + current.push(character); + } else if quote.is_none() && matches!(character, '}' | ']') { + json_depth = json_depth + .checked_sub(1) + .ok_or("unexpected JSON closing delimiter")?; + current.push(character); + } else if quote.is_none() && json_depth == 0 && character.is_whitespace() { + if !current.is_empty() { + arguments.push(std::mem::take(&mut current)); + } + } else { + current.push(character); + } + } + if escaped || quote.is_some() || json_depth != 0 { + return Err("unterminated quoted argument".into()); + } + if !current.is_empty() { + arguments.push(current); + } + Ok(arguments) +} + +fn list_operations(domain: Option<&str>) { + for entry in BASELINE_OPERATION_MANIFEST + .iter() + .filter(|entry| domain.is_none_or(|domain| entry.domain == domain)) + { + let auth = match entry.auth { + AuthClass::Public => "public", + AuthClass::Private => "private", + }; + println!( + "{:<18} {:<42} {:<4} {auth}", + entry.domain, entry.operation, entry.method + ); + } +} + +fn print_help() { + println!( + "request [QUERY_JSON] [BODY_JSON] [--confirm-write]\n\ + [QUERY_JSON] [BODY_JSON] [--confirm-write]\n\ + catalog [domain]\n\ + overview [INST_ID]\n\ +\n\ + `request` covers every REST operation audited by truefix-okx-client; `list` prints the\n\ + accepted domain/operation pairs. GET entries use QUERY_JSON; POST entries additionally\n\ + need BODY_JSON and --confirm-write. Authentication is derived from the operation manifest.\n\ +\n\ + Examples:\n\ + request market_data get_ticker {{\"instId\":\"BTC-USDT\"}}\n\ + request public_data get_instruments {{\"instType\":\"SPOT\"}}\n\ + request account get_account_balance\n\ + request trade place_order {{}} {{\"instId\":\"BTC-USDT\",\"tdMode\":\"cash\",\"side\":\"buy\",\"ordType\":\"limit\",\"sz\":\"0.001\",\"px\":\"1\"}} --confirm-write\n\ +\n\ + Credentials: set OKX_PASSPHRASE; API key and secret come from OKX_API_KEY/OKX_SECRET\n\ + or OKX_CREDENTIAL_FILE. Default is Demo. Live also requires OKX_ENV=live and\n\ + OKX_CONFIRM_LIVE=1." + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repl_tokenizer_keeps_json_with_whitespace_together() { + assert_eq!( + split_args(r#"request market_data get_ticker {"instId": "BTC-USDT"}"#).unwrap(), + [ + "request", + "market_data", + "get_ticker", + r#"{"instId": "BTC-USDT"}"#, + ] + ); + } + + #[test] + fn parser_converts_query_scalars_to_strings() { + let request = parse_request(&[ + "request".to_owned(), + "market_data".to_owned(), + "get_ticker".to_owned(), + r#"{"instId":"BTC-USDT","limit":5}"#.to_owned(), + ]) + .unwrap(); + assert_eq!(request.query["instId"], "BTC-USDT"); + assert_eq!(request.query["limit"], "5"); + } + + #[test] + fn get_alias_uses_the_audited_operation_name() { + let request = parse_request(&[ + "get".to_owned(), + "market_data".to_owned(), + "get_ticker".to_owned(), + r#"{"instId":"BTC-USDT"}"#.to_owned(), + ]) + .unwrap(); + assert_eq!(request.domain, "market_data"); + assert_eq!(request.operation, "get_ticker"); + assert_eq!(request.requested_method.as_deref(), Some("GET")); + } +} diff --git a/crates/truefix-okx-client/examples/public_market_data.rs b/crates/truefix-okx-client/examples/public_market_data.rs new file mode 100644 index 0000000..eec9d0e --- /dev/null +++ b/crates/truefix-okx-client/examples/public_market_data.rs @@ -0,0 +1,10 @@ +//! Credential-free public ticker lookup: `cargo run -p truefix-okx-client --example public_market_data`. + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = truefix_okx_client::OkxClient::new(Default::default())?; + for ticker in client.market().ticker("BTC-USDT").await? { + println!("{} last={}", ticker.instrument_id, ticker.last); + } + Ok(()) +} diff --git a/crates/truefix-okx-client/scripts/generate_operation_inventory.py b/crates/truefix-okx-client/scripts/generate_operation_inventory.py new file mode 100644 index 0000000..2deefdb --- /dev/null +++ b/crates/truefix-okx-client/scripts/generate_operation_inventory.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Extract the pinned python-okx REST surface and bind it to Rust service methods.""" + +import ast +import pathlib +import re + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +PY_ROOT = ROOT / "thrdpty/clientapi/python-okx/okx" +SERVICE_ROOT = ROOT / "crates/truefix-okx-client/src/services" +OUTPUT = ROOT / "crates/truefix-okx-client/src/generated_operation_inventory.rs" +SKIP = {"consts.py", "exceptions.py", "okxclient.py", "utils.py"} +PATH_OVERRIDES = { + # Keep the generated manifest aligned with OKX's intentionally irregular + # spellings in the pinned Python baseline. + ("Account.py", "get_simulated_margin"): "/api/v5/account/simulated_margin", + ("Account.py", "set_risk_offset_typel"): "/api/v5/account/set-riskOffset-type", +} +DOMAINS = { + "BlockTrading.py": "block_trading", + "CopyTrading.py": "copy_trading", + "DualInvest.py": "dual_invest", + "FDBroker.py": "fd_broker", + "Finance/EthStaking.py": "eth_staking", + "Finance/FlexibleLoan.py": "flexible_loan", + "Finance/SolStaking.py": "sol_staking", + "Finance/StakingDefi.py": "staking_defi", + "MarketData.py": "market_data", + "PublicData.py": "public_data", + "SpreadTrading.py": "spread_trading", + "SubAccount.py": "sub_account", + "TradingData.py": "trading_data", +} + + +def constants(): + result = {} + tree = ast.parse((PY_ROOT / "consts.py").read_text()) + for node in tree.body: + if not isinstance(node, ast.Assign) or len(node.targets) != 1: + continue + if not isinstance(node.targets[0], ast.Name): + continue + try: + result[node.targets[0].id] = ast.literal_eval(node.value) + except (ValueError, TypeError): + pass + return result + + +def rust_binding(path, sources): + for source_path, source in sources.items(): + match = re.search(re.escape(path), source) + if match: + methods = re.findall(r"pub async fn\s+(\w+)", source[: match.start()]) + if methods: + return f"{source_path.stem}::{methods[-1]}" + return "UNSUPPORTED" + + +def quoted(value): + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +values = constants() +services = {path: path.read_text() for path in SERVICE_ROOT.glob("*.rs")} +files = sorted([*PY_ROOT.glob("*.py"), *PY_ROOT.glob("Finance/*.py")]) +rows = [] +for path in files: + relative = path.relative_to(PY_ROOT).as_posix() + if path.name.startswith("_") or path.name in SKIP: + continue + tree = ast.parse(path.read_text()) + for cls in (node for node in tree.body if isinstance(node, ast.ClassDef)): + for function in cls.body: + if not isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if function.name.startswith("_"): + continue + call = next( + ( + node + for node in ast.walk(function) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in {"_request_without_params", "_request_with_params"} + ), + None, + ) + if call is None: + continue + method_arg, path_arg = call.args[:2] + method = values[method_arg.id] if isinstance(method_arg, ast.Name) else ast.literal_eval(method_arg) + source_path = values[path_arg.id] + approved_path = PATH_OVERRIDES.get((relative, function.name), source_path) + entrypoint = rust_binding(approved_path, services) + domain = DOMAINS.get( + relative, relative.removesuffix(".py").replace("Finance/", "").lower() + ) + auth = "Public" if relative in {"MarketData.py", "PublicData.py", "Status.py"} else "Private" + replay = "ReadOnly" if method == "GET" else "NeverReplay" + rows.append( + (f"{relative}:{function.lineno}:{function.name}", domain, function.name, method, + approved_path, auth, replay, entrypoint) + ) + +if len(rows) != 264: + raise SystemExit(f"expected 264 operations, extracted {len(rows)}") +unsupported = [row for row in rows if row[-1] == "UNSUPPORTED"] +if unsupported: + raise SystemExit("unsupported baseline operations:\n" + "\n".join(map(str, unsupported))) + +lines = [ + "// @generated by scripts/generate_operation_inventory.py; do not edit by hand.", + "use super::{AuthClass, BaselineOperation, ReplayClass};", + "pub const BASELINE_OPERATION_MANIFEST: &[BaselineOperation] = &[", +] +for source_id, domain, operation, method, path, auth, replay, entrypoint in rows: + lines.extend([ + " BaselineOperation {", + f" source_identity: {quoted(source_id)},", + f" domain: {quoted(domain)},", + f" operation: {quoted(operation)},", + f" method: {quoted(method)},", + f" path: {quoted(path)},", + f" auth: AuthClass::{auth},", + f" replay: ReplayClass::{replay},", + f" native_entrypoint: {quoted(entrypoint)},", + ' fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified",', + " },", + ]) +lines.append("];") +OUTPUT.write_text("\n".join(lines) + "\n") diff --git a/crates/truefix-okx-client/src/auth.rs b/crates/truefix-okx-client/src/auth.rs new file mode 100644 index 0000000..f11d575 --- /dev/null +++ b/crates/truefix-okx-client/src/auth.rs @@ -0,0 +1,217 @@ +use base64::{Engine, engine::general_purpose::STANDARD}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use time::OffsetDateTime; + +use crate::{ + config::Credentials, + error::{OkxError, OkxResult}, + request::CanonicalRequest, +}; + +type HmacSha256 = Hmac; + +/// Clock abstraction allowing deterministic signing tests. +pub trait Clock: Send + Sync { + fn now(&self) -> OffsetDateTime; +} + +impl Clock for std::sync::Arc { + fn now(&self) -> OffsetDateTime { + self.as_ref().now() + } +} + +/// Production system clock. +#[derive(Debug, Default)] +pub struct SystemClock; +impl Clock for SystemClock { + fn now(&self) -> OffsetDateTime { + OffsetDateTime::now_utc() + } +} + +/// A clock which applies a known offset to another clock. +/// +/// Use this when an application has measured the difference between its local clock and +/// OKX server time. The offset is applied consistently to REST signatures and to WebSocket +/// login timestamps when this clock is supplied to those APIs. It deliberately does not +/// contact OKX or silently adjust time: applications control when and how an offset is +/// measured. +#[derive(Debug)] +pub struct OffsetClock { + inner: C, + offset: time::Duration, +} + +impl OffsetClock { + /// Wraps `inner`, adding `offset` to every reported time. + pub const fn new(inner: C, offset: time::Duration) -> Self { + Self { inner, offset } + } +} + +impl Clock for OffsetClock { + fn now(&self) -> OffsetDateTime { + self.inner.now() + self.offset + } +} + +/// Returns a V5 REST signature and ISO-8601 timestamp for one canonical request. +pub fn sign_rest( + credentials: &Credentials, + request: &CanonicalRequest, + now: OffsetDateTime, +) -> OkxResult<(String, String)> { + let now = now.to_offset(time::UtcOffset::UTC); + let timestamp = format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z", + now.year(), + now.month() as u8, + now.day(), + now.hour(), + now.minute(), + now.second(), + now.millisecond(), + ); + let payload = format!( + "{}{}{}{}", + timestamp, + request.method.as_str(), + request.path_and_query, + String::from_utf8_lossy(&request.body) + ); + let mut signer = HmacSha256::new_from_slice(credentials.secret().as_bytes()) + .map_err(|error| OkxError::Signing(error.to_string()))?; + signer.update(payload.as_bytes()); + Ok((STANDARD.encode(signer.finalize().into_bytes()), timestamp)) +} + +/// Returns a V5 WebSocket login signature for the supplied Unix-second timestamp. +pub fn sign_websocket_login( + credentials: &Credentials, + timestamp_seconds: i64, +) -> OkxResult { + let payload = format!("{timestamp_seconds}GET/users/self/verify"); + let mut signer = HmacSha256::new_from_slice(credentials.secret().as_bytes()) + .map_err(|error| OkxError::Signing(error.to_string()))?; + signer.update(payload.as_bytes()); + Ok(STANDARD.encode(signer.finalize().into_bytes())) +} + +/// Private header fields excluding the secret. +pub fn private_headers( + credentials: &Credentials, + signature: String, + timestamp: String, +) -> [(String, String); 4] { + [ + ("OK-ACCESS-KEY".to_owned(), credentials.api_key().to_owned()), + ("OK-ACCESS-SIGN".to_owned(), signature), + ("OK-ACCESS-TIMESTAMP".to_owned(), timestamp), + ( + "OK-ACCESS-PASSPHRASE".to_owned(), + credentials.passphrase().to_owned(), + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::request::RetrySafety; + use std::collections::BTreeMap; + use time::macros::datetime; + + #[test] + fn rest_signature_uses_canonical_payload() { + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + let request = CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/account/balance", + BTreeMap::new(), + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + ) + .unwrap(); + let (signature, timestamp) = + sign_rest(&credentials, &request, datetime!(2024-01-01 0:00 UTC)).unwrap(); + assert_eq!(timestamp, "2024-01-01T00:00:00.000Z"); + assert!(!signature.is_empty()); + } + + #[test] + fn websocket_signature_is_stable() { + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + assert_eq!( + sign_websocket_login(&credentials, 1_700_000_000).unwrap(), + sign_websocket_login(&credentials, 1_700_000_000).unwrap() + ); + } + + #[test] + fn rest_signing_covers_encoded_query_and_empty_body() { + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + let mut query = BTreeMap::new(); + query.insert("instId".to_owned(), "BTC-USDT SWAP".to_owned()); + let request = CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/market/ticker", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + false, + ) + .unwrap(); + let (signature, timestamp) = + sign_rest(&credentials, &request, datetime!(2024-01-01 0:00 UTC)).unwrap(); + assert_eq!(timestamp, "2024-01-01T00:00:00.000Z"); + assert!(!signature.is_empty()); + assert!(request.path_and_query.contains("BTC-USDT+SWAP")); + assert!(request.body.is_empty()); + } + + #[test] + fn private_headers_do_not_contain_the_secret() { + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + let headers = private_headers(&credentials, "signature".to_owned(), "time".to_owned()); + assert!(headers.iter().all(|(_, value)| value != "secret")); + assert!(headers.iter().any(|(name, _)| name == "OK-ACCESS-SIGN")); + } + + #[test] + fn rest_timestamps_always_use_exact_milliseconds() { + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + let request = CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/account/balance", + BTreeMap::new(), + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + ) + .unwrap(); + for (now, expected) in [ + (datetime!(2024-01-01 0:00 UTC), "2024-01-01T00:00:00.000Z"), + ( + datetime!(2024-01-01 0:00:00.123456789 UTC), + "2024-01-01T00:00:00.123Z", + ), + ] { + assert_eq!(sign_rest(&credentials, &request, now).unwrap().1, expected); + } + } + + #[test] + fn offset_clock_applies_a_measured_server_time_difference() { + struct FixedClock; + impl Clock for FixedClock { + fn now(&self) -> OffsetDateTime { + datetime!(2024-01-01 0:00 UTC) + } + } + let clock = OffsetClock::new(FixedClock, time::Duration::seconds(2)); + assert_eq!(clock.now(), datetime!(2024-01-01 0:00:02 UTC)); + } +} diff --git a/crates/truefix-okx-client/src/client.rs b/crates/truefix-okx-client/src/client.rs new file mode 100644 index 0000000..b4382d2 --- /dev/null +++ b/crates/truefix-okx-client/src/client.rs @@ -0,0 +1,281 @@ +use std::sync::Arc; + +use time::OffsetDateTime; + +use crate::{ + auth::{Clock, OffsetClock, SystemClock}, + config::ClientConfig, + error::OkxResult, + inventory::{AuthClass, BASELINE_OPERATION_MANIFEST, ReplayClass}, + limiter::RateLimiter, + request::CanonicalRequest, + response::{OkxResponse, decode_envelope_with_metadata}, + services::{ + account::AccountService, finance::FinanceService, funding::FundingService, + market::MarketService, professional::ProfessionalService, public_data::PublicDataService, + strategy::StrategyService, subaccount::SubaccountService, trade::TradeService, + }, + transport::http::HttpTransport, +}; + +/// Native OKX client with one immutable identity and environment. +pub struct OkxClient { + config: Arc, + http: HttpTransport, + limiter: RateLimiter, +} + +impl OkxClient { + /// Creates a client using the system clock. + pub fn new(config: ClientConfig) -> OkxResult { + Self::with_clock(config, Arc::new(SystemClock)) + } + /// Creates a client with a testable signing clock. + pub fn with_clock(config: ClientConfig, clock: Arc) -> OkxResult { + let config = Arc::new(config); + let clock = Arc::new(OffsetClock::new(clock, config.clock_offset)); + let http = HttpTransport::new(Arc::clone(&config), clock)?; + Ok(Self { + config, + http, + limiter: RateLimiter::default(), + }) + } + /// Returns the immutable configuration bound to this client. + pub fn config(&self) -> &ClientConfig { + &self.config + } + pub fn account(&self) -> AccountService<'_> { + AccountService(self) + } + pub fn market(&self) -> MarketService<'_> { + MarketService(self) + } + /// Accesses public reference data without signing requests. + pub fn public_data(&self) -> PublicDataService<'_> { + PublicDataService(self) + } + pub fn trade(&self) -> TradeService<'_> { + TradeService(self) + } + /// Returns funding-account and asset-movement operations. + pub fn funding(&self) -> FundingService<'_> { + FundingService(self) + } + /// Returns sub-account administrative operations. + pub fn subaccounts(&self) -> SubaccountService<'_> { + SubaccountService(self) + } + /// Returns savings, staking, loan, and investment operations. + pub fn finance(&self) -> FinanceService<'_> { + FinanceService(self) + } + /// Returns grid, recurring-buy, and copy-trading operations. + pub fn strategy(&self) -> StrategyService<'_> { + StrategyService(self) + } + /// Returns block, spread, broker, conversion, and status operations. + pub fn professional(&self) -> ProfessionalService<'_> { + ProfessionalService(self) + } + + /// Measures the difference between the local clock and OKX server time. + /// + /// The returned value is `server_time - local_time_at_response_midpoint` and can be passed + /// directly to [`ClientConfig::with_clock_offset`]. Taking local timestamps immediately + /// before and after the request compensates for approximately half of the request round-trip + /// time. Callers should repeat the measurement and prefer a result with a small round-trip + /// time when their network path is asymmetric. + pub async fn measure_server_time_offset(&self) -> OkxResult { + #[derive(serde::Deserialize)] + struct ServerTime { + ts: String, + } + + let started_at = OffsetDateTime::now_utc(); + let response: Vec = self + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/public/time", + std::collections::BTreeMap::new(), + None::<&serde_json::Value>, + crate::request::RetrySafety::ReadOnly, + false, + )?) + .await?; + let received_at = OffsetDateTime::now_utc(); + let server_time = response + .into_iter() + .next() + .ok_or_else(|| { + crate::error::OkxError::InvalidConfiguration( + "OKX server-time response contained no timestamp".to_owned(), + ) + })? + .ts + .parse::() + .map_err(|error| { + crate::error::OkxError::InvalidConfiguration(format!( + "OKX server-time response contained an invalid millisecond timestamp: {error}" + )) + })?; + let server_time = OffsetDateTime::from_unix_timestamp_nanos(server_time * 1_000_000) + .map_err(|error| { + crate::error::OkxError::InvalidConfiguration(format!( + "OKX server-time timestamp is out of range: {error}" + )) + })?; + let midpoint = started_at + (received_at - started_at) / 2; + Ok(server_time - midpoint) + } + + /// Executes one REST operation recorded in the SDK's audited baseline manifest. + /// + /// This is intended for tools that need generic access to the complete supported REST + /// surface. `domain` and `operation` must match a manifest entry exactly; arbitrary paths + /// cannot be submitted through this method. Typed service methods remain preferable for + /// application code. + pub async fn execute_baseline_operation( + &self, + domain: &str, + operation: &str, + query: std::collections::BTreeMap, + body: Option<&serde_json::Value>, + ) -> OkxResult> { + Ok(self + .execute_baseline_operation_with_metadata(domain, operation, query, body) + .await? + .data) + } + + /// Executes a manifest operation and retains OKX pagination and request metadata. + pub async fn execute_baseline_operation_with_metadata( + &self, + domain: &str, + operation: &str, + query: std::collections::BTreeMap, + body: Option<&serde_json::Value>, + ) -> OkxResult> { + let entry = BASELINE_OPERATION_MANIFEST + .iter() + .find(|entry| entry.domain == domain && entry.operation == operation) + .ok_or_else(|| { + crate::error::OkxError::InvalidConfiguration(format!( + "unknown OKX baseline operation `{domain}/{operation}`" + )) + })?; + let method = match entry.method { + "GET" => reqwest::Method::GET, + "POST" => reqwest::Method::POST, + _ => { + return Err(crate::error::OkxError::InvalidConfiguration(format!( + "unsupported method in operation manifest: {}", + entry.method + ))); + } + }; + if entry.method == "GET" && body.is_some() { + return Err(crate::error::OkxError::InvalidConfiguration( + "GET operations do not accept a JSON body".to_owned(), + )); + } + if entry.method == "POST" && body.is_none() && !baseline_post_accepts_empty_body(entry) { + return Err(crate::error::OkxError::InvalidConfiguration( + "POST operations require a JSON body".to_owned(), + )); + } + // The pinned python-okx baseline invokes these endpoints via + // `_request_without_params(POST, ...)`. Its request helper sends an + // empty JSON object, rather than an absent body; preserve those exact + // wire semantics for generic callers too. + let empty_body = serde_json::json!({}); + let body = if body.is_none() && baseline_post_accepts_empty_body(entry) { + Some(&empty_body) + } else { + body + }; + self.execute_with_metadata(CanonicalRequest::new( + method, + entry.path, + query, + body, + match entry.replay { + ReplayClass::ReadOnly => crate::request::RetrySafety::ReadOnly, + ReplayClass::NeverReplay => crate::request::RetrySafety::NeverReplay, + }, + matches!(entry.auth, AuthClass::Private), + )?) + .await + } + + pub(crate) async fn execute( + &self, + request: CanonicalRequest, + ) -> OkxResult> { + Ok(self.execute_with_metadata(request).await?.data) + } + + async fn execute_with_metadata( + &self, + request: CanonicalRequest, + ) -> OkxResult> { + self.limiter.reserve().await; + let safety = request.retry_safety; + let response = match self.http.execute(request.clone()).await { + Ok(response) => response, + Err(error) if RateLimiter::may_retry(safety, &error) => { + if let crate::error::OkxError::RateLimited { retry_after } = &error { + self.limiter + .throttle_for(retry_after.unwrap_or(std::time::Duration::from_millis(100))) + .await; + } + self.limiter.reserve().await; + self.http.execute(request).await? + } + Err(error) => return Err(error), + }; + decode_envelope_with_metadata(&response.body, response.metadata) + } +} + +fn baseline_post_accepts_empty_body(entry: &crate::inventory::BaselineOperation) -> bool { + matches!( + (entry.domain, entry.operation), + ("account", "activate_option") | ("block_trading", "reset_mmp") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn baseline_operation_rejects_unknown_and_malformed_requests_before_transport() { + let client = OkxClient::new(ClientConfig::default()).unwrap(); + let query = std::collections::BTreeMap::new(); + + assert!(matches!( + client + .execute_baseline_operation("unknown", "operation", query.clone(), None) + .await, + Err(crate::error::OkxError::InvalidConfiguration(_)) + )); + assert!(matches!( + client + .execute_baseline_operation( + "market_data", + "get_ticker", + query.clone(), + Some(&serde_json::json!({})) + ) + .await, + Err(crate::error::OkxError::InvalidConfiguration(_)) + )); + assert!(matches!( + client + .execute_baseline_operation("trade", "place_order", query, None) + .await, + Err(crate::error::OkxError::InvalidConfiguration(_)) + )); + } +} diff --git a/crates/truefix-okx-client/src/config.rs b/crates/truefix-okx-client/src/config.rs new file mode 100644 index 0000000..66f99c7 --- /dev/null +++ b/crates/truefix-okx-client/src/config.rs @@ -0,0 +1,238 @@ +use std::time::Duration; + +use crate::error::{OkxError, OkxResult}; + +/// Credentials bound to one immutable client identity. +#[derive(Clone, PartialEq, Eq)] +pub struct Credentials { + api_key: String, + secret: String, + passphrase: String, +} + +impl Credentials { + /// Creates credentials after rejecting empty values. + pub fn new( + api_key: impl Into, + secret: impl Into, + passphrase: impl Into, + ) -> OkxResult { + let credentials = Self { + api_key: api_key.into(), + secret: secret.into(), + passphrase: passphrase.into(), + }; + if credentials.api_key.is_empty() + || credentials.secret.is_empty() + || credentials.passphrase.is_empty() + { + return Err(OkxError::InvalidConfiguration( + "API key, secret, and passphrase must be non-empty".to_owned(), + )); + } + Ok(credentials) + } + + pub(crate) fn api_key(&self) -> &str { + &self.api_key + } + pub(crate) fn secret(&self) -> &str { + &self.secret + } + pub(crate) fn passphrase(&self) -> &str { + &self.passphrase + } +} + +impl std::fmt::Debug for Credentials { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("Credentials(REDACTED)") + } +} + +/// Marker required to select a live trading environment. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LiveTradingConfirmation(()); + +impl LiveTradingConfirmation { + /// Explicitly acknowledges that the client may submit live asset-changing requests. + pub const fn acknowledge_risk() -> Self { + Self(()) + } +} + +/// Endpoint environment selected once at construction. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum Environment { + /// Simulated trading endpoints and declaration header. + #[default] + Demo, + /// Production endpoints, permitted only with an explicit confirmation. + Live(LiveTradingConfirmation), + /// Caller-controlled endpoints for tests or supported regional deployments. + Custom { + rest_base: String, + public_ws: String, + private_ws: String, + business_ws: String, + simulated: bool, + }, +} + +impl Environment { + const OKX_REST_BASE: &str = "https://www.okx.com"; + const LIVE_PUBLIC_WS: &str = "wss://ws.okx.com:8443/ws/v5/public"; + const LIVE_PRIVATE_WS: &str = "wss://ws.okx.com:8443/ws/v5/private"; + const LIVE_BUSINESS_WS: &str = "wss://ws.okx.com:8443/ws/v5/business"; + const DEMO_PUBLIC_WS: &str = "wss://wspap.okx.com:8443/ws/v5/public"; + const DEMO_PRIVATE_WS: &str = "wss://wspap.okx.com:8443/ws/v5/private"; + const DEMO_BUSINESS_WS: &str = "wss://wspap.okx.com:8443/ws/v5/business"; + + /// Returns the REST API base URL for this environment. + pub fn rest_base(&self) -> &str { + match self { + Self::Demo | Self::Live(_) => Self::OKX_REST_BASE, + Self::Custom { rest_base, .. } => rest_base, + } + } + + /// Returns the public WebSocket URL for this environment. + pub fn public_ws_url(&self) -> &str { + match self { + Self::Demo => Self::DEMO_PUBLIC_WS, + Self::Live(_) => Self::LIVE_PUBLIC_WS, + Self::Custom { public_ws, .. } => public_ws, + } + } + + /// Returns the private WebSocket URL for this environment. + pub fn private_ws_url(&self) -> &str { + match self { + Self::Demo => Self::DEMO_PRIVATE_WS, + Self::Live(_) => Self::LIVE_PRIVATE_WS, + Self::Custom { private_ws, .. } => private_ws, + } + } + + /// Returns the business WebSocket URL for this environment. + pub fn business_ws_url(&self) -> &str { + match self { + Self::Demo => Self::DEMO_BUSINESS_WS, + Self::Live(_) => Self::LIVE_BUSINESS_WS, + Self::Custom { business_ws, .. } => business_ws, + } + } +} + +/// Fully immutable client configuration. +#[derive(Debug, Clone)] +pub struct ClientConfig { + pub environment: Environment, + pub credentials: Option, + pub timeout: Duration, + pub proxy: Option, + /// Signed-time adjustment measured against OKX server time. + /// + /// Leave this at zero unless the application has independently measured a stable clock + /// difference. The client never changes it automatically, preventing an untrusted or + /// stale time response from silently affecting authenticated requests. + pub clock_offset: time::Duration, +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + environment: Environment::Demo, + credentials: None, + timeout: Duration::from_secs(15), + proxy: None, + clock_offset: time::Duration::ZERO, + } + } +} + +impl ClientConfig { + /// Returns a builder-like Demo configuration with optional credentials. + pub fn demo(credentials: Option) -> Self { + Self { + credentials, + ..Self::default() + } + } + + /// Creates an explicitly confirmed live configuration. + pub fn live(credentials: Credentials, confirmation: LiveTradingConfirmation) -> Self { + Self { + environment: Environment::Live(confirmation), + credentials: Some(credentials), + ..Self::default() + } + } + + /// Returns this configuration with a measured OKX server-time offset. + pub fn with_clock_offset(mut self, offset: time::Duration) -> Self { + self.clock_offset = offset; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn credentials_redact_debug_output() { + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + let rendered = format!("{credentials:?}"); + assert!(rendered.contains("REDACTED")); + assert!(!rendered.contains("secret")); + } + + #[test] + fn empty_credential_is_rejected() { + assert!(Credentials::new("", "secret", "passphrase").is_err()); + } + + #[test] + fn demo_is_the_default_environment() { + assert_eq!(ClientConfig::default().environment, Environment::Demo); + } + + #[test] + fn standard_environments_expose_documented_endpoints() { + let demo = Environment::Demo; + assert_eq!(demo.rest_base(), "https://www.okx.com"); + assert_eq!( + demo.public_ws_url(), + "wss://wspap.okx.com:8443/ws/v5/public" + ); + assert_eq!( + demo.private_ws_url(), + "wss://wspap.okx.com:8443/ws/v5/private" + ); + assert_eq!( + demo.business_ws_url(), + "wss://wspap.okx.com:8443/ws/v5/business" + ); + + let live = Environment::Live(LiveTradingConfirmation::acknowledge_risk()); + assert_eq!(live.rest_base(), "https://www.okx.com"); + assert_eq!(live.public_ws_url(), "wss://ws.okx.com:8443/ws/v5/public"); + assert_eq!(live.private_ws_url(), "wss://ws.okx.com:8443/ws/v5/private"); + assert_eq!( + live.business_ws_url(), + "wss://ws.okx.com:8443/ws/v5/business" + ); + } + + #[test] + fn measured_clock_offset_is_opt_in() { + assert_eq!(ClientConfig::default().clock_offset, time::Duration::ZERO); + assert_eq!( + ClientConfig::default() + .with_clock_offset(time::Duration::seconds(-3)) + .clock_offset, + time::Duration::seconds(-3) + ); + } +} diff --git a/crates/truefix-okx-client/src/error.rs b/crates/truefix-okx-client/src/error.rs new file mode 100644 index 0000000..de6485f --- /dev/null +++ b/crates/truefix-okx-client/src/error.rs @@ -0,0 +1,47 @@ +use std::time::Duration; + +use thiserror::Error; + +/// Result alias returned by the OKX client. +pub type OkxResult = Result; + +/// Recoverable errors emitted by the OKX client. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum OkxError { + #[error("invalid client configuration: {0}")] + InvalidConfiguration(String), + #[error("live trading requires explicit confirmation")] + LiveTradingConfirmationRequired, + #[error("credentials are required for this operation")] + MissingCredentials, + #[error("failed to construct a signed request: {0}")] + Signing(String), + #[error("server clock skew prevents authentication")] + ClockSkew, + #[error("HTTP transport error: {0}")] + Transport(#[from] reqwest::Error), + #[error("request timed out")] + Timeout, + #[error("rate limited; retry after {retry_after:?}")] + RateLimited { retry_after: Option }, + #[error("OKX rejected request with code {code}: {message}")] + Exchange { + code: String, + message: String, + request_id: Option, + }, + #[error("one or more order operations were rejected")] + PartialFailure { + /// Every acknowledgement returned by OKX, including any items that succeeded. + /// Retaining the complete set lets callers reconcile an indeterminate batch without + /// treating the enclosing request as successful. + acknowledgements: Vec, + }, + #[error("response decoding failed: {0}")] + Decode(#[from] serde_json::Error), + #[error("connection lost before completion could be confirmed")] + UnknownCompletion, + #[error("WebSocket transport error: {0}")] + WebSocket(#[from] tokio_tungstenite::tungstenite::Error), +} diff --git a/crates/truefix-okx-client/src/generated_operation_inventory.rs b/crates/truefix-okx-client/src/generated_operation_inventory.rs new file mode 100644 index 0000000..495a6d7 --- /dev/null +++ b/crates/truefix-okx-client/src/generated_operation_inventory.rs @@ -0,0 +1,2908 @@ +// @generated by scripts/generate_operation_inventory.py; do not edit by hand. +use super::{AuthClass, BaselineOperation, ReplayClass}; +pub const BASELINE_OPERATION_MANIFEST: &[BaselineOperation] = &[ + BaselineOperation { + source_identity: "Account.py:12:get_position_risk", + domain: "account", + operation: "get_position_risk", + method: "GET", + path: "/api/v5/account/account-position-risk", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::position_risk", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:19:get_account_balance", + domain: "account", + operation: "get_account_balance", + method: "GET", + path: "/api/v5/account/balance", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::balances_with_currency", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:26:get_positions", + domain: "account", + operation: "get_positions", + method: "GET", + path: "/api/v5/account/positions", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::positions_with_filters", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:30:position_builder", + domain: "account", + operation: "position_builder", + method: "POST", + path: "/api/v5/account/position-builder", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::position_builder", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:50:get_account_bills", + domain: "account", + operation: "get_account_bills", + method: "GET", + path: "/api/v5/account/bills", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::bills", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:57:get_account_bills_archive", + domain: "account", + operation: "get_account_bills_archive", + method: "GET", + path: "/api/v5/account/bills-archive", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::bills_archive", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:65:get_account_config", + domain: "account", + operation: "get_account_config", + method: "GET", + path: "/api/v5/account/config", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::configuration", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:69:set_position_mode", + domain: "account", + operation: "set_position_mode", + method: "POST", + path: "/api/v5/account/set-position-mode", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::set_position_mode", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:74:set_leverage", + domain: "account", + operation: "set_leverage", + method: "POST", + path: "/api/v5/account/set-leverage", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::set_leverage", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:79:get_max_order_size", + domain: "account", + operation: "get_max_order_size", + method: "GET", + path: "/api/v5/account/max-size", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::max_order_size", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:86:get_max_avail_size", + domain: "account", + operation: "get_max_avail_size", + method: "GET", + path: "/api/v5/account/max-avail-size", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::max_available_size", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:94:adjustment_margin", + domain: "account", + operation: "adjustment_margin", + method: "POST", + path: "/api/v5/account/position/margin-balance", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::adjust_margin", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:99:get_leverage", + domain: "account", + operation: "get_leverage", + method: "GET", + path: "/api/v5/account/leverage-info", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::leverage", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:104:get_instruments", + domain: "account", + operation: "get_instruments", + method: "GET", + path: "/api/v5/account/instruments", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::account_instruments", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:109:get_max_loan", + domain: "account", + operation: "get_max_loan", + method: "GET", + path: "/api/v5/account/max-loan", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::max_loan", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:116:get_fee_rates", + domain: "account", + operation: "get_fee_rates", + method: "GET", + path: "/api/v5/account/trade-fee", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::fee_rates", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:123:get_interest_accrued", + domain: "account", + operation: "get_interest_accrued", + method: "GET", + path: "/api/v5/account/interest-accrued", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::interest_accrued", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:128:get_interest_rate", + domain: "account", + operation: "get_interest_rate", + method: "GET", + path: "/api/v5/account/interest-rate", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::interest_rate", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:133:set_greeks", + domain: "account", + operation: "set_greeks", + method: "POST", + path: "/api/v5/account/set-greeks", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::set_greeks", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:138:set_isolated_mode", + domain: "account", + operation: "set_isolated_mode", + method: "POST", + path: "/api/v5/account/set-isolated-mode", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::set_isolated_mode", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:143:get_max_withdrawal", + domain: "account", + operation: "get_max_withdrawal", + method: "GET", + path: "/api/v5/account/max-withdrawal", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::max_withdrawal", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:148:borrow_repay", + domain: "account", + operation: "borrow_repay", + method: "POST", + path: "/api/v5/account/borrow-repay", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::borrow_repay", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:153:get_borrow_repay_history", + domain: "account", + operation: "get_borrow_repay_history", + method: "GET", + path: "/api/v5/account/borrow-repay-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::borrow_repay_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:158:get_interest_limits", + domain: "account", + operation: "get_interest_limits", + method: "GET", + path: "/api/v5/account/interest-limits", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::interest_limits", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:163:get_simulated_margin", + domain: "account", + operation: "get_simulated_margin", + method: "POST", + path: "/api/v5/account/simulated_margin", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::simulated_margin", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:168:get_greeks", + domain: "account", + operation: "get_greeks", + method: "GET", + path: "/api/v5/account/greeks", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::greeks", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:173:get_account_position_risk", + domain: "account", + operation: "get_account_position_risk", + method: "GET", + path: "/api/v5/account/risk-state", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::risk_state", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:177:get_positions_history", + domain: "account", + operation: "get_positions_history", + method: "GET", + path: "/api/v5/account/positions-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::positions_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:192:get_account_position_tiers", + domain: "account", + operation: "get_account_position_tiers", + method: "GET", + path: "/api/v5/account/position-tiers", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::account_position_tiers", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:201:get_VIP_interest_accrued_data", + domain: "account", + operation: "get_VIP_interest_accrued_data", + method: "GET", + path: "/api/v5/account/vip-interest-accrued", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::vip_loan_interest_accrued", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:206:get_VIP_interest_deducted_data", + domain: "account", + operation: "get_VIP_interest_deducted_data", + method: "GET", + path: "/api/v5/account/vip-interest-deducted", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::vip_interest_deducted", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:211:get_VIP_loan_order_list", + domain: "account", + operation: "get_VIP_loan_order_list", + method: "GET", + path: "/api/v5/account/vip-loan-order-list", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::vip_loan_orders", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:216:get_VIP_loan_order_detail", + domain: "account", + operation: "get_VIP_loan_order_detail", + method: "GET", + path: "/api/v5/account/vip-loan-order-detail", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::vip_loan_order", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:221:set_risk_offset_typel", + domain: "account", + operation: "set_risk_offset_typel", + method: "POST", + path: "/api/v5/account/set-riskOffset-type", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::set_risk_offset_type", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:226:set_auto_loan", + domain: "account", + operation: "set_auto_loan", + method: "POST", + path: "/api/v5/account/set-auto-loan", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::set_auto_loan", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:233:set_account_level", + domain: "account", + operation: "set_account_level", + method: "POST", + path: "/api/v5/account/set-account-level", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::set_account_level", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:240:activate_option", + domain: "account", + operation: "activate_option", + method: "POST", + path: "/api/v5/account/activate-option", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::activate_option", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:243:get_fix_loan_borrowing_limit", + domain: "account", + operation: "get_fix_loan_borrowing_limit", + method: "GET", + path: "/api/v5/account/fixed-loan/borrowing-limit", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::fixed_loan_borrowing_limit", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:246:get_fix_loan_borrowing_quote", + domain: "account", + operation: "get_fix_loan_borrowing_quote", + method: "GET", + path: "/api/v5/account/fixed-loan/borrowing-quote", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::fixed_loan_borrowing_quote", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:262:place_fix_loan_borrowing_order", + domain: "account", + operation: "place_fix_loan_borrowing_order", + method: "POST", + path: "/api/v5/account/fixed-loan/borrowing-order", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::fixed_loan_borrowing_orders", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:278:amend_fix_loan_borrowing_order", + domain: "account", + operation: "amend_fix_loan_borrowing_order", + method: "POST", + path: "/api/v5/account/fixed-loan/amend-borrowing-order", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::fixed_loan_amend", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:288:fix_loan_manual_reborrow", + domain: "account", + operation: "fix_loan_manual_reborrow", + method: "POST", + path: "/api/v5/account/fixed-loan/manual-reborrow", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::fixed_loan_reborrow", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:296:repay_fix_loan_borrowing_order", + domain: "account", + operation: "repay_fix_loan_borrowing_order", + method: "POST", + path: "/api/v5/account/fixed-loan/repay-borrowing-order", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::fixed_loan_repay", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:301:get_fix_loan_borrowing_orders_list", + domain: "account", + operation: "get_fix_loan_borrowing_orders_list", + method: "GET", + path: "/api/v5/account/fixed-loan/borrowing-orders-list", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::fixed_loan_borrowing_orders", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:317:spot_manual_borrow_repay", + domain: "account", + operation: "spot_manual_borrow_repay", + method: "POST", + path: "/api/v5/account/spot-manual-borrow-repay", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::set_spot_manual_borrowing", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:327:set_auto_repay", + domain: "account", + operation: "set_auto_repay", + method: "POST", + path: "/api/v5/account/set-auto-repay", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::set_auto_repay", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:333:spot_borrow_repay_history", + domain: "account", + operation: "spot_borrow_repay_history", + method: "GET", + path: "/api/v5/account/spot-borrow-repay-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::spot_borrow_repay_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:337:set_auto_earn", + domain: "account", + operation: "set_auto_earn", + method: "POST", + path: "/api/v5/account/set-auto-earn", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::set_auto_earn", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:345:set_trading_config", + domain: "account", + operation: "set_trading_config", + method: "POST", + path: "/api/v5/account/set-trading-config", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::set_trading_config", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:352:precheck_set_delta_neutral", + domain: "account", + operation: "precheck_set_delta_neutral", + method: "GET", + path: "/api/v5/account/precheck-set-delta-neutral", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::precheck_delta_neutral", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:357:get_bill_type", + domain: "account", + operation: "get_bill_type", + method: "GET", + path: "/api/v5/account/subtypes", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::bill_subtypes", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Account.py:364:apply_bills", + domain: "account", + operation: "apply_bills", + method: "POST", + path: "/api/v5/account/bills-history-archive", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "account::archive_bills", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "BlockTrading.py:9:counterparties", + domain: "block_trading", + operation: "counterparties", + method: "GET", + path: "/api/v5/rfq/counterparties", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::counterparties", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "BlockTrading.py:13:create_rfq", + domain: "block_trading", + operation: "create_rfq", + method: "POST", + path: "/api/v5/rfq/create-rfq", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "professional::create_rfq", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "BlockTrading.py:19:cancel_rfq", + domain: "block_trading", + operation: "cancel_rfq", + method: "POST", + path: "/api/v5/rfq/cancel-rfq", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "professional::cancel_rfq", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "BlockTrading.py:23:cancel_batch_rfqs", + domain: "block_trading", + operation: "cancel_batch_rfqs", + method: "POST", + path: "/api/v5/rfq/cancel-batch-rfqs", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "professional::cancel_batch_rfqs", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "BlockTrading.py:27:cancel_all_rfqs", + domain: "block_trading", + operation: "cancel_all_rfqs", + method: "POST", + path: "/api/v5/rfq/cancel-all-rfqs", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "professional::cancel_all_rfqs", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "BlockTrading.py:31:execute_quote", + domain: "block_trading", + operation: "execute_quote", + method: "POST", + path: "/api/v5/rfq/execute-quote", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "professional::execute_quote", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "BlockTrading.py:35:create_quote", + domain: "block_trading", + operation: "create_quote", + method: "POST", + path: "/api/v5/rfq/create-quote", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "professional::create_quote", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "BlockTrading.py:40:cancel_quote", + domain: "block_trading", + operation: "cancel_quote", + method: "POST", + path: "/api/v5/rfq/cancel-quote", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "professional::cancel_quote", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "BlockTrading.py:44:cancel_batch_quotes", + domain: "block_trading", + operation: "cancel_batch_quotes", + method: "POST", + path: "/api/v5/rfq/cancel-batch-quotes", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "professional::cancel_batch_quotes", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "BlockTrading.py:48:cancel_all_quotes", + domain: "block_trading", + operation: "cancel_all_quotes", + method: "POST", + path: "/api/v5/rfq/cancel-all-quotes", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "professional::cancel_all_quotes", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "BlockTrading.py:52:get_rfqs", + domain: "block_trading", + operation: "get_rfqs", + method: "GET", + path: "/api/v5/rfq/rfqs", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::rfqs", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "BlockTrading.py:56:get_quotes", + domain: "block_trading", + operation: "get_quotes", + method: "GET", + path: "/api/v5/rfq/quotes", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::quotes", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "BlockTrading.py:60:get_trades", + domain: "block_trading", + operation: "get_trades", + method: "GET", + path: "/api/v5/rfq/trades", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::rfq_trades", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "BlockTrading.py:66:get_public_trades", + domain: "block_trading", + operation: "get_public_trades", + method: "GET", + path: "/api/v5/rfq/public-trades", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::public_rfq_trades", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "BlockTrading.py:70:reset_mmp", + domain: "block_trading", + operation: "reset_mmp", + method: "POST", + path: "/api/v5/rfq/mmp-reset", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "professional::reset_mmp", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "BlockTrading.py:73:set_marker_instrument", + domain: "block_trading", + operation: "set_marker_instrument", + method: "POST", + path: "/api/v5/rfq/maker-instrument-settings", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "professional::maker_instrument_settings", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "BlockTrading.py:78:get_quote_products", + domain: "block_trading", + operation: "get_quote_products", + method: "GET", + path: "/api/v5/rfq/maker-instrument-settings", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::maker_instrument_settings", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Convert.py:9:get_currencies", + domain: "convert", + operation: "get_currencies", + method: "GET", + path: "/api/v5/asset/convert/currencies", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::convert_currencies", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Convert.py:13:get_currency_pair", + domain: "convert", + operation: "get_currency_pair", + method: "GET", + path: "/api/v5/asset/convert/currency-pair", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::convert_currency_pair", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Convert.py:19:estimate_quote", + domain: "convert", + operation: "estimate_quote", + method: "POST", + path: "/api/v5/asset/convert/estimate-quote", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "professional::convert_quote", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Convert.py:25:convert_trade", + domain: "convert", + operation: "convert_trade", + method: "POST", + path: "/api/v5/asset/convert/trade", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "professional::convert_trade", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Convert.py:31:get_convert_history", + domain: "convert", + operation: "get_convert_history", + method: "GET", + path: "/api/v5/asset/convert/history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::convert_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "CopyTrading.py:12:get_existing_leading_positions", + domain: "copy_trading", + operation: "get_existing_leading_positions", + method: "GET", + path: "/api/v5/copytrading/current-subpositions", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "strategy::leading_positions", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "CopyTrading.py:19:get_leading_position_history", + domain: "copy_trading", + operation: "get_leading_position_history", + method: "GET", + path: "/api/v5/copytrading/subpositions-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "strategy::leading_position_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "CopyTrading.py:29:place_leading_stop_order", + domain: "copy_trading", + operation: "place_leading_stop_order", + method: "POST", + path: "/api/v5/copytrading/algo-order", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "strategy::leading_order", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "CopyTrading.py:41:close_leading_position", + domain: "copy_trading", + operation: "close_leading_position", + method: "POST", + path: "/api/v5/copytrading/close-subposition", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "strategy::close_leading_position", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "CopyTrading.py:48:get_leading_instruments", + domain: "copy_trading", + operation: "get_leading_instruments", + method: "GET", + path: "/api/v5/copytrading/instruments", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "strategy::leading_instruments", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "CopyTrading.py:52:amend_leading_instruments", + domain: "copy_trading", + operation: "amend_leading_instruments", + method: "POST", + path: "/api/v5/copytrading/set-instruments", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "strategy::set_leading_instruments", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "CopyTrading.py:59:get_profit_sharing_details", + domain: "copy_trading", + operation: "get_profit_sharing_details", + method: "GET", + path: "/api/v5/copytrading/profit-sharing-details", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "strategy::profit_sharing_details", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "CopyTrading.py:68:get_total_profit_sharing", + domain: "copy_trading", + operation: "get_total_profit_sharing", + method: "GET", + path: "/api/v5/copytrading/total-profit-sharing", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "strategy::total_profit_sharing", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "CopyTrading.py:72:get_unrealized_profit_sharing_details", + domain: "copy_trading", + operation: "get_unrealized_profit_sharing_details", + method: "GET", + path: "/api/v5/copytrading/unrealized-profit-sharing-details", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "strategy::unrealized_profit_sharing_details", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "DualInvest.py:21:get_currency_pairs", + domain: "dual_invest", + operation: "get_currency_pairs", + method: "GET", + path: "/api/v5/finance/sfp/dcd/currency-pair", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::dual_currency_pairs", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "DualInvest.py:25:get_product_info", + domain: "dual_invest", + operation: "get_product_info", + method: "GET", + path: "/api/v5/finance/sfp/dcd/products", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::dual_investments", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "DualInvest.py:30:request_quote", + domain: "dual_invest", + operation: "request_quote", + method: "POST", + path: "/api/v5/finance/sfp/dcd/quote", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "finance::dual_quote", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "DualInvest.py:35:trade", + domain: "dual_invest", + operation: "trade", + method: "POST", + path: "/api/v5/finance/sfp/dcd/trade", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "finance::dual_invest", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "DualInvest.py:40:request_redeem_quote", + domain: "dual_invest", + operation: "request_redeem_quote", + method: "POST", + path: "/api/v5/finance/sfp/dcd/redeem-quote", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "finance::dual_redeem_quote", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "DualInvest.py:45:redeem", + domain: "dual_invest", + operation: "redeem", + method: "POST", + path: "/api/v5/finance/sfp/dcd/redeem", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "finance::dual_redeem_quote", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "DualInvest.py:50:get_order_state", + domain: "dual_invest", + operation: "get_order_state", + method: "GET", + path: "/api/v5/finance/sfp/dcd/order-status", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::dual_order_status", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "DualInvest.py:55:get_order_history", + domain: "dual_invest", + operation: "get_order_history", + method: "GET", + path: "/api/v5/finance/sfp/dcd/order-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::dual_invest_orders", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "FDBroker.py:9:generate_rebate_details_download_link", + domain: "fd_broker", + operation: "generate_rebate_details_download_link", + method: "POST", + path: "/api/v5/broker/fd/rebate-per-orders", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "professional::broker_rebate_per_orders", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "FDBroker.py:13:get_rebate_details_download_link", + domain: "fd_broker", + operation: "get_rebate_details_download_link", + method: "GET", + path: "/api/v5/broker/fd/rebate-per-orders", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::broker_rebate_per_orders", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/EthStaking.py:9:eth_product_info", + domain: "eth_staking", + operation: "eth_product_info", + method: "GET", + path: "/api/v5/finance/staking-defi/eth/product-info", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::eth_products", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/EthStaking.py:13:eth_purchase", + domain: "eth_staking", + operation: "eth_purchase", + method: "POST", + path: "/api/v5/finance/staking-defi/eth/purchase", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "finance::eth_purchase", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/EthStaking.py:20:eth_redeem", + domain: "eth_staking", + operation: "eth_redeem", + method: "POST", + path: "/api/v5/finance/staking-defi/eth/redeem", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "finance::eth_redeem", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/EthStaking.py:27:eth_cancel_redeem", + domain: "eth_staking", + operation: "eth_cancel_redeem", + method: "POST", + path: "/api/v5/finance/staking-defi/eth/cancel-redeem", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "finance::eth_cancel_redeem", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/EthStaking.py:34:eth_balance", + domain: "eth_staking", + operation: "eth_balance", + method: "GET", + path: "/api/v5/finance/staking-defi/eth/balance", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::eth_balance", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/EthStaking.py:39:eth_purchase_redeem_history", + domain: "eth_staking", + operation: "eth_purchase_redeem_history", + method: "GET", + path: "/api/v5/finance/staking-defi/eth/purchase-redeem-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::eth_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/EthStaking.py:54:eth_apy_history", + domain: "eth_staking", + operation: "eth_apy_history", + method: "GET", + path: "/api/v5/finance/staking-defi/eth/apy-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::eth_apy_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/FlexibleLoan.py:10:borrow_currencies", + domain: "flexible_loan", + operation: "borrow_currencies", + method: "GET", + path: "/api/v5/finance/flexible-loan/borrow-currencies", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::flexible_loan_currencies", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/FlexibleLoan.py:13:collateral_assets", + domain: "flexible_loan", + operation: "collateral_assets", + method: "GET", + path: "/api/v5/finance/flexible-loan/collateral-assets", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::flexible_loan_collateral_assets", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/FlexibleLoan.py:19:max_loan", + domain: "flexible_loan", + operation: "max_loan", + method: "POST", + path: "/api/v5/finance/flexible-loan/max-loan", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "finance::flexible_loan_max_loan", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/FlexibleLoan.py:26:max_collateral_redeem_amount", + domain: "flexible_loan", + operation: "max_collateral_redeem_amount", + method: "GET", + path: "/api/v5/finance/flexible-loan/max-collateral-redeem-amount", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::flexible_loan_max_collateral_redeem", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/FlexibleLoan.py:32:adjust_collateral", + domain: "flexible_loan", + operation: "adjust_collateral", + method: "POST", + path: "/api/v5/finance/flexible-loan/adjust-collateral", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "finance::flexible_loan_adjust_collateral", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/FlexibleLoan.py:40:loan_info", + domain: "flexible_loan", + operation: "loan_info", + method: "GET", + path: "/api/v5/finance/flexible-loan/loan-info", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::flexible_loan_positions", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/FlexibleLoan.py:43:loan_history", + domain: "flexible_loan", + operation: "loan_history", + method: "GET", + path: "/api/v5/finance/flexible-loan/loan-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::flexible_loan_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/FlexibleLoan.py:55:interest_accrued", + domain: "flexible_loan", + operation: "interest_accrued", + method: "GET", + path: "/api/v5/finance/flexible-loan/interest-accrued", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::flexible_loan_interest_accrued", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/Savings.py:10:get_saving_balance", + domain: "savings", + operation: "get_saving_balance", + method: "GET", + path: "/api/v5/finance/savings/balance", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::savings_balance", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/Savings.py:17:savings_purchase_redemption", + domain: "savings", + operation: "savings_purchase_redemption", + method: "POST", + path: "/api/v5/finance/savings/purchase-redempt", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "finance::savings_purchase", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/Savings.py:28:set_lending_rate", + domain: "savings", + operation: "set_lending_rate", + method: "POST", + path: "/api/v5/finance/savings/set-lending-rate", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "finance::savings_set_lending_rate", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/Savings.py:36:get_lending_history", + domain: "savings", + operation: "get_lending_history", + method: "GET", + path: "/api/v5/finance/savings/lending-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::savings_lending_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/Savings.py:46:get_public_borrow_history", + domain: "savings", + operation: "get_public_borrow_history", + method: "GET", + path: "/api/v5/finance/savings/lending-rate-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::savings_public_borrow_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/Savings.py:56:get_public_borrow_info", + domain: "savings", + operation: "get_public_borrow_info", + method: "GET", + path: "/api/v5/finance/savings/lending-rate-summary", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::savings_public_borrow_info", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/SolStaking.py:9:sol_purchase", + domain: "sol_staking", + operation: "sol_purchase", + method: "POST", + path: "/api/v5/finance/staking-defi/sol/purchase", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "finance::sol_purchase", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/SolStaking.py:16:sol_redeem", + domain: "sol_staking", + operation: "sol_redeem", + method: "POST", + path: "/api/v5/finance/staking-defi/sol/redeem", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "finance::sol_redeem", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/SolStaking.py:23:sol_balance", + domain: "sol_staking", + operation: "sol_balance", + method: "GET", + path: "/api/v5/finance/staking-defi/sol/balance", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::sol_balance", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/SolStaking.py:28:sol_purchase_redeem_history", + domain: "sol_staking", + operation: "sol_purchase_redeem_history", + method: "GET", + path: "/api/v5/finance/staking-defi/sol/purchase-redeem-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::sol_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/SolStaking.py:43:sol_apy_history", + domain: "sol_staking", + operation: "sol_apy_history", + method: "GET", + path: "/api/v5/finance/staking-defi/sol/apy-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::sol_apy_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/SolStaking.py:50:sol_product_info", + domain: "sol_staking", + operation: "sol_product_info", + method: "GET", + path: "/api/v5/finance/staking-defi/sol/product-info", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::sol_products", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/StakingDefi.py:9:get_offers", + domain: "staking_defi", + operation: "get_offers", + method: "GET", + path: "/api/v5/finance/staking-defi/offers", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::defi_products", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/StakingDefi.py:17:purchase", + domain: "staking_defi", + operation: "purchase", + method: "POST", + path: "/api/v5/finance/staking-defi/purchase", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "finance::defi_purchase", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/StakingDefi.py:29:redeem", + domain: "staking_defi", + operation: "redeem", + method: "POST", + path: "/api/v5/finance/staking-defi/redeem", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "finance::defi_redeem", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/StakingDefi.py:37:cancel", + domain: "staking_defi", + operation: "cancel", + method: "POST", + path: "/api/v5/finance/staking-defi/cancel", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "finance::defi_cancel", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/StakingDefi.py:44:get_activity_orders", + domain: "staking_defi", + operation: "get_activity_orders", + method: "GET", + path: "/api/v5/finance/staking-defi/orders-active", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::defi_active_orders", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Finance/StakingDefi.py:53:get_orders_history", + domain: "staking_defi", + operation: "get_orders_history", + method: "GET", + path: "/api/v5/finance/staking-defi/orders-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "finance::defi_order_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Funding.py:12:get_non_tradable_assets", + domain: "funding", + operation: "get_non_tradable_assets", + method: "GET", + path: "/api/v5/asset/non-tradable-assets", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "funding::non_tradable_assets", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Funding.py:17:get_deposit_address", + domain: "funding", + operation: "get_deposit_address", + method: "GET", + path: "/api/v5/asset/deposit-address", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "funding::deposit_addresses", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Funding.py:22:transfer_state", + domain: "funding", + operation: "transfer_state", + method: "GET", + path: "/api/v5/asset/transfer-state", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "funding::transfer_state", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Funding.py:27:get_balances", + domain: "funding", + operation: "get_balances", + method: "GET", + path: "/api/v5/asset/balances", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "funding::balances", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Funding.py:32:funds_transfer", + domain: "funding", + operation: "funds_transfer", + method: "POST", + path: "/api/v5/asset/transfer", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "funding::transfer", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Funding.py:38:withdrawal", + domain: "funding", + operation: "withdrawal", + method: "POST", + path: "/api/v5/asset/withdrawal", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "funding::withdraw", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Funding.py:46:get_deposit_history", + domain: "funding", + operation: "get_deposit_history", + method: "GET", + path: "/api/v5/asset/deposit-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "funding::deposits", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Funding.py:54:get_currencies", + domain: "funding", + operation: "get_currencies", + method: "GET", + path: "/api/v5/asset/currencies", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "funding::currencies", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Funding.py:59:purchase_redempt", + domain: "funding", + operation: "purchase_redempt", + method: "POST", + path: "/api/v5/asset/purchase_redempt", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "funding::purchase_redempt", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Funding.py:64:get_bills", + domain: "funding", + operation: "get_bills", + method: "GET", + path: "/api/v5/asset/bills", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "funding::bills", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Funding.py:70:get_deposit_lightning", + domain: "funding", + operation: "get_deposit_lightning", + method: "GET", + path: "/api/v5/asset/deposit-lightning", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "funding::deposit_lightning", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Funding.py:77:withdrawal_lightning", + domain: "funding", + operation: "withdrawal_lightning", + method: "POST", + path: "/api/v5/asset/withdrawal-lightning", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "funding::withdrawal_lightning", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Funding.py:82:cancel_withdrawal", + domain: "funding", + operation: "cancel_withdrawal", + method: "POST", + path: "/api/v5/asset/cancel-withdrawal", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "funding::cancel_withdrawal", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Funding.py:89:convert_dust_assets", + domain: "funding", + operation: "convert_dust_assets", + method: "POST", + path: "/api/v5/asset/convert-dust-assets", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "funding::convert_dust", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Funding.py:96:get_asset_valuation", + domain: "funding", + operation: "get_asset_valuation", + method: "GET", + path: "/api/v5/asset/asset-valuation", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "funding::asset_valuation", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Funding.py:103:get_non_tradable_assets", + domain: "funding", + operation: "get_non_tradable_assets", + method: "GET", + path: "/api/v5/asset/non-tradable-assets", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "funding::non_tradable_assets", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Funding.py:110:get_deposit_withdraw_status", + domain: "funding", + operation: "get_deposit_withdraw_status", + method: "GET", + path: "/api/v5/asset/deposit-withdraw-status", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "funding::deposit_withdraw_status", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Funding.py:115:get_withdrawal_history", + domain: "funding", + operation: "get_withdrawal_history", + method: "GET", + path: "/api/v5/asset/withdrawal-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "funding::withdrawals", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:9:grid_order_algo", + domain: "grid", + operation: "grid_order_algo", + method: "POST", + path: "/api/v5/tradingBot/grid/order-algo", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "strategy::grid_order", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:19:grid_amend_order_algo", + domain: "grid", + operation: "grid_amend_order_algo", + method: "POST", + path: "/api/v5/tradingBot/grid/amend-order-algo", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "strategy::amend_grid_order", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:23:grid_stop_order_algo", + domain: "grid", + operation: "grid_stop_order_algo", + method: "POST", + path: "/api/v5/tradingBot/grid/stop-order-algo", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "strategy::stop_grid_order", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:27:grid_orders_algo_pending", + domain: "grid", + operation: "grid_orders_algo_pending", + method: "GET", + path: "/api/v5/tradingBot/grid/orders-algo-pending", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "strategy::grid_orders", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:33:grid_orders_algo_history", + domain: "grid", + operation: "grid_orders_algo_history", + method: "GET", + path: "/api/v5/tradingBot/grid/orders-algo-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "strategy::grid_order_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:39:grid_orders_algo_details", + domain: "grid", + operation: "grid_orders_algo_details", + method: "GET", + path: "/api/v5/tradingBot/grid/orders-algo-details", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "strategy::grid_order_details", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:43:grid_sub_orders", + domain: "grid", + operation: "grid_sub_orders", + method: "GET", + path: "/api/v5/tradingBot/grid/sub-orders", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "strategy::grid_sub_orders", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:48:grid_positions", + domain: "grid", + operation: "grid_positions", + method: "GET", + path: "/api/v5/tradingBot/grid/positions", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "strategy::grid_positions", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:52:grid_withdraw_income", + domain: "grid", + operation: "grid_withdraw_income", + method: "POST", + path: "/api/v5/tradingBot/grid/withdraw-income", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "strategy::grid_withdraw_income", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:56:grid_compute_margin_balance", + domain: "grid", + operation: "grid_compute_margin_balance", + method: "POST", + path: "/api/v5/tradingBot/grid/compute-margin-balance", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "strategy::grid_compute_margin", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:64:grid_adjust_margin_balance", + domain: "grid", + operation: "grid_adjust_margin_balance", + method: "POST", + path: "/api/v5/tradingBot/grid/margin-balance", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "strategy::grid_adjust_margin", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:73:grid_ai_param", + domain: "grid", + operation: "grid_ai_param", + method: "GET", + path: "/api/v5/tradingBot/grid/ai-param", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "strategy::grid_ai_param", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:83:place_recurring_buy_order", + domain: "grid", + operation: "place_recurring_buy_order", + method: "POST", + path: "/api/v5/tradingBot/recurring/order-algo", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "strategy::recurring_buy_order", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:94:amend_recurring_buy_order", + domain: "grid", + operation: "amend_recurring_buy_order", + method: "POST", + path: "/api/v5/tradingBot/recurring/amend-order-algo", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "strategy::recurring_buy_amend", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:99:stop_recurring_buy_order", + domain: "grid", + operation: "stop_recurring_buy_order", + method: "POST", + path: "/api/v5/tradingBot/recurring/stop-order-algo", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "strategy::recurring_buy_stop", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:103:get_recurring_buy_order_list", + domain: "grid", + operation: "get_recurring_buy_order_list", + method: "GET", + path: "/api/v5/tradingBot/recurring/orders-algo-pending", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "strategy::recurring_buy_orders", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:113:get_recurring_buy_order_history", + domain: "grid", + operation: "get_recurring_buy_order_history", + method: "GET", + path: "/api/v5/tradingBot/recurring/orders-algo-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "strategy::recurring_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:123:get_recurring_buy_order_details", + domain: "grid", + operation: "get_recurring_buy_order_details", + method: "GET", + path: "/api/v5/tradingBot/recurring/orders-algo-details", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "strategy::recurring_details", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Grid.py:128:get_recurring_buy_sub_orders", + domain: "grid", + operation: "get_recurring_buy_sub_orders", + method: "GET", + path: "/api/v5/tradingBot/recurring/sub-orders", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "strategy::recurring_sub_orders", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:12:get_tickers", + domain: "market_data", + operation: "get_tickers", + method: "GET", + path: "/api/v5/market/tickers", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::tickers", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:20:get_ticker", + domain: "market_data", + operation: "get_ticker", + method: "GET", + path: "/api/v5/market/ticker", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::ticker", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:25:get_index_tickers", + domain: "market_data", + operation: "get_index_tickers", + method: "GET", + path: "/api/v5/market/index-tickers", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::index_tickers", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:30:get_orderbook", + domain: "market_data", + operation: "get_orderbook", + method: "GET", + path: "/api/v5/market/books", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::books", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:35:get_candlesticks", + domain: "market_data", + operation: "get_candlesticks", + method: "GET", + path: "/api/v5/market/candles", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::candles", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:40:get_history_candlesticks", + domain: "market_data", + operation: "get_history_candlesticks", + method: "GET", + path: "/api/v5/market/history-candles", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::history_candles", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:45:get_index_candlesticks", + domain: "market_data", + operation: "get_index_candlesticks", + method: "GET", + path: "/api/v5/market/index-candles", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::index_candles", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:50:get_mark_price_candlesticks", + domain: "market_data", + operation: "get_mark_price_candlesticks", + method: "GET", + path: "/api/v5/market/mark-price-candles", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::mark_price_candles", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:55:get_trades", + domain: "market_data", + operation: "get_trades", + method: "GET", + path: "/api/v5/market/trades", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::trades", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:60:get_volume", + domain: "market_data", + operation: "get_volume", + method: "GET", + path: "/api/v5/market/platform-24-volume", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::platform_volume", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:64:get_tier", + domain: "market_data", + operation: "get_tier", + method: "GET", + path: "/api/v5/public/position-tiers", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::position_tiers", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:69:get_index_components", + domain: "market_data", + operation: "get_index_components", + method: "GET", + path: "/api/v5/market/index-components", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::index_components", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:77:get_exchange_rate", + domain: "market_data", + operation: "get_exchange_rate", + method: "GET", + path: "/api/v5/market/exchange-rate", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::exchange_rate", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:81:get_history_trades", + domain: "market_data", + operation: "get_history_trades", + method: "GET", + path: "/api/v5/market/history-trades", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::history_trades", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:92:get_block_ticker", + domain: "market_data", + operation: "get_block_ticker", + method: "GET", + path: "/api/v5/market/block-ticker", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::block_ticker", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:99:get_block_tickers", + domain: "market_data", + operation: "get_block_tickers", + method: "GET", + path: "/api/v5/market/block-tickers", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::block_tickers", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:108:get_block_trades", + domain: "market_data", + operation: "get_block_trades", + method: "GET", + path: "/api/v5/market/block-trades", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::block_trades", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:115:get_order_lite_book", + domain: "market_data", + operation: "get_order_lite_book", + method: "GET", + path: "/api/v5/market/books-lite", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::books_lite", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "MarketData.py:122:get_option_trades", + domain: "market_data", + operation: "get_option_trades", + method: "GET", + path: "/api/v5/public/option-trades", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::option_trades", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:11:get_instruments", + domain: "public_data", + operation: "get_instruments", + method: "GET", + path: "/api/v5/public/instruments", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::instruments", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:16:get_delivery_exercise_history", + domain: "public_data", + operation: "get_delivery_exercise_history", + method: "GET", + path: "/api/v5/public/delivery-exercise-history", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::delivery_exercise_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:21:get_open_interest", + domain: "public_data", + operation: "get_open_interest", + method: "GET", + path: "/api/v5/public/open-interest", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::open_interest", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:26:get_funding_rate", + domain: "public_data", + operation: "get_funding_rate", + method: "GET", + path: "/api/v5/public/funding-rate", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::funding_rate", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:31:funding_rate_history", + domain: "public_data", + operation: "funding_rate_history", + method: "GET", + path: "/api/v5/public/funding-rate-history", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::funding_rate_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:36:get_price_limit", + domain: "public_data", + operation: "get_price_limit", + method: "GET", + path: "/api/v5/public/price-limit", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::price_limit", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:41:get_opt_summary", + domain: "public_data", + operation: "get_opt_summary", + method: "GET", + path: "/api/v5/public/opt-summary", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::option_summary", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:46:get_estimated_price", + domain: "public_data", + operation: "get_estimated_price", + method: "GET", + path: "/api/v5/public/estimated-price", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::estimated_price", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:51:discount_interest_free_quota", + domain: "public_data", + operation: "discount_interest_free_quota", + method: "GET", + path: "/api/v5/public/discount-rate-interest-free-quota", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::interest_free_quota", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:56:get_system_time", + domain: "public_data", + operation: "get_system_time", + method: "GET", + path: "/api/v5/public/time", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::time", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:60:get_mark_price", + domain: "public_data", + operation: "get_mark_price", + method: "GET", + path: "/api/v5/public/mark-price", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "market::mark_price", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:65:get_position_tiers", + domain: "public_data", + operation: "get_position_tiers", + method: "GET", + path: "/api/v5/public/position-tiers", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::position_tiers", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:70:get_interest_rate_loan_quota", + domain: "public_data", + operation: "get_interest_rate_loan_quota", + method: "GET", + path: "/api/v5/public/interest-rate-loan-quota", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::interest_rate_loan_quota", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:74:get_vip_interest_rate_loan_quota", + domain: "public_data", + operation: "get_vip_interest_rate_loan_quota", + method: "GET", + path: "/api/v5/public/vip-interest-rate-loan-quota", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::vip_interest_rate_loan_quota", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:78:get_underlying", + domain: "public_data", + operation: "get_underlying", + method: "GET", + path: "/api/v5/public/underlying", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::underlying", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:85:get_insurance_fund", + domain: "public_data", + operation: "get_insurance_fund", + method: "GET", + path: "/api/v5/public/insurance-fund", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::insurance_fund", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:99:get_convert_contract_coin", + domain: "public_data", + operation: "get_convert_contract_coin", + method: "GET", + path: "/api/v5/public/convert-contract-coin", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::contract_coin", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:110:get_option_tickBands", + domain: "public_data", + operation: "get_option_tickBands", + method: "GET", + path: "/api/v5/public/instrument-tick-bands", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::instrument_tick_bands", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:118:get_option_trades", + domain: "public_data", + operation: "get_option_trades", + method: "GET", + path: "/api/v5/public/option-trades", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::option_trades", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:127:get_market_data_history", + domain: "public_data", + operation: "get_market_data_history", + method: "GET", + path: "/api/v5/public/market-data-history", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::market_data_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "PublicData.py:142:get_announcements", + domain: "public_data", + operation: "get_announcements", + method: "GET", + path: "/api/v5/support/announcements", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "public_data::announcements", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SpreadTrading.py:11:place_order", + domain: "spread_trading", + operation: "place_order", + method: "POST", + path: "/api/v5/sprd/order", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "professional::place_spread_order", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SpreadTrading.py:17:cancel_order", + domain: "spread_trading", + operation: "cancel_order", + method: "POST", + path: "/api/v5/sprd/cancel-order", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "professional::cancel_spread_order", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SpreadTrading.py:22:cancel_all_orders", + domain: "spread_trading", + operation: "cancel_all_orders", + method: "POST", + path: "/api/v5/sprd/mass-cancel", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "professional::cancel_all_spread_orders", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SpreadTrading.py:27:get_order_details", + domain: "spread_trading", + operation: "get_order_details", + method: "GET", + path: "/api/v5/sprd/order", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::place_spread_order", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SpreadTrading.py:32:get_active_orders", + domain: "spread_trading", + operation: "get_active_orders", + method: "GET", + path: "/api/v5/sprd/orders-pending", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::spread_orders", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SpreadTrading.py:37:get_orders", + domain: "spread_trading", + operation: "get_orders", + method: "GET", + path: "/api/v5/sprd/orders-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::spread_order_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SpreadTrading.py:43:get_trades", + domain: "spread_trading", + operation: "get_trades", + method: "GET", + path: "/api/v5/sprd/trades", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::spread_trades", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SpreadTrading.py:49:get_spreads", + domain: "spread_trading", + operation: "get_spreads", + method: "GET", + path: "/api/v5/sprd/spreads", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::spreads", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SpreadTrading.py:54:get_order_book", + domain: "spread_trading", + operation: "get_order_book", + method: "GET", + path: "/api/v5/sprd/books", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::spread_books", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SpreadTrading.py:59:get_ticker", + domain: "spread_trading", + operation: "get_ticker", + method: "GET", + path: "/api/v5/sprd/ticker", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::spread_ticker", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SpreadTrading.py:64:get_public_trades", + domain: "spread_trading", + operation: "get_public_trades", + method: "GET", + path: "/api/v5/sprd/public-trades", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::spread_public_trades", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Status.py:9:status", + domain: "status", + operation: "status", + method: "GET", + path: "/api/v5/system/status", + auth: AuthClass::Public, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::status", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SubAccount.py:9:get_account_balance", + domain: "sub_account", + operation: "get_account_balance", + method: "GET", + path: "/api/v5/account/subaccount/balances", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "subaccount::balances", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SubAccount.py:13:bills", + domain: "sub_account", + operation: "bills", + method: "GET", + path: "/api/v5/asset/subaccount/bills", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "subaccount::bills", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SubAccount.py:18:reset_subaccount_apikey", + domain: "sub_account", + operation: "reset_subaccount_apikey", + method: "POST", + path: "/api/v5/users/subaccount/modify-apikey", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "subaccount::modify_api_key", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SubAccount.py:31:get_subaccount_list", + domain: "sub_account", + operation: "get_subaccount_list", + method: "GET", + path: "/api/v5/users/subaccount/list", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "subaccount::list", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SubAccount.py:35:subAccount_transfer", + domain: "sub_account", + operation: "subAccount_transfer", + method: "POST", + path: "/api/v5/asset/subaccount/transfer", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "subaccount::transfer", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SubAccount.py:40:get_entrust_subaccount_list", + domain: "sub_account", + operation: "get_entrust_subaccount_list", + method: "GET", + path: "/api/v5/users/entrust-subaccount-list", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "subaccount::entrust_list", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SubAccount.py:47:set_permission_transfer_out", + domain: "sub_account", + operation: "set_permission_transfer_out", + method: "POST", + path: "/api/v5/users/subaccount/set-transfer-out", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "subaccount::set_transfer_out", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SubAccount.py:55:get_funding_balance", + domain: "sub_account", + operation: "get_funding_balance", + method: "GET", + path: "/api/v5/asset/subaccount/balances", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "subaccount::funding_balance", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SubAccount.py:63:set_sub_accounts_VIP_loan", + domain: "sub_account", + operation: "set_sub_accounts_VIP_loan", + method: "POST", + path: "/api/v5/account/subaccount/set-loan-allocation", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "subaccount::set_loan_allocation", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "SubAccount.py:71:get_sub_account_borrow_interest_and_limit", + domain: "sub_account", + operation: "get_sub_account_borrow_interest_and_limit", + method: "GET", + path: "/api/v5/account/subaccount/interest-limits", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "subaccount::interest_limits", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:14:place_order", + domain: "trade", + operation: "place_order", + method: "POST", + path: "/api/v5/trade/order", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "trade::place_order", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:32:place_multiple_orders", + domain: "trade", + operation: "place_multiple_orders", + method: "POST", + path: "/api/v5/trade/batch-orders", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "trade::place_batch", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:36:cancel_order", + domain: "trade", + operation: "cancel_order", + method: "POST", + path: "/api/v5/trade/cancel-order", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "trade::cancel_order", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:41:cancel_multiple_orders", + domain: "trade", + operation: "cancel_multiple_orders", + method: "POST", + path: "/api/v5/trade/cancel-batch-orders", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "trade::cancel_batch", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:45:amend_order", + domain: "trade", + operation: "amend_order", + method: "POST", + path: "/api/v5/trade/amend-order", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "trade::amend_order", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:63:amend_multiple_orders", + domain: "trade", + operation: "amend_multiple_orders", + method: "POST", + path: "/api/v5/trade/amend-batch-orders", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "trade::amend_batch", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:67:close_positions", + domain: "trade", + operation: "close_positions", + method: "POST", + path: "/api/v5/trade/close-position", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "trade::close_positions", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:73:get_order", + domain: "trade", + operation: "get_order", + method: "GET", + path: "/api/v5/trade/order", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "trade::place_order", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:78:get_order_list", + domain: "trade", + operation: "get_order_list", + method: "GET", + path: "/api/v5/trade/orders-pending", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "trade::orders", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:85:get_orders_history", + domain: "trade", + operation: "get_orders_history", + method: "GET", + path: "/api/v5/trade/orders-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "trade::order_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:93:get_orders_history_archive", + domain: "trade", + operation: "get_orders_history_archive", + method: "GET", + path: "/api/v5/trade/orders-history-archive", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "trade::orders_history_archive", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:101:get_fills", + domain: "trade", + operation: "get_fills", + method: "GET", + path: "/api/v5/trade/fills", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "trade::fills", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:107:place_algo_order", + domain: "trade", + operation: "place_algo_order", + method: "POST", + path: "/api/v5/trade/order-algo", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "trade::place_algo_order", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:141:cancel_algo_order", + domain: "trade", + operation: "cancel_algo_order", + method: "POST", + path: "/api/v5/trade/cancel-algos", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "trade::cancel_algo_order", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:155:order_algos_list", + domain: "trade", + operation: "order_algos_list", + method: "GET", + path: "/api/v5/trade/orders-algo-pending", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "trade::algo_orders_pending", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:161:order_algos_history", + domain: "trade", + operation: "order_algos_history", + method: "GET", + path: "/api/v5/trade/orders-algo-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "trade::algo_orders_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:167:get_fills_history", + domain: "trade", + operation: "get_fills_history", + method: "GET", + path: "/api/v5/trade/fills-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "trade::fills_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:172:get_easy_convert_currency_list", + domain: "trade", + operation: "get_easy_convert_currency_list", + method: "GET", + path: "/api/v5/trade/easy-convert-currency-list", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "trade::easy_convert_currency_list", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:175:easy_convert", + domain: "trade", + operation: "easy_convert", + method: "POST", + path: "/api/v5/trade/easy-convert", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "trade::easy_convert_currency_list", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:182:get_easy_convert_history", + domain: "trade", + operation: "get_easy_convert_history", + method: "GET", + path: "/api/v5/trade/easy-convert-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "trade::easy_convert_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:190:get_oneclick_repay_list", + domain: "trade", + operation: "get_oneclick_repay_list", + method: "GET", + path: "/api/v5/trade/one-click-repay-currency-list", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "trade::one_click_repay_currency_list", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:196:oneclick_repay", + domain: "trade", + operation: "oneclick_repay", + method: "POST", + path: "/api/v5/trade/one-click-repay", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "trade::one_click_repay_currency_list", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:203:oneclick_repay_history", + domain: "trade", + operation: "oneclick_repay_history", + method: "GET", + path: "/api/v5/trade/one-click-repay-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "trade::one_click_repay_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:212:get_algo_order_details", + domain: "trade", + operation: "get_algo_order_details", + method: "GET", + path: "/api/v5/trade/order-algo", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "trade::place_algo_order", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:217:amend_algo_order", + domain: "trade", + operation: "amend_algo_order", + method: "POST", + path: "/api/v5/trade/amend-algos", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "trade::amend_algo_order", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:226:get_oneclick_repay_list_v2", + domain: "trade", + operation: "get_oneclick_repay_list_v2", + method: "GET", + path: "/api/v5/trade/one-click-repay-currency-list-v2", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "trade::one_click_repay_currency_list_v2", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:229:oneclick_repay_v2", + domain: "trade", + operation: "oneclick_repay_v2", + method: "POST", + path: "/api/v5/trade/one-click-repay-v2", + auth: AuthClass::Private, + replay: ReplayClass::NeverReplay, + native_entrypoint: "trade::one_click_repay_v2", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "Trade.py:236:oneclick_repay_history_v2", + domain: "trade", + operation: "oneclick_repay_history_v2", + method: "GET", + path: "/api/v5/trade/one-click-repay-history-v2", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "trade::one_click_repay_history_v2", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "TradingData.py:11:get_support_coin", + domain: "trading_data", + operation: "get_support_coin", + method: "GET", + path: "/api/v5/rubik/stat/trading-data/support-coin", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::support_coin", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "TradingData.py:14:get_taker_volume", + domain: "trading_data", + operation: "get_taker_volume", + method: "GET", + path: "/api/v5/rubik/stat/taker-volume", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::taker_volume", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "TradingData.py:18:get_margin_lending_ratio", + domain: "trading_data", + operation: "get_margin_lending_ratio", + method: "GET", + path: "/api/v5/rubik/stat/margin/loan-ratio", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::margin_lending_ratio", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "TradingData.py:22:get_long_short_ratio", + domain: "trading_data", + operation: "get_long_short_ratio", + method: "GET", + path: "/api/v5/rubik/stat/contracts/long-short-account-ratio", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::long_short_ratio", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "TradingData.py:26:get_contracts_interest_volume", + domain: "trading_data", + operation: "get_contracts_interest_volume", + method: "GET", + path: "/api/v5/rubik/stat/contracts/open-interest-volume", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::contracts_interest_volume", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "TradingData.py:30:get_options_interest_volume", + domain: "trading_data", + operation: "get_options_interest_volume", + method: "GET", + path: "/api/v5/rubik/stat/option/open-interest-volume", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::options_interest_volume", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "TradingData.py:34:get_put_call_ratio", + domain: "trading_data", + operation: "get_put_call_ratio", + method: "GET", + path: "/api/v5/rubik/stat/option/open-interest-volume-ratio", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::put_call_ratio", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "TradingData.py:38:get_interest_volume_expiry", + domain: "trading_data", + operation: "get_interest_volume_expiry", + method: "GET", + path: "/api/v5/rubik/stat/option/open-interest-volume-expiry", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::interest_volume_expiry", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "TradingData.py:42:get_interest_volume_strike", + domain: "trading_data", + operation: "get_interest_volume_strike", + method: "GET", + path: "/api/v5/rubik/stat/option/open-interest-volume-strike", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::interest_volume_strike", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "TradingData.py:46:get_taker_block_volume", + domain: "trading_data", + operation: "get_taker_block_volume", + method: "GET", + path: "/api/v5/rubik/stat/option/taker-block-volume", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::taker_block_volume", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, + BaselineOperation { + source_identity: "TradingData.py:50:get_open_interest_history", + domain: "trading_data", + operation: "get_open_interest_history", + method: "GET", + path: "/api/v5/rubik/stat/contracts/open-interest-history", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "professional::open_interest_history", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", + }, +]; diff --git a/crates/truefix-okx-client/src/inventory.rs b/crates/truefix-okx-client/src/inventory.rs new file mode 100644 index 0000000..d85fe4b --- /dev/null +++ b/crates/truefix-okx-client/src/inventory.rs @@ -0,0 +1,291 @@ +//! Auditable source-capability inventory. + +use std::collections::HashSet; + +/// Authentication required by a baseline operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthClass { + /// The operation is available without API credentials. + Public, + /// The operation requires the OKX REST signing headers. + Private, +} + +/// Automatic replay classification for a baseline operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplayClass { + /// A safe read may be retried once after a transient failure. + ReadOnly, + /// A state-changing request must never be replayed automatically. + NeverReplay, +} + +/// One source operation and all evidence required to audit its Rust implementation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BaselineOperation { + /// Stable source file, line, and Python method identity. + pub source_identity: &'static str, + /// Source business domain. + pub domain: &'static str, + /// Python capability name. + pub operation: &'static str, + /// Approved HTTP method. + pub method: &'static str, + /// Approved current OKX path. + pub path: &'static str, + /// Authentication classification. + pub auth: AuthClass, + /// Automatic replay classification. + pub replay: ReplayClass, + /// Native Rust service and method implementing the operation. + pub native_entrypoint: &'static str, + /// Local fixture which audits the record. + pub fixture_id: &'static str, +} + +/// Why an operation manifest cannot be accepted as complete. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ManifestValidationError { + /// The manifest does not contain exactly the pinned baseline count. + WrongCount { expected: usize, actual: usize }, + /// More than one record claims the same source identity. + DuplicateSourceIdentity(&'static str), + /// A required evidence field is empty or has an unknown classification. + Unclassified(&'static str), + /// The baseline operation has no native Rust implementation. + Unsupported(&'static str), +} + +#[path = "generated_operation_inventory.rs"] +mod generated; +pub use generated::BASELINE_OPERATION_MANIFEST; + +/// Validates exact count, uniqueness, classifications, native support, and fixture evidence. +pub fn validate_operation_manifest( + records: &[BaselineOperation], + expected_count: usize, +) -> Result<(), ManifestValidationError> { + if records.len() != expected_count { + return Err(ManifestValidationError::WrongCount { + expected: expected_count, + actual: records.len(), + }); + } + let mut identities = HashSet::with_capacity(records.len()); + for record in records { + if !identities.insert(record.source_identity) { + return Err(ManifestValidationError::DuplicateSourceIdentity( + record.source_identity, + )); + } + if record.source_identity.is_empty() + || record.domain.is_empty() + || record.operation.is_empty() + || !matches!(record.method, "GET" | "POST") + || !record.path.starts_with("/api/v5/") + || record.fixture_id.is_empty() + { + return Err(ManifestValidationError::Unclassified( + record.source_identity, + )); + } + if record.native_entrypoint.is_empty() || record.native_entrypoint == "UNSUPPORTED" { + return Err(ManifestValidationError::Unsupported(record.source_identity)); + } + if (record.method == "GET" && record.replay != ReplayClass::ReadOnly) + || (record.method == "POST" && record.replay != ReplayClass::NeverReplay) + { + return Err(ManifestValidationError::Unclassified( + record.source_identity, + )); + } + } + Ok(()) +} + +/// A source capability that must have a native entrypoint and automated evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OperationInventoryEntry { + /// Source business domain. + pub domain: &'static str, + /// Stable source capability identity. + pub operation: &'static str, + /// REST, public WebSocket, private WebSocket, or business WebSocket. + pub transport: &'static str, + /// Native Rust method which implements this operation. + pub native_entrypoint: &'static str, + /// Fixture scenario proving request and response behavior. + pub fixture_id: &'static str, +} + +/// US1 operations already linked to their public method and fixture evidence. +pub const US1_OPERATION_MANIFEST: &[OperationInventoryEntry] = &[ + OperationInventoryEntry { + domain: "account", + operation: "balance", + transport: "REST", + native_entrypoint: "AccountService::balances", + fixture_id: "http_contract::demo_writes_are_signed_once_and_send_exact_body", + }, + OperationInventoryEntry { + domain: "account", + operation: "positions", + transport: "REST", + native_entrypoint: "AccountService::positions", + fixture_id: "http_contract::public_requests_are_unsigned_and_preserve_encoded_query", + }, + OperationInventoryEntry { + domain: "trade", + operation: "place_order", + transport: "REST", + native_entrypoint: "TradeService::place_order", + fixture_id: "http_contract::demo_writes_are_signed_once_and_send_exact_body", + }, + OperationInventoryEntry { + domain: "market_data", + operation: "ticker", + transport: "REST", + native_entrypoint: "MarketService::ticker", + fixture_id: "http_contract::public_requests_are_unsigned_and_preserve_encoded_query", + }, + OperationInventoryEntry { + domain: "market_data", + operation: "pagination", + transport: "REST", + native_entrypoint: "response::page_metadata", + fixture_id: "http_contract::exchange_rejection_and_pagination_metadata_are_preserved", + }, + OperationInventoryEntry { + domain: "account", + operation: "bills_leverage_margin_risk_fee", + transport: "REST", + native_entrypoint: "AccountService::bills/leverage/adjust_margin/position_risk/fee_rates", + fixture_id: "http_contract::demo_writes_are_signed_once_and_send_exact_body", + }, + OperationInventoryEntry { + domain: "trade", + operation: "batch_cancel_amend_close_query_fills_history", + transport: "REST", + native_entrypoint: "TradeService::place_batch/cancel_batch/amend_batch/close_positions/order/orders/order_history/fills", + fixture_id: "http_contract::demo_writes_are_signed_once_and_send_exact_body", + }, + OperationInventoryEntry { + domain: "market_data", + operation: "books_candles_trades_index_mark_funding", + transport: "REST", + native_entrypoint: "MarketService::books/candles/history_candles/trades/index_tickers/mark_price/funding_rate/funding_rate_history", + fixture_id: "http_contract::public_requests_are_unsigned_and_preserve_encoded_query", + }, +]; + +/// High-level baseline domains derived from `python-okx@fa8d738`. +/// +/// The expanded generated manifest is added with the domain services so tests can require every +/// operation, not merely these categories. +pub const BASELINE_DOMAINS: &[(&str, usize)] = &[ + ("account", 52), + ("trade", 28), + ("public_data", 21), + ("market_data", 19), + ("grid", 19), + ("funding", 18), + ("block_trading", 17), + ("trading_data", 11), + ("spread_trading", 11), + ("sub_account", 10), + ("copy_trading", 9), + ("dual_invest", 8), + ("flexible_loan", 8), + ("eth_staking", 7), + ("savings", 6), + ("sol_staking", 6), + ("staking_defi", 6), + ("convert", 5), + ("fd_broker", 2), + ("status", 1), +]; + +/// Count of baseline REST capabilities. +pub const BASELINE_REST_OPERATION_COUNT: usize = 264; + +/// WebSocket trade commands that require corresponding native support. +pub const WEBSOCKET_TRADE_COMMANDS: &[&str] = &[ + "order", + "batch-orders", + "cancel-order", + "batch-cancel-orders", + "amend-order", + "batch-amend-orders", + "mass-cancel", +]; + +/// Real-time capability evidence; write commands deliberately share the no-replay fixture. +pub const WEBSOCKET_OPERATION_MANIFEST: &[OperationInventoryEntry] = &[ + OperationInventoryEntry { + domain: "websocket", + operation: "public_subscribe", + transport: "public WebSocket", + native_entrypoint: "PublicSession", + fixture_id: "ws_session::public_session_correlates_subscription_ack_and_event", + }, + OperationInventoryEntry { + domain: "websocket", + operation: "private_login_gate", + transport: "private WebSocket", + native_entrypoint: "PrivateSession::write_allowed", + fixture_id: "ws_session::private_write_is_rejected_before_login", + }, + OperationInventoryEntry { + domain: "websocket", + operation: "business", + transport: "business WebSocket", + native_entrypoint: "BusinessSession", + fixture_id: "ws_session::business_session_supports_optional_login", + }, + OperationInventoryEntry { + domain: "websocket", + operation: "trade_commands_no_replay", + transport: "private/business WebSocket", + native_entrypoint: "PrivateSession::write_allowed", + fixture_id: "ws_session::write_is_never_replayed_after_disconnect", + }, +]; + +/// Long-tail domain endpoint metadata and fixture evidence. +pub const US3_OPERATION_MANIFEST: &[OperationInventoryEntry] = &[ + OperationInventoryEntry { + domain: "public_data", + operation: "instruments_platform", + transport: "REST", + native_entrypoint: "PublicDataService", + fixture_id: "operation_inventory::every_manifest_record_has_operation_transport_and_evidence", + }, + OperationInventoryEntry { + domain: "funding", + operation: "assets_transfer_withdrawal", + transport: "REST", + native_entrypoint: "FundingService", + fixture_id: "operation_inventory::every_manifest_record_has_operation_transport_and_evidence", + }, + OperationInventoryEntry { + domain: "sub_account", + operation: "balances_transfers_permissions", + transport: "REST", + native_entrypoint: "SubAccountService", + fixture_id: "operation_inventory::every_manifest_record_has_operation_transport_and_evidence", + }, + OperationInventoryEntry { + domain: "finance_strategy", + operation: "staking_grid_copy", + transport: "REST", + native_entrypoint: "FinanceService/StrategyService", + fixture_id: "operation_inventory::every_manifest_record_has_operation_transport_and_evidence", + }, + OperationInventoryEntry { + domain: "professional", + operation: "rfq_spread_convert_status", + transport: "REST", + native_entrypoint: "ProfessionalService", + fixture_id: "operation_inventory::every_manifest_record_has_operation_transport_and_evidence", + }, +]; diff --git a/crates/truefix-okx-client/src/lib.rs b/crates/truefix-okx-client/src/lib.rs new file mode 100644 index 0000000..66d04cb --- /dev/null +++ b/crates/truefix-okx-client/src/lib.rs @@ -0,0 +1,38 @@ +//! Native, typed client for the OKX V5 REST and WebSocket APIs. +//! +//! # Gateway composition +//! +//! This crate is an independent, native OKX client. It deliberately has no dependency on +//! `truefix-gateway`: applications that use a unified gateway compose an adapter at their own +//! boundary. The optional projection types expose only the common order, balance, position, +//! fill, and market-data shape. They are not a replacement for the native records. +//! +//! Product-specific fields (for example, option Greeks, portfolio-margin risk, grid strategy +//! state, RFQ data, and funding/earn attributes) are intentionally non-projectable. Keep and +//! pass the native OKX value alongside any projection whenever those semantics matter. +#![cfg_attr( + not(test), + deny( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + ) +)] + +pub mod auth; +pub mod client; +pub mod config; +pub mod error; +pub mod inventory; +pub mod limiter; +pub mod request; +pub mod response; +pub mod services; +pub mod transport; +pub mod types; +pub mod ws; + +pub use client::OkxClient; +pub use config::{ClientConfig, Credentials, Environment, LiveTradingConfirmation}; +pub use error::{OkxError, OkxResult}; diff --git a/crates/truefix-okx-client/src/limiter.rs b/crates/truefix-okx-client/src/limiter.rs new file mode 100644 index 0000000..aef0ea2 --- /dev/null +++ b/crates/truefix-okx-client/src/limiter.rs @@ -0,0 +1,41 @@ +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::sync::Mutex; + +use crate::{error::OkxError, request::RetrySafety}; + +/// Minimal shared reservation gate for REST and WebSocket operations. +#[derive(Debug, Clone)] +pub struct RateLimiter { + next_allowed: Arc>, +} + +impl Default for RateLimiter { + fn default() -> Self { + Self { + next_allowed: Arc::new(Mutex::new(Instant::now())), + } + } +} + +impl RateLimiter { + /// Waits until a local reservation can start. + pub async fn reserve(&self) { + let deadline = *self.next_allowed.lock().await; + tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await; + } + /// Delays later reservations after a server throttle response. + pub async fn throttle_for(&self, delay: Duration) { + *self.next_allowed.lock().await = Instant::now() + delay; + } + /// Classifies whether an error is eligible for automatic retry. + pub fn may_retry(safety: RetrySafety, error: &OkxError) -> bool { + safety == RetrySafety::ReadOnly + && matches!( + error, + OkxError::Transport(_) | OkxError::Timeout | OkxError::RateLimited { .. } + ) + } +} diff --git a/crates/truefix-okx-client/src/request.rs b/crates/truefix-okx-client/src/request.rs new file mode 100644 index 0000000..292db6c --- /dev/null +++ b/crates/truefix-okx-client/src/request.rs @@ -0,0 +1,132 @@ +use std::collections::BTreeMap; + +use serde::Serialize; + +use crate::{ + error::{OkxError, OkxResult}, + types::common::ExpirationTime, +}; + +/// Whether a request can be retried automatically. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RetrySafety { + ReadOnly, + RequiresReconciliation, + NeverReplay, +} + +/// Exact bytes and metadata for an outbound REST operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CanonicalRequest { + pub method: reqwest::Method, + pub path_and_query: String, + pub body: Vec, + pub retry_safety: RetrySafety, + pub requires_auth: bool, + /// Optional OKX `expTime` deadline for order and amend operations. + pub expiration_time: Option, +} + +impl CanonicalRequest { + /// Constructs a request with deterministic query ordering and exact JSON bytes. + pub fn new( + method: reqwest::Method, + path: &str, + query: BTreeMap, + body: Option<&T>, + retry_safety: RetrySafety, + requires_auth: bool, + ) -> OkxResult { + if !path.starts_with('/') { + return Err(OkxError::InvalidConfiguration( + "request paths must start with '/'".to_owned(), + )); + } + let encoded_query = serde_urlencoded::to_string( + query + .into_iter() + .filter(|(_, value)| !value.is_empty()) + .collect::>(), + ) + .map_err(|error| OkxError::Signing(error.to_string()))?; + let path_and_query = if encoded_query.is_empty() { + path.to_owned() + } else { + format!("{path}?{encoded_query}") + }; + let body = match body { + Some(value) => serde_json::to_vec(value).map_err(OkxError::Decode)?, + None => Vec::new(), + }; + Ok(Self { + method, + path_and_query, + body, + retry_safety, + requires_auth, + expiration_time: None, + }) + } + + /// Adds the OKX order/amend request expiry header. + pub fn with_expiration_time(mut self, expiration_time: ExpirationTime) -> Self { + self.expiration_time = Some(expiration_time); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn query_is_sorted_and_body_is_exact_json() { + let mut query = BTreeMap::new(); + query.insert("z".to_owned(), "last value".to_owned()); + query.insert("a".to_owned(), "first".to_owned()); + let request = CanonicalRequest::new( + reqwest::Method::POST, + "/api/v5/test", + query, + Some(&serde_json::json!({"sz":"1.2"})), + RetrySafety::NeverReplay, + true, + ) + .unwrap(); + assert_eq!(request.path_and_query, "/api/v5/test?a=first&z=last+value"); + assert_eq!(request.body, br#"{"sz":"1.2"}"#); + } + + #[test] + fn invalid_paths_are_rejected_before_signing() { + let result = CanonicalRequest::new( + reqwest::Method::GET, + "api/v5/market/ticker", + BTreeMap::new(), + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + false, + ); + assert!(matches!(result, Err(OkxError::InvalidConfiguration(_)))); + } + + #[test] + fn empty_query_values_are_omitted() { + let mut query = BTreeMap::new(); + query.insert("instId".to_owned(), "BTC-USDT".to_owned()); + query.insert("after".to_owned(), String::new()); + let request = CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/market/ticker", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + false, + ) + .unwrap(); + assert_eq!( + request.path_and_query, + "/api/v5/market/ticker?instId=BTC-USDT" + ); + } +} diff --git a/crates/truefix-okx-client/src/response.rs b/crates/truefix-okx-client/src/response.rs new file mode 100644 index 0000000..34ec833 --- /dev/null +++ b/crates/truefix-okx-client/src/response.rs @@ -0,0 +1,130 @@ +use serde::Deserialize; +use serde::de::DeserializeOwned; + +use crate::error::{OkxError, OkxResult}; + +/// Standard OKX response envelope preserving server result details. +#[derive(Debug, Deserialize)] +pub struct ResponseEnvelope { + pub code: String, + pub msg: String, + pub data: Vec, +} + +impl ResponseEnvelope { + /// Converts an exchange success envelope into its data collection. + pub fn into_data(self) -> OkxResult> { + self.into_data_with_metadata(ResponseMetadata::default()) + .map(|response| response.data) + } + + /// Converts an exchange envelope while retaining HTTP response metadata. + pub fn into_data_with_metadata(self, metadata: ResponseMetadata) -> OkxResult> { + if self.code == "0" { + Ok(OkxResponse { + data: self.data, + metadata, + }) + } else if self.code == "50102" { + Err(OkxError::ClockSkew) + } else { + Err(OkxError::Exchange { + code: self.code, + message: self.msg, + request_id: metadata.request_id, + }) + } + } +} + +/// Decodes and validates one response body. +pub fn decode_envelope(body: &[u8]) -> OkxResult> { + serde_json::from_slice::>(body)?.into_data() +} + +/// Decodes an OKX envelope and preserves pagination and correlation metadata. +pub fn decode_envelope_with_metadata( + body: &[u8], + metadata: ResponseMetadata, +) -> OkxResult> { + serde_json::from_slice::>(body)?.into_data_with_metadata(metadata) +} + +/// Server-preserved cursor metadata for list responses. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PageMetadata { + pub before: Option, + pub after: Option, +} + +/// Metadata returned with an OKX REST response. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResponseMetadata { + /// Pagination cursors sent by OKX for list endpoints. + pub page: PageMetadata, + /// OKX request correlation identifier, if supplied by the server. + pub request_id: Option, +} + +impl ResponseMetadata { + /// Extracts response metadata from OKX HTTP headers. + pub fn from_headers(headers: &reqwest::header::HeaderMap) -> Self { + Self { + page: page_metadata(headers), + request_id: headers + .get("x-request-id") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned), + } + } +} + +/// Decoded OKX response data with HTTP metadata retained. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OkxResponse { + /// Decoded OKX response records. + pub data: Vec, + /// Pagination and request correlation metadata. + pub metadata: ResponseMetadata, +} + +/// Extracts pagination cursors without inventing offset semantics. +pub fn page_metadata(headers: &reqwest::header::HeaderMap) -> PageMetadata { + let value = |name| { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + }; + PageMetadata { + before: value("OK-BEFORE"), + after: value("OK-AFTER"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_exchange_error_envelope() { + let result: OkxResult> = + decode_envelope(br#"{"code":"50011","msg":"rate limit","data":[]}"#); + assert!(matches!(result, Err(OkxError::Exchange { .. }))); + } + + #[test] + fn exchange_error_preserves_request_id_from_metadata() { + let result: OkxResult> = decode_envelope_with_metadata( + br#"{"code":"51008","msg":"insufficient balance","data":[]}"#, + ResponseMetadata { + request_id: Some("okx-request-42".to_owned()), + ..ResponseMetadata::default() + }, + ); + assert!(matches!( + result, + Err(OkxError::Exchange { request_id: Some(request_id), .. }) + if request_id == "okx-request-42" + )); + } +} diff --git a/crates/truefix-okx-client/src/services/account.rs b/crates/truefix-okx-client/src/services/account.rs new file mode 100644 index 0000000..e686fbe --- /dev/null +++ b/crates/truefix-okx-client/src/services/account.rs @@ -0,0 +1,537 @@ +use crate::{ + client::OkxClient, + error::OkxResult, + request::{CanonicalRequest, RetrySafety}, + types::account::{ + AccountBalance, Bill, FeeRate, Leverage, MarginAdjustment, Position, PositionRisk, + }, +}; +use std::collections::BTreeMap; +pub struct AccountService<'a>(pub(crate) &'a OkxClient); +impl AccountService<'_> { + async fn read_json( + &self, + path: &'static str, + query: BTreeMap, + ) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + path, + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + async fn write_json( + &self, + path: &'static str, + request: &serde_json::Value, + ) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::POST, + path, + BTreeMap::new(), + Some(request), + RetrySafety::NeverReplay, + true, + )?) + .await + } + /// Executes the `balances` OKX V5 operation with its classified auth and replay policy. + pub async fn balances(&self) -> OkxResult> { + self.balances_with_currency(None).await + } + /// Executes the `balances` OKX V5 operation with an optional currency filter. + pub async fn balances_with_currency( + &self, + ccy: Option<&str>, + ) -> OkxResult> { + let mut query = BTreeMap::new(); + if let Some(ccy) = ccy.filter(|ccy| !ccy.is_empty()) { + query.insert("ccy".to_owned(), ccy.to_owned()); + } + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/account/balance", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + /// Executes the `positions` OKX V5 operation with its classified auth and replay policy. + pub async fn positions(&self) -> OkxResult> { + self.positions_with_filters(None, None, None).await + } + /// Executes the `positions` OKX V5 operation with optional OKX filters. + pub async fn positions_with_filters( + &self, + inst_type: Option<&str>, + inst_id: Option<&str>, + pos_id: Option<&str>, + ) -> OkxResult> { + let mut query = BTreeMap::new(); + if let Some(inst_type) = inst_type.filter(|value| !value.is_empty()) { + query.insert("instType".to_owned(), inst_type.to_owned()); + } + if let Some(inst_id) = inst_id.filter(|value| !value.is_empty()) { + query.insert("instId".to_owned(), inst_id.to_owned()); + } + if let Some(pos_id) = pos_id.filter(|value| !value.is_empty()) { + query.insert("posId".to_owned(), pos_id.to_owned()); + } + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/account/positions", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + /// Gets account bills using the server's cursor pagination parameters. + pub async fn bills(&self, query: BTreeMap) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/account/bills", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + /// Gets immutable account configuration records. + pub async fn configuration(&self) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/account/config", + BTreeMap::new(), + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + /// Gets leverage for the requested margin scope. + pub async fn leverage(&self, query: BTreeMap) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/account/leverage-info", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + /// Sets leverage; writes are never replayed automatically. + pub async fn set_leverage(&self, request: &serde_json::Value) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::POST, + "/api/v5/account/set-leverage", + BTreeMap::new(), + Some(request), + RetrySafety::NeverReplay, + true, + )?) + .await + } + /// Adjusts isolated margin and returns the acknowledged amount. + pub async fn adjust_margin( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::POST, + "/api/v5/account/position/margin-balance", + BTreeMap::new(), + Some(request), + RetrySafety::NeverReplay, + true, + )?) + .await + } + /// Gets account position-risk records. + pub async fn position_risk( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/account/account-position-risk", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + /// Gets the fee schedule for a product scope. + pub async fn fee_rates(&self, query: BTreeMap) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/account/trade-fee", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + + /// Borrows or repays a margin currency. This command is never replayed. + pub async fn borrow_repay( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/account/borrow-repay", request) + .await + } + /// Executes the `borrow_repay_history` OKX V5 operation with its classified auth and replay policy. + pub async fn borrow_repay_history( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/borrow-repay-history", query) + .await + } + /// Executes the `interest_accrued` OKX V5 operation with its classified auth and replay policy. + pub async fn interest_accrued( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/interest-accrued", query) + .await + } + /// Executes the `interest_rate` OKX V5 operation with its classified auth and replay policy. + pub async fn interest_rate( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/interest-rate", query).await + } + /// Executes the `max_loan` OKX V5 operation with its classified auth and replay policy. + pub async fn max_loan( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/max-loan", query).await + } + /// Executes the `quick_margin_borrow_repay` OKX V5 operation with its classified auth and replay policy. + pub async fn quick_margin_borrow_repay( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/account/quick-margin-borrow-repay", request) + .await + } + /// Executes the `quick_margin_borrow_repay_history` OKX V5 operation with its classified auth and replay policy. + pub async fn quick_margin_borrow_repay_history( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/quick-margin-borrow-repay-history", query) + .await + } + + /// Executes the `fixed_loan_borrowing_orders` OKX V5 operation with its classified auth and replay policy. + pub async fn fixed_loan_borrowing_orders( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/fixed-loan/borrowing-orders-list", query) + .await + } + /// Executes the `fixed_loan_borrow` OKX V5 operation with its classified auth and replay policy. + pub async fn fixed_loan_borrow( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/account/fixed-loan/borrowing-order", request) + .await + } + /// Executes the `fixed_loan_amend` OKX V5 operation with its classified auth and replay policy. + pub async fn fixed_loan_amend( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/account/fixed-loan/amend-borrowing-order", request) + .await + } + /// Executes the `fixed_loan_reborrow` OKX V5 operation with its classified auth and replay policy. + pub async fn fixed_loan_reborrow( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/account/fixed-loan/manual-reborrow", request) + .await + } + /// Executes the `fixed_loan_repay` OKX V5 operation with its classified auth and replay policy. + pub async fn fixed_loan_repay( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/account/fixed-loan/repay-borrowing-order", request) + .await + } + /// Executes the `vip_loan_orders` OKX V5 operation with its classified auth and replay policy. + pub async fn vip_loan_orders( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/vip-loan-order-list", query) + .await + } + /// Executes the `vip_loan_order` OKX V5 operation with its classified auth and replay policy. + pub async fn vip_loan_order( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/vip-loan-order-detail", query) + .await + } + /// Executes the `vip_loan_interest_accrued` OKX V5 operation with its classified auth and replay policy. + pub async fn vip_loan_interest_accrued( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/vip-interest-accrued", query) + .await + } + + /// Executes the `greeks` OKX V5 operation with its classified auth and replay policy. + pub async fn greeks( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/greeks", query).await + } + /// Executes the `set_greeks` OKX V5 operation with its classified auth and replay policy. + pub async fn set_greeks( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/account/set-greeks", request).await + } + /// Executes the `set_position_mode` OKX V5 operation with its classified auth and replay policy. + pub async fn set_position_mode( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/account/set-position-mode", request) + .await + } + /// Executes the `set_account_level` OKX V5 operation with its classified auth and replay policy. + pub async fn set_account_level( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/account/set-account-level", request) + .await + } + /// Executes the `set_auto_loan` OKX V5 operation with its classified auth and replay policy. + pub async fn set_auto_loan( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/account/set-auto-loan", request) + .await + } + /// Executes the `set_risk_offset_type` OKX V5 operation with its classified auth and replay policy. + pub async fn set_risk_offset_type( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/account/set-riskOffset-type", request) + .await + } + /// Executes the `activate_option` OKX V5 operation with its classified auth and replay policy. + pub async fn activate_option(&self) -> OkxResult> { + self.write_json("/api/v5/account/activate-option", &serde_json::json!({})) + .await + } + /// Executes the `activate_option` OKX V5 operation with an explicit JSON body. + pub async fn activate_option_request( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/account/activate-option", request) + .await + } + /// Executes the `set_spot_manual_borrowing` OKX V5 operation with its classified auth and replay policy. + pub async fn set_spot_manual_borrowing( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/account/spot-manual-borrow-repay", request) + .await + } + /// Executes the `risk_state` OKX V5 operation with its classified auth and replay policy. + pub async fn risk_state(&self) -> OkxResult> { + self.read_json("/api/v5/account/risk-state", BTreeMap::new()) + .await + } + + /// Executes the `bills_archive` OKX V5 operation with its classified auth and replay policy. + pub async fn bills_archive( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/bills-archive", q).await + } + /// Executes the `max_order_size` OKX V5 operation with its classified auth and replay policy. + pub async fn max_order_size( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/max-size", q).await + } + /// Executes the `max_available_size` OKX V5 operation with its classified auth and replay policy. + pub async fn max_available_size( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/max-avail-size", q).await + } + /// Executes the `max_withdrawal` OKX V5 operation with its classified auth and replay policy. + pub async fn max_withdrawal( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/max-withdrawal", q).await + } + /// Executes the `interest_limits` OKX V5 operation with its classified auth and replay policy. + pub async fn interest_limits( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/interest-limits", q).await + } + /// Executes the `positions_history` OKX V5 operation with its classified auth and replay policy. + pub async fn positions_history( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/positions-history", q).await + } + /// Executes the `account_position_tiers` OKX V5 operation with its classified auth and replay policy. + pub async fn account_position_tiers( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/position-tiers", q).await + } + /// Executes the `vip_interest_deducted` OKX V5 operation with its classified auth and replay policy. + pub async fn vip_interest_deducted( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/vip-interest-deducted", q) + .await + } + /// Executes the `account_instruments` OKX V5 operation with its classified auth and replay policy. + pub async fn account_instruments( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/instruments", q).await + } + /// Executes the `fixed_loan_borrowing_limit` OKX V5 operation with its classified auth and replay policy. + pub async fn fixed_loan_borrowing_limit( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/fixed-loan/borrowing-limit", q) + .await + } + /// Executes the `fixed_loan_borrowing_quote` OKX V5 operation with its classified auth and replay policy. + pub async fn fixed_loan_borrowing_quote( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/fixed-loan/borrowing-quote", q) + .await + } + /// Executes the `spot_borrow_repay_history` OKX V5 operation with its classified auth and replay policy. + pub async fn spot_borrow_repay_history( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/spot-borrow-repay-history", q) + .await + } + /// Executes the `precheck_delta_neutral` OKX V5 operation with its classified auth and replay policy. + pub async fn precheck_delta_neutral( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/precheck-set-delta-neutral", q) + .await + } + /// Executes the `bill_subtypes` OKX V5 operation with its classified auth and replay policy. + pub async fn bill_subtypes( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/account/subtypes", q).await + } + /// Executes the `simulated_margin` OKX V5 operation with its classified auth and replay policy. + pub async fn simulated_margin( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/account/simulated_margin", b).await + } + /// Executes the `position_builder` OKX V5 operation with its classified auth and replay policy. + pub async fn position_builder( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/account/position-builder", b).await + } + /// Executes the `set_isolated_mode` OKX V5 operation with its classified auth and replay policy. + pub async fn set_isolated_mode( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/account/set-isolated-mode", b) + .await + } + /// Executes the `set_auto_repay` OKX V5 operation with its classified auth and replay policy. + pub async fn set_auto_repay(&self, b: &serde_json::Value) -> OkxResult> { + self.write_json("/api/v5/account/set-auto-repay", b).await + } + /// Executes the `set_auto_earn` OKX V5 operation with its classified auth and replay policy. + pub async fn set_auto_earn(&self, b: &serde_json::Value) -> OkxResult> { + self.write_json("/api/v5/account/set-auto-earn", b).await + } + /// Executes the `set_trading_config` OKX V5 operation with its classified auth and replay policy. + pub async fn set_trading_config( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/account/set-trading-config", b) + .await + } + /// Executes the `archive_bills` OKX V5 operation with its classified auth and replay policy. + pub async fn archive_bills(&self, b: &serde_json::Value) -> OkxResult> { + self.write_json("/api/v5/account/bills-history-archive", b) + .await + } +} diff --git a/crates/truefix-okx-client/src/services/finance.rs b/crates/truefix-okx-client/src/services/finance.rs new file mode 100644 index 0000000..115d569 --- /dev/null +++ b/crates/truefix-okx-client/src/services/finance.rs @@ -0,0 +1,343 @@ +//! Savings, staking, loans, and investment-product operations. +use crate::{ + client::OkxClient, + error::OkxResult, + request::{CanonicalRequest, RetrySafety}, +}; +use std::collections::BTreeMap; +/// Native finance-product API grouped by product family. +pub struct FinanceService<'a>(pub(crate) &'a OkxClient); +impl FinanceService<'_> { + async fn get(&self, p: &str, q: BTreeMap) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + p, + q, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + async fn public_get( + &self, + p: &str, + q: BTreeMap, + ) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + p, + q, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + false, + )?) + .await + } + async fn write(&self, p: &str, b: &serde_json::Value) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::POST, + p, + BTreeMap::new(), + Some(b), + RetrySafety::NeverReplay, + true, + )?) + .await + } + /// Gets the public Simple Earn lending-rate summary. + pub async fn savings_public_borrow_info( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.public_get("/api/v5/finance/savings/lending-rate-summary", q) + .await + } + /// Executes the `savings_purchase` OKX V5 operation with its classified auth and replay policy. + pub async fn savings_purchase( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/finance/savings/purchase-redempt", b) + .await + } + /// Executes the `savings_balance` OKX V5 operation with its classified auth and replay policy. + pub async fn savings_balance( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/savings/balance", q).await + } + /// Executes the `eth_products` OKX V5 operation with its classified auth and replay policy. + pub async fn eth_products( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/staking-defi/eth/product-info", q) + .await + } + /// Executes the `eth_purchase` OKX V5 operation with its classified auth and replay policy. + pub async fn eth_purchase(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/finance/staking-defi/eth/purchase", b) + .await + } + /// Executes the `eth_redeem` OKX V5 operation with its classified auth and replay policy. + pub async fn eth_redeem(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/finance/staking-defi/eth/redeem", b) + .await + } + /// Executes the `sol_products` OKX V5 operation with its classified auth and replay policy. + pub async fn sol_products( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/staking-defi/sol/product-info", q) + .await + } + /// Executes the `sol_purchase` OKX V5 operation with its classified auth and replay policy. + pub async fn sol_purchase(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/finance/staking-defi/sol/purchase", b) + .await + } + /// Executes the `defi_products` OKX V5 operation with its classified auth and replay policy. + pub async fn defi_products( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/staking-defi/offers", q).await + } + /// Executes the `defi_purchase` OKX V5 operation with its classified auth and replay policy. + pub async fn defi_purchase(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/finance/staking-defi/purchase", b).await + } + /// Executes the `flexible_loan_currencies` OKX V5 operation with its classified auth and replay policy. + pub async fn flexible_loan_currencies(&self) -> OkxResult> { + self.get( + "/api/v5/finance/flexible-loan/borrow-currencies", + BTreeMap::new(), + ) + .await + } + /// Returns assets accepted as flexible-loan collateral. + pub async fn flexible_loan_collateral_assets( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/flexible-loan/collateral-assets", q) + .await + } + /// Calculates the maximum flexible-loan amount for the supplied collateral. + pub async fn flexible_loan_max_loan( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/finance/flexible-loan/max-loan", b) + .await + } + /// Returns the maximum collateral amount that can be redeemed. + pub async fn flexible_loan_max_collateral_redeem( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get( + "/api/v5/finance/flexible-loan/max-collateral-redeem-amount", + q, + ) + .await + } + /// Adjusts collateral for a flexible loan without replaying the write. + pub async fn flexible_loan_adjust_collateral( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/finance/flexible-loan/adjust-collateral", b) + .await + } + /// Executes the `flexible_loan_positions` OKX V5 operation with its classified auth and replay policy. + pub async fn flexible_loan_positions( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/flexible-loan/loan-info", q).await + } + /// Returns flexible-loan history. + pub async fn flexible_loan_history( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/flexible-loan/loan-history", q) + .await + } + /// Returns flexible-loan accrued interest. + pub async fn flexible_loan_interest_accrued( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/flexible-loan/interest-accrued", q) + .await + } + /// Executes the `dual_investments` OKX V5 operation with its classified auth and replay policy. + pub async fn dual_investments( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/sfp/dcd/products", q).await + } + /// Executes the `dual_invest` OKX V5 operation with its classified auth and replay policy. + pub async fn dual_invest(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/finance/sfp/dcd/trade", b).await + } + /// Executes the `dual_invest_orders` OKX V5 operation with its classified auth and replay policy. + pub async fn dual_invest_orders( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/sfp/dcd/order-history", q).await + } + /// Executes the `eth_cancel_redeem` OKX V5 operation with its classified auth and replay policy. + pub async fn eth_cancel_redeem( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/finance/staking-defi/eth/cancel-redeem", b) + .await + } + /// Executes the `eth_balance` OKX V5 operation with its classified auth and replay policy. + pub async fn eth_balance( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/staking-defi/eth/balance", q) + .await + } + /// Executes the `eth_history` OKX V5 operation with its classified auth and replay policy. + pub async fn eth_history( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get( + "/api/v5/finance/staking-defi/eth/purchase-redeem-history", + q, + ) + .await + } + /// Executes the `eth_apy_history` OKX V5 operation with its classified auth and replay policy. + pub async fn eth_apy_history( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/staking-defi/eth/apy-history", q) + .await + } + /// Executes the `sol_redeem` OKX V5 operation with its classified auth and replay policy. + pub async fn sol_redeem(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/finance/staking-defi/sol/redeem", b) + .await + } + /// Executes the `sol_balance` OKX V5 operation with its classified auth and replay policy. + pub async fn sol_balance( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/staking-defi/sol/balance", q) + .await + } + /// Executes the `sol_history` OKX V5 operation with its classified auth and replay policy. + pub async fn sol_history( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get( + "/api/v5/finance/staking-defi/sol/purchase-redeem-history", + q, + ) + .await + } + /// Executes the `sol_apy_history` OKX V5 operation with its classified auth and replay policy. + pub async fn sol_apy_history( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/staking-defi/sol/apy-history", q) + .await + } + /// Executes the `defi_redeem` OKX V5 operation with its classified auth and replay policy. + pub async fn defi_redeem(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/finance/staking-defi/redeem", b).await + } + /// Executes the `defi_cancel` OKX V5 operation with its classified auth and replay policy. + pub async fn defi_cancel(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/finance/staking-defi/cancel", b).await + } + /// Executes the `defi_active_orders` OKX V5 operation with its classified auth and replay policy. + pub async fn defi_active_orders( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/staking-defi/orders-active", q) + .await + } + /// Executes the `defi_order_history` OKX V5 operation with its classified auth and replay policy. + pub async fn defi_order_history( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/staking-defi/orders-history", q) + .await + } + /// Executes the `savings_set_lending_rate` OKX V5 operation with its classified auth and replay policy. + pub async fn savings_set_lending_rate( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/finance/savings/set-lending-rate", b) + .await + } + /// Executes the `savings_lending_history` OKX V5 operation with its classified auth and replay policy. + pub async fn savings_lending_history( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/savings/lending-history", q).await + } + /// Executes the `savings_public_borrow_history` OKX V5 operation with its classified auth and replay policy. + pub async fn savings_public_borrow_history( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.public_get("/api/v5/finance/savings/lending-rate-history", q) + .await + } + /// Executes the `dual_currency_pairs` OKX V5 operation with its classified auth and replay policy. + pub async fn dual_currency_pairs( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/sfp/dcd/currency-pair", q).await + } + /// Executes the `dual_quote` OKX V5 operation with its classified auth and replay policy. + pub async fn dual_quote(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/finance/sfp/dcd/quote", b).await + } + /// Executes the `dual_redeem_quote` OKX V5 operation with its classified auth and replay policy. + pub async fn dual_redeem_quote( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/finance/sfp/dcd/redeem-quote", b).await + } + /// Executes the `dual_redeem` OKX V5 operation with its classified auth and replay policy. + pub async fn dual_redeem(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/finance/sfp/dcd/redeem", b).await + } + /// Executes the `dual_order_status` OKX V5 operation with its classified auth and replay policy. + pub async fn dual_order_status( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/finance/sfp/dcd/order-status", q).await + } +} diff --git a/crates/truefix-okx-client/src/services/funding.rs b/crates/truefix-okx-client/src/services/funding.rs new file mode 100644 index 0000000..2e757f8 --- /dev/null +++ b/crates/truefix-okx-client/src/services/funding.rs @@ -0,0 +1,144 @@ +//! Funding-account and asset-movement operations. +use crate::{ + client::OkxClient, + error::OkxResult, + request::{CanonicalRequest, RetrySafety}, +}; +use std::collections::BTreeMap; +/// Operations for the OKX funding account. +pub struct FundingService<'a>(pub(crate) &'a OkxClient); +impl FundingService<'_> { + async fn get( + &self, + path: &str, + query: BTreeMap, + ) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + path, + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + async fn write( + &self, + path: &str, + body: &serde_json::Value, + ) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::POST, + path, + BTreeMap::new(), + Some(body), + RetrySafety::NeverReplay, + true, + )?) + .await + } + /// Executes the `balances` OKX V5 operation with its classified auth and replay policy. + pub async fn balances(&self, q: BTreeMap) -> OkxResult> { + self.get("/api/v5/asset/balances", q).await + } + /// Executes the `deposit_addresses` OKX V5 operation with its classified auth and replay policy. + pub async fn deposit_addresses( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/asset/deposit-address", q).await + } + /// Executes the `deposits` OKX V5 operation with its classified auth and replay policy. + pub async fn deposits(&self, q: BTreeMap) -> OkxResult> { + self.get("/api/v5/asset/deposit-history", q).await + } + /// Executes the `withdraw` OKX V5 operation with its classified auth and replay policy. + pub async fn withdraw(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/asset/withdrawal", b).await + } + /// Executes the `cancel_withdrawal` OKX V5 operation with its classified auth and replay policy. + pub async fn cancel_withdrawal( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/asset/cancel-withdrawal", b).await + } + /// Executes the `withdrawals` OKX V5 operation with its classified auth and replay policy. + pub async fn withdrawals( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/asset/withdrawal-history", q).await + } + /// Executes the `transfer` OKX V5 operation with its classified auth and replay policy. + pub async fn transfer(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/asset/transfer", b).await + } + /// Executes the `transfer_state` OKX V5 operation with its classified auth and replay policy. + pub async fn transfer_state( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/asset/transfer-state", q).await + } + /// Executes the `convert_dust` OKX V5 operation with its classified auth and replay policy. + pub async fn convert_dust(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/asset/convert-dust-assets", b).await + } + /// Executes the `asset_valuation` OKX V5 operation with its classified auth and replay policy. + pub async fn asset_valuation( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/asset/asset-valuation", q).await + } + /// Executes the `bills` OKX V5 operation with its classified auth and replay policy. + pub async fn bills(&self, q: BTreeMap) -> OkxResult> { + self.get("/api/v5/asset/bills", q).await + } + /// Executes the `non_tradable_assets` OKX V5 operation with its classified auth and replay policy. + pub async fn non_tradable_assets( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/asset/non-tradable-assets", q).await + } + /// Executes the `currencies` OKX V5 operation with its classified auth and replay policy. + pub async fn currencies( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/asset/currencies", q).await + } + /// Executes the `purchase_redempt` OKX V5 operation with its classified auth and replay policy. + pub async fn purchase_redempt( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/asset/purchase_redempt", b).await + } + /// Executes the `deposit_lightning` OKX V5 operation with its classified auth and replay policy. + pub async fn deposit_lightning( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/asset/deposit-lightning", q).await + } + /// Executes the `withdrawal_lightning` OKX V5 operation with its classified auth and replay policy. + pub async fn withdrawal_lightning( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/asset/withdrawal-lightning", b).await + } + /// Executes the `deposit_withdraw_status` OKX V5 operation with its classified auth and replay policy. + pub async fn deposit_withdraw_status( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/asset/deposit-withdraw-status", q).await + } +} diff --git a/crates/truefix-okx-client/src/services/market.rs b/crates/truefix-okx-client/src/services/market.rs new file mode 100644 index 0000000..819e7bd --- /dev/null +++ b/crates/truefix-okx-client/src/services/market.rs @@ -0,0 +1,245 @@ +use crate::{ + client::OkxClient, + error::OkxResult, + request::{CanonicalRequest, RetrySafety}, + types::market::{Candle, FundingRate, MarketTrade, OrderBook, PriceIndex, Ticker}, +}; +use std::collections::BTreeMap; +pub struct MarketService<'a>(pub(crate) &'a OkxClient); +impl MarketService<'_> { + async fn read_json( + &self, + path: &str, + query: BTreeMap, + ) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + path, + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + false, + )?) + .await + } + /// Executes the `ticker` OKX V5 operation with its classified auth and replay policy. + pub async fn ticker(&self, instrument_id: &str) -> OkxResult> { + let mut q = BTreeMap::new(); + q.insert("instId".into(), instrument_id.into()); + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/market/ticker", + q, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + false, + )?) + .await + } + /// Lists tickers for an instrument type. + pub async fn tickers(&self, query: BTreeMap) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/market/tickers", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + false, + )?) + .await + } + /// Gets an order-book snapshot. + pub async fn books(&self, instrument_id: &str, size: Option) -> OkxResult> { + let mut query = BTreeMap::new(); + query.insert("instId".to_owned(), instrument_id.to_owned()); + if let Some(size) = size { + query.insert("sz".to_owned(), size.to_string()); + } + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/market/books", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + false, + )?) + .await + } + /// Lists candles with opaque server cursor filters. + pub async fn candles(&self, query: BTreeMap) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/market/candles", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + false, + )?) + .await + } + /// Lists historical candles with opaque server cursor filters. + pub async fn history_candles(&self, query: BTreeMap) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/market/history-candles", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + false, + )?) + .await + } + /// Lists recent public trades. + pub async fn trades(&self, query: BTreeMap) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/market/trades", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + false, + )?) + .await + } + /// Gets index ticker values. + pub async fn index_tickers( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/market/index-tickers", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + false, + )?) + .await + } + /// Gets mark-price values. + pub async fn mark_price(&self, query: BTreeMap) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/public/mark-price", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + false, + )?) + .await + } + /// Gets current funding rates. + pub async fn funding_rate(&self, instrument_id: &str) -> OkxResult> { + let mut query = BTreeMap::new(); + query.insert("instId".to_owned(), instrument_id.to_owned()); + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/public/funding-rate", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + false, + )?) + .await + } + /// Lists funding-rate history with opaque server cursor filters. + pub async fn funding_rate_history( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/public/funding-rate-history", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + false, + )?) + .await + } + /// Executes the `index_candles` OKX V5 operation with its classified auth and replay policy. + pub async fn index_candles( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/market/index-candles", q).await + } + /// Executes the `mark_price_candles` OKX V5 operation with its classified auth and replay policy. + pub async fn mark_price_candles( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/market/mark-price-candles", q).await + } + /// Executes the `platform_volume` OKX V5 operation with its classified auth and replay policy. + pub async fn platform_volume(&self) -> OkxResult> { + self.read_json("/api/v5/market/platform-24-volume", BTreeMap::new()) + .await + } + /// Executes the `index_components` OKX V5 operation with its classified auth and replay policy. + pub async fn index_components( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/market/index-components", q).await + } + /// Executes the `exchange_rate` OKX V5 operation with its classified auth and replay policy. + pub async fn exchange_rate(&self) -> OkxResult> { + self.read_json("/api/v5/market/exchange-rate", BTreeMap::new()) + .await + } + /// Executes the `history_trades` OKX V5 operation with its classified auth and replay policy. + pub async fn history_trades( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/market/history-trades", q).await + } + /// Executes the `block_ticker` OKX V5 operation with its classified auth and replay policy. + pub async fn block_ticker( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/market/block-ticker", q).await + } + /// Executes the `block_tickers` OKX V5 operation with its classified auth and replay policy. + pub async fn block_tickers( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/market/block-tickers", q).await + } + /// Executes the `block_trades` OKX V5 operation with its classified auth and replay policy. + pub async fn block_trades( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/market/block-trades", q).await + } + /// Executes the `books_lite` OKX V5 operation with its classified auth and replay policy. + pub async fn books_lite( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/market/books-lite", q).await + } + /// Executes the `option_family_trades` OKX V5 operation with its classified auth and replay policy. + pub async fn option_family_trades( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/market/option/instrument-family-trades", q) + .await + } +} diff --git a/crates/truefix-okx-client/src/services/mod.rs b/crates/truefix-okx-client/src/services/mod.rs new file mode 100644 index 0000000..0bf82fb --- /dev/null +++ b/crates/truefix-okx-client/src/services/mod.rs @@ -0,0 +1,10 @@ +//! Native OKX business-domain services. +pub mod account; +pub mod finance; +pub mod funding; +pub mod market; +pub mod professional; +pub mod public_data; +pub mod strategy; +pub mod subaccount; +pub mod trade; diff --git a/crates/truefix-okx-client/src/services/professional.rs b/crates/truefix-okx-client/src/services/professional.rs new file mode 100644 index 0000000..66fdfcd --- /dev/null +++ b/crates/truefix-okx-client/src/services/professional.rs @@ -0,0 +1,367 @@ +//! Professional trading, conversion, broker, and status operations. +use crate::{ + client::OkxClient, + error::OkxResult, + request::{CanonicalRequest, RetrySafety}, +}; +use std::collections::BTreeMap; +/// Native API for RFQ, spread trading, trading data, conversion, brokers, and status. +pub struct ProfessionalService<'a>(pub(crate) &'a OkxClient); +impl ProfessionalService<'_> { + async fn get(&self, p: &str, q: BTreeMap) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + p, + q, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + async fn public_get( + &self, + p: &str, + q: BTreeMap, + ) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + p, + q, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + false, + )?) + .await + } + async fn write(&self, p: &str, b: &serde_json::Value) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::POST, + p, + BTreeMap::new(), + Some(b), + RetrySafety::NeverReplay, + true, + )?) + .await + } + /// Creates an RFQ. + pub async fn create_rfq(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/rfq/create-rfq", b).await + } + /// Executes the `cancel_rfq` OKX V5 operation with its classified auth and replay policy. + pub async fn cancel_rfq(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/rfq/cancel-rfq", b).await + } + /// Executes the `rfqs` OKX V5 operation with its classified auth and replay policy. + pub async fn rfqs(&self, q: BTreeMap) -> OkxResult> { + self.get("/api/v5/rfq/rfqs", q).await + } + /// Executes the `create_quote` OKX V5 operation with its classified auth and replay policy. + pub async fn create_quote(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/rfq/create-quote", b).await + } + /// Executes the `cancel_quote` OKX V5 operation with its classified auth and replay policy. + pub async fn cancel_quote(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/rfq/cancel-quote", b).await + } + /// Executes the `quotes` OKX V5 operation with its classified auth and replay policy. + pub async fn quotes(&self, q: BTreeMap) -> OkxResult> { + self.get("/api/v5/rfq/quotes", q).await + } + /// Executes the `execute_quote` OKX V5 operation with its classified auth and replay policy. + pub async fn execute_quote(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/rfq/execute-quote", b).await + } + /// Executes the `rfq_trades` OKX V5 operation with its classified auth and replay policy. + pub async fn rfq_trades( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/rfq/trades", q).await + } + /// Executes the parameterless `reset_mmp` OKX V5 operation. + pub async fn reset_mmp(&self) -> OkxResult> { + self.write("/api/v5/rfq/mmp-reset", &serde_json::json!({})) + .await + } + /// Executes `reset_mmp` with an explicit JSON body for forward-compatible use. + pub async fn reset_mmp_request( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/rfq/mmp-reset", request).await + } + /// Executes the `place_spread_order` OKX V5 operation with its classified auth and replay policy. + pub async fn place_spread_order( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/sprd/order", b).await + } + /// Executes the `cancel_spread_order` OKX V5 operation with its classified auth and replay policy. + pub async fn cancel_spread_order( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/sprd/cancel-order", b).await + } + /// Executes the `spread_orders` OKX V5 operation with its classified auth and replay policy. + pub async fn spread_orders( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/sprd/orders-pending", q).await + } + /// Returns historical spread orders. + pub async fn spread_order_history( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/sprd/orders-history", q).await + } + /// Executes the `spread_trades` OKX V5 operation with its classified auth and replay policy. + pub async fn spread_trades( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/sprd/trades", q).await + } + /// Executes the `support_coin` OKX V5 operation with its classified auth and replay policy. + pub async fn support_coin(&self) -> OkxResult> { + self.public_get( + "/api/v5/rubik/stat/trading-data/support-coin", + BTreeMap::new(), + ) + .await + } + /// Executes the `taker_volume` OKX V5 operation with its classified auth and replay policy. + pub async fn taker_volume( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.public_get("/api/v5/rubik/stat/taker-volume", q).await + } + /// Executes the `long_short_ratio` OKX V5 operation with its classified auth and replay policy. + pub async fn long_short_ratio( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.public_get("/api/v5/rubik/stat/contracts/long-short-account-ratio", q) + .await + } + /// Executes the `convert_currencies` OKX V5 operation with its classified auth and replay policy. + pub async fn convert_currencies(&self) -> OkxResult> { + self.get("/api/v5/asset/convert/currencies", BTreeMap::new()) + .await + } + /// Executes the `convert_quote` OKX V5 operation with its classified auth and replay policy. + pub async fn convert_quote(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/asset/convert/estimate-quote", b).await + } + /// Executes the `convert_trade` OKX V5 operation with its classified auth and replay policy. + pub async fn convert_trade(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/asset/convert/trade", b).await + } + /// Executes the `convert_history` OKX V5 operation with its classified auth and replay policy. + pub async fn convert_history( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/asset/convert/history", q).await + } + /// Executes the `broker_rebate_per_orders` OKX V5 operation with its classified auth and replay policy. + pub async fn broker_rebate_per_orders( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/broker/fd/rebate-per-orders", q).await + } + /// Generates a rebate-details download link. + pub async fn generate_rebate_details_download_link( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/broker/fd/rebate-per-orders", b).await + } + /// Returns exchange/platform status; this endpoint is public. + pub async fn status(&self, q: BTreeMap) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/system/status", + q, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + false, + )?) + .await + } + /// Executes the `counterparties` OKX V5 operation with its classified auth and replay policy. + pub async fn counterparties(&self) -> OkxResult> { + self.get("/api/v5/rfq/counterparties", BTreeMap::new()) + .await + } + /// Executes the `cancel_batch_rfqs` OKX V5 operation with its classified auth and replay policy. + pub async fn cancel_batch_rfqs( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/rfq/cancel-batch-rfqs", b).await + } + /// Executes the `cancel_all_rfqs` OKX V5 operation with its classified auth and replay policy. + pub async fn cancel_all_rfqs( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/rfq/cancel-all-rfqs", b).await + } + /// Executes the `cancel_batch_quotes` OKX V5 operation with its classified auth and replay policy. + pub async fn cancel_batch_quotes( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/rfq/cancel-batch-quotes", b).await + } + /// Executes the `cancel_all_quotes` OKX V5 operation with its classified auth and replay policy. + pub async fn cancel_all_quotes( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/rfq/cancel-all-quotes", b).await + } + /// Executes the `public_rfq_trades` OKX V5 operation with its classified auth and replay policy. + pub async fn public_rfq_trades( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.public_get("/api/v5/rfq/public-trades", q).await + } + /// Executes the `maker_instrument_settings` OKX V5 operation with its classified auth and replay policy. + pub async fn maker_instrument_settings( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/rfq/maker-instrument-settings", q).await + } + /// Executes the `set_maker_instrument_settings` OKX V5 operation with its classified auth and replay policy. + pub async fn set_maker_instrument_settings( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/rfq/maker-instrument-settings", b).await + } + /// Executes the `cancel_all_spread_orders` OKX V5 operation with its classified auth and replay policy. + pub async fn cancel_all_spread_orders( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/sprd/mass-cancel", b).await + } + /// Executes the `spread_order` OKX V5 operation with its classified auth and replay policy. + pub async fn spread_order( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/sprd/order", q).await + } + /// Executes the `spreads` OKX V5 operation with its classified auth and replay policy. + pub async fn spreads(&self, q: BTreeMap) -> OkxResult> { + self.public_get("/api/v5/sprd/spreads", q).await + } + /// Executes the `spread_books` OKX V5 operation with its classified auth and replay policy. + pub async fn spread_books( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.public_get("/api/v5/sprd/books", q).await + } + /// Executes the `spread_ticker` OKX V5 operation with its classified auth and replay policy. + pub async fn spread_ticker( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.public_get("/api/v5/sprd/ticker", q).await + } + /// Executes the `spread_public_trades` OKX V5 operation with its classified auth and replay policy. + pub async fn spread_public_trades( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.public_get("/api/v5/sprd/public-trades", q).await + } + /// Executes the `margin_lending_ratio` OKX V5 operation with its classified auth and replay policy. + pub async fn margin_lending_ratio( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.public_get("/api/v5/rubik/stat/margin/loan-ratio", q) + .await + } + /// Executes the `contracts_interest_volume` OKX V5 operation with its classified auth and replay policy. + pub async fn contracts_interest_volume( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.public_get("/api/v5/rubik/stat/contracts/open-interest-volume", q) + .await + } + /// Executes the `options_interest_volume` OKX V5 operation with its classified auth and replay policy. + pub async fn options_interest_volume( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.public_get("/api/v5/rubik/stat/option/open-interest-volume", q) + .await + } + /// Executes the `put_call_ratio` OKX V5 operation with its classified auth and replay policy. + pub async fn put_call_ratio( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.public_get("/api/v5/rubik/stat/option/open-interest-volume-ratio", q) + .await + } + /// Executes the `interest_volume_expiry` OKX V5 operation with its classified auth and replay policy. + pub async fn interest_volume_expiry( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.public_get("/api/v5/rubik/stat/option/open-interest-volume-expiry", q) + .await + } + /// Executes the `interest_volume_strike` OKX V5 operation with its classified auth and replay policy. + pub async fn interest_volume_strike( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.public_get("/api/v5/rubik/stat/option/open-interest-volume-strike", q) + .await + } + /// Executes the `taker_block_volume` OKX V5 operation with its classified auth and replay policy. + pub async fn taker_block_volume( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.public_get("/api/v5/rubik/stat/option/taker-block-volume", q) + .await + } + /// Executes the `open_interest_history` OKX V5 operation with its classified auth and replay policy. + pub async fn open_interest_history( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.public_get("/api/v5/rubik/stat/contracts/open-interest-history", q) + .await + } + /// Executes the `convert_currency_pair` OKX V5 operation with its classified auth and replay policy. + pub async fn convert_currency_pair( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/asset/convert/currency-pair", q).await + } +} diff --git a/crates/truefix-okx-client/src/services/public_data.rs b/crates/truefix-okx-client/src/services/public_data.rs new file mode 100644 index 0000000..23fc10e --- /dev/null +++ b/crates/truefix-okx-client/src/services/public_data.rs @@ -0,0 +1,164 @@ +//! Public reference-data endpoints. These calls never require credentials. + +use crate::{ + client::OkxClient, + error::OkxResult, + request::{CanonicalRequest, RetrySafety}, +}; +use std::collections::BTreeMap; + +/// Access to OKX public instruments, reference data, and platform status. +pub struct PublicDataService<'a>(pub(crate) &'a OkxClient); + +impl PublicDataService<'_> { + async fn get( + &self, + path: &'static str, + query: BTreeMap, + ) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + path, + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + false, + )?) + .await + } + + /// Lists instruments for an `instType` and optional filters. + pub async fn instruments( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/public/instruments", query).await + } + /// Executes the `delivery_exercise_history` OKX V5 operation with its classified auth and replay policy. + pub async fn delivery_exercise_history( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/public/delivery-exercise-history", query) + .await + } + /// Executes the `open_interest` OKX V5 operation with its classified auth and replay policy. + pub async fn open_interest( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/public/open-interest", query).await + } + /// Executes the `price_limit` OKX V5 operation with its classified auth and replay policy. + pub async fn price_limit( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/public/price-limit", query).await + } + /// Executes the `option_summary` OKX V5 operation with its classified auth and replay policy. + pub async fn option_summary( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/public/opt-summary", query).await + } + /// Executes the `estimated_price` OKX V5 operation with its classified auth and replay policy. + pub async fn estimated_price( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/public/estimated-price", query).await + } + /// Executes the `interest_free_quota` OKX V5 operation with its classified auth and replay policy. + pub async fn interest_free_quota( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/public/discount-rate-interest-free-quota", query) + .await + } + /// Executes the `time` OKX V5 operation with its classified auth and replay policy. + pub async fn time(&self) -> OkxResult> { + self.get("/api/v5/public/time", BTreeMap::new()).await + } + /// Executes the `insurance_fund` OKX V5 operation with its classified auth and replay policy. + pub async fn insurance_fund( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/public/insurance-fund", query).await + } + /// Executes the `contract_coin` OKX V5 operation with its classified auth and replay policy. + pub async fn contract_coin( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/public/convert-contract-coin", query) + .await + } + /// Executes the `instrument_tick_bands` OKX V5 operation with its classified auth and replay policy. + pub async fn instrument_tick_bands( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/public/instrument-tick-bands", query) + .await + } + /// Executes the `option_trades` OKX V5 operation with its classified auth and replay policy. + pub async fn option_trades( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/public/option-trades", query).await + } + /// Executes the `announcements` OKX V5 operation with its classified auth and replay policy. + pub async fn announcements( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/support/announcements", query).await + } + /// Executes the `position_tiers` OKX V5 operation with its classified auth and replay policy. + pub async fn position_tiers( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/public/position-tiers", q).await + } + /// Executes the `interest_rate_loan_quota` OKX V5 operation with its classified auth and replay policy. + pub async fn interest_rate_loan_quota(&self) -> OkxResult> { + self.get("/api/v5/public/interest-rate-loan-quota", BTreeMap::new()) + .await + } + /// Executes the `vip_interest_rate_loan_quota` OKX V5 operation with its classified auth and replay policy. + pub async fn vip_interest_rate_loan_quota(&self) -> OkxResult> { + self.get( + "/api/v5/public/vip-interest-rate-loan-quota", + BTreeMap::new(), + ) + .await + } + /// Executes the `underlying` OKX V5 operation with its classified auth and replay policy. + pub async fn underlying( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/public/underlying", q).await + } + /// Executes the `market_data_history` OKX V5 operation with its classified auth and replay policy. + pub async fn market_data_history( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/public/market-data-history", q).await + } + /// Executes the `liquidation_orders` OKX V5 operation with its classified auth and replay policy. + pub async fn liquidation_orders( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/public/liquidation-orders", q).await + } +} diff --git a/crates/truefix-okx-client/src/services/strategy.rs b/crates/truefix-okx-client/src/services/strategy.rs new file mode 100644 index 0000000..ea5de96 --- /dev/null +++ b/crates/truefix-okx-client/src/services/strategy.rs @@ -0,0 +1,244 @@ +//! Grid, recurring-buy, and copy-trading operations. +use crate::{ + client::OkxClient, + error::OkxResult, + request::{CanonicalRequest, RetrySafety}, +}; +use std::collections::BTreeMap; +/// Native strategy-product API. +pub struct StrategyService<'a>(pub(crate) &'a OkxClient); +impl StrategyService<'_> { + async fn get(&self, p: &str, q: BTreeMap) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + p, + q, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + async fn write(&self, p: &str, b: &serde_json::Value) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::POST, + p, + BTreeMap::new(), + Some(b), + RetrySafety::NeverReplay, + true, + )?) + .await + } + /// Executes the `grid_order` OKX V5 operation with its classified auth and replay policy. + pub async fn grid_order(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/tradingBot/grid/order-algo", b).await + } + /// Executes the `amend_grid_order` OKX V5 operation with its classified auth and replay policy. + pub async fn amend_grid_order( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/tradingBot/grid/amend-order-algo", b) + .await + } + /// Executes the `stop_grid_order` OKX V5 operation with its classified auth and replay policy. + pub async fn stop_grid_order( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/tradingBot/grid/stop-order-algo", b) + .await + } + /// Executes the `grid_orders` OKX V5 operation with its classified auth and replay policy. + pub async fn grid_orders( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/tradingBot/grid/orders-algo-pending", q) + .await + } + /// Executes the `grid_order_history` OKX V5 operation with its classified auth and replay policy. + pub async fn grid_order_history( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/tradingBot/grid/orders-algo-history", q) + .await + } + /// Executes the `grid_sub_orders` OKX V5 operation with its classified auth and replay policy. + pub async fn grid_sub_orders( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/tradingBot/grid/sub-orders", q).await + } + /// Executes the `recurring_buy_order` OKX V5 operation with its classified auth and replay policy. + pub async fn recurring_buy_order( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/tradingBot/recurring/order-algo", b) + .await + } + /// Executes the `recurring_buy_amend` OKX V5 operation with its classified auth and replay policy. + pub async fn recurring_buy_amend( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/tradingBot/recurring/amend-order-algo", b) + .await + } + /// Executes the `recurring_buy_stop` OKX V5 operation with its classified auth and replay policy. + pub async fn recurring_buy_stop( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/tradingBot/recurring/stop-order-algo", b) + .await + } + /// Executes the `recurring_buy_orders` OKX V5 operation with its classified auth and replay policy. + pub async fn recurring_buy_orders( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/tradingBot/recurring/orders-algo-pending", q) + .await + } + /// Executes the `grid_order_details` OKX V5 operation with its classified auth and replay policy. + pub async fn grid_order_details( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/tradingBot/grid/orders-algo-details", q) + .await + } + /// Executes the `grid_positions` OKX V5 operation with its classified auth and replay policy. + pub async fn grid_positions( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/tradingBot/grid/positions", q).await + } + /// Executes the `grid_withdraw_income` OKX V5 operation with its classified auth and replay policy. + pub async fn grid_withdraw_income( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/tradingBot/grid/withdraw-income", b) + .await + } + /// Executes the `grid_compute_margin` OKX V5 operation with its classified auth and replay policy. + pub async fn grid_compute_margin( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/tradingBot/grid/compute-margin-balance", b) + .await + } + /// Executes the `grid_adjust_margin` OKX V5 operation with its classified auth and replay policy. + pub async fn grid_adjust_margin( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/tradingBot/grid/margin-balance", b) + .await + } + /// Executes the `grid_ai_param` OKX V5 operation with its classified auth and replay policy. + pub async fn grid_ai_param( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/tradingBot/grid/ai-param", q).await + } + /// Executes the `recurring_details` OKX V5 operation with its classified auth and replay policy. + pub async fn recurring_details( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/tradingBot/recurring/orders-algo-details", q) + .await + } + /// Executes the `recurring_sub_orders` OKX V5 operation with its classified auth and replay policy. + pub async fn recurring_sub_orders( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/tradingBot/recurring/sub-orders", q).await + } + /// Executes the `recurring_history` OKX V5 operation with its classified auth and replay policy. + pub async fn recurring_history( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/tradingBot/recurring/orders-algo-history", q) + .await + } + /// Executes the `leading_positions` OKX V5 operation with its classified auth and replay policy. + pub async fn leading_positions( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/copytrading/current-subpositions", q) + .await + } + /// Returns historical lead-trading subpositions. + pub async fn leading_position_history( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/copytrading/subpositions-history", q) + .await + } + /// Executes the `leading_order` OKX V5 operation with its classified auth and replay policy. + pub async fn leading_order(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/copytrading/algo-order", b).await + } + /// Executes the `close_leading_position` OKX V5 operation with its classified auth and replay policy. + pub async fn close_leading_position( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/copytrading/close-subposition", b).await + } + /// Executes the `leading_instruments` OKX V5 operation with its classified auth and replay policy. + pub async fn leading_instruments( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/copytrading/instruments", q).await + } + /// Executes the `set_leading_instruments` OKX V5 operation with its classified auth and replay policy. + pub async fn set_leading_instruments( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/copytrading/set-instruments", b).await + } + /// Returns realized profit-sharing details for lead trading. + pub async fn profit_sharing_details( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/copytrading/profit-sharing-details", q) + .await + } + /// Returns total lead-trading profit sharing. + pub async fn total_profit_sharing( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/copytrading/total-profit-sharing", q) + .await + } + /// Returns unrealized profit-sharing details for lead trading. + pub async fn unrealized_profit_sharing_details( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/copytrading/unrealized-profit-sharing-details", q) + .await + } +} diff --git a/crates/truefix-okx-client/src/services/subaccount.rs b/crates/truefix-okx-client/src/services/subaccount.rs new file mode 100644 index 0000000..3444ee3 --- /dev/null +++ b/crates/truefix-okx-client/src/services/subaccount.rs @@ -0,0 +1,106 @@ +//! Sub-account operations. +use crate::{ + client::OkxClient, + error::OkxResult, + request::{CanonicalRequest, RetrySafety}, +}; +use std::collections::BTreeMap; +/// Operations for administrative sub-accounts. +pub struct SubaccountService<'a>(pub(crate) &'a OkxClient); +impl SubaccountService<'_> { + async fn get(&self, p: &str, q: BTreeMap) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + p, + q, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + async fn write(&self, p: &str, b: &serde_json::Value) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::POST, + p, + BTreeMap::new(), + Some(b), + RetrySafety::NeverReplay, + true, + )?) + .await + } + /// Executes the `list` OKX V5 operation with its classified auth and replay policy. + pub async fn list(&self, q: BTreeMap) -> OkxResult> { + self.get("/api/v5/users/subaccount/list", q).await + } + /// Executes the `balances` OKX V5 operation with its classified auth and replay policy. + pub async fn balances(&self, q: BTreeMap) -> OkxResult> { + self.get("/api/v5/account/subaccount/balances", q).await + } + /// Executes the `bills` OKX V5 operation with its classified auth and replay policy. + pub async fn bills(&self, q: BTreeMap) -> OkxResult> { + self.get("/api/v5/asset/subaccount/bills", q).await + } + /// Executes the `transfer` OKX V5 operation with its classified auth and replay policy. + pub async fn transfer(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/asset/subaccount/transfer", b).await + } + /// Executes the `set_transfer_out` OKX V5 operation with its classified auth and replay policy. + pub async fn set_transfer_out( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/users/subaccount/set-transfer-out", b) + .await + } + /// Executes the `api_keys` OKX V5 operation with its classified auth and replay policy. + pub async fn api_keys(&self, q: BTreeMap) -> OkxResult> { + self.get("/api/v5/users/subaccount/apikey", q).await + } + /// Executes the `create_api_key` OKX V5 operation with its classified auth and replay policy. + pub async fn create_api_key(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/users/subaccount/apikey", b).await + } + /// Executes the `modify_api_key` OKX V5 operation with its classified auth and replay policy. + pub async fn modify_api_key(&self, b: &serde_json::Value) -> OkxResult> { + self.write("/api/v5/users/subaccount/modify-apikey", b) + .await + } + /// Executes the `loaned` OKX V5 operation with its classified auth and replay policy. + pub async fn loaned(&self, q: BTreeMap) -> OkxResult> { + self.get("/api/v5/account/subaccount/loaned", q).await + } + /// Executes the `interest_limits` OKX V5 operation with its classified auth and replay policy. + pub async fn interest_limits( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/account/subaccount/interest-limits", q) + .await + } + /// Executes the `funding_balance` OKX V5 operation with its classified auth and replay policy. + pub async fn funding_balance( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/asset/subaccount/balances", q).await + } + /// Executes the `entrust_list` OKX V5 operation with its classified auth and replay policy. + pub async fn entrust_list( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.get("/api/v5/users/entrust-subaccount-list", q).await + } + /// Executes the `set_loan_allocation` OKX V5 operation with its classified auth and replay policy. + pub async fn set_loan_allocation( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write("/api/v5/account/subaccount/set-loan-allocation", b) + .await + } +} diff --git a/crates/truefix-okx-client/src/services/trade.rs b/crates/truefix-okx-client/src/services/trade.rs new file mode 100644 index 0000000..22c4a22 --- /dev/null +++ b/crates/truefix-okx-client/src/services/trade.rs @@ -0,0 +1,344 @@ +use crate::{ + client::OkxClient, + error::OkxResult, + request::{CanonicalRequest, RetrySafety}, + types::{ + common::ExpirationTime, + order::{ + AmendOrder, BatchResult, Fill, Order, OrderAck, OrderReference, PlaceOrder, + require_successful_acknowledgements, + }, + }, +}; +use std::collections::BTreeMap; +pub struct TradeService<'a>(pub(crate) &'a OkxClient); +impl TradeService<'_> { + async fn read_json( + &self, + path: &'static str, + query: BTreeMap, + ) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + path, + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + async fn write_json( + &self, + path: &'static str, + request: &serde_json::Value, + ) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::POST, + path, + BTreeMap::new(), + Some(request), + RetrySafety::NeverReplay, + true, + )?) + .await + } + async fn write_order_json( + &self, + path: &'static str, + request: &T, + expiration_time: ExpirationTime, + ) -> OkxResult> { + let acknowledgements = self + .0 + .execute( + CanonicalRequest::new( + reqwest::Method::POST, + path, + BTreeMap::new(), + Some(request), + RetrySafety::NeverReplay, + true, + )? + .with_expiration_time(expiration_time), + ) + .await?; + require_successful_acknowledgements(acknowledgements) + } + + async fn write_order( + &self, + path: &'static str, + request: &T, + ) -> OkxResult> { + let acknowledgements = self + .0 + .execute(CanonicalRequest::new( + reqwest::Method::POST, + path, + BTreeMap::new(), + Some(request), + RetrySafety::NeverReplay, + true, + )?) + .await?; + require_successful_acknowledgements(acknowledgements) + } + /// Executes the `place_order` OKX V5 operation with its classified auth and replay policy. + pub async fn place_order(&self, order: &PlaceOrder) -> OkxResult> { + self.write_order("/api/v5/trade/order", order).await + } + /// Places an order which OKX must reject if it has not begun processing by `expiration_time`. + pub async fn place_order_with_expiration( + &self, + order: &PlaceOrder, + expiration_time: ExpirationTime, + ) -> OkxResult> { + self.write_order_json("/api/v5/trade/order", order, expiration_time) + .await + } + /// Places a batch of orders; each acknowledgement retains its own exchange code. + pub async fn place_batch(&self, orders: &[PlaceOrder]) -> OkxResult { + self.write_order("/api/v5/trade/batch-orders", orders).await + } + /// Places a batch with one shared OKX processing deadline. + pub async fn place_batch_with_expiration( + &self, + orders: &[PlaceOrder], + expiration_time: ExpirationTime, + ) -> OkxResult { + self.write_order_json("/api/v5/trade/batch-orders", orders, expiration_time) + .await + } + /// Cancels one order. + pub async fn cancel_order(&self, order: &OrderReference) -> OkxResult> { + self.write_order("/api/v5/trade/cancel-order", order).await + } + /// Cancels a batch of orders without replaying the write. + pub async fn cancel_batch(&self, orders: &[OrderReference]) -> OkxResult { + self.write_order("/api/v5/trade/cancel-batch-orders", orders) + .await + } + /// Amends one order. + pub async fn amend_order(&self, order: &AmendOrder) -> OkxResult> { + self.write_order("/api/v5/trade/amend-order", order).await + } + /// Amends an order with an OKX processing deadline. + pub async fn amend_order_with_expiration( + &self, + order: &AmendOrder, + expiration_time: ExpirationTime, + ) -> OkxResult> { + self.write_order_json("/api/v5/trade/amend-order", order, expiration_time) + .await + } + /// Amends a batch of orders without replaying the write. + pub async fn amend_batch(&self, orders: &[AmendOrder]) -> OkxResult { + self.write_order("/api/v5/trade/amend-batch-orders", orders) + .await + } + /// Amends a batch with one shared OKX processing deadline. + pub async fn amend_batch_with_expiration( + &self, + orders: &[AmendOrder], + expiration_time: ExpirationTime, + ) -> OkxResult { + self.write_order_json("/api/v5/trade/amend-batch-orders", orders, expiration_time) + .await + } + /// Closes positions according to the supplied native request. + pub async fn close_positions(&self, request: &serde_json::Value) -> OkxResult> { + self.write_order("/api/v5/trade/close-position", request) + .await + } + /// Gets a single order by exchange or client identifier. + pub async fn order(&self, query: BTreeMap) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/trade/order", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + /// Lists orders and preserves caller-supplied cursor filters. + pub async fn orders(&self, query: BTreeMap) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/trade/orders-pending", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + /// Lists completed order history while preserving server cursor filters. + pub async fn order_history(&self, query: BTreeMap) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/trade/orders-history", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + /// Lists fills and preserves exact decimal execution data. + pub async fn fills(&self, query: BTreeMap) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/trade/fills", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + + /// Places an algo order. Asset-changing commands are not automatically replayed. + pub async fn place_algo_order( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/trade/order-algo", request).await + } + /// Executes the `cancel_algo_order` OKX V5 operation with its classified auth and replay policy. + pub async fn cancel_algo_order( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/trade/cancel-algos", request).await + } + /// Executes the `amend_algo_order` OKX V5 operation with its classified auth and replay policy. + pub async fn amend_algo_order( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/trade/amend-algos", request).await + } + /// Executes the `algo_orders_pending` OKX V5 operation with its classified auth and replay policy. + pub async fn algo_orders_pending( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/trade/orders-algo-pending", query) + .await + } + /// Executes the `algo_orders_history` OKX V5 operation with its classified auth and replay policy. + pub async fn algo_orders_history( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/trade/orders-algo-history", query) + .await + } + /// Gets the conversion currencies available to the account. + pub async fn easy_convert_currency_list( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/trade/easy-convert-currency-list", query) + .await + } + /// Executes the `easy_convert` OKX V5 operation with its classified auth and replay policy. + pub async fn easy_convert( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/trade/easy-convert", request).await + } + /// Executes the `easy_convert_history` OKX V5 operation with its classified auth and replay policy. + pub async fn easy_convert_history( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/trade/easy-convert-history", query) + .await + } + /// Executes the `one_click_repay_currency_list` OKX V5 operation with its classified auth and replay policy. + pub async fn one_click_repay_currency_list( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/trade/one-click-repay-currency-list", query) + .await + } + /// Executes the `one_click_repay` OKX V5 operation with its classified auth and replay policy. + pub async fn one_click_repay( + &self, + request: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/trade/one-click-repay", request) + .await + } + /// Executes the `one_click_repay_history` OKX V5 operation with its classified auth and replay policy. + pub async fn one_click_repay_history( + &self, + query: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/trade/one-click-repay-history", query) + .await + } + /// Executes the `fills_history` OKX V5 operation with its classified auth and replay policy. + pub async fn fills_history(&self, query: BTreeMap) -> OkxResult> { + self.0 + .execute(CanonicalRequest::new( + reqwest::Method::GET, + "/api/v5/trade/fills-history", + query, + None::<&serde_json::Value>, + RetrySafety::ReadOnly, + true, + )?) + .await + } + /// Executes the `orders_history_archive` OKX V5 operation with its classified auth and replay policy. + pub async fn orders_history_archive( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/trade/orders-history-archive", q) + .await + } + /// Executes the `algo_order_details` OKX V5 operation with its classified auth and replay policy. + pub async fn algo_order_details( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/trade/order-algo", q).await + } + /// Executes the `one_click_repay_currency_list_v2` OKX V5 operation with its classified auth and replay policy. + pub async fn one_click_repay_currency_list_v2(&self) -> OkxResult> { + self.read_json( + "/api/v5/trade/one-click-repay-currency-list-v2", + BTreeMap::new(), + ) + .await + } + /// Executes the `one_click_repay_v2` OKX V5 operation with its classified auth and replay policy. + pub async fn one_click_repay_v2( + &self, + b: &serde_json::Value, + ) -> OkxResult> { + self.write_json("/api/v5/trade/one-click-repay-v2", b).await + } + /// Executes the `one_click_repay_history_v2` OKX V5 operation with its classified auth and replay policy. + pub async fn one_click_repay_history_v2( + &self, + q: BTreeMap, + ) -> OkxResult> { + self.read_json("/api/v5/trade/one-click-repay-history-v2", q) + .await + } +} diff --git a/crates/truefix-okx-client/src/transport/http.rs b/crates/truefix-okx-client/src/transport/http.rs new file mode 100644 index 0000000..2b1a9fe --- /dev/null +++ b/crates/truefix-okx-client/src/transport/http.rs @@ -0,0 +1,121 @@ +use std::sync::Arc; + +use crate::{ + auth::{Clock, private_headers, sign_rest}, + config::{ClientConfig, Environment}, + error::{OkxError, OkxResult}, + request::CanonicalRequest, + response::ResponseMetadata, +}; + +/// Exact HTTP response bytes plus metadata that is not present in the OKX JSON envelope. +pub(crate) struct HttpResponse { + pub body: Vec, + pub metadata: ResponseMetadata, +} + +/// Reusable HTTP transport with the client configuration and signing clock. +pub struct HttpTransport { + client: reqwest::Client, + config: Arc, + clock: Arc, +} + +impl HttpTransport { + pub fn new(config: Arc, clock: Arc) -> OkxResult { + let mut builder = reqwest::Client::builder().timeout(config.timeout); + if let Some(proxy) = &config.proxy { + builder = builder.proxy( + reqwest::Proxy::all(proxy) + .map_err(|error| OkxError::InvalidConfiguration(error.to_string()))?, + ); + } + Ok(Self { + client: builder.build()?, + config, + clock, + }) + } + + /// Executes exact canonical bytes and returns raw response bytes for typed decoding. + pub(crate) async fn execute(&self, request: CanonicalRequest) -> OkxResult { + let url = format!( + "{}{}", + self.config.environment.rest_base(), + request.path_and_query + ); + let mut builder = self + .client + .request(request.method.clone(), url) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(request.body.clone()); + let simulated = match self.config.environment { + Environment::Demo => "1", + Environment::Live(_) => "0", + Environment::Custom { + simulated: true, .. + } => "1", + Environment::Custom { + simulated: false, .. + } => "0", + }; + builder = builder.header("x-simulated-trading", simulated); + if let Some(expiration_time) = request.expiration_time { + builder = builder.header("expTime", expiration_time.unix_milliseconds().to_string()); + } + if request.requires_auth { + let credentials = self + .config + .credentials + .as_ref() + .ok_or(OkxError::MissingCredentials)?; + let (signature, timestamp) = sign_rest(credentials, &request, self.clock.now())?; + for (name, value) in private_headers(credentials, signature, timestamp) { + builder = builder.header(name, value); + } + } + let response = builder.send().await?; + if response.status().as_u16() == 429 { + let retry_after = response + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .map(std::time::Duration::from_secs); + return Err(OkxError::RateLimited { retry_after }); + } + if !response.status().is_success() { + let message = response + .status() + .canonical_reason() + .map(str::to_owned) + .unwrap_or_else(|| "HTTP failure".to_owned()); + return Err(OkxError::Exchange { + code: response.status().as_u16().to_string(), + message, + request_id: response + .headers() + .get("x-request-id") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned), + }); + } + let metadata = ResponseMetadata::from_headers(response.headers()); + let body = response.bytes().await?.to_vec(); + // OKX can signal a rate-limit rejection in a successful HTTP response. + // Surface it before typed envelope decoding so read-only calls receive + // the same bounded retry path as an HTTP 429 response. + if serde_json::from_slice::(&body) + .ok() + .is_some_and(|envelope| envelope.code == "50011") + { + return Err(OkxError::RateLimited { retry_after: None }); + } + Ok(HttpResponse { body, metadata }) + } +} + +#[derive(serde::Deserialize)] +struct RateLimitEnvelope { + code: String, +} diff --git a/crates/truefix-okx-client/src/transport/mod.rs b/crates/truefix-okx-client/src/transport/mod.rs new file mode 100644 index 0000000..fbaf4c6 --- /dev/null +++ b/crates/truefix-okx-client/src/transport/mod.rs @@ -0,0 +1,3 @@ +//! Shared network transports. +pub mod http; +pub mod websocket; diff --git a/crates/truefix-okx-client/src/transport/websocket.rs b/crates/truefix-okx-client/src/transport/websocket.rs new file mode 100644 index 0000000..6d11c53 --- /dev/null +++ b/crates/truefix-okx-client/src/transport/websocket.rs @@ -0,0 +1,123 @@ +//! Minimal redacted WebSocket transport used by the session layer. + +use futures_util::{SinkExt, StreamExt}; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungstenite::Message}; + +use crate::{ + error::{OkxError, OkxResult}, + types::websocket::WsHeartbeat, + ws::session::Session, +}; + +/// Connected WebSocket transport. Credentials are never retained or logged here. +pub struct WebSocketTransport { + socket: WebSocketStream>, +} + +impl WebSocketTransport { + /// Connects to an explicitly selected OKX endpoint. + pub async fn connect(endpoint: &str) -> OkxResult { + let (socket, _) = connect_async(endpoint).await?; + Ok(Self { socket }) + } + + /// Sends one serialized protocol record. + pub async fn send(&mut self, value: &T) -> OkxResult<()> { + let body = serde_json::to_string(value).map_err(OkxError::Decode)?; + self.socket.send(Message::Text(body.into())).await?; + Ok(()) + } + + /// Sends OKX's required literal application heartbeat text frame. + pub async fn send_heartbeat(&mut self, _: WsHeartbeat) -> OkxResult<()> { + self.socket.send(Message::Text("ping".into())).await?; + Ok(()) + } + + /// Sends an OKX application heartbeat when the session's inbound-idle timer expires. + pub async fn send_heartbeat_if_due(&mut self, session: &mut Session) -> OkxResult { + if session.ping_due(std::time::Instant::now()) { + heartbeat_send_result(session, self.send_heartbeat(WsHeartbeat).await) + } else { + Ok(false) + } + } + + /// Receives one text event; close before acknowledgement is an unknown completion. + pub async fn receive(&mut self) -> OkxResult { + match self.socket.next().await { + Some(Ok(Message::Text(text))) => Ok(text.to_string()), + Some(Ok(Message::Close(_))) | None => Err(OkxError::UnknownCompletion), + Some(Ok(_)) => Err(OkxError::Decode(serde_json::Error::io( + std::io::Error::other("unexpected non-text WebSocket message"), + ))), + Some(Err(error)) => Err(error.into()), + } + } + + /// Receives one application message while keeping the supplied session's heartbeat state in + /// sync. A `pong` is consumed after acknowledging the pending heartbeat; other text is + /// returned to the caller for normal acknowledgement/event handling. + pub async fn receive_for_session( + &mut self, + session: &mut Session, + ) -> OkxResult> { + let message = match self.receive().await { + Ok(message) => message, + Err(error) => { + // A close frame, transport failure, and malformed frame all make the + // connection unusable. Do not leave callers with an Active session + // after returning an error. + session.disconnected(); + return Err(error); + } + }; + let now = std::time::Instant::now(); + if message == "pong" { + session.pong_received(); + Ok(None) + } else { + session.message_received(now); + Ok(Some(message)) + } + } + + /// Sends a normal close frame. + pub async fn close(&mut self) -> OkxResult<()> { + self.socket.close(None).await?; + Ok(()) + } +} + +/// Applies the lifecycle effect of a heartbeat write. Kept separate from the socket I/O so the +/// connection-state invariant remains directly testable. +fn heartbeat_send_result(session: &mut Session, result: OkxResult<()>) -> OkxResult { + match result { + Ok(()) => Ok(true), + Err(error) => { + // `ping_due` has already recorded an outstanding pong. A failed write means that + // pong can never arrive through this transport, so keep the lifecycle gate closed + // until reconnect recovery completes. + session.disconnected(); + Err(error) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ws::session::SessionState; + + #[test] + fn failed_heartbeat_write_disconnects_the_session() { + let mut session = Session::default(); + session.connected(); + + assert!(matches!( + heartbeat_send_result(&mut session, Err(OkxError::UnknownCompletion)), + Err(OkxError::UnknownCompletion) + )); + assert_eq!(session.state(), SessionState::Backoff); + } +} diff --git a/crates/truefix-okx-client/src/types/account.rs b/crates/truefix-okx-client/src/types/account.rs new file mode 100644 index 0000000..2a4cdea --- /dev/null +++ b/crates/truefix-okx-client/src/types/account.rs @@ -0,0 +1,196 @@ +use crate::types::{ + common::DecimalValue, + gateway::{GatewayBalance, GatewayPosition, GatewayProject, NativeFields}, +}; +use serde::{Deserialize, Deserializer}; + +/// Deserializes OKX optional numeric fields, treating its `""` sentinel as absent. +fn empty_string_as_none<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + match value { + None => Ok(None), + Some(serde_json::Value::String(ref value)) if value.is_empty() => Ok(None), + Some(value) => serde_json::from_value(value) + .map(Some) + .map_err(serde::de::Error::custom), + } +} + +/// Account-level balance summary returned by `/api/v5/account/balance`. +/// +/// Currency balances are nested in [`Self::details`]; the endpoint does not return +/// `Balance` records directly. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct AccountBalance { + pub details: Vec, + /// OKX account-level fields outside the currency balance representation. + #[serde(flatten)] + pub native_fields: NativeFields, +} + +/// Account balance with native availability and equity fields. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct Balance { + #[serde(rename = "ccy")] + pub currency: String, + #[serde( + rename = "availBal", + default, + deserialize_with = "empty_string_as_none" + )] + pub available: Option, + #[serde(rename = "eq", default, deserialize_with = "empty_string_as_none")] + pub equity: Option, + #[serde( + rename = "frozenBal", + default, + deserialize_with = "empty_string_as_none" + )] + pub frozen: Option, + /// OKX fields outside the core balance representation. + #[serde(flatten)] + pub native_fields: NativeFields, +} +/// Open position returned by the account service. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct Position { + #[serde(rename = "instId")] + pub instrument_id: String, + #[serde(rename = "pos")] + pub quantity: DecimalValue, + #[serde(rename = "avgPx", default, deserialize_with = "empty_string_as_none")] + pub average_price: Option, + #[serde(rename = "upl", default, deserialize_with = "empty_string_as_none")] + pub unrealized_pnl: Option, + /// OKX fields outside the core position representation. + #[serde(flatten)] + pub native_fields: NativeFields, +} + +impl GatewayProject for Balance { + type Projection = GatewayBalance; + + fn project_gateway(&self) -> Self::Projection { + GatewayBalance { + currency: self.currency.clone(), + available: self.available, + equity: self.equity, + native_fields: self.native_fields.clone(), + } + } +} + +impl GatewayProject for Position { + type Projection = GatewayPosition; + + fn project_gateway(&self) -> Self::Projection { + GatewayPosition { + instrument_id: self.instrument_id.clone(), + quantity: self.quantity, + average_price: self.average_price, + unrealized_pnl: self.unrealized_pnl, + native_fields: self.native_fields.clone(), + } + } +} +/// Account bill preserving the exchange's accounting classification. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct Bill { + #[serde(rename = "billId")] + pub bill_id: String, + #[serde(rename = "instId")] + pub instrument_id: Option, + #[serde(rename = "balChg", default, deserialize_with = "empty_string_as_none")] + pub balance_change: Option, + #[serde(rename = "ts")] + pub timestamp: String, +} +/// Current leverage for an instrument or currency scope. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct Leverage { + pub lever: DecimalValue, + #[serde(rename = "mgnMode")] + pub margin_mode: String, + #[serde(rename = "instId")] + pub instrument_id: Option, +} +/// Margin adjustment acknowledgement. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct MarginAdjustment { + #[serde(rename = "amt")] + pub amount: DecimalValue, + #[serde(rename = "instId")] + pub instrument_id: String, +} +/// Position-risk record supplied by OKX. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct PositionRisk { + #[serde(rename = "instId")] + pub instrument_id: Option, + #[serde( + rename = "liquidationPx", + default, + deserialize_with = "empty_string_as_none" + )] + pub liquidation_price: Option, + #[serde( + rename = "mgnRatio", + default, + deserialize_with = "empty_string_as_none" + )] + pub margin_ratio: Option, +} +/// Fee schedule for a product and account level. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct FeeRate { + #[serde(rename = "instType")] + pub instrument_type: String, + #[serde(rename = "maker", default, deserialize_with = "empty_string_as_none")] + pub maker: Option, + #[serde(rename = "taker", default, deserialize_with = "empty_string_as_none")] + pub taker: Option, +} + +#[cfg(test)] +mod tests { + use super::{AccountBalance, FeeRate, Position}; + + #[test] + fn account_balance_preserves_nested_currency_details() { + let summary: AccountBalance = serde_json::from_value(serde_json::json!({ + "totalEq": "12.5", + "details": [{ + "ccy": "BTC", "availBal": "", "eq": "", "frozenBal": "" + }] + })) + .unwrap(); + + assert_eq!(summary.details.len(), 1); + assert_eq!(summary.details[0].currency, "BTC"); + assert_eq!(summary.details[0].available, None); + assert_eq!( + summary.native_fields.get("totalEq").unwrap().as_str(), + Some("12.5") + ); + } + + #[test] + fn empty_numeric_sentinels_decode_as_none() { + let position: Position = serde_json::from_value(serde_json::json!({ + "instId": "BTC-USDT-SWAP", "pos": "0", "avgPx": "", "upl": "" + })) + .unwrap(); + let fee: FeeRate = serde_json::from_value(serde_json::json!({ + "instType": "SWAP", "maker": "", "taker": "" + })) + .unwrap(); + + assert_eq!(position.average_price, None); + assert_eq!(position.unrealized_pnl, None); + assert_eq!(fee.maker, None); + assert_eq!(fee.taker, None); + } +} diff --git a/crates/truefix-okx-client/src/types/common.rs b/crates/truefix-okx-client/src/types/common.rs new file mode 100644 index 0000000..eb627c3 --- /dev/null +++ b/crates/truefix-okx-client/src/types/common.rs @@ -0,0 +1,67 @@ +use rust_decimal::Decimal; + +/// Exact price or size representation used in public models. +pub type DecimalValue = Decimal; +/// OKX instrument identifier. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct InstrumentId(pub String); +/// Opaque cursor pair returned by OKX list endpoints. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PageCursor { + pub before: Option, + pub after: Option, +} +/// Millisecond Unix timestamp represented as text by the OKX wire API. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MillisecondTimestamp(pub String); + +/// A millisecond Unix timestamp after which OKX must reject an order or amendment request. +/// +/// The value is sent as the `expTime` REST header or WebSocket argument. It must be based on +/// the same (optionally offset) server-time basis as the corresponding request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExpirationTime(i64); + +impl ExpirationTime { + /// Creates an expiration timestamp. Zero and negative Unix timestamps are rejected. + pub fn new(unix_milliseconds: i64) -> Result { + if unix_milliseconds > 0 { + Ok(Self(unix_milliseconds)) + } else { + Err("expiration time must be a positive Unix-millisecond timestamp") + } + } + + /// Returns the exact Unix-millisecond timestamp sent to OKX. + pub const fn unix_milliseconds(self) -> i64 { + self.0 + } +} + +impl serde::Serialize for ExpirationTime { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::ExpirationTime; + + #[test] + fn expiration_time_is_a_positive_string_timestamp() { + let expiry = ExpirationTime::new(1_704_067_200_123).unwrap(); + assert_eq!(serde_json::to_value(expiry).unwrap(), "1704067200123"); + assert!(ExpirationTime::new(0).is_err()); + } +} + +/// Native long-tail REST record retaining product-specific exchange fields. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct NativeRecord { + #[serde(flatten)] + pub fields: std::collections::BTreeMap, +} diff --git a/crates/truefix-okx-client/src/types/gateway.rs b/crates/truefix-okx-client/src/types/gateway.rs new file mode 100644 index 0000000..3938f8e --- /dev/null +++ b/crates/truefix-okx-client/src/types/gateway.rs @@ -0,0 +1,97 @@ +//! Narrow, dependency-free common-data projections for gateway adapters. +//! +//! These types deliberately describe only the data that is meaningful across venues. They do +//! not attempt to turn an OKX product into a different product: callers that need exchange +//! semantics can always inspect the source record's `native_fields`. + +use std::collections::BTreeMap; + +use crate::types::{common::DecimalValue, order::ClientOrderId}; + +/// Exchange-specific fields which are not part of a common projection. +pub type NativeFields = BTreeMap; + +/// Converts an OKX value into the small common representation useful to a future gateway. +/// +/// This crate owns no gateway dependency. An adapter can consume these values while retaining +/// the originating OKX model (and its [`NativeFields`]) alongside the projection. +pub trait GatewayProject { + /// The common, venue-neutral representation of this value. + type Projection; + + /// Produces a loss-aware common projection. + fn project_gateway(&self) -> Self::Projection; +} + +/// Common order state with the OKX identity retained for reconciliation. +#[derive(Debug, Clone, PartialEq)] +pub struct GatewayOrder { + /// OKX exchange order identifier. + pub venue_order_id: String, + /// Optional caller supplied order identifier. + pub client_order_id: Option, + /// Venue instrument identifier. + pub instrument_id: String, + /// Venue order state; gateway adapters may map it to their own state vocabulary. + pub state: String, + /// Cumulative filled quantity, when supplied by OKX. + pub filled_size: Option, + /// Fields outside this projection's common vocabulary. + pub native_fields: NativeFields, +} + +/// Common execution/fill representation. +#[derive(Debug, Clone, PartialEq)] +pub struct GatewayExecution { + /// OKX fill identifier. + pub venue_execution_id: String, + /// OKX order identifier. + pub venue_order_id: String, + /// Executed price. + pub price: DecimalValue, + /// Executed size. + pub size: DecimalValue, + /// Fields outside this projection's common vocabulary. + pub native_fields: NativeFields, +} + +/// Common cash-balance representation. +#[derive(Debug, Clone, PartialEq)] +pub struct GatewayBalance { + /// Currency code. + pub currency: String, + /// Spendable balance when supplied by OKX. + pub available: Option, + /// Account equity when supplied by OKX. + pub equity: Option, + /// Fields outside this projection's common vocabulary. + pub native_fields: NativeFields, +} + +/// Common open-position representation. +#[derive(Debug, Clone, PartialEq)] +pub struct GatewayPosition { + /// Venue instrument identifier. + pub instrument_id: String, + /// Signed position quantity. + pub quantity: DecimalValue, + /// Average entry price when supplied by OKX. + pub average_price: Option, + /// Unrealized profit/loss when supplied by OKX. + pub unrealized_pnl: Option, + /// Fields outside this projection's common vocabulary. + pub native_fields: NativeFields, +} + +/// Common last-price market-data representation. +#[derive(Debug, Clone, PartialEq)] +pub struct GatewayTicker { + /// Venue instrument identifier. + pub instrument_id: String, + /// Last traded price. + pub last: DecimalValue, + /// Rolling 24-hour volume when supplied by OKX. + pub volume_24h: Option, + /// Fields outside this projection's common vocabulary. + pub native_fields: NativeFields, +} diff --git a/crates/truefix-okx-client/src/types/instrument.rs b/crates/truefix-okx-client/src/types/instrument.rs new file mode 100644 index 0000000..b0d1b09 --- /dev/null +++ b/crates/truefix-okx-client/src/types/instrument.rs @@ -0,0 +1,10 @@ +/// Supported OKX instrument product classes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstrumentType { + Spot, + Margin, + Swap, + Futures, + Option, + Any, +} diff --git a/crates/truefix-okx-client/src/types/market.rs b/crates/truefix-okx-client/src/types/market.rs new file mode 100644 index 0000000..69ffce6 --- /dev/null +++ b/crates/truefix-okx-client/src/types/market.rs @@ -0,0 +1,112 @@ +use crate::types::{ + common::DecimalValue, + gateway::{GatewayProject, GatewayTicker, NativeFields}, +}; +/// Last-price ticker. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct Ticker { + #[serde(rename = "instId")] + pub instrument_id: String, + #[serde(rename = "last")] + pub last: DecimalValue, + #[serde(rename = "vol24h")] + pub volume_24h: Option, + /// OKX fields outside the core ticker representation. + #[serde(flatten)] + pub native_fields: NativeFields, +} + +impl GatewayProject for Ticker { + type Projection = GatewayTicker; + + fn project_gateway(&self) -> Self::Projection { + GatewayTicker { + instrument_id: self.instrument_id.clone(), + last: self.last, + volume_24h: self.volume_24h, + native_fields: self.native_fields.clone(), + } + } +} +/// Order book level. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct BookLevel( + pub DecimalValue, + pub DecimalValue, + pub DecimalValue, + pub DecimalValue, +); +/// Market order book snapshot. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct OrderBook { + #[serde(rename = "instId")] + pub instrument_id: String, + pub asks: Vec, + pub bids: Vec, + #[serde(rename = "ts")] + pub timestamp: String, +} +/// Candle represented with exact values and exchange timestamp. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct Candle( + pub String, + pub DecimalValue, + pub DecimalValue, + pub DecimalValue, + pub DecimalValue, + pub DecimalValue, + pub DecimalValue, + pub DecimalValue, + pub String, +); +/// Public trade record. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct MarketTrade { + #[serde(rename = "tradeId")] + pub trade_id: String, + #[serde(rename = "instId")] + pub instrument_id: String, + pub px: DecimalValue, + pub sz: DecimalValue, + pub side: String, + pub ts: String, +} +/// Index or mark-price ticker. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct PriceIndex { + #[serde(rename = "instId")] + pub instrument_id: String, + #[serde(rename = "idxPx")] + pub index_price: Option, + #[serde(rename = "markPx")] + pub mark_price: Option, +} +/// Current or historical funding rate. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct FundingRate { + #[serde(rename = "instId")] + pub instrument_id: String, + #[serde(rename = "fundingRate")] + pub rate: DecimalValue, + #[serde(rename = "fundingTime")] + pub funding_time: String, +} + +#[cfg(test)] +mod tests { + use super::Candle; + + #[test] + fn candle_deserializes_the_complete_okx_wire_tuple() { + let candle: Candle = serde_json::from_str( + r#"["1697025780000","27000.5","27001.0","26999.8","27000.2","15","405003","405003","1"]"#, + ) + .unwrap(); + + assert_eq!(candle.0, "1697025780000"); + assert_eq!(candle.5.to_string(), "15"); + assert_eq!(candle.6.to_string(), "405003"); + assert_eq!(candle.7.to_string(), "405003"); + assert_eq!(candle.8, "1"); + } +} diff --git a/crates/truefix-okx-client/src/types/mod.rs b/crates/truefix-okx-client/src/types/mod.rs new file mode 100644 index 0000000..e021003 --- /dev/null +++ b/crates/truefix-okx-client/src/types/mod.rs @@ -0,0 +1,8 @@ +//! Domain types shared by all OKX services. +pub mod account; +pub mod common; +pub mod gateway; +pub mod instrument; +pub mod market; +pub mod order; +pub mod websocket; diff --git a/crates/truefix-okx-client/src/types/order.rs b/crates/truefix-okx-client/src/types/order.rs new file mode 100644 index 0000000..a31b479 --- /dev/null +++ b/crates/truefix-okx-client/src/types/order.rs @@ -0,0 +1,305 @@ +use crate::types::{ + common::DecimalValue, + gateway::{GatewayExecution, GatewayOrder, GatewayProject, NativeFields}, +}; +/// Stable caller-provided order identity. +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] +pub struct ClientOrderId(pub String); +/// Completion state of an asset-changing command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompletionState { + Confirmed, + Rejected, + Unknown, +} +/// Ordinary order submission. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PlaceOrder { + #[serde(rename = "instId")] + pub instrument_id: String, + #[serde(rename = "tdMode")] + pub trade_mode: String, + pub side: String, + #[serde(rename = "ordType")] + pub order_type: String, + #[serde(rename = "sz")] + pub size: DecimalValue, + #[serde(rename = "px", skip_serializing_if = "Option::is_none")] + pub price: Option, + #[serde(rename = "clOrdId", skip_serializing_if = "Option::is_none")] + pub client_order_id: Option, + #[serde(rename = "ccy", skip_serializing_if = "Option::is_none")] + pub margin_currency: Option, + #[serde(rename = "tag", skip_serializing_if = "Option::is_none")] + pub tag: Option, + #[serde(rename = "posSide", skip_serializing_if = "Option::is_none")] + pub position_side: Option, + #[serde(rename = "reduceOnly", skip_serializing_if = "Option::is_none")] + pub reduce_only: Option, + #[serde(rename = "tgtCcy", skip_serializing_if = "Option::is_none")] + pub target_currency: Option, + #[serde(rename = "stpMode", skip_serializing_if = "Option::is_none")] + pub self_trade_prevention_mode: Option, + #[serde(rename = "attachAlgoOrds", skip_serializing_if = "Option::is_none")] + pub attached_algo_orders: Option, + #[serde(rename = "pxUsd", skip_serializing_if = "Option::is_none")] + pub price_usd: Option, + #[serde(rename = "pxVol", skip_serializing_if = "Option::is_none")] + pub price_volatility: Option, + #[serde(rename = "banAmend", skip_serializing_if = "Option::is_none")] + pub ban_amend: Option, + #[serde(rename = "tradeQuoteCcy", skip_serializing_if = "Option::is_none")] + pub trade_quote_currency: Option, + #[serde(rename = "pxAmendType", skip_serializing_if = "Option::is_none")] + pub price_amend_type: Option, + #[serde(rename = "isElpTakerAccess", skip_serializing_if = "Option::is_none")] + pub elp_taker_access: Option, + #[serde(rename = "instIdCode", skip_serializing_if = "Option::is_none")] + pub instrument_id_code: Option, +} +impl PlaceOrder { + /// Creates a minimal order; advanced OKX fields default to omitted. + pub fn new( + instrument_id: impl Into, + trade_mode: impl Into, + side: impl Into, + order_type: impl Into, + size: DecimalValue, + ) -> Self { + Self { + instrument_id: instrument_id.into(), + trade_mode: trade_mode.into(), + side: side.into(), + order_type: order_type.into(), + size, + price: None, + client_order_id: None, + margin_currency: None, + tag: None, + position_side: None, + reduce_only: None, + target_currency: None, + self_trade_prevention_mode: None, + attached_algo_orders: None, + price_usd: None, + price_volatility: None, + ban_amend: None, + trade_quote_currency: None, + price_amend_type: None, + elp_taker_access: None, + instrument_id_code: None, + } + } +} +/// An order reference used for cancellation, amendment and lookup. +#[derive(Debug, Clone, serde::Serialize)] +pub struct OrderReference { + #[serde(rename = "instId")] + pub instrument_id: String, + #[serde(rename = "ordId", skip_serializing_if = "Option::is_none")] + pub order_id: Option, + #[serde(rename = "clOrdId", skip_serializing_if = "Option::is_none")] + pub client_order_id: Option, +} +/// Amendment request preserving the idempotency request identifier. +#[derive(Debug, Clone, serde::Serialize)] +pub struct AmendOrder { + #[serde(flatten)] + pub order: OrderReference, + #[serde(rename = "newSz", skip_serializing_if = "Option::is_none")] + pub new_size: Option, + #[serde(rename = "newPx", skip_serializing_if = "Option::is_none")] + pub new_price: Option, + #[serde(rename = "reqId", skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(rename = "newTpTriggerPx", skip_serializing_if = "Option::is_none")] + pub new_take_profit_trigger_price: Option, + #[serde(rename = "newTpOrdPx", skip_serializing_if = "Option::is_none")] + pub new_take_profit_order_price: Option, + #[serde(rename = "newSlTriggerPx", skip_serializing_if = "Option::is_none")] + pub new_stop_loss_trigger_price: Option, + #[serde(rename = "newSlOrdPx", skip_serializing_if = "Option::is_none")] + pub new_stop_loss_order_price: Option, + #[serde(rename = "newTpTriggerPxType", skip_serializing_if = "Option::is_none")] + pub new_take_profit_trigger_price_type: Option, + #[serde(rename = "newSlTriggerPxType", skip_serializing_if = "Option::is_none")] + pub new_stop_loss_trigger_price_type: Option, + #[serde(rename = "attachAlgoOrds", skip_serializing_if = "Option::is_none")] + pub attached_algo_orders: Option, + #[serde(rename = "newTriggerPx", skip_serializing_if = "Option::is_none")] + pub new_trigger_price: Option, + #[serde(rename = "newOrdPx", skip_serializing_if = "Option::is_none")] + pub new_order_price: Option, + #[serde(rename = "pxAmendType", skip_serializing_if = "Option::is_none")] + pub price_amend_type: Option, + #[serde(rename = "newTpTriggerRatio", skip_serializing_if = "Option::is_none")] + pub new_take_profit_trigger_ratio: Option, + #[serde(rename = "newSlTriggerRatio", skip_serializing_if = "Option::is_none")] + pub new_stop_loss_trigger_ratio: Option, + #[serde(rename = "cxlOnFail", skip_serializing_if = "Option::is_none")] + pub cancel_on_fail: Option, +} +/// Exchange order record. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct Order { + #[serde(rename = "ordId")] + pub order_id: String, + #[serde(rename = "clOrdId")] + pub client_order_id: Option, + #[serde(rename = "instId")] + pub instrument_id: String, + pub state: String, + #[serde(rename = "accFillSz")] + pub filled_size: Option, + /// OKX fields outside the core order representation. + #[serde(flatten)] + pub native_fields: NativeFields, +} +/// Execution/fill record. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct Fill { + #[serde(rename = "fillId")] + pub fill_id: String, + #[serde(rename = "ordId")] + pub order_id: String, + #[serde(rename = "fillPx")] + pub price: DecimalValue, + #[serde(rename = "fillSz")] + pub size: DecimalValue, + /// OKX fields outside the core execution representation. + #[serde(flatten)] + pub native_fields: NativeFields, +} + +impl GatewayProject for Order { + type Projection = GatewayOrder; + + fn project_gateway(&self) -> Self::Projection { + GatewayOrder { + venue_order_id: self.order_id.clone(), + client_order_id: self.client_order_id.clone(), + instrument_id: self.instrument_id.clone(), + state: self.state.clone(), + filled_size: self.filled_size, + native_fields: self.native_fields.clone(), + } + } +} + +impl GatewayProject for Fill { + type Projection = GatewayExecution; + + fn project_gateway(&self) -> Self::Projection { + GatewayExecution { + venue_execution_id: self.fill_id.clone(), + venue_order_id: self.order_id.clone(), + price: self.price, + size: self.size, + native_fields: self.native_fields.clone(), + } + } +} +/// Algorithmic order record. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct AlgorithmicOrder { + #[serde(rename = "algoId")] + pub algorithm_id: String, + #[serde(rename = "instId")] + pub instrument_id: String, + pub state: String, +} +/// Per-item result returned by batch commands. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct OrderAck { + #[serde(rename = "ordId")] + pub order_id: String, + #[serde(rename = "sCode")] + pub code: String, + #[serde(rename = "sMsg")] + pub message: String, + /// Exchange fields such as clOrdId, tag, and reqId retained for reconciliation. + #[serde(flatten)] + pub native_fields: NativeFields, +} + +impl OrderAck { + /// Whether OKX accepted this individual command item. + pub fn is_success(&self) -> bool { + self.code == "0" + } + + /// Completion state reported for this individual command item. + pub fn completion_state(&self) -> CompletionState { + if self.is_success() { + CompletionState::Confirmed + } else { + CompletionState::Rejected + } + } +} + +/// Rejects an otherwise-successful envelope when any contained command item failed. +/// +/// OKX reports batch item failures through `sCode` while leaving the enclosing response +/// `code` at `0`. Returning a typed error prevents callers from mistaking that result for a +/// fully completed state change, while retaining all item acknowledgements for reconciliation. +pub fn require_successful_acknowledgements( + acknowledgements: Vec, +) -> crate::error::OkxResult> { + if acknowledgements.iter().all(OrderAck::is_success) { + Ok(acknowledgements) + } else { + Err(crate::error::OkxError::PartialFailure { acknowledgements }) + } +} +/// Batch result preserving successful and failed item acknowledgements. +pub type BatchResult = Vec; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn place_order_serializes_elp_taker_access_with_the_okx_field_name() { + let mut order = PlaceOrder::new( + "BTC-USDT", + "cash", + "buy", + "market", + "1".parse::().unwrap(), + ); + order.elp_taker_access = Some(true); + + let rendered = serde_json::to_value(order).unwrap(); + assert_eq!(rendered["isElpTakerAccess"], true); + assert!(!rendered.as_object().unwrap().contains_key("isElpTaker")); + } + + #[test] + fn failed_acknowledgements_are_returned_as_a_typed_partial_failure() { + let acknowledgements = vec![ + OrderAck { + order_id: "accepted".to_owned(), + code: "0".to_owned(), + message: String::new(), + native_fields: NativeFields::default(), + }, + OrderAck { + order_id: String::new(), + code: "51008".to_owned(), + message: "insufficient balance".to_owned(), + native_fields: NativeFields::default(), + }, + ]; + + let result = require_successful_acknowledgements(acknowledgements); + assert!(matches!( + result, + Err(crate::error::OkxError::PartialFailure { acknowledgements }) + if acknowledgements.len() == 2 + && acknowledgements[0].completion_state() == CompletionState::Confirmed + && acknowledgements[1].completion_state() == CompletionState::Rejected + )); + } +} diff --git a/crates/truefix-okx-client/src/types/websocket.rs b/crates/truefix-okx-client/src/types/websocket.rs new file mode 100644 index 0000000..d05394b --- /dev/null +++ b/crates/truefix-okx-client/src/types/websocket.rs @@ -0,0 +1,240 @@ +//! Typed OKX V5 WebSocket protocol records. + +/// Request identifier used to correlate acknowledgements and commands. +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] +pub struct RequestId(pub String); + +/// OKX WebSocket application-level heartbeat. +/// +/// This is deliberately not serializable: OKX requires the literal text frame `ping`, +/// rather than a JSON command. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct WsHeartbeat; + +/// A channel subscription argument. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SubscriptionArg { + /// OKX channel name. + pub channel: String, + /// Optional instrument identifier. + #[serde(rename = "instId", skip_serializing_if = "Option::is_none")] + pub instrument_id: Option, + /// Channel-specific parameters such as `instType`, `uly`, and `instFamily`. + #[serde(flatten)] + pub extra: std::collections::BTreeMap, +} + +impl SubscriptionArg { + /// Creates an argument with the fields common to most market channels. + pub fn new(channel: impl Into, instrument_id: Option) -> Self { + Self { + channel: channel.into(), + instrument_id, + extra: std::collections::BTreeMap::new(), + } + } + + /// Adds a channel-specific OKX subscription parameter. + pub fn with_parameter(mut self, name: impl Into, value: impl Into) -> Self { + self.extra.insert(name.into(), value.into()); + self + } +} + +/// WebSocket command sent to OKX. +#[derive(Debug, Clone, serde::Serialize)] +pub struct WsCommand { + /// `login`, `subscribe`, `unsubscribe`, or a trading operation. + pub op: String, + /// Correlation identifier. + #[serde(rename = "id", skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Optional Unix-millisecond deadline for order and amendment commands. + #[serde(rename = "expTime", skip_serializing_if = "Option::is_none")] + expiration_time: Option, + /// Command arguments. + pub args: T, +} + +impl WsCommand { + /// Builds a WebSocket command without a processing deadline. + /// + /// OKX only accepts `expTime` for private order and amendment operations. Those + /// operations are exposed through the dedicated `PrivateSession` helpers rather + /// than this general-purpose constructor. + pub fn new(op: impl Into, id: Option, args: T) -> Self { + Self { + op: op.into(), + id, + expiration_time: None, + args, + } + } + + /// Returns the optional processing deadline selected by a supported private + /// order or amendment helper. + pub fn expiration_time(&self) -> Option { + self.expiration_time + } + + /// Adds an OKX command-level processing deadline for supported order and amendment + /// operations. Public session methods enforce that scope. + pub(crate) fn with_expiration_time( + mut self, + expiration_time: crate::types::common::ExpirationTime, + ) -> Self { + self.expiration_time = Some(expiration_time); + self + } +} + +/// Server acknowledgement or error. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct WsAcknowledgement { + /// Acknowledged operation. + /// + /// OKX names this field `event` (for example `login`, `subscribe`, + /// `unsubscribe`, `error`, or `notice`); it is distinct from the `op` + /// field used in client commands. + pub event: Option, + /// Server correlation identifier. + #[serde(rename = "id")] + pub request_id: Option, + /// OKX status code. + pub code: Option, + /// Error text when non-successful. + pub msg: Option, +} + +/// A routed real-time event preserving the source channel and raw native payload. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct WsEvent { + /// Event argument including the source channel. + pub arg: SubscriptionArg, + /// Native exchange records. + pub data: Vec, +} + +/// Typed arguments accepted by the real-time order command. +#[derive(Debug, Clone, serde::Serialize)] +pub struct WsOrder { + #[serde(rename = "instId")] + pub instrument_id: String, + #[serde(rename = "tdMode")] + pub trade_mode: String, + pub side: String, + #[serde(rename = "ordType")] + pub order_type: String, + #[serde(rename = "sz")] + pub size: String, + #[serde(rename = "px", skip_serializing_if = "Option::is_none")] + pub price: Option, + #[serde(rename = "clOrdId", skip_serializing_if = "Option::is_none")] + pub client_order_id: Option, +} + +/// Typed order identity used by cancel and amend commands. +#[derive(Debug, Clone, serde::Serialize)] +pub struct WsOrderReference { + #[serde(rename = "instId", skip_serializing_if = "Option::is_none")] + pub instrument_id: Option, + #[serde(rename = "ordId", skip_serializing_if = "Option::is_none")] + pub order_id: Option, + #[serde(rename = "clOrdId", skip_serializing_if = "Option::is_none")] + pub client_order_id: Option, +} + +/// Typed arguments accepted by the real-time amend command. +#[derive(Debug, Clone, serde::Serialize)] +pub struct WsAmendOrder { + #[serde(flatten)] + pub order: WsOrderReference, + #[serde(rename = "newSz", skip_serializing_if = "Option::is_none")] + pub new_size: Option, + #[serde(rename = "newPx", skip_serializing_if = "Option::is_none")] + pub new_price: Option, +} + +/// Typed arguments accepted by the real-time mass-cancel command. +#[derive(Debug, Clone, serde::Serialize)] +pub struct WsMassCancel { + #[serde(rename = "instType")] + pub instrument_type: String, + #[serde(rename = "uly", skip_serializing_if = "Option::is_none")] + pub underlying: Option, + #[serde(rename = "instFamily", skip_serializing_if = "Option::is_none")] + pub instrument_family: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn websocket_order_uses_okx_size_field_name() { + let order = WsOrder { + instrument_id: "BTC-USDT".to_owned(), + trade_mode: "cash".to_owned(), + side: "buy".to_owned(), + order_type: "market".to_owned(), + size: "1".to_owned(), + price: None, + client_order_id: None, + }; + let value = serde_json::to_value(order).unwrap(); + assert_eq!( + value.get("sz").and_then(serde_json::Value::as_str), + Some("1") + ); + assert!(value.get("size").is_none()); + } + + #[test] + fn websocket_command_expiry_is_encoded_at_the_top_level() { + let order = WsOrder { + instrument_id: "BTC-USDT".to_owned(), + trade_mode: "cash".to_owned(), + side: "buy".to_owned(), + order_type: "market".to_owned(), + size: "1".to_owned(), + price: None, + client_order_id: None, + }; + let command = WsCommand::new("order", Some(RequestId("1".to_owned())), vec![order]) + .with_expiration_time( + crate::types::common::ExpirationTime::new(1_704_067_200_123).unwrap(), + ); + assert_eq!( + serde_json::to_value(command).unwrap()["expTime"], + "1704067200123" + ); + } + + #[test] + fn subscription_arguments_preserve_channel_specific_fields() { + let argument = SubscriptionArg::new("orders", None) + .with_parameter("instType", "FUTURES") + .with_parameter("instFamily", "BTC-USD"); + assert_eq!( + serde_json::to_value(argument).unwrap(), + serde_json::json!({ + "channel": "orders", + "instType": "FUTURES", + "instFamily": "BTC-USD", + }) + ); + } + + #[test] + fn acknowledgements_use_the_okx_event_field() { + for event in ["login", "subscribe", "unsubscribe", "error", "notice"] { + let acknowledgement: WsAcknowledgement = serde_json::from_value(serde_json::json!({ + "event": event, + "code": "0", + })) + .unwrap(); + assert_eq!(acknowledgement.event.as_deref(), Some(event)); + } + } +} diff --git a/crates/truefix-okx-client/src/ws/business.rs b/crates/truefix-okx-client/src/ws/business.rs new file mode 100644 index 0000000..9e744a3 --- /dev/null +++ b/crates/truefix-okx-client/src/ws/business.rs @@ -0,0 +1,120 @@ +use crate::{ + auth::{Clock, sign_websocket_login}, + config::Credentials, + error::{OkxError, OkxResult}, + types::websocket::{SubscriptionArg, WsCommand, WsHeartbeat, WsMassCancel}, + ws::session::Session, +}; + +/// Business session supports public feeds and becomes authenticated only after an explicit login. +#[derive(Debug, Default)] +pub struct BusinessSession(pub Session); +impl BusinessSession { + pub fn connected(&mut self) { + self.0.connected(); + } + pub fn login_required(&mut self) { + self.0.login_required(); + } + /// Records whether OKX accepted the optional business login command. + pub fn login_acknowledged(&mut self, success: bool) { + self.0.login_acknowledged(success); + } + pub fn subscriptions_replayed(&mut self) { + self.0.subscriptions_replayed(); + } + pub fn subscribe( + &mut self, + args: Vec, + ) -> OkxResult>> { + self.active()?; + Ok(WsCommand::new( + "subscribe", + Some(self.0.next_request_id()), + args, + )) + } + pub fn subscribe_raw( + &mut self, + args: Vec, + ) -> OkxResult>> { + self.active()?; + Ok(WsCommand::new( + "subscribe", + Some(self.0.next_request_id()), + args, + )) + } + pub fn unsubscribe( + &mut self, + args: Vec, + ) -> OkxResult>> { + self.active()?; + Ok(WsCommand::new( + "unsubscribe", + Some(self.0.next_request_id()), + args, + )) + } + pub fn unsubscribe_raw( + &mut self, + args: Vec, + ) -> OkxResult>> { + self.active()?; + Ok(WsCommand::new( + "unsubscribe", + Some(self.0.next_request_id()), + args, + )) + } + pub fn ping(&mut self) -> WsHeartbeat { + WsHeartbeat + } + pub fn login(&mut self, args: Vec) -> WsCommand> { + self.0.login_required(); + WsCommand::new("login", None, args) + } + pub fn signed_login( + &mut self, + credentials: &Credentials, + clock: &dyn Clock, + ) -> OkxResult>> { + let timestamp = clock.now().unix_timestamp(); + let sign = sign_websocket_login(credentials, timestamp)?; + Ok(self.login(vec![serde_json::json!({ + "apiKey": credentials.api_key(), + "passphrase": credentials.passphrase(), + "timestamp": timestamp.to_string(), + "sign": sign, + })])) + } + pub fn send( + &mut self, + op: impl Into, + args: Vec, + ) -> WsCommand> { + WsCommand::new(op, Some(self.0.next_request_id()), args) + } + /// Business mass cancel has its own server rate-limit class; callers can reserve that + /// limiter before sending this command. It is never queued or replayed by a session. + pub fn mass_cancel( + &mut self, + request: WsMassCancel, + ) -> OkxResult>> { + if !self.0.can_write() { + return Err(OkxError::UnknownCompletion); + } + Ok(WsCommand::new( + "mass-cancel", + Some(self.0.next_request_id()), + vec![request], + )) + } + fn active(&self) -> OkxResult<()> { + if self.0.is_active() { + Ok(()) + } else { + Err(OkxError::UnknownCompletion) + } + } +} diff --git a/crates/truefix-okx-client/src/ws/coordinator.rs b/crates/truefix-okx-client/src/ws/coordinator.rs new file mode 100644 index 0000000..0b58c7e --- /dev/null +++ b/crates/truefix-okx-client/src/ws/coordinator.rs @@ -0,0 +1,112 @@ +//! Applies OKX acknowledgement messages to session and subscription state. + +use crate::{ + types::websocket::{RequestId, WsAcknowledgement}, + ws::{ + session::{Session, SessionState}, + subscription::{SubscriptionKey, Subscriptions}, + }, +}; + +/// The state transition performed for one OKX acknowledgement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AcknowledgementOutcome { + /// The server accepted or rejected a login command. + Login { success: bool }, + /// A correlated subscribe request completed. + Subscription { + request_id: RequestId, + key: SubscriptionKey, + success: bool, + }, + /// An error not otherwise correlated to a subscription request. + Error { request_id: Option }, + /// OKX requested that the connection be replaced (for example during maintenance). + Notice, + /// An acknowledgement that does not mutate session/subscription state. + Ignored, +} + +/// Coordinates protocol acknowledgements with the locally tracked WebSocket state. +/// +/// The caller records every outbound subscribe with [`Subscriptions::correlate`], then passes +/// each decoded [`WsAcknowledgement`] to [`Self::apply`]. A successful subscribe becomes routable +/// only after the corresponding acknowledgement arrives. +#[derive(Debug, Default)] +pub struct WsStateCoordinator { + subscriptions: Subscriptions, +} + +impl WsStateCoordinator { + pub fn new(subscriptions: Subscriptions) -> Self { + Self { subscriptions } + } + + pub fn subscriptions(&self) -> &Subscriptions { + &self.subscriptions + } + + pub fn subscriptions_mut(&mut self) -> &mut Subscriptions { + &mut self.subscriptions + } + + /// Applies one decoded acknowledgement. `code == "0"` is the only successful OKX result. + pub fn apply( + &mut self, + session: &mut Session, + acknowledgement: &WsAcknowledgement, + ) -> AcknowledgementOutcome { + let success = acknowledgement.code.as_deref() == Some("0"); + match acknowledgement.event.as_deref() { + Some("login") => { + session.login_acknowledged(success); + AcknowledgementOutcome::Login { success } + } + Some("subscribe") => self.apply_subscription(acknowledgement, success), + Some("error") => { + if let Some(request_id) = acknowledgement.request_id.as_ref() + && let Some(key) = self.subscriptions.acknowledge_id(request_id, false) + { + return AcknowledgementOutcome::Subscription { + request_id: request_id.clone(), + key, + success: false, + }; + } + // Login replies may be surfaced as a generic error rather than `event: login`. + if session.state() == SessionState::Authenticating { + session.login_acknowledged(false); + } + AcknowledgementOutcome::Error { + request_id: acknowledgement.request_id.clone(), + } + } + Some("notice") => { + // A notice asks the client to reconnect; no prior subscriptions are active on + // the replacement connection, but desired keys remain available for replay. + self.subscriptions.disconnect(); + session.disconnected(); + AcknowledgementOutcome::Notice + } + _ => AcknowledgementOutcome::Ignored, + } + } + + fn apply_subscription( + &mut self, + acknowledgement: &WsAcknowledgement, + success: bool, + ) -> AcknowledgementOutcome { + let Some(request_id) = acknowledgement.request_id.as_ref() else { + return AcknowledgementOutcome::Ignored; + }; + let Some(key) = self.subscriptions.acknowledge_id(request_id, success) else { + return AcknowledgementOutcome::Ignored; + }; + AcknowledgementOutcome::Subscription { + request_id: request_id.clone(), + key, + success, + } + } +} diff --git a/crates/truefix-okx-client/src/ws/event.rs b/crates/truefix-okx-client/src/ws/event.rs new file mode 100644 index 0000000..4620618 --- /dev/null +++ b/crates/truefix-okx-client/src/ws/event.rs @@ -0,0 +1,17 @@ +use crate::{ + types::websocket::WsEvent, + ws::subscription::{SubscriptionKey, Subscriptions}, +}; + +/// Checks whether an event belongs to a client subscription key. +/// +/// Server-added `arg` metadata is ignored, while requested subscription +/// parameters remain part of the match. +pub fn matches(event: &WsEvent, key: &SubscriptionKey) -> bool { + key.matches_event(&event.arg) +} + +/// Returns an event only after a matching desired subscription became active. +pub fn route(subscriptions: &Subscriptions, event: WsEvent) -> Option { + subscriptions.routes_event(&event.arg).then_some(event) +} diff --git a/crates/truefix-okx-client/src/ws/mod.rs b/crates/truefix-okx-client/src/ws/mod.rs new file mode 100644 index 0000000..87ad8d7 --- /dev/null +++ b/crates/truefix-okx-client/src/ws/mod.rs @@ -0,0 +1,8 @@ +//! Real-time session primitives. +pub mod business; +pub mod coordinator; +pub mod event; +pub mod private; +pub mod public; +pub mod session; +pub mod subscription; diff --git a/crates/truefix-okx-client/src/ws/private.rs b/crates/truefix-okx-client/src/ws/private.rs new file mode 100644 index 0000000..f424b07 --- /dev/null +++ b/crates/truefix-okx-client/src/ws/private.rs @@ -0,0 +1,197 @@ +use crate::{ + auth::{Clock, sign_websocket_login}, + config::Credentials, + error::{OkxError, OkxResult}, + types::websocket::{ + SubscriptionArg, WsAmendOrder, WsCommand, WsHeartbeat, WsMassCancel, WsOrder, + WsOrderReference, + }, + ws::session::Session, +}; + +/// Authenticated private session entrypoint. +#[derive(Debug, Default)] +pub struct PrivateSession(pub Session); +impl PrivateSession { + pub fn connected(&mut self) { + // Private endpoints always require a login, including on the initial + // connection. Mark that requirement before Session::connected so it + // cannot incorrectly enter Active as a public session would. + self.0.login_required(); + self.0.connected(); + } + pub fn login_required(&mut self) { + self.0.login_required(); + } + /// Records whether OKX accepted the login command. + pub fn login_acknowledged(&mut self, success: bool) { + self.0.login_acknowledged(success); + } + pub fn subscriptions_replayed(&mut self) { + self.0.subscriptions_replayed(); + } + /// Rejects writes until authentication and subscription recovery complete. + pub fn write_allowed(&self) -> OkxResult<()> { + if self.0.can_write() { + Ok(()) + } else { + Err(OkxError::UnknownCompletion) + } + } + pub fn login(&mut self, args: Vec) -> WsCommand> { + self.0.login_required(); + WsCommand::new("login", None, args) + } + pub fn signed_login( + &mut self, + credentials: &Credentials, + clock: &dyn Clock, + ) -> OkxResult>> { + let timestamp = clock.now().unix_timestamp(); + let sign = sign_websocket_login(credentials, timestamp)?; + Ok(self.login(vec![serde_json::json!({ + "apiKey": credentials.api_key(), + "passphrase": credentials.passphrase(), + "timestamp": timestamp.to_string(), + "sign": sign, + })])) + } + pub fn subscribe( + &mut self, + args: Vec, + ) -> OkxResult>> { + self.active()?; + Ok(WsCommand::new( + "subscribe", + Some(self.0.next_request_id()), + args, + )) + } + pub fn subscribe_raw( + &mut self, + args: Vec, + ) -> OkxResult>> { + self.active()?; + Ok(WsCommand::new( + "subscribe", + Some(self.0.next_request_id()), + args, + )) + } + pub fn unsubscribe( + &mut self, + args: Vec, + ) -> OkxResult>> { + self.active()?; + Ok(WsCommand::new( + "unsubscribe", + Some(self.0.next_request_id()), + args, + )) + } + pub fn unsubscribe_raw( + &mut self, + args: Vec, + ) -> OkxResult>> { + self.active()?; + Ok(WsCommand::new( + "unsubscribe", + Some(self.0.next_request_id()), + args, + )) + } + pub fn ping(&mut self) -> WsHeartbeat { + WsHeartbeat + } + pub fn send( + &mut self, + op: impl Into, + args: Vec, + ) -> WsCommand> { + WsCommand::new(op, Some(self.0.next_request_id()), args) + } + pub fn place_order(&mut self, order: WsOrder) -> OkxResult>> { + self.write("order", vec![order]) + } + /// Places one order with an OKX processing deadline. + pub fn place_order_with_expiration( + &mut self, + order: WsOrder, + expiration_time: crate::types::common::ExpirationTime, + ) -> OkxResult>> { + Ok(self + .write("order", vec![order])? + .with_expiration_time(expiration_time)) + } + pub fn batch_orders(&mut self, orders: Vec) -> OkxResult>> { + self.write("batch-orders", orders) + } + /// Places a batch of orders with one shared OKX processing deadline. + pub fn batch_orders_with_expiration( + &mut self, + orders: Vec, + expiration_time: crate::types::common::ExpirationTime, + ) -> OkxResult>> { + Ok(self + .write("batch-orders", orders)? + .with_expiration_time(expiration_time)) + } + pub fn cancel_order( + &mut self, + order: WsOrderReference, + ) -> OkxResult>> { + self.write("cancel-order", vec![order]) + } + pub fn batch_cancel_orders( + &mut self, + orders: Vec, + ) -> OkxResult>> { + self.write("batch-cancel-orders", orders) + } + pub fn amend_order(&mut self, order: WsAmendOrder) -> OkxResult>> { + self.write("amend-order", vec![order]) + } + /// Amends one order with an OKX processing deadline. + pub fn amend_order_with_expiration( + &mut self, + order: WsAmendOrder, + expiration_time: crate::types::common::ExpirationTime, + ) -> OkxResult>> { + Ok(self + .write("amend-order", vec![order])? + .with_expiration_time(expiration_time)) + } + pub fn batch_amend_orders( + &mut self, + orders: Vec, + ) -> OkxResult>> { + self.write("batch-amend-orders", orders) + } + /// Amends a batch with one shared OKX processing deadline. + pub fn batch_amend_orders_with_expiration( + &mut self, + orders: Vec, + expiration_time: crate::types::common::ExpirationTime, + ) -> OkxResult>> { + Ok(self + .write("batch-amend-orders", orders)? + .with_expiration_time(expiration_time)) + } + pub fn mass_cancel( + &mut self, + request: WsMassCancel, + ) -> OkxResult>> { + self.write("mass-cancel", vec![request]) + } + fn write(&mut self, op: &str, args: T) -> OkxResult> { + self.write_allowed()?; + Ok(WsCommand::new(op, Some(self.0.next_request_id()), args)) + } + fn active(&self) -> OkxResult<()> { + if self.0.is_active() { + Ok(()) + } else { + Err(OkxError::UnknownCompletion) + } + } +} diff --git a/crates/truefix-okx-client/src/ws/public.rs b/crates/truefix-okx-client/src/ws/public.rs new file mode 100644 index 0000000..b754c0a --- /dev/null +++ b/crates/truefix-okx-client/src/ws/public.rs @@ -0,0 +1,76 @@ +use crate::{ + error::{OkxError, OkxResult}, + types::websocket::{SubscriptionArg, WsCommand, WsHeartbeat}, + ws::session::Session, +}; + +/// Public session entrypoint. It becomes active without credentials after connection setup. +#[derive(Debug, Default)] +pub struct PublicSession(pub Session); +impl PublicSession { + pub fn connected(&mut self) { + self.0.connected(); + } + pub fn subscribe( + &mut self, + args: Vec, + ) -> OkxResult>> { + self.active()?; + Ok(WsCommand::new( + "subscribe", + Some(self.0.next_request_id()), + args, + )) + } + pub fn subscribe_raw( + &mut self, + args: Vec, + ) -> OkxResult>> { + self.active()?; + Ok(WsCommand::new( + "subscribe", + Some(self.0.next_request_id()), + args, + )) + } + pub fn unsubscribe( + &mut self, + args: Vec, + ) -> OkxResult>> { + self.active()?; + Ok(WsCommand::new( + "unsubscribe", + Some(self.0.next_request_id()), + args, + )) + } + pub fn unsubscribe_raw( + &mut self, + args: Vec, + ) -> OkxResult>> { + self.active()?; + Ok(WsCommand::new( + "unsubscribe", + Some(self.0.next_request_id()), + args, + )) + } + pub fn ping(&mut self) -> WsHeartbeat { + WsHeartbeat + } + pub fn send( + &mut self, + op: impl Into, + args: Vec, + ) -> OkxResult>> { + self.active()?; + Ok(WsCommand::new(op, Some(self.0.next_request_id()), args)) + } + fn active(&self) -> OkxResult<()> { + if self.0.is_active() { + Ok(()) + } else { + Err(OkxError::UnknownCompletion) + } + } +} diff --git a/crates/truefix-okx-client/src/ws/session.rs b/crates/truefix-okx-client/src/ws/session.rs new file mode 100644 index 0000000..7b989fc --- /dev/null +++ b/crates/truefix-okx-client/src/ws/session.rs @@ -0,0 +1,150 @@ +//! Connection lifecycle, heartbeat and bounded reconnect policy. + +use std::time::{Duration, Instant}; + +use crate::types::websocket::RequestId; + +/// Observable session lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionState { + Disconnected, + Connecting, + Authenticating, + Resubscribing, + Active, + Backoff, +} + +/// State machine used by all WebSocket entrypoints. +/// +/// It deliberately does not retain a command queue: a connection loss makes a write's +/// completion unknown, so writes can never be replayed by this layer. +#[derive(Debug)] +pub struct Session { + state: SessionState, + authentication_required: bool, + authenticated: bool, + next_request_id: u64, + reconnect_attempt: u8, + heartbeat_deadline: Option, + awaiting_pong: bool, +} + +impl Default for Session { + fn default() -> Self { + Self { + state: SessionState::Disconnected, + authentication_required: false, + authenticated: false, + next_request_id: 0, + reconnect_attempt: 0, + heartbeat_deadline: None, + awaiting_pong: false, + } + } +} + +impl Session { + pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(20); + const MAX_BACKOFF: Duration = Duration::from_secs(30); + + pub fn state(&self) -> SessionState { + self.state + } + pub fn connecting(&mut self) { + self.state = SessionState::Connecting; + } + pub fn login_required(&mut self) { + self.authentication_required = true; + self.authenticated = false; + self.state = SessionState::Authenticating; + } + /// Records the outcome of a login acknowledgement. + /// + /// A rejected login must not transition the session into the authenticated + /// recovery path: private subscriptions and writes remain unavailable until + /// OKX has explicitly acknowledged a successful login. + pub fn login_acknowledged(&mut self, success: bool) { + if success { + self.authenticated = true; + self.state = SessionState::Resubscribing; + } else { + self.authenticated = false; + self.state = SessionState::Authenticating; + } + } + /// Marks a public connection ready, or completes authenticated subscription recovery. + pub fn subscriptions_replayed(&mut self) { + if !self.authentication_required || self.authenticated { + self.state = SessionState::Active; + } + } + pub fn can_write(&self) -> bool { + self.authentication_required && self.authenticated && self.state == SessionState::Active + } + pub fn is_active(&self) -> bool { + self.state == SessionState::Active + } + pub fn is_authenticated(&self) -> bool { + self.authenticated + } + pub fn next_request_id(&mut self) -> RequestId { + self.next_request_id = self.next_request_id.wrapping_add(1); + RequestId(self.next_request_id.to_string()) + } + /// Records an inbound OKX application message and resets the idle heartbeat timer. + pub fn message_received(&mut self, now: Instant) { + self.heartbeat_deadline = Some(now + Self::HEARTBEAT_INTERVAL); + self.awaiting_pong = false; + } + /// Returns whether an application ping must be sent because the peer has been idle. + /// + /// The first call arms the idle timer. Once a ping is sent, the next expiry without a + /// response disconnects the session rather than sending another ping. + pub fn ping_due(&mut self, now: Instant) -> bool { + match self.heartbeat_deadline { + None => { + self.heartbeat_deadline = Some(now + Self::HEARTBEAT_INTERVAL); + false + } + Some(deadline) if now >= deadline => { + if self.awaiting_pong { + self.disconnected(); + false + } else { + self.awaiting_pong = true; + self.heartbeat_deadline = Some(now + Self::HEARTBEAT_INTERVAL); + true + } + } + Some(_) => false, + } + } + pub fn pong_received(&mut self) { + self.heartbeat_deadline = None; + self.awaiting_pong = false; + } + /// Moves to bounded exponential reconnect backoff and invalidates authentication. + pub fn disconnected(&mut self) { + self.authenticated = false; + self.heartbeat_deadline = None; + self.awaiting_pong = false; + self.reconnect_attempt = self.reconnect_attempt.saturating_add(1); + self.state = SessionState::Backoff; + } + pub fn reconnect_delay(&self) -> Duration { + let seconds = 1_u64 << u32::from(self.reconnect_attempt.saturating_sub(1).min(5)); + Duration::from_secs(seconds).min(Self::MAX_BACKOFF) + } + pub fn reconnecting(&mut self) { + self.state = SessionState::Connecting; + } + pub fn connected(&mut self) { + self.reconnect_attempt = 0; + if self.authentication_required { + self.login_required(); + } else { + self.subscriptions_replayed(); + } + } +} diff --git a/crates/truefix-okx-client/src/ws/subscription.rs b/crates/truefix-okx-client/src/ws/subscription.rs new file mode 100644 index 0000000..feb267b --- /dev/null +++ b/crates/truefix-okx-client/src/ws/subscription.rs @@ -0,0 +1,108 @@ +use std::collections::{HashMap, HashSet}; + +use crate::types::websocket::{RequestId, SubscriptionArg}; + +/// Canonical subscription identity used for routing and deduplication. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SubscriptionKey { + pub channel: String, + pub instrument_id: Option, + pub extra: Vec<(String, String)>, +} +impl From<&SubscriptionArg> for SubscriptionKey { + fn from(arg: &SubscriptionArg) -> Self { + Self { + channel: arg.channel.clone(), + instrument_id: arg.instrument_id.clone(), + extra: arg + .extra + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect(), + } + } +} + +impl SubscriptionKey { + /// Matches an event argument against this client-requested subscription. + /// + /// OKX may add server metadata (for example `uid`) to the event's `arg`. + /// Those fields are not part of the client's subscription identity, whereas + /// every parameter the client did request must still agree exactly. + pub fn matches_event(&self, event: &SubscriptionArg) -> bool { + self.channel == event.channel + // A subscription without `instId` is deliberately broad. OKX includes the + // concrete instrument in many matching push messages, so requiring `None == + // Some(instId)` here would silently drop every event for an instType-wide feed. + && self + .instrument_id + .as_ref() + .is_none_or(|instrument_id| event.instrument_id.as_ref() == Some(instrument_id)) + && self + .extra + .iter() + .all(|(name, value)| event.extra.get(name) == Some(value)) + } +} + +/// Desired, active, and in-flight subscriptions. Request identifiers are bounded by +/// `MAX_IN_FLIGHT`, avoiding unbounded correlation state under a broken peer. +#[derive(Debug, Default)] +pub struct Subscriptions { + desired: HashSet, + active: HashSet, + pending: HashMap, +} +impl Subscriptions { + pub const MAX_IN_FLIGHT: usize = 1_024; + /// Adds a desired key. `true` means the caller should issue a subscribe command. + pub fn request(&mut self, key: SubscriptionKey) -> bool { + self.desired.insert(key) + } + /// Records correlation for a pending subscribe. Returns false when saturated or duplicate. + pub fn correlate(&mut self, request_id: RequestId, key: &SubscriptionKey) -> bool { + self.desired.contains(key) + && self.pending.len() < Self::MAX_IN_FLIGHT + && self.pending.insert(request_id, key.clone()).is_none() + } + /// Activates the correlated subscription only on a successful acknowledgement. + pub fn acknowledge_id( + &mut self, + request_id: &RequestId, + success: bool, + ) -> Option { + let key = self.pending.remove(request_id)?; + if success && self.desired.contains(&key) { + self.active.insert(key.clone()); + } + Some(key) + } + pub fn acknowledge(&mut self, key: SubscriptionKey) { + if self.desired.contains(&key) { + self.active.insert(key); + } + } + /// Cancels a desired subscription and prevents future event routing/replay. + pub fn cancel(&mut self, key: &SubscriptionKey) -> bool { + self.active.remove(key); + self.pending.retain(|_, pending| pending != key); + self.desired.remove(key) + } + pub fn active(&self, key: &SubscriptionKey) -> bool { + self.active.contains(key) + } + /// Returns whether an event belongs to any active client subscription. + pub fn routes_event(&self, event: &SubscriptionArg) -> bool { + self.active.iter().any(|key| key.matches_event(event)) + } + pub fn desired(&self, key: &SubscriptionKey) -> bool { + self.desired.contains(key) + } + pub fn disconnect(&mut self) { + self.active.clear(); + self.pending.clear(); + } + pub fn replay(&self) -> impl Iterator { + self.desired.iter() + } +} diff --git a/crates/truefix-okx-client/tests/decimal_roundtrip.rs b/crates/truefix-okx-client/tests/decimal_roundtrip.rs new file mode 100644 index 0000000..c3d18bb --- /dev/null +++ b/crates/truefix-okx-client/tests/decimal_roundtrip.rs @@ -0,0 +1,23 @@ +use rust_decimal::Decimal; + +#[test] +fn decimal_wire_values_preserve_precision() { + let value = "0.00000001".parse::().unwrap(); + assert_eq!(value.to_string(), "0.00000001"); +} + +#[test] +fn spot_margin_swap_and_option_values_round_trip_without_float_conversion() { + for wire in [ + "0.00000001", + "123456789.12345678", + "0.125", + "99999.99999999", + ] { + let value = wire.parse::().unwrap(); + assert_eq!(value.to_string(), wire); + let encoded = serde_json::to_string(&value).unwrap(); + let decoded: Decimal = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded.to_string(), wire); + } +} diff --git a/crates/truefix-okx-client/tests/environment_safety.rs b/crates/truefix-okx-client/tests/environment_safety.rs new file mode 100644 index 0000000..5ba195a --- /dev/null +++ b/crates/truefix-okx-client/tests/environment_safety.rs @@ -0,0 +1,24 @@ +use truefix_okx_client::{ClientConfig, Credentials, Environment, LiveTradingConfirmation}; + +#[test] +fn demo_is_the_default_even_when_credentials_are_supplied() { + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + let config = ClientConfig::demo(Some(credentials)); + assert_eq!(config.environment, Environment::Demo); +} + +#[test] +fn live_environment_requires_the_typed_confirmation_token() { + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + let config = ClientConfig::live(credentials, LiveTradingConfirmation::acknowledge_risk()); + assert!(matches!(config.environment, Environment::Live(_))); +} + +#[test] +fn credential_debug_representation_never_discloses_identity_material() { + let credentials = Credentials::new("api-key", "api-secret", "passphrase").unwrap(); + let rendered = format!("{credentials:?}"); + for secret in ["api-key", "api-secret", "passphrase"] { + assert!(!rendered.contains(secret)); + } +} diff --git a/crates/truefix-okx-client/tests/gateway_projection.rs b/crates/truefix-okx-client/tests/gateway_projection.rs new file mode 100644 index 0000000..c136664 --- /dev/null +++ b/crates/truefix-okx-client/tests/gateway_projection.rs @@ -0,0 +1,81 @@ +use truefix_okx_client::types::{ + account::{Balance, Position}, + gateway::GatewayProject, + market::Ticker, + order::{Fill, Order}, +}; + +#[test] +fn common_projections_preserve_order_and_execution_extension_fields() { + let order: Order = serde_json::from_value(serde_json::json!({ + "ordId": "100", "clOrdId": "client-100", "instId": "BTC-USDT-SWAP", + "state": "live", "accFillSz": "0.10", "px": "65000.25", "reduceOnly": "true" + })) + .expect("test fixture is a valid OKX order"); + let fill: Fill = serde_json::from_value(serde_json::json!({ + "fillId": "200", "ordId": "100", "fillPx": "65000.25", "fillSz": "0.10", + "fee": "-0.02", "liquidity": "M" + })) + .expect("test fixture is a valid OKX fill"); + + let projected_order = order.project_gateway(); + assert_eq!(projected_order.venue_order_id, "100"); + assert_eq!(projected_order.instrument_id, "BTC-USDT-SWAP"); + assert_eq!(projected_order.native_fields["reduceOnly"], "true"); + assert_eq!(projected_order.native_fields["px"], "65000.25"); + assert_eq!(order.native_fields, projected_order.native_fields); + + let projected_fill = fill.project_gateway(); + assert_eq!(projected_fill.venue_execution_id, "200"); + assert_eq!(projected_fill.venue_order_id, "100"); + assert_eq!(projected_fill.native_fields["fee"], "-0.02"); + assert_eq!(fill.native_fields, projected_fill.native_fields); +} + +#[test] +fn account_position_and_market_projections_preserve_native_data() { + let balance: Balance = serde_json::from_value(serde_json::json!({ + "ccy": "USDT", "availBal": "12.34", "eq": "15.00", "frozenBal": "2.66", + "crossLiab": "1.20" + })) + .expect("test fixture is a valid OKX balance"); + let position: Position = serde_json::from_value(serde_json::json!({ + "instId": "BTC-USDT-SWAP", "pos": "0.10", "avgPx": "64000", "upl": "100", + "liqPx": "50000", "mgnRatio": "0.23" + })) + .expect("test fixture is a valid OKX position"); + let ticker: Ticker = serde_json::from_value(serde_json::json!({ + "instId": "BTC-USDT", "last": "65000.25", "vol24h": "123.4", + "sodUtc0": "64000", "bidPx": "65000.20" + })) + .expect("test fixture is a valid OKX ticker"); + + let projected_balance = balance.project_gateway(); + assert_eq!(projected_balance.currency, "USDT"); + // `frozenBal` is intentionally outside the small common balance shape; callers retain the + // source native model alongside the projection to access it. + assert_eq!( + balance.frozen.expect("fixture supplies frozen balance"), + "2.66".parse().expect("valid decimal") + ); + assert_eq!(projected_balance.native_fields["crossLiab"], "1.20"); + + let projected_position = position.project_gateway(); + assert_eq!(projected_position.instrument_id, "BTC-USDT-SWAP"); + assert_eq!(projected_position.native_fields["liqPx"], "50000"); + assert_eq!(projected_position.native_fields["mgnRatio"], "0.23"); + + let projected_ticker = ticker.project_gateway(); + assert_eq!(projected_ticker.instrument_id, "BTC-USDT"); + assert_eq!(projected_ticker.native_fields["sodUtc0"], "64000"); + assert_eq!(projected_ticker.native_fields["bidPx"], "65000.20"); +} + +#[test] +fn client_crate_has_no_gateway_dependency() { + let manifest = include_str!("../Cargo.toml"); + assert!( + !manifest.contains("truefix-gateway"), + "the native OKX client must remain independently usable" + ); +} diff --git a/crates/truefix-okx-client/tests/http_contract.rs b/crates/truefix-okx-client/tests/http_contract.rs new file mode 100644 index 0000000..992abea --- /dev/null +++ b/crates/truefix-okx-client/tests/http_contract.rs @@ -0,0 +1,594 @@ +mod support; + +use truefix_okx_client::types::{ + common::{DecimalValue, ExpirationTime}, + order::PlaceOrder, +}; +use truefix_okx_client::{ClientConfig, Credentials, Environment, OkxClient}; + +struct FixedClock(time::OffsetDateTime); + +impl truefix_okx_client::auth::Clock for FixedClock { + fn now(&self) -> time::OffsetDateTime { + self.0 + } +} + +fn custom_config(base: String, credentials: Option) -> ClientConfig { + ClientConfig { + environment: Environment::Custom { + rest_base: base.clone(), + public_ws: format!("ws://{base}"), + private_ws: format!("ws://{base}"), + business_ws: format!("ws://{base}"), + simulated: true, + }, + credentials, + ..ClientConfig::default() + } +} + +#[test] +fn exchange_rejection_and_pagination_metadata_are_preserved() { + let rejected: Result, _> = truefix_okx_client::response::decode_envelope( + br#"{"code":"51008","msg":"insufficient balance","data":[]}"#, + ); + assert!( + matches!(rejected, Err(truefix_okx_client::OkxError::Exchange { code, .. }) if code == "51008") + ); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("OK-BEFORE", "old".parse().unwrap()); + headers.insert("OK-AFTER", "new".parse().unwrap()); + let page = truefix_okx_client::response::page_metadata(&headers); + assert_eq!(page.before.as_deref(), Some("old")); + assert_eq!(page.after.as_deref(), Some("new")); +} + +#[tokio::test] +async fn public_requests_are_unsigned_and_preserve_encoded_query() { + let (base, captured) = + support::http::start(r#"{"code":"0","msg":"","data":[{"instId":"BTC-USDT","last":"1"}]}"#) + .await; + let client = OkxClient::new(custom_config(base, None)).unwrap(); + let tickers = client.market().ticker("BTC-USDT").await.unwrap(); + assert_eq!(tickers.len(), 1); + let request = captured.await.unwrap(); + assert_eq!(request.method, "GET"); + assert_eq!(request.target, "/api/v5/market/ticker?instId=BTC-USDT"); + assert_eq!( + request + .headers + .get("x-simulated-trading") + .map(String::as_str), + Some("1") + ); + assert!(!request.headers.contains_key("ok-access-key")); +} + +#[tokio::test] +async fn account_operations_use_okx_canonical_risk_offset_and_simulated_margin_paths() { + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + + let (base, captured) = support::http::start(r#"{"code":"0","msg":"","data":[]}"#).await; + let client = OkxClient::new(custom_config(base, Some(credentials.clone()))).unwrap(); + client + .account() + .set_risk_offset_type(&serde_json::json!({"type": "1"})) + .await + .unwrap(); + assert_eq!( + captured.await.unwrap().target, + "/api/v5/account/set-riskOffset-type" + ); + + let (base, captured) = support::http::start(r#"{"code":"0","msg":"","data":[]}"#).await; + let client = OkxClient::new(custom_config(base, Some(credentials))).unwrap(); + client + .account() + .simulated_margin(&serde_json::json!({"instType": "SWAP"})) + .await + .unwrap(); + assert_eq!( + captured.await.unwrap().target, + "/api/v5/account/simulated_margin" + ); +} + +#[tokio::test] +async fn baseline_operations_preserve_pagination_and_request_metadata() { + let (base, captured) = support::http::start_with_headers( + r#"{"code":"0","msg":"","data":[{"instId":"BTC-USDT"}]}"#, + &[ + ("OK-BEFORE", "older"), + ("OK-AFTER", "newer"), + ("x-request-id", "okx-request-42"), + ], + ) + .await; + let client = OkxClient::new(custom_config(base, None)).unwrap(); + let response = client + .execute_baseline_operation_with_metadata( + "market_data", + "get_ticker", + std::collections::BTreeMap::from([("instId".to_owned(), "BTC-USDT".to_owned())]), + None, + ) + .await + .unwrap(); + assert_eq!(response.data.len(), 1); + assert_eq!(response.metadata.page.before.as_deref(), Some("older")); + assert_eq!(response.metadata.page.after.as_deref(), Some("newer")); + assert_eq!( + response.metadata.request_id.as_deref(), + Some("okx-request-42") + ); + assert_eq!( + captured.await.unwrap().target, + "/api/v5/market/ticker?instId=BTC-USDT" + ); +} + +#[tokio::test] +async fn account_helpers_preserve_optional_filters_and_empty_body_semantics() { + let (base, captured) = support::http::start( + r#"{"code":"0","msg":"","data":[{"totalEq":"1","details":[{"ccy":"BTC","availBal":"1","eq":"1","frozenBal":"0"}]}]}"#, + ) + .await; + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + let client = OkxClient::new(custom_config(base, Some(credentials))).unwrap(); + + let balances = client + .account() + .balances_with_currency(Some("BTC")) + .await + .unwrap(); + assert_eq!(balances.len(), 1); + assert_eq!(balances[0].details.len(), 1); + assert_eq!(balances[0].details[0].currency, "BTC"); + let request = captured.await.unwrap(); + assert_eq!(request.method, "GET"); + assert_eq!(request.target, "/api/v5/account/balance?ccy=BTC"); + + let (base, captured) = support::http::start(r#"{"code":"0","msg":"","data":[]}"#).await; + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + let client = OkxClient::new(custom_config(base, Some(credentials))).unwrap(); + let _ = client + .account() + .positions_with_filters(Some("SWAP"), Some("BTC-USDT-SWAP"), Some("123")) + .await + .unwrap(); + let request = captured.await.unwrap(); + assert_eq!( + request.target, + "/api/v5/account/positions?instId=BTC-USDT-SWAP&instType=SWAP&posId=123" + ); + + let (base, captured) = support::http::start(r#"{"code":"0","msg":"","data":[]}"#).await; + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + let client = OkxClient::new(custom_config(base, Some(credentials))).unwrap(); + let _ = client.account().activate_option().await.unwrap(); + let request = captured.await.unwrap(); + assert_eq!(request.method, "POST"); + assert_eq!(request.target, "/api/v5/account/activate-option"); + assert_eq!(request.body, b"{}"); +} + +#[tokio::test] +async fn parameterless_baseline_posts_send_empty_json_bodies() { + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + + let (base, captured) = support::http::start(r#"{"code":"0","msg":"","data":[]}"#).await; + let client = OkxClient::new(custom_config(base, Some(credentials.clone()))).unwrap(); + client + .execute_baseline_operation( + "account", + "activate_option", + std::collections::BTreeMap::new(), + None, + ) + .await + .unwrap(); + let request = captured.await.unwrap(); + assert_eq!(request.method, "POST"); + assert_eq!(request.target, "/api/v5/account/activate-option"); + assert_eq!(request.body, b"{}"); + + let (base, captured) = support::http::start(r#"{"code":"0","msg":"","data":[]}"#).await; + let client = OkxClient::new(custom_config(base, Some(credentials))).unwrap(); + client.professional().reset_mmp().await.unwrap(); + let request = captured.await.unwrap(); + assert_eq!(request.method, "POST"); + assert_eq!(request.target, "/api/v5/rfq/mmp-reset"); + assert_eq!(request.body, b"{}"); +} + +#[tokio::test] +async fn parameterized_baseline_posts_still_require_a_body() { + let client = OkxClient::new(custom_config("http://127.0.0.1:1".to_owned(), None)).unwrap(); + let error = client + .execute_baseline_operation( + "trade", + "place_order", + std::collections::BTreeMap::new(), + None, + ) + .await + .unwrap_err(); + assert!(matches!( + error, + truefix_okx_client::OkxError::InvalidConfiguration(message) + if message == "POST operations require a JSON body" + )); +} + +#[tokio::test] +async fn demo_writes_are_signed_once_and_send_exact_body() { + let (base, captured) = support::http::start( + r#"{"code":"0","msg":"","data":[{"ordId":"42","sCode":"0","sMsg":""}]}"#, + ) + .await; + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + let client = OkxClient::new(custom_config(base, Some(credentials))).unwrap(); + let order = PlaceOrder { + instrument_id: "BTC-USDT".to_owned(), + trade_mode: "cash".to_owned(), + side: "buy".to_owned(), + order_type: "market".to_owned(), + size: "1".parse::().unwrap(), + price: None, + client_order_id: None, + margin_currency: None, + tag: None, + position_side: None, + reduce_only: None, + target_currency: None, + self_trade_prevention_mode: None, + attached_algo_orders: None, + price_usd: None, + price_volatility: None, + ban_amend: None, + trade_quote_currency: None, + price_amend_type: None, + elp_taker_access: None, + instrument_id_code: None, + }; + assert_eq!( + client.trade().place_order(&order).await.unwrap()[0].order_id, + "42" + ); + let request = captured.await.unwrap(); + assert_eq!(request.method, "POST"); + assert_eq!(request.target, "/api/v5/trade/order"); + assert!(request.headers.contains_key("ok-access-key")); + assert!(request.headers.contains_key("ok-access-sign")); + assert_eq!( + request.headers.get("content-type").map(String::as_str), + Some("application/json") + ); + let timestamp = request.headers.get("ok-access-timestamp").unwrap(); + assert!(timestamp.ends_with('Z')); + assert_eq!(timestamp.split_once('.').unwrap().1.len(), 4); + assert_eq!( + request + .headers + .get("x-simulated-trading") + .map(String::as_str), + Some("1") + ); + assert_eq!( + request.body, + br#"{"instId":"BTC-USDT","tdMode":"cash","side":"buy","ordType":"market","sz":"1"}"# + ); +} + +#[tokio::test] +async fn order_item_failure_is_not_reported_as_a_successful_write() { + let (base, captured) = support::http::start( + r#"{"code":"0","msg":"","data":[{"ordId":"42","sCode":"0","sMsg":""},{"ordId":"","sCode":"51008","sMsg":"insufficient balance","clOrdId":"second"}]}"#, + ) + .await; + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + let client = OkxClient::new(custom_config(base, Some(credentials))).unwrap(); + let order = PlaceOrder::new( + "BTC-USDT", + "cash", + "buy", + "market", + "1".parse::().unwrap(), + ); + + assert!(matches!( + client.trade().place_order(&order).await, + Err(truefix_okx_client::OkxError::PartialFailure { acknowledgements }) + if acknowledgements.len() == 2 + && acknowledgements[0].order_id == "42" + && acknowledgements[1].code == "51008" + && acknowledgements[1].message == "insufficient balance" + )); + assert_eq!(captured.await.unwrap().target, "/api/v5/trade/order"); +} + +#[tokio::test] +async fn advanced_order_fields_use_the_okx_wire_names() { + let (base, captured) = support::http::start( + r#"{"code":"0","msg":"","data":[{"ordId":"42","sCode":"0","sMsg":""}]}"#, + ) + .await; + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + let client = OkxClient::new(custom_config(base, Some(credentials))).unwrap(); + let mut order = PlaceOrder::new( + "BTC-USDT", + "cash", + "buy", + "market", + "1".parse::().unwrap(), + ); + order.elp_taker_access = Some(true); + let _ = client.trade().place_order(&order).await.unwrap(); + let request = captured.await.unwrap(); + assert!( + std::str::from_utf8(&request.body) + .unwrap() + .contains("\"isElpTakerAccess\":true") + ); +} + +#[tokio::test] +async fn order_expiration_is_sent_as_the_okx_request_header() { + let (base, captured) = support::http::start( + r#"{"code":"0","msg":"","data":[{"ordId":"42","sCode":"0","sMsg":""}]}"#, + ) + .await; + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + let client = OkxClient::new(custom_config(base, Some(credentials))).unwrap(); + let order = PlaceOrder::new( + "BTC-USDT", + "cash", + "buy", + "market", + "1".parse::().unwrap(), + ); + client + .trade() + .place_order_with_expiration(&order, ExpirationTime::new(1_704_067_200_123).unwrap()) + .await + .unwrap(); + assert_eq!( + captured + .await + .unwrap() + .headers + .get("exptime") + .map(String::as_str), + Some("1704067200123") + ); +} + +#[tokio::test] +async fn configured_clock_offset_is_applied_to_private_rest_signatures() { + let (base, captured) = support::http::start(r#"{"code":"0","msg":"","data":[]}"#).await; + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + let config = + custom_config(base, Some(credentials)).with_clock_offset(time::Duration::seconds(2)); + let client = OkxClient::with_clock( + config, + std::sync::Arc::new(FixedClock(time::macros::datetime!(2024-01-01 0:00 UTC))), + ) + .unwrap(); + client.account().activate_option().await.unwrap(); + assert_eq!( + captured + .await + .unwrap() + .headers + .get("ok-access-timestamp") + .map(String::as_str), + Some("2024-01-01T00:00:02.000Z") + ); +} + +#[tokio::test] +async fn server_time_measurement_returns_an_offset_suitable_for_client_config() { + let (base, captured) = + support::http::start(r#"{"code":"0","msg":"","data":[{"ts":"1704067200000"}]}"#).await; + let client = OkxClient::new(custom_config(base, None)).unwrap(); + + let before = time::OffsetDateTime::now_utc(); + let offset = client.measure_server_time_offset().await.unwrap(); + let after = time::OffsetDateTime::now_utc(); + + let midpoint = before + (after - before) / 2; + let expected: time::Duration = time::macros::datetime!(2024-01-01 0:00 UTC) - midpoint; + assert!((offset - expected).abs() < time::Duration::seconds(1)); + assert_eq!(captured.await.unwrap().target, "/api/v5/public/time"); + assert_eq!( + ClientConfig::default() + .with_clock_offset(offset) + .clock_offset, + offset + ); +} + +#[test] +fn timestamp_expiry_is_reported_as_clock_skew() { + let error = truefix_okx_client::response::decode_envelope::( + br#"{"code":"50102","msg":"Timestamp request expired","data":[]}"#, + ) + .unwrap_err(); + assert!(matches!(error, truefix_okx_client::OkxError::ClockSkew)); +} + +#[tokio::test] +async fn safe_read_retries_one_429_after_the_server_throttle() { + let (base, captured) = support::http::start_sequence(vec![ + ( + 429, + r#"{"code":"50011","msg":"rate limited","data":[]}"#, + Some("1"), + ), + ( + 200, + r#"{"code":"0","msg":"","data":[{"instId":"BTC-USDT","last":"1"}]}"#, + None, + ), + ]) + .await; + let client = OkxClient::new(custom_config(base, None)).unwrap(); + let started = std::time::Instant::now(); + assert_eq!(client.market().ticker("BTC-USDT").await.unwrap().len(), 1); + assert!( + started.elapsed() >= std::time::Duration::from_millis(900), + "the Retry-After throttle was not observed" + ); + let requests = captured.await.unwrap(); + assert_eq!(requests.len(), 2); + assert!(requests.iter().all(|request| request.method == "GET")); + assert_eq!(requests[0].target, requests[1].target); +} + +#[tokio::test] +async fn safe_read_retries_an_okx_50011_envelope_returned_with_http_200() { + let (base, captured) = support::http::start_sequence(vec![ + ( + 200, + r#"{"code":"50011","msg":"rate limited","data":[]}"#, + None, + ), + ( + 200, + r#"{"code":"0","msg":"","data":[{"instId":"BTC-USDT","last":"1"}]}"#, + None, + ), + ]) + .await; + let client = OkxClient::new(custom_config(base, None)).unwrap(); + + assert_eq!(client.market().ticker("BTC-USDT").await.unwrap().len(), 1); + assert_eq!(captured.await.unwrap().len(), 2); +} + +#[tokio::test] +async fn state_changing_write_is_not_replayed_after_429() { + let (base, captured) = support::http::start_sequence(vec![( + 429, + r#"{"code":"50011","msg":"rate limited","data":[]}"#, + Some("0"), + )]) + .await; + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + let client = OkxClient::new(custom_config(base, Some(credentials))).unwrap(); + let order = PlaceOrder { + instrument_id: "BTC-USDT".to_owned(), + trade_mode: "cash".to_owned(), + side: "buy".to_owned(), + order_type: "market".to_owned(), + size: "1".parse::().unwrap(), + price: None, + client_order_id: None, + margin_currency: None, + tag: None, + position_side: None, + reduce_only: None, + target_currency: None, + self_trade_prevention_mode: None, + attached_algo_orders: None, + price_usd: None, + price_volatility: None, + ban_amend: None, + trade_quote_currency: None, + price_amend_type: None, + elp_taker_access: None, + instrument_id_code: None, + }; + assert!(matches!( + client.trade().place_order(&order).await, + Err(truefix_okx_client::OkxError::RateLimited { .. }) + )); + let requests = captured.await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "POST"); +} + +#[test] +fn corrected_domain_paths_match_the_baseline_and_reject_known_invalid_paths() { + let sources = [ + include_str!("../src/services/account.rs"), + include_str!("../src/services/funding.rs"), + include_str!("../src/services/finance.rs"), + include_str!("../src/services/professional.rs"), + include_str!("../src/services/strategy.rs"), + include_str!("../src/services/trade.rs"), + ] + .join("\n"); + for required in [ + "/api/v5/account/vip-loan-order-list", + "/api/v5/account/fixed-loan/borrowing-orders-list", + "/api/v5/account/spot-manual-borrow-repay", + "/api/v5/asset/currencies", + "/api/v5/asset/deposit-lightning", + "/api/v5/finance/flexible-loan/borrow-currencies", + "/api/v5/finance/staking-defi/offers", + "/api/v5/finance/sfp/dcd/products", + "/api/v5/broker/fd/rebate-per-orders", + ] { + assert!( + sources.contains(required), + "missing corrected path {required}" + ); + } + for public_spread_method in [ + "pub async fn spreads", + "pub async fn spread_books", + "pub async fn spread_ticker", + "pub async fn spread_public_trades", + "pub async fn public_rfq_trades", + ] { + assert!( + sources.contains(public_spread_method), + "missing expected public method {public_spread_method}" + ); + } + for public_spread_get in [ + "spreads(&self, q: BTreeMap) -> OkxResult> {", + "self.public_get(\"/api/v5/sprd/spreads\", q).await", + "self.public_get(\"/api/v5/sprd/books\", q).await", + "self.public_get(\"/api/v5/sprd/ticker\", q).await", + "self.public_get(\"/api/v5/sprd/public-trades\", q).await", + "self.public_get(\"/api/v5/rfq/public-trades\", q).await", + ] { + assert!( + sources.contains(public_spread_get), + "missing expected public endpoint usage {public_spread_get}" + ); + } + for invalid in [ + "/api/v5/asset/lightning", + "/api/v5/asset/deposit/currencies", + "/api/v5/account/vip-loan/loan-order-list", + "/api/v5/account/fixed-loan/borrowing-order-list", + "/api/v5/finance/staking-defi/defi/offer-list", + "/api/v5/finance/staking-defi/dual-investment/products", + "/api/v5/broker/nd/rebate-per-orders", + "/api/v5/finance/savings/purchase-redempt-history", + "/api/v5/finance/savings/interest-accrued", + ] { + assert!( + !sources.contains(invalid), + "invalid path remains: {invalid}" + ); + } + for duplicate_or_misleading_method in [ + "deposit_currencies", + "fixed_loan_repayments", + "algo_advance_orders_pending", + "algo_advance_orders_history", + "copy_lead_positions", + "dust_assets", + "savings_products", + ] { + assert!( + !sources.contains(duplicate_or_misleading_method), + "duplicate, unsupported, or misleading method remains: {duplicate_or_misleading_method}" + ); + } +} diff --git a/crates/truefix-okx-client/tests/operation_inventory.rs b/crates/truefix-okx-client/tests/operation_inventory.rs new file mode 100644 index 0000000..b98869b --- /dev/null +++ b/crates/truefix-okx-client/tests/operation_inventory.rs @@ -0,0 +1,137 @@ +use truefix_okx_client::inventory::{ + AuthClass, BASELINE_DOMAINS, BASELINE_OPERATION_MANIFEST, BASELINE_REST_OPERATION_COUNT, + BaselineOperation, ManifestValidationError, ReplayClass, WEBSOCKET_TRADE_COMMANDS, + validate_operation_manifest, +}; + +#[test] +fn source_baseline_count_is_stable() { + assert_eq!( + BASELINE_DOMAINS + .iter() + .map(|(_, count)| count) + .sum::(), + BASELINE_REST_OPERATION_COUNT + ); + assert_eq!(WEBSOCKET_TRADE_COMMANDS.len(), 7); +} + +#[test] +fn baseline_domain_counts_cover_all_264_operations() { + assert_eq!(BASELINE_REST_OPERATION_COUNT, 264); + assert_eq!( + BASELINE_DOMAINS + .iter() + .map(|(_, count)| *count) + .sum::(), + 264 + ); +} + +#[test] +fn baseline_manifest_is_complete_and_classified() { + assert_eq!(BASELINE_OPERATION_MANIFEST.len(), 264); + validate_operation_manifest(BASELINE_OPERATION_MANIFEST, BASELINE_REST_OPERATION_COUNT) + .unwrap(); + + for (domain, expected) in BASELINE_DOMAINS { + assert_eq!( + BASELINE_OPERATION_MANIFEST + .iter() + .filter(|record| record.domain == *domain) + .count(), + *expected, + "baseline count changed for {domain}" + ); + } +} + +const VALID_RECORD: BaselineOperation = BaselineOperation { + source_identity: "Account.py:1:balance", + domain: "account", + operation: "balance", + method: "GET", + path: "/api/v5/account/balance", + auth: AuthClass::Private, + replay: ReplayClass::ReadOnly, + native_entrypoint: "account::balances", + fixture_id: "operation_inventory::baseline_manifest_is_complete_and_classified", +}; + +#[test] +fn duplicate_baseline_identity_is_rejected() { + assert_eq!( + validate_operation_manifest(&[VALID_RECORD, VALID_RECORD], 2), + Err(ManifestValidationError::DuplicateSourceIdentity( + VALID_RECORD.source_identity + )) + ); +} + +#[test] +fn unsupported_and_unclassified_records_are_rejected() { + let unsupported = BaselineOperation { + native_entrypoint: "UNSUPPORTED", + ..VALID_RECORD + }; + assert_eq!( + validate_operation_manifest(&[unsupported], 1), + Err(ManifestValidationError::Unsupported( + unsupported.source_identity + )) + ); + + let unclassified = BaselineOperation { + method: "", + ..VALID_RECORD + }; + assert_eq!( + validate_operation_manifest(&[unclassified], 1), + Err(ManifestValidationError::Unclassified( + unclassified.source_identity + )) + ); +} + +#[test] +fn us1_manifest_entries_have_native_and_fixture_evidence() { + assert!( + truefix_okx_client::inventory::US1_OPERATION_MANIFEST + .iter() + .all(|entry| !entry.native_entrypoint.is_empty() && !entry.fixture_id.is_empty()) + ); +} + +#[test] +fn every_manifest_record_has_operation_transport_and_evidence() { + for record in truefix_okx_client::inventory::US1_OPERATION_MANIFEST + .iter() + .chain(truefix_okx_client::inventory::WEBSOCKET_OPERATION_MANIFEST.iter()) + .chain(truefix_okx_client::inventory::US3_OPERATION_MANIFEST.iter()) + { + assert!(!record.domain.is_empty()); + assert!(!record.operation.is_empty()); + assert!(!record.transport.is_empty()); + assert!(!record.native_entrypoint.is_empty()); + assert!(!record.fixture_id.is_empty()); + } +} + +#[test] +fn parameterless_python_posts_remain_present_in_the_baseline_manifest() { + for (source_identity, domain, operation) in [ + ( + "Account.py:240:activate_option", + "account", + "activate_option", + ), + ("BlockTrading.py:70:reset_mmp", "block_trading", "reset_mmp"), + ] { + assert!(BASELINE_OPERATION_MANIFEST.iter().any(|entry| { + entry.source_identity == source_identity + && entry.domain == domain + && entry.operation == operation + && entry.method == "POST" + })); + } +} diff --git a/crates/truefix-okx-client/tests/support/http.rs b/crates/truefix-okx-client/tests/support/http.rs new file mode 100644 index 0000000..558efa6 --- /dev/null +++ b/crates/truefix-okx-client/tests/support/http.rs @@ -0,0 +1,180 @@ +//! Minimal local HTTP fixture used to verify the bytes emitted by the SDK. + +use std::collections::BTreeMap; + +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::oneshot, +}; + +/// An HTTP request captured by a one-shot local fixture. +#[derive(Debug)] +pub struct CapturedRequest { + /// Request method. + pub method: String, + /// Target path and query. + pub target: String, + /// Lowercase header names and their values. + pub headers: BTreeMap, + /// Exact body bytes. + pub body: Vec, +} + +/// Starts a one-shot HTTP/1.1 server returning an OKX envelope and its captured request receiver. +pub async fn start(response_body: &'static str) -> (String, oneshot::Receiver) { + start_with_headers(response_body, &[]).await +} + +/// Starts a one-shot HTTP/1.1 server with additional response headers. +pub async fn start_with_headers( + response_body: &'static str, + response_headers: &[(&str, &str)], +) -> (String, oneshot::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = oneshot::channel(); + let response_headers = response_headers + .iter() + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect::>(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + let count = stream.read(&mut buffer).await.unwrap(); + if count == 0 { + return; + } + bytes.extend_from_slice(&buffer[..count]); + if bytes.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let header_end = bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + .unwrap() + + 4; + let headers_text = std::str::from_utf8(&bytes[..header_end]).unwrap(); + let mut lines = headers_text.split("\r\n"); + let request_line = lines.next().unwrap(); + let mut request_words = request_line.split_whitespace(); + let method = request_words.next().unwrap().to_owned(); + let target = request_words.next().unwrap().to_owned(); + let mut headers = BTreeMap::new(); + for line in lines.filter(|line| !line.is_empty()) { + let (name, value) = line.split_once(':').unwrap(); + headers.insert(name.to_ascii_lowercase(), value.trim().to_owned()); + } + let content_length = headers + .get("content-length") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + while bytes.len() - header_end < content_length { + let count = stream.read(&mut buffer).await.unwrap(); + if count == 0 { + break; + } + bytes.extend_from_slice(&buffer[..count]); + } + let body = bytes[header_end..header_end + content_length].to_vec(); + let _ = sender.send(CapturedRequest { + method, + target, + headers, + body, + }); + let extra_headers = response_headers + .iter() + .map(|(name, value)| format!("{name}: {value}\r\n")) + .collect::(); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n{extra_headers}content-length: {}\r\nconnection: close\r\n\r\n{}", + response_body.len(), + response_body + ); + stream.write_all(response.as_bytes()).await.unwrap(); + }); + (format!("http://{address}"), receiver) +} + +/// Starts a fixture which returns each supplied status/body pair to consecutive requests. +pub async fn start_sequence( + responses: Vec<(u16, &'static str, Option<&'static str>)>, +) -> (String, oneshot::Receiver>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = oneshot::channel(); + tokio::spawn(async move { + let mut captured = Vec::with_capacity(responses.len()); + for (status, body, retry_after) in responses { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_request(&mut stream).await; + captured.push(request); + let reason = if status == 200 { + "OK" + } else { + "Too Many Requests" + }; + let retry_header = retry_after + .map(|value| format!("retry-after: {value}\r\n")) + .unwrap_or_default(); + let response = format!( + "HTTP/1.1 {status} {reason}\r\ncontent-type: application/json\r\n{retry_header}content-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + } + let _ = sender.send(captured); + }); + (format!("http://{address}"), receiver) +} + +async fn read_request(stream: &mut tokio::net::TcpStream) -> CapturedRequest { + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + let count = stream.read(&mut buffer).await.unwrap(); + if count == 0 { + break; + } + bytes.extend_from_slice(&buffer[..count]); + if bytes.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let header_end = bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + .unwrap() + + 4; + let headers_text = std::str::from_utf8(&bytes[..header_end]).unwrap(); + let mut lines = headers_text.split("\r\n"); + let mut request_words = lines.next().unwrap().split_whitespace(); + let method = request_words.next().unwrap().to_owned(); + let target = request_words.next().unwrap().to_owned(); + let mut headers = BTreeMap::new(); + for line in lines.filter(|line| !line.is_empty()) { + let (name, value) = line.split_once(':').unwrap(); + headers.insert(name.to_ascii_lowercase(), value.trim().to_owned()); + } + let content_length = headers + .get("content-length") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + while bytes.len() - header_end < content_length { + let count = stream.read(&mut buffer).await.unwrap(); + if count == 0 { + break; + } + bytes.extend_from_slice(&buffer[..count]); + } + CapturedRequest { + method, + target, + headers, + body: bytes[header_end..header_end + content_length].to_vec(), + } +} diff --git a/crates/truefix-okx-client/tests/support/mod.rs b/crates/truefix-okx-client/tests/support/mod.rs new file mode 100644 index 0000000..9af9773 --- /dev/null +++ b/crates/truefix-okx-client/tests/support/mod.rs @@ -0,0 +1,8 @@ +// Each integration-test binary imports this shared fixture module, but only uses one transport. +// Keep the helpers available to their respective tests without treating the unused half as +// production dead code when the workspace runs Clippy with `-D warnings`. +#[allow(dead_code)] +pub mod http; + +#[allow(dead_code)] +pub mod websocket; diff --git a/crates/truefix-okx-client/tests/support/websocket.rs b/crates/truefix-okx-client/tests/support/websocket.rs new file mode 100644 index 0000000..764a964 --- /dev/null +++ b/crates/truefix-okx-client/tests/support/websocket.rs @@ -0,0 +1,76 @@ +//! Scripted local WebSocket fixtures used by session integration tests. + +use futures_util::{SinkExt, StreamExt}; +use tokio::{net::TcpListener, sync::oneshot}; +use tokio_tungstenite::{accept_async, tungstenite::Message}; + +/// Starts a server which captures one command and sends a subscribe acknowledgement and event. +pub async fn start() -> (String, oneshot::Receiver) { + start_scripted( + &[ + r#"{"event":"subscribe","code":"0"}"#, + r#"{"arg":{"channel":"tickers","instId":"BTC-USDT"},"data":[]}"#, + ], + false, + ) + .await +} + +/// Starts a server that captures one client command, then emits the supplied text frames. +/// +/// A final close frame is useful for asserting that an unacknowledged command has unknown +/// completion. The helper deliberately keeps credentials out of its return value. +pub async fn start_scripted( + frames: &[&str], + close_after_frames: bool, +) -> (String, oneshot::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let script = frames + .iter() + .map(|frame| (*frame).to_owned()) + .collect::>(); + let (sender, receiver) = oneshot::channel(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = accept_async(stream).await.unwrap(); + if let Some(Ok(Message::Text(command))) = socket.next().await { + let _ = sender.send(command.to_string()); + for frame in script { + socket.send(Message::Text(frame.into())).await.unwrap(); + } + if close_after_frames { + socket.send(Message::Close(None)).await.unwrap(); + } + } + }); + (format!("ws://{address}"), receiver) +} + +/// Script a successful login acknowledgement followed by a liveness ping and disconnect. +pub async fn start_login_ping_disconnect() -> (String, oneshot::Receiver) { + start_scripted(&[r#"{"event":"login","code":"0"}"#, "ping"], true).await +} + +/// Sends a binary frame after capturing one command, to exercise protocol-frame failures. +pub async fn start_binary_frame() -> (String, oneshot::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = oneshot::channel(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = accept_async(stream).await.unwrap(); + if let Some(Ok(Message::Text(command))) = socket.next().await { + let _ = sender.send(command.to_string()); + socket.send(Message::Binary(vec![1].into())).await.unwrap(); + } + }); + (format!("ws://{address}"), receiver) +} + +/// Script an exchange error acknowledgement for correlation/error-path tests. +pub async fn start_error(code: &str, request_id: &str) -> (String, oneshot::Receiver) { + let error = + format!(r#"{{"event":"error","id":"{request_id}","code":"{code}","msg":"fixture error"}}"#); + start_scripted(&[&error], false).await +} diff --git a/crates/truefix-okx-client/tests/telemetry.rs b/crates/truefix-okx-client/tests/telemetry.rs new file mode 100644 index 0000000..e533cf2 --- /dev/null +++ b/crates/truefix-okx-client/tests/telemetry.rs @@ -0,0 +1,13 @@ +use truefix_okx_client::{ + config::Credentials, error::OkxError, limiter::RateLimiter, request::RetrySafety, +}; + +#[test] +fn telemetry_context_is_redacted_and_writes_are_not_retried() { + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + assert!(!format!("{credentials:?}").contains("secret")); + assert!(!RateLimiter::may_retry( + RetrySafety::NeverReplay, + &OkxError::UnknownCompletion + )); +} diff --git a/crates/truefix-okx-client/tests/ws_session.rs b/crates/truefix-okx-client/tests/ws_session.rs new file mode 100644 index 0000000..dbaf8f8 --- /dev/null +++ b/crates/truefix-okx-client/tests/ws_session.rs @@ -0,0 +1,451 @@ +mod support; + +use time::macros::datetime; +use truefix_okx_client::{ + auth::{Clock, sign_websocket_login}, + config::Credentials, + error::OkxError, + transport::websocket::WebSocketTransport, + types::{ + common::ExpirationTime, + websocket::{RequestId, SubscriptionArg, WsAcknowledgement, WsCommand, WsEvent, WsOrder}, + }, + ws::{ + business::BusinessSession, + coordinator::{AcknowledgementOutcome, WsStateCoordinator}, + event, + private::PrivateSession, + public::PublicSession, + session::{Session, SessionState}, + subscription::{SubscriptionKey, Subscriptions}, + }, +}; + +struct FixedClock(time::OffsetDateTime); + +impl Clock for FixedClock { + fn now(&self) -> time::OffsetDateTime { + self.0 + } +} + +fn ticker() -> SubscriptionArg { + SubscriptionArg::new("tickers", Some("BTC-USDT".to_owned())) +} + +#[tokio::test] +async fn public_session_correlates_subscription_ack_and_event() { + let (endpoint, command) = support::websocket::start().await; + let mut socket = WebSocketTransport::connect(&endpoint).await.unwrap(); + socket + .send(&WsCommand::new( + "subscribe", + Some(RequestId("subscription-1".to_owned())), + vec![ticker()], + )) + .await + .unwrap(); + assert!(command.await.unwrap().contains("subscription-1")); + let acknowledgement: WsAcknowledgement = + serde_json::from_str(&socket.receive().await.unwrap()).unwrap(); + assert_eq!(acknowledgement.event.as_deref(), Some("subscribe")); + assert_eq!(acknowledgement.code.as_deref(), Some("0")); + let event: WsEvent = serde_json::from_str(&socket.receive().await.unwrap()).unwrap(); + assert_eq!(event.arg, ticker()); +} + +#[tokio::test] +async fn fixture_drives_login_ping_and_disconnect() { + let (endpoint, command) = support::websocket::start_login_ping_disconnect().await; + let mut socket = WebSocketTransport::connect(&endpoint).await.unwrap(); + socket + .send(&WsCommand::new( + "login", + Some(RequestId("login-1".to_owned())), + Vec::::new(), + )) + .await + .unwrap(); + assert!(command.await.unwrap().contains("login")); + assert!( + socket + .receive() + .await + .unwrap() + .contains("\"event\":\"login\"") + ); + assert_eq!(socket.receive().await.unwrap(), "ping"); + assert!(matches!( + socket.receive().await, + Err(OkxError::UnknownCompletion) + )); +} + +#[tokio::test] +async fn fixture_exposes_correlated_exchange_error() { + let (endpoint, command) = support::websocket::start_error("60012", "bad-subscription").await; + let mut socket = WebSocketTransport::connect(&endpoint).await.unwrap(); + socket + .send(&WsCommand::new( + "subscribe", + Some(RequestId("bad-subscription".to_owned())), + vec![ticker()], + )) + .await + .unwrap(); + assert!(command.await.unwrap().contains("bad-subscription")); + let acknowledgement: WsAcknowledgement = + serde_json::from_str(&socket.receive().await.unwrap()).unwrap(); + assert_eq!( + acknowledgement.request_id, + Some(RequestId("bad-subscription".to_owned())) + ); + assert_eq!(acknowledgement.code.as_deref(), Some("60012")); + assert_eq!(acknowledgement.event.as_deref(), Some("error")); +} + +#[tokio::test] +async fn receive_for_session_disconnects_after_a_peer_close() { + let (endpoint, command) = support::websocket::start_scripted(&[], true).await; + let mut socket = WebSocketTransport::connect(&endpoint).await.unwrap(); + let mut session = Session::default(); + session.connected(); + socket + .send(&WsCommand::new( + "subscribe", + Some(RequestId("close-1".to_owned())), + vec![ticker()], + )) + .await + .unwrap(); + assert!(command.await.unwrap().contains("close-1")); + + assert!(matches!( + socket.receive_for_session(&mut session).await, + Err(OkxError::UnknownCompletion) + )); + assert_eq!(session.state(), SessionState::Backoff); +} + +#[tokio::test] +async fn receive_for_session_disconnects_after_a_non_text_frame() { + let (endpoint, command) = support::websocket::start_binary_frame().await; + let mut socket = WebSocketTransport::connect(&endpoint).await.unwrap(); + let mut session = Session::default(); + session.connected(); + socket + .send(&WsCommand::new( + "subscribe", + Some(RequestId("binary-1".to_owned())), + vec![ticker()], + )) + .await + .unwrap(); + assert!(command.await.unwrap().contains("binary-1")); + + assert!(matches!( + socket.receive_for_session(&mut session).await, + Err(OkxError::Decode(_)) + )); + assert_eq!(session.state(), SessionState::Backoff); +} + +#[test] +fn session_login_recovery_and_private_write_gate() { + let mut session = Session::default(); + assert_eq!(session.state(), SessionState::Disconnected); + session.connecting(); + session.login_required(); + assert_eq!(session.state(), SessionState::Authenticating); + session.login_acknowledged(true); + assert_eq!(session.state(), SessionState::Resubscribing); + assert!(!session.can_write()); + session.subscriptions_replayed(); + assert!(session.can_write()); + session.disconnected(); + assert_eq!(session.state(), SessionState::Backoff); + assert!(!session.can_write()); + + let private = PrivateSession::default(); + assert!(matches!( + private.write_allowed(), + Err(OkxError::UnknownCompletion) + )); +} + +#[test] +fn subscriptions_require_an_active_session() { + let mut public = PublicSession::default(); + assert!(matches!( + public.subscribe(vec![ticker()]), + Err(OkxError::UnknownCompletion) + )); + public.connected(); + assert!(public.subscribe(vec![ticker()]).is_ok()); + assert!(public.send("ping", Vec::new()).is_ok()); + assert!( + public + .subscribe_raw(vec![serde_json::json!({ + "channel": "instruments", + "instType": "FUTURES" + })]) + .is_ok() + ); + + let mut private = PrivateSession::default(); + assert!(matches!( + private.subscribe(vec![ticker()]), + Err(OkxError::UnknownCompletion) + )); + private.connected(); + assert_eq!(private.0.state(), SessionState::Authenticating); + assert!(matches!( + private.subscribe(vec![ticker()]), + Err(OkxError::UnknownCompletion) + )); + private.login_acknowledged(false); + private.subscriptions_replayed(); + assert!(matches!( + private.subscribe(vec![ticker()]), + Err(OkxError::UnknownCompletion) + )); + private.login_acknowledged(true); + private.subscriptions_replayed(); + assert!(private.subscribe(vec![ticker()]).is_ok()); +} + +#[test] +fn subscriptions_deduplicate_route_only_active_events_and_replay_after_disconnect() { + let key = SubscriptionKey::from(&ticker()); + let other = SubscriptionKey { + channel: "trades".to_owned(), + instrument_id: Some("BTC-USDT".to_owned()), + extra: Vec::new(), + }; + let mut subscriptions = Subscriptions::default(); + assert!(subscriptions.request(key.clone())); + assert!(!subscriptions.request(key.clone())); + subscriptions.acknowledge(key.clone()); + assert!(subscriptions.active(&key)); + let event = WsEvent { + arg: ticker(), + data: Vec::new(), + }; + assert!(event::matches(&event, &key)); + assert!(!event::matches(&event, &other)); + subscriptions.disconnect(); + assert!(!subscriptions.active(&key)); + assert_eq!(subscriptions.replay().collect::>(), vec![&key]); +} + +#[test] +fn business_session_keeps_optional_login_state_separate_from_private_writes() { + let business = BusinessSession::default(); + assert_eq!(business.0.state(), SessionState::Disconnected); + let private = PrivateSession::default(); + assert!(matches!( + private.write_allowed(), + Err(OkxError::UnknownCompletion) + )); +} + +#[test] +fn signed_login_uses_the_injected_clock_for_private_and_business_sessions() { + let credentials = Credentials::new("key", "secret", "passphrase").unwrap(); + let clock = FixedClock(datetime!(2024-01-01 0:00 UTC)); + + let mut private = PrivateSession::default(); + let private_login = private.signed_login(&credentials, &clock).unwrap(); + assert_eq!(private_login.op, "login"); + assert!(private_login.id.is_none()); + let private_args = &private_login.args[0]; + assert_eq!(private_args["apiKey"], "key"); + assert_eq!(private_args["passphrase"], "passphrase"); + assert_eq!(private_args["timestamp"], "1704067200"); + assert_eq!( + private_args["sign"], + sign_websocket_login(&credentials, 1_704_067_200).unwrap() + ); + + let mut business = BusinessSession::default(); + let business_login = business.signed_login(&credentials, &clock).unwrap(); + assert_eq!(business_login.op, "login"); + assert!(business_login.id.is_none()); + let business_args = &business_login.args[0]; + assert_eq!(business_args["apiKey"], "key"); + assert_eq!(business_args["passphrase"], "passphrase"); + assert_eq!(business_args["timestamp"], "1704067200"); + assert_eq!( + business_args["sign"], + sign_websocket_login(&credentials, 1_704_067_200).unwrap() + ); +} + +#[test] +fn subscriptions_with_distinct_channel_parameters_have_distinct_keys() { + let futures = SubscriptionArg::new("orders", None).with_parameter("instType", "FUTURES"); + let swaps = SubscriptionArg::new("orders", None).with_parameter("instType", "SWAP"); + assert_ne!( + SubscriptionKey::from(&futures), + SubscriptionKey::from(&swaps) + ); +} + +#[test] +fn inbound_messages_arm_heartbeat_and_a_missing_pong_disconnects_the_session() { + let mut session = Session::default(); + session.connected(); + let now = std::time::Instant::now(); + session.message_received(now); + assert!( + !session.ping_due(now + Session::HEARTBEAT_INTERVAL - std::time::Duration::from_millis(1)) + ); + assert!(session.ping_due(now + Session::HEARTBEAT_INTERVAL)); + assert!(!session.ping_due(now + Session::HEARTBEAT_INTERVAL * 2)); + assert_eq!(session.state(), SessionState::Backoff); +} + +#[test] +fn private_order_expiration_is_available_only_on_order_and_amend_helpers() { + let mut session = PrivateSession::default(); + session.connected(); + session.login_acknowledged(true); + session.subscriptions_replayed(); + let command = session + .place_order_with_expiration( + WsOrder { + instrument_id: "BTC-USDT".to_owned(), + trade_mode: "cash".to_owned(), + side: "buy".to_owned(), + order_type: "market".to_owned(), + size: "1".to_owned(), + price: None, + client_order_id: None, + }, + ExpirationTime::new(1_704_067_200_123).unwrap(), + ) + .unwrap(); + assert_eq!(command.op, "order"); + assert_eq!( + command.expiration_time(), + Some(ExpirationTime::new(1_704_067_200_123).unwrap()) + ); +} + +#[test] +fn routing_ignores_server_metadata_but_keeps_requested_parameters() { + let requested = SubscriptionArg::new("orders", None) + .with_parameter("instType", "FUTURES") + .with_parameter("instFamily", "BTC-USD"); + let key = SubscriptionKey::from(&requested); + let mut subscriptions = Subscriptions::default(); + assert!(subscriptions.request(key.clone())); + subscriptions.acknowledge(key); + + let server_event = WsEvent { + arg: requested.clone().with_parameter("uid", "12345"), + data: Vec::new(), + }; + assert!(event::matches( + &server_event, + &SubscriptionKey::from(&requested) + )); + assert!(event::route(&subscriptions, server_event).is_some()); + + let wrong_instrument_type = WsEvent { + arg: requested.with_parameter("instType", "SWAP"), + data: Vec::new(), + }; + assert!(event::route(&subscriptions, wrong_instrument_type).is_none()); +} + +#[test] +fn wildcard_subscription_routes_events_with_server_selected_instrument() { + let requested = SubscriptionArg::new("instruments", None).with_parameter("instType", "SWAP"); + let key = SubscriptionKey::from(&requested); + let mut subscriptions = Subscriptions::default(); + assert!(subscriptions.request(key.clone())); + subscriptions.acknowledge(key); + + let event = WsEvent { + arg: SubscriptionArg::new("instruments", Some("BTC-USDT-SWAP".to_owned())) + .with_parameter("instType", "SWAP"), + data: Vec::new(), + }; + assert!(event::route(&subscriptions, event).is_some()); +} + +#[test] +fn acknowledgement_coordinator_applies_login_subscription_error_and_notice() { + let requested = SubscriptionArg::new("orders", None).with_parameter("instType", "SWAP"); + let key = SubscriptionKey::from(&requested); + let request_id = RequestId("subscribe-1".to_owned()); + let mut coordinator = WsStateCoordinator::default(); + assert!(coordinator.subscriptions_mut().request(key.clone())); + assert!( + coordinator + .subscriptions_mut() + .correlate(request_id.clone(), &key) + ); + let mut session = Session::default(); + session.login_required(); + + let login = WsAcknowledgement { + event: Some("login".to_owned()), + request_id: None, + code: Some("0".to_owned()), + msg: None, + }; + assert_eq!( + coordinator.apply(&mut session, &login), + AcknowledgementOutcome::Login { success: true } + ); + assert_eq!(session.state(), SessionState::Resubscribing); + + let subscribed = WsAcknowledgement { + event: Some("subscribe".to_owned()), + request_id: Some(request_id.clone()), + code: Some("0".to_owned()), + msg: None, + }; + assert_eq!( + coordinator.apply(&mut session, &subscribed), + AcknowledgementOutcome::Subscription { + request_id, + key: key.clone(), + success: true, + } + ); + assert!(coordinator.subscriptions().active(&key)); + + let error_id = RequestId("subscribe-2".to_owned()); + assert!( + coordinator + .subscriptions_mut() + .correlate(error_id.clone(), &key) + ); + let error = WsAcknowledgement { + event: Some("error".to_owned()), + request_id: Some(error_id.clone()), + code: Some("60012".to_owned()), + msg: Some("bad subscription".to_owned()), + }; + assert!(matches!( + coordinator.apply(&mut session, &error), + AcknowledgementOutcome::Subscription { success: false, .. } + )); + + let notice = WsAcknowledgement { + event: Some("notice".to_owned()), + request_id: None, + code: Some("0".to_owned()), + msg: None, + }; + assert_eq!( + coordinator.apply(&mut session, ¬ice), + AcknowledgementOutcome::Notice + ); + assert_eq!(session.state(), SessionState::Backoff); + assert!(!coordinator.subscriptions().active(&key)); + assert!(coordinator.subscriptions().desired(&key)); +} diff --git a/crates/truefix-transport/tests/reconnect.rs b/crates/truefix-transport/tests/reconnect.rs index 2d204e7..fdb1aa3 100644 --- a/crates/truefix-transport/tests/reconnect.rs +++ b/crates/truefix-transport/tests/reconnect.rs @@ -108,22 +108,33 @@ async fn initiator_connects_from_the_configured_local_bind_address() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); - // Reserve a specific local port, then release it for the initiator to bind — a strong - // assertion (the peer-observed source *port* must match exactly) that the bind actually - // took effect, unlike merely checking the source IP (which would be 127.0.0.1 regardless). - let local_bind_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let local_port = local_bind_listener.local_addr().unwrap().port(); - drop(local_bind_listener); - let mut cfg = SessionConfig::new("FIX.4.4", "CLIENT", "SERVER", Role::Initiator); - cfg.local_bind_addr = Some(([127, 0, 0, 1], local_port).into()); + // A port has to be released before the initiator can bind it. Another concurrently running + // test may claim it in that tiny interval, so retry an `AddrInUse` allocation race while + // retaining the stronger peer-source-port assertion. + let (local_port, handle) = { + let mut result = None; + for _ in 0..16 { + let local_bind_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let local_port = local_bind_listener.local_addr().unwrap().port(); + drop(local_bind_listener); + + cfg.local_bind_addr = Some(([127, 0, 0, 1], local_port).into()); + match connect_initiator_with(addr, cfg.clone(), Arc::new(NoopApp), Services::default()) + .await + { + Ok(handle) => { + result = Some((local_port, handle)); + break; + } + Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => continue, + Err(error) => panic!("initiator connection failed: {error}"), + } + } + result.expect("a local port should become available for the initiator") + }; - let (accepted, handle) = tokio::join!(listener.accept(), async { - connect_initiator_with(addr, cfg, Arc::new(NoopApp), Services::default()) - .await - .unwrap() - }); - let (server_stream, _) = accepted.unwrap(); + let (server_stream, _) = listener.accept().await.unwrap(); let peer = server_stream.peer_addr().unwrap(); assert_eq!(peer.port(), local_port); handle.logout().await; diff --git a/crates/truefix-twsapi-client/examples/twsapi/main.rs b/crates/truefix-twsapi-client/examples/twsapi/main.rs index f59eda8..b8afbcc 100644 --- a/crates/truefix-twsapi-client/examples/twsapi/main.rs +++ b/crates/truefix-twsapi-client/examples/twsapi/main.rs @@ -800,13 +800,20 @@ fn print_event(event: &Event) { Event::MarketDepthExchanges { descriptions } => { println!("market_depth_exchanges count={}", descriptions.len()); for description in descriptions { - println!(" {description}"); + println!( + " {} {} {} {} {}", + description.exchange, + description.security_type, + description.listing_exchange, + description.service_data_type, + description.aggregate_group + ); } } Event::NewsProviders { providers } => { println!("news_providers count={}", providers.len()); for provider in providers { - println!(" {} {}", provider.0, provider.1); + println!(" {} {}", provider.code, provider.name); } } Event::ScannerParameters { xml } => println!("{xml}"), @@ -1680,6 +1687,7 @@ async fn run_executions(client: &mut TwsApiClient) -> Result<(), CliError> { sec_type: env_string("TWS_SEC_TYPE", ""), exchange: env_string("TWS_EXCHANGE", ""), side: env_string("TWS_EXEC_SIDE", ""), + ..ExecutionFilter::default() }, }) .await?; diff --git a/crates/truefix-twsapi-client/src/client.rs b/crates/truefix-twsapi-client/src/client.rs index 86f3f6d..2a58ecf 100644 --- a/crates/truefix-twsapi-client/src/client.rs +++ b/crates/truefix-twsapi-client/src/client.rs @@ -1072,19 +1072,26 @@ impl TwsApiClient { } async fn read_handshake(&mut self) -> TwsApiResult<()> { - let payload = self.read_payload().await?; - let fields = comm::read_fields(&payload); - if fields.len() != 2 { - return Err(TwsApiError::MalformedHandshake); - } + loop { + let payload = self.read_payload().await?; + let fields = comm::read_fields(&payload); + if fields.len() == 2 { + let mut fields = fields.into_iter(); + let server_version = + parse_i32(fields.next().ok_or(TwsApiError::MalformedHandshake)?)?; + self.server_version = server_version; + self.connection_time = + String::from_utf8_lossy(fields.next().ok_or(TwsApiError::MalformedHandshake)?) + .into_owned(); + return Ok(()); + } - let mut fields = fields.into_iter(); - let server_version = parse_i32(fields.next().ok_or(TwsApiError::MalformedHandshake)?)?; - self.server_version = server_version; - self.connection_time = - String::from_utf8_lossy(fields.next().ok_or(TwsApiError::MalformedHandshake)?) - .into_owned(); - Ok(()) + let event = decoder::decode_payload(false, &payload)?; + self.pending_events.push_back(event.clone()); + if let Some(next_event) = follow_up_event(&event) { + self.pending_events.push_back(next_event); + } + } } } diff --git a/crates/truefix-twsapi-client/src/comm.rs b/crates/truefix-twsapi-client/src/comm.rs index 7334459..91b3be8 100644 --- a/crates/truefix-twsapi-client/src/comm.rs +++ b/crates/truefix-twsapi-client/src/comm.rs @@ -185,10 +185,12 @@ impl TwsField for usize { impl TwsField for f64 { fn to_tws_field(self) -> TwsApiResult { - if self.is_infinite() && self.is_sign_positive() { - return Ok(INFINITY_STR.to_owned()); + let mut value = self.to_string(); + if self.is_finite() && !value.contains('.') && !value.contains('e') && !value.contains('E') + { + value.push_str(".0"); } - Ok(self.to_string()) + Ok(value) } } @@ -206,6 +208,9 @@ impl TwsNullableField for f64 { if self == UNSET_DOUBLE { return Ok(String::new()); } + if self.is_infinite() && self.is_sign_positive() { + return Ok(INFINITY_STR.to_owned()); + } self.to_tws_field() } } diff --git a/crates/truefix-twsapi-client/src/constants.rs b/crates/truefix-twsapi-client/src/constants.rs index 4c80f2c..812aed5 100644 --- a/crates/truefix-twsapi-client/src/constants.rs +++ b/crates/truefix-twsapi-client/src/constants.rs @@ -21,6 +21,9 @@ pub const UNSET_DECIMAL: Decimal = Decimal::MAX; /// String representation used by the official client for positive infinity. pub const INFINITY_STR: &str = "Infinity"; +/// PEG BEST sentinel requesting competition up to the midpoint. +pub const COMPETE_AGAINST_BEST_OFFSET_UP_TO_MID: f64 = f64::INFINITY; + /// Standard tags accepted by `reqAccountSummary`. pub struct AccountSummaryTags; @@ -57,6 +60,8 @@ impl AccountSummaryTags { /// All tags in the order used by the official Python client. pub const ALL: &str = "AccountType,NetLiquidation,TotalCashValue,SettledCash,AccruedCash,BuyingPower,EquityWithLoanValue,PreviousDayEquityWithLoanValue,GrossPositionValue,ReqTEquity,ReqTMargin,SMA,InitMarginReq,MaintMarginReq,AvailableFunds,ExcessLiquidity,Cushion,FullInitMarginReq,FullMaintMarginReq,FullAvailableFunds,FullExcessLiquidity,LookAheadNextChange,LookAheadInitMarginReq,LookAheadMaintMarginReq,LookAheadAvailableFunds,LookAheadExcessLiquidity,HighestSeverity,DayTradesRemaining,Leverage"; + /// Descriptive alias for [`Self::ALL`]. + pub const ALL_TAGS: &str = Self::ALL; } /// TWS error code and its standard description prefix. @@ -67,6 +72,298 @@ pub struct ErrorCode { } impl ErrorCode { + pub const ALREADY_CONNECTED: Self = Self { + code: 501, + message: "Already connected.", + }; + pub const CONNECT_FAIL: Self = Self { + code: 502, + message: "Couldn't connect to TWS.", + }; + pub const UPDATE_TWS: Self = Self { + code: 503, + message: "The TWS is out of date and must be upgraded.", + }; + pub const NOT_CONNECTED: Self = Self { + code: 504, + message: "Not connected", + }; + pub const UNKNOWN_ID: Self = Self { + code: 505, + message: "Fatal Error: Unknown message id.", + }; + pub const BAD_LENGTH: Self = Self { + code: 507, + message: "Bad message length", + }; + pub const BAD_MESSAGE: Self = Self { + code: 508, + message: "Bad message", + }; + pub const FAIL_SEND_ACCT: Self = Self { + code: 513, + message: "Account Update Request Sending Error - ", + }; + pub const FAIL_SEND_EXEC: Self = Self { + code: 514, + message: "Request For Executions Sending Error - ", + }; + pub const FAIL_SEND_CORDER: Self = Self { + code: 515, + message: "Cancel Order Sending Error - ", + }; + pub const FAIL_SEND_OORDER: Self = Self { + code: 516, + message: "Request Open Order Sending Error - ", + }; + pub const FAIL_SEND_REQ_MKT_DEPTH: Self = Self { + code: 519, + message: "Request Market Depth Sending Error - ", + }; + pub const FAIL_CREATE_SOCK: Self = Self { + code: 520, + message: "Failed to create socket", + }; + pub const FAIL_SEND_SERVER_LOG_LEVEL: Self = Self { + code: 521, + message: "Set Server Log Level Sending Error - ", + }; + pub const FAIL_SEND_FA_REQUEST: Self = Self { + code: 522, + message: "FA Information Request Sending Error - ", + }; + pub const FAIL_SEND_FA_REPLACE: Self = Self { + code: 523, + message: "FA Information Replace Sending Error - ", + }; + pub const FAIL_SEND_REQ_SCANNER: Self = Self { + code: 524, + message: "Request Scanner Subscription Sending Error - ", + }; + pub const FAIL_SEND_CAN_SCANNER: Self = Self { + code: 525, + message: "Cancel Scanner Subscription Sending Error - ", + }; + pub const FAIL_SEND_REQ_SCANNER_PARAMETERS: Self = Self { + code: 526, + message: "Request Scanner Parameter Sending Error - ", + }; + pub const FAIL_SEND_REQ_CALC_IMPLIED_VOLAT: Self = Self { + code: 534, + message: "Request Calculate Implied Volatility Sending Error - ", + }; + pub const FAIL_SEND_REQ_CALC_OPTION_PRICE: Self = Self { + code: 535, + message: "Request Calculate Option Price Sending Error - ", + }; + pub const FAIL_SEND_CAN_CALC_IMPLIED_VOLAT: Self = Self { + code: 536, + message: "Cancel Calculate Implied Volatility Sending Error - ", + }; + pub const FAIL_SEND_CAN_CALC_OPTION_PRICE: Self = Self { + code: 537, + message: "Cancel Calculate Option Price Sending Error - ", + }; + pub const FAIL_SEND_REQ_GLOBAL_CANCEL: Self = Self { + code: 538, + message: "Request Global Cancel Sending Error - ", + }; + pub const FAIL_SEND_REQ_MARKET_DATA_TYPE: Self = Self { + code: 539, + message: "Request Market Data Type Sending Error - ", + }; + pub const FAIL_SEND_CAN_ACCOUNT_DATA: Self = Self { + code: 543, + message: "Cancel Account Data Sending Error - ", + }; + pub const FAIL_SEND_VERIFY_REQUEST: Self = Self { + code: 544, + message: "Verify Request Sending Error - ", + }; + pub const FAIL_SEND_VERIFY_MESSAGE: Self = Self { + code: 545, + message: "Verify Message Sending Error - ", + }; + pub const FAIL_SEND_QUERY_DISPLAY_GROUPS: Self = Self { + code: 546, + message: "Query Display Groups Sending Error - ", + }; + pub const FAIL_SEND_SUBSCRIBE_TO_GROUP_EVENTS: Self = Self { + code: 547, + message: "Subscribe To Group Events Sending Error - ", + }; + pub const FAIL_SEND_UPDATE_DISPLAY_GROUP: Self = Self { + code: 548, + message: "Update Display Group Sending Error - ", + }; + pub const FAIL_SEND_UNSUBSCRIBE_FROM_GROUP_EVENTS: Self = Self { + code: 549, + message: "Unsubscribe From Group Events Sending Error - ", + }; + pub const FAIL_SEND_START_API: Self = Self { + code: 550, + message: "Start API Sending Error - ", + }; + pub const FAIL_SEND_VERIFY_AND_AUTH_REQUEST: Self = Self { + code: 551, + message: "Verify And Auth Request Sending Error - ", + }; + pub const FAIL_SEND_VERIFY_AND_AUTH_MESSAGE: Self = Self { + code: 552, + message: "Verify And Auth Message Sending Error - ", + }; + pub const FAIL_SEND_REQ_POSITIONS_MULTI: Self = Self { + code: 553, + message: "Request Positions Multi Sending Error - ", + }; + pub const FAIL_SEND_CAN_POSITIONS_MULTI: Self = Self { + code: 554, + message: "Cancel Positions Multi Sending Error - ", + }; + pub const FAIL_SEND_REQ_ACCOUNT_UPDATES_MULTI: Self = Self { + code: 555, + message: "Request Account Updates Multi Sending Error - ", + }; + pub const FAIL_SEND_CAN_ACCOUNT_UPDATES_MULTI: Self = Self { + code: 556, + message: "Cancel Account Updates Multi Sending Error - ", + }; + pub const FAIL_SEND_REQ_SEC_DEF_OPT_PARAMS: Self = Self { + code: 557, + message: "Request Security Definition Option Params Sending Error - ", + }; + pub const FAIL_SEND_REQ_SOFT_DOLLAR_TIERS: Self = Self { + code: 558, + message: "Request Soft Dollar Tiers Sending Error - ", + }; + pub const FAIL_SEND_REQ_FAMILY_CODES: Self = Self { + code: 559, + message: "Request Family Codes Sending Error - ", + }; + pub const FAIL_SEND_REQ_MATCHING_SYMBOLS: Self = Self { + code: 560, + message: "Request Matching Symbols Sending Error - ", + }; + pub const FAIL_SEND_REQ_MKT_DEPTH_EXCHANGES: Self = Self { + code: 561, + message: "Request Market Depth Exchanges Sending Error - ", + }; + pub const FAIL_SEND_REQ_SMART_COMPONENTS: Self = Self { + code: 562, + message: "Request Smart Components Sending Error - ", + }; + pub const FAIL_SEND_REQ_NEWS_PROVIDERS: Self = Self { + code: 563, + message: "Request News Providers Sending Error - ", + }; + pub const FAIL_SEND_REQ_NEWS_ARTICLE: Self = Self { + code: 564, + message: "Request News Article Sending Error - ", + }; + pub const FAIL_SEND_REQ_HISTORICAL_NEWS: Self = Self { + code: 565, + message: "Request Historical News Sending Error - ", + }; + pub const FAIL_SEND_REQ_HEAD_TIMESTAMP: Self = Self { + code: 566, + message: "Request Head Time Stamp Sending Error - ", + }; + pub const FAIL_SEND_REQ_HISTOGRAM_DATA: Self = Self { + code: 567, + message: "Request Histogram Data Sending Error - ", + }; + pub const FAIL_SEND_CANCEL_HISTOGRAM_DATA: Self = Self { + code: 568, + message: "Cancel Request Histogram Data Sending Error - ", + }; + pub const FAIL_SEND_CANCEL_HEAD_TIMESTAMP: Self = Self { + code: 569, + message: "Cancel Head Time Stamp Sending Error - ", + }; + pub const FAIL_SEND_REQ_MARKET_RULE: Self = Self { + code: 570, + message: "Request Market Rule Sending Error - ", + }; + pub const FAIL_SEND_REQ_PNL: Self = Self { + code: 571, + message: "Request PnL Sending Error - ", + }; + pub const FAIL_SEND_CANCEL_PNL: Self = Self { + code: 572, + message: "Cancel PnL Sending Error - ", + }; + pub const FAIL_SEND_REQ_PNL_SINGLE: Self = Self { + code: 573, + message: "Request PnL Single Error - ", + }; + pub const FAIL_SEND_CANCEL_PNL_SINGLE: Self = Self { + code: 574, + message: "Cancel PnL Single Sending Error - ", + }; + pub const FAIL_SEND_REQ_HISTORICAL_TICKS: Self = Self { + code: 575, + message: "Request Historical Ticks Error - ", + }; + pub const FAIL_SEND_REQ_TICK_BY_TICK_DATA: Self = Self { + code: 576, + message: "Request Tick-By-Tick Data Sending Error - ", + }; + pub const FAIL_SEND_CANCEL_TICK_BY_TICK_DATA: Self = Self { + code: 577, + message: "Cancel Tick-By-Tick Data Sending Error - ", + }; + pub const FAIL_SEND_REQ_COMPLETED_ORDERS: Self = Self { + code: 578, + message: "Request Completed Orders Sending Error - ", + }; + pub const FAIL_SEND_REQ_WSH_META_DATA: Self = Self { + code: 580, + message: "Request WSH Meta Data Sending Error - ", + }; + pub const FAIL_SEND_CAN_WSH_META_DATA: Self = Self { + code: 581, + message: "Cancel WSH Meta Data Sending Error - ", + }; + pub const FAIL_SEND_REQ_WSH_EVENT_DATA: Self = Self { + code: 582, + message: "Request WSH Event Data Sending Error - ", + }; + pub const FAIL_SEND_CAN_WSH_EVENT_DATA: Self = Self { + code: 583, + message: "Cancel WSH Event Data Sending Error - ", + }; + pub const FAIL_SEND_REQ_USER_INFO: Self = Self { + code: 584, + message: "Request User Info Sending Error - ", + }; + pub const FA_PROFILE_NOT_SUPPORTED: Self = Self { + code: 585, + message: "FA Profile is not supported anymore, use FA Group instead - ", + }; + pub const FAIL_SEND_REQ_CURRENT_TIME_IN_MILLIS: Self = Self { + code: 587, + message: "Request Current Time In Millis Sending Error - ", + }; + pub const FAIL_SEND_CAN_MKT_DEPTH: Self = Self { + code: 589, + message: "Cancel Market Depth Sending Error - ", + }; + pub const FAIL_SEND_CANCEL_CONTRACT_DATA: Self = Self { + code: 590, + message: "Cancel Contract Data Sending Error - ", + }; + pub const FAIL_SEND_CANCEL_HISTORICAL_TICKS: Self = Self { + code: 591, + message: "Cancel Historical Ticks Sending Error - ", + }; + pub const FAIL_SEND_REQ_CONFIG: Self = Self { + code: 592, + message: "Request Config Sending Error - ", + }; + pub const FAIL_SEND_UPDATE_CONFIG: Self = Self { + code: 593, + message: "Update Config Request Sending Error - ", + }; pub const FAIL_SEND_REQ_MKT_DATA: Self = Self { code: 510, message: "Request Market Data Sending Error - ", @@ -79,10 +376,7 @@ impl ErrorCode { code: 512, message: "Order Sending Error - ", }; - pub const FAIL_SEND_CANCEL_ORDER: Self = Self { - code: 513, - message: "Cancel Order Sending Error - ", - }; + pub const FAIL_SEND_CANCEL_ORDER: Self = Self::FAIL_SEND_CORDER; pub const FAIL_SEND_REQ_CONTRACT_DATA: Self = Self { code: 518, message: "Request Contract Data Sending Error - ", diff --git a/crates/truefix-twsapi-client/src/decoder.rs b/crates/truefix-twsapi-client/src/decoder.rs index 302405c..6ae73fc 100644 --- a/crates/truefix-twsapi-client/src/decoder.rs +++ b/crates/truefix-twsapi-client/src/decoder.rs @@ -7,11 +7,12 @@ use crate::message::{Incoming, PROTOBUF_MSG_ID}; use crate::protobuf; use crate::types::{ BarData, ComboLeg, CommissionAndFeesReport, Contract, ContractDescription, ContractDetails, - DeltaNeutralContract, Execution, HistoricalSession, HistoricalTick, HistoricalTickBidAsk, - HistoricalTickLast, IneligibilityReason, LegOpenClose, Order, OrderAllocation, OrderCondition, - OrderState, Origin, SmartComponent, SoftDollarTier, TagValue, TickAttribBidAsk, TickAttribLast, - TickByTick, + DeltaNeutralContract, Execution, HistogramEntry, HistoricalSession, HistoricalTick, + HistoricalTickBidAsk, HistoricalTickLast, IneligibilityReason, LegOpenClose, NewsProvider, + Order, OrderAllocation, OrderCondition, OrderState, Origin, PriceIncrement, SmartComponent, + SoftDollarTier, TagValue, TickAttribBidAsk, TickAttribLast, TickByTick, }; +use crate::types::{DepthMarketDataDescription, FamilyCode}; mod order; mod order_proto; @@ -513,7 +514,13 @@ pub fn decode_protobuf_event(msg_id: i32, payload: &[u8]) -> TwsApiResult descriptions: msg .depth_market_data_descriptions .into_iter() - .map(|description| format!("{description:?}")) + .map(|description| DepthMarketDataDescription { + exchange: description.exchange.unwrap_or_default(), + security_type: description.sec_type.unwrap_or_default(), + listing_exchange: description.listing_exch.unwrap_or_default(), + service_data_type: description.service_data_type.unwrap_or_default(), + aggregate_group: description.agg_group.unwrap_or_default(), + }) .collect(), }) } @@ -645,10 +652,10 @@ pub fn decode_protobuf_event(msg_id: i32, payload: &[u8]) -> TwsApiResult .histogram_data_entries .into_iter() .map(|entry| { - Ok(( - entry.price.unwrap_or_default(), - parse_decimal_string(entry.size.as_deref())?, - )) + Ok(HistogramEntry { + price: entry.price.unwrap_or_default(), + size: parse_decimal_string(entry.size.as_deref())?, + }) }) .collect::>>()?, }) @@ -699,11 +706,9 @@ pub fn decode_protobuf_event(msg_id: i32, payload: &[u8]) -> TwsApiResult family_codes: msg .family_codes .into_iter() - .map(|code| { - ( - code.account_id.unwrap_or_default(), - code.family_code.unwrap_or_default(), - ) + .map(|code| FamilyCode { + account_id: code.account_id.unwrap_or_default(), + family_code: code.family_code.unwrap_or_default(), }) .collect(), }) @@ -726,11 +731,9 @@ pub fn decode_protobuf_event(msg_id: i32, payload: &[u8]) -> TwsApiResult price_increments: msg .price_increments .into_iter() - .map(|increment| { - ( - increment.low_edge.unwrap_or_default(), - increment.increment.unwrap_or_default(), - ) + .map(|increment| PriceIncrement { + low_edge: increment.low_edge.unwrap_or_default(), + increment: increment.increment.unwrap_or_default(), }) .collect(), }) @@ -904,11 +907,9 @@ pub fn decode_protobuf_event(msg_id: i32, payload: &[u8]) -> TwsApiResult providers: msg .news_providers .into_iter() - .map(|provider| { - ( - provider.provider_code.unwrap_or_default(), - provider.provider_name.unwrap_or_default(), - ) + .map(|provider| NewsProvider { + code: provider.provider_code.unwrap_or_default(), + name: provider.provider_name.unwrap_or_default(), }) .collect(), }) @@ -1224,6 +1225,13 @@ fn proto_contract_details_to_contract_details( }); }; + let mut sec_id_list = details + .sec_id_list + .into_iter() + .map(|(tag, value)| TagValue { tag, value }) + .collect::>(); + sec_id_list.sort_by(|left, right| left.tag.cmp(&right.tag)); + Ok(ContractDetails { contract: proto_contract_to_contract(contract), market_name: details.market_name.unwrap_or_default(), @@ -1244,6 +1252,12 @@ fn proto_contract_details_to_contract_details( time_zone_id: details.time_zone_id.unwrap_or_default(), trading_hours: details.trading_hours.unwrap_or_default(), liquid_hours: details.liquid_hours.unwrap_or_default(), + ev_rule: details.ev_rule.unwrap_or_default(), + ev_multiplier: details.ev_multiplier.unwrap_or_default(), + sec_id_list, + aggregate_group: details.agg_group.unwrap_or_default(), + under_symbol: details.under_symbol.unwrap_or_default(), + under_sec_type: details.under_sec_type.unwrap_or_default(), market_rule_ids: details.market_rule_ids.unwrap_or_default(), cusip: details.cusip.unwrap_or_default(), issue_date: details.issue_date.unwrap_or_default(), @@ -1570,10 +1584,10 @@ fn decode_family_codes_fields(fields: &[String]) -> TwsApiResult { let count = next_i32(fields, &mut index)?.max(0) as usize; let mut family_codes = Vec::with_capacity(count); for _ in 0..count { - family_codes.push(( - next_string(fields, &mut index), - next_string(fields, &mut index), - )); + family_codes.push(FamilyCode { + account_id: next_string(fields, &mut index), + family_code: next_string(fields, &mut index), + }); } Ok(Event::FamilyCodes { family_codes }) } @@ -1632,14 +1646,13 @@ fn decode_market_depth_exchanges_fields(fields: &[String]) -> TwsApiResult TwsApiResult { let count = next_i32(fields, &mut index)?.max(0) as usize; let mut items = Vec::with_capacity(count); for _ in 0..count { - items.push(( - next_f64(fields, &mut index)?, - next_decimal(fields, &mut index)?, - )); + items.push(HistogramEntry { + price: next_f64(fields, &mut index)?, + size: next_decimal(fields, &mut index)?, + }); } Ok(Event::HistogramData { req_id, items }) } @@ -1664,7 +1677,10 @@ fn decode_market_rule_fields(fields: &[String]) -> TwsApiResult { let count = next_i32(fields, &mut index)?.max(0) as usize; let mut price_increments = Vec::with_capacity(count); for _ in 0..count { - price_increments.push((next_f64(fields, &mut index)?, next_f64(fields, &mut index)?)); + price_increments.push(PriceIncrement { + low_edge: next_f64(fields, &mut index)?, + increment: next_f64(fields, &mut index)?, + }); } Ok(Event::MarketRule { market_rule_id, @@ -1727,10 +1743,10 @@ fn decode_news_providers_fields(fields: &[String]) -> TwsApiResult { let count = next_i32(fields, &mut index)?.max(0) as usize; let mut providers = Vec::with_capacity(count); for _ in 0..count { - providers.push(( - next_string(fields, &mut index), - next_string(fields, &mut index), - )); + providers.push(NewsProvider { + code: next_string(fields, &mut index), + name: next_string(fields, &mut index), + }); } Ok(Event::NewsProviders { providers }) } @@ -1902,22 +1918,24 @@ fn decode_contract_data_fields(fields: &[String]) -> TwsApiResult { details.liquid_hours = next_string(fields, &mut index); } if version >= 8 && index + 1 < fields.len() { - let _ev_rule = next_string(fields, &mut index); - let _ev_multiplier = next_f64(fields, &mut index).unwrap_or_default(); + details.ev_rule = next_string(fields, &mut index); + details.ev_multiplier = next_f64(fields, &mut index).unwrap_or_default(); } if version >= 7 && index < fields.len() { let sec_id_count = next_i32(fields, &mut index).unwrap_or_default().max(0) as usize; for _ in 0..sec_id_count { - let _tag = next_string(fields, &mut index); - let _value = next_string(fields, &mut index); + details.sec_id_list.push(TagValue { + tag: next_string(fields, &mut index), + value: next_string(fields, &mut index), + }); } } if index < fields.len() { - let _agg_group = next_i32(fields, &mut index).unwrap_or_default(); + details.aggregate_group = next_i32(fields, &mut index).unwrap_or_default(); } if index + 1 < fields.len() { - let _under_symbol = next_string(fields, &mut index); - let _under_sec_type = next_string(fields, &mut index); + details.under_symbol = next_string(fields, &mut index); + details.under_sec_type = next_string(fields, &mut index); } if index < fields.len() { details.market_rule_ids = next_string(fields, &mut index); @@ -2030,18 +2048,20 @@ fn decode_bond_contract_data_fields(fields: &[String]) -> TwsApiResult { details.liquid_hours = next_string(fields, &mut index); } if version >= 6 && index + 1 < fields.len() { - let _ev_rule = next_string(fields, &mut index); - let _ev_multiplier = next_f64(fields, &mut index).unwrap_or_default(); + details.ev_rule = next_string(fields, &mut index); + details.ev_multiplier = next_f64(fields, &mut index).unwrap_or_default(); } if version >= 5 && index < fields.len() { let sec_id_count = next_i32(fields, &mut index).unwrap_or_default().max(0) as usize; for _ in 0..sec_id_count { - let _tag = next_string(fields, &mut index); - let _value = next_string(fields, &mut index); + details.sec_id_list.push(TagValue { + tag: next_string(fields, &mut index), + value: next_string(fields, &mut index), + }); } } if index < fields.len() { - let _agg_group = next_i32(fields, &mut index).unwrap_or_default(); + details.aggregate_group = next_i32(fields, &mut index).unwrap_or_default(); } if index < fields.len() { details.market_rule_ids = next_string(fields, &mut index); diff --git a/crates/truefix-twsapi-client/src/decoder/order.rs b/crates/truefix-twsapi-client/src/decoder/order.rs index c78a0eb..6c7648d 100644 --- a/crates/truefix-twsapi-client/src/decoder/order.rs +++ b/crates/truefix-twsapi-client/src/decoder/order.rs @@ -107,10 +107,10 @@ fn decode_order_common_after_perm_id( order.fa_group = reader.next_string(); order.fa_method = reader.next_string(); order.fa_percentage = reader.next_string(); - if version < 177 { + if version < crate::server_versions::MIN_SERVER_VER_FA_PROFILE_DESUPPORT { let _fa_profile = reader.next_string(); } - if version >= 177 { + if version >= crate::server_versions::MIN_SERVER_VER_FA_PROFILE_DESUPPORT { order.model_code = reader.next_string(); } order.good_till_date = reader.next_string(); diff --git a/crates/truefix-twsapi-client/src/events.rs b/crates/truefix-twsapi-client/src/events.rs index d3a70dd..e92737e 100644 --- a/crates/truefix-twsapi-client/src/events.rs +++ b/crates/truefix-twsapi-client/src/events.rs @@ -3,12 +3,11 @@ use rust_decimal::Decimal; use crate::enums::{FaDataType, MarketDataType, TickType}; use crate::types::{ BarData, CommissionAndFeesReport, Contract, ContractDescription, ContractDetails, - DeltaNeutralContract, Execution, HistoricalSession, HistoricalTick, HistoricalTickBidAsk, - HistoricalTickLast, Order, OrderState, SmartComponent, SoftDollarTier, TickByTick, -}; -use crate::types::{ - DepthMarketDataDescription, FamilyCode, NewsProvider, PriceIncrement, RealTimeBar, WshEventData, + DeltaNeutralContract, Execution, FamilyCode, HistogramEntry, HistoricalSession, HistoricalTick, + HistoricalTickBidAsk, HistoricalTickLast, NewsProvider, Order, OrderState, PriceIncrement, + SmartComponent, SoftDollarTier, TickByTick, }; +use crate::types::{DepthMarketDataDescription, RealTimeBar, WshEventData}; /// Scanner row data. #[derive(Debug, Clone, PartialEq)] @@ -279,8 +278,8 @@ pub enum Event { }, /// Market depth exchanges. MarketDepthExchanges { - /// Raw exchange descriptions as debug strings. - descriptions: Vec, + /// Exchange descriptions. + descriptions: Vec, }, /// Smart components callback. SmartComponents { @@ -518,7 +517,7 @@ pub enum Event { /// Request id. req_id: i32, /// Price/size entries. - items: Vec<(f64, Decimal)>, + items: Vec, }, /// Scanner parameters XML. ScannerParameters { @@ -546,8 +545,8 @@ pub enum Event { }, /// Family codes. FamilyCodes { - /// `(account_id, family_code)` pairs. - family_codes: Vec<(String, String)>, + /// Account family-code mappings. + family_codes: Vec, }, /// Symbol samples callback. SymbolSamples { @@ -582,8 +581,8 @@ pub enum Event { MarketRule { /// Market rule id. market_rule_id: i32, - /// `(low_edge, increment)` entries. - price_increments: Vec<(f64, f64)>, + /// Price increments. + price_increments: Vec, }, /// PnL update. Pnl { @@ -633,8 +632,8 @@ pub enum Event { }, /// News providers. NewsProviders { - /// `(provider_code, provider_name)` pairs. - providers: Vec<(String, String)>, + /// Provider metadata. + providers: Vec, }, /// Historical news item. HistoricalNews { @@ -1149,40 +1148,15 @@ pub trait Wrapper: Send { currency, } => self.account_update_multi(req_id, account, model_code, key, value, currency), Event::AccountUpdateMultiEnd { req_id } => self.account_update_multi_end(req_id), - Event::FamilyCodes { family_codes } => self.family_codes( - family_codes - .into_iter() - .map(|(account_id, family_code)| FamilyCode { - account_id, - family_code, - }) - .collect(), - ), - Event::NewsProviders { providers } => self.news_providers( - providers - .into_iter() - .map(|(code, name)| NewsProvider { code, name }) - .collect(), - ), - Event::MarketDepthExchanges { descriptions } => self.market_depth_exchanges( - descriptions - .into_iter() - .filter_map(|value| parse_depth_description(&value)) - .collect(), - ), + Event::FamilyCodes { family_codes } => self.family_codes(family_codes), + Event::NewsProviders { providers } => self.news_providers(providers), + Event::MarketDepthExchanges { descriptions } => { + self.market_depth_exchanges(descriptions) + } Event::MarketRule { market_rule_id, price_increments, - } => self.market_rule( - market_rule_id, - price_increments - .into_iter() - .map(|(low_edge, increment)| PriceIncrement { - low_edge, - increment, - }) - .collect(), - ), + } => self.market_rule(market_rule_id, price_increments), Event::ReceiveFa { fa_data_type, xml } => { self.receive_fa(FaDataType::from_i32(fa_data_type), xml) } @@ -1538,7 +1512,7 @@ pub trait Wrapper: Send { } fn account_update_multi_end(&mut self, _req_id: i32) {} fn head_timestamp(&mut self, _req_id: i32, _timestamp: String) {} - fn histogram_data(&mut self, _req_id: i32, _items: Vec<(f64, Decimal)>) {} + fn histogram_data(&mut self, _req_id: i32, _items: Vec) {} fn scanner_parameters(&mut self, _xml: String) {} fn scanner_data(&mut self, _req_id: i32, _rows: Vec) {} fn scanner_data_end(&mut self, _req_id: i32) {} @@ -1617,17 +1591,6 @@ pub trait Wrapper: Send { fn raw_protobuf(&mut self, _msg_id: i32, _payload: Vec) {} } -fn parse_depth_description(value: &str) -> Option { - let mut parts = value.split(':'); - Some(DepthMarketDataDescription { - exchange: parts.next()?.to_owned(), - security_type: parts.next()?.to_owned(), - listing_exchange: parts.next()?.to_owned(), - service_data_type: parts.next()?.to_owned(), - aggregate_group: parts.next()?.parse().ok()?, - }) -} - impl Event { /// Returns a semantic tick type for tick events. pub fn tick_type(&self) -> Option { diff --git a/crates/truefix-twsapi-client/src/requests.rs b/crates/truefix-twsapi-client/src/requests.rs index 0b7fccb..4cd7205 100644 --- a/crates/truefix-twsapi-client/src/requests.rs +++ b/crates/truefix-twsapi-client/src/requests.rs @@ -14,28 +14,34 @@ use crate::server_versions::{ MIN_SERVER_VER_CONTRACT_DATA_CHAIN, MIN_SERVER_VER_CUSTOMER_ACCOUNT, MIN_SERVER_VER_D_PEG_ORDERS, MIN_SERVER_VER_DECISION_MAKER, MIN_SERVER_VER_DELTA_NEUTRAL, MIN_SERVER_VER_DELTA_NEUTRAL_CONID, MIN_SERVER_VER_DELTA_NEUTRAL_OPEN_CLOSE, - MIN_SERVER_VER_DURATION, MIN_SERVER_VER_EXT_OPERATOR, MIN_SERVER_VER_HEDGE_MAX_SIZE, + MIN_SERVER_VER_DURATION, MIN_SERVER_VER_EXECUTION_DATA_CHAIN, MIN_SERVER_VER_EXT_OPERATOR, + MIN_SERVER_VER_FA_PROFILE_DESUPPORT, MIN_SERVER_VER_HEDGE_MAX_SIZE, MIN_SERVER_VER_HEDGE_ORDERS, MIN_SERVER_VER_HISTORICAL_TICKS, MIN_SERVER_VER_IMBALANCE_ONLY, MIN_SERVER_VER_INCLUDE_OVERNIGHT, MIN_SERVER_VER_LINKING, MIN_SERVER_VER_MANUAL_ORDER_TIME, MIN_SERVER_VER_MANUAL_ORDER_TIME_EXERCISE_OPTIONS, MIN_SERVER_VER_MIFID_EXECUTION, - MIN_SERVER_VER_MODELS_SUPPORT, MIN_SERVER_VER_NOT_HELD, MIN_SERVER_VER_OPT_OUT_SMART_ROUTING, - MIN_SERVER_VER_ORDER_COMBO_LEGS_PRICE, MIN_SERVER_VER_ORDER_CONTAINER, - MIN_SERVER_VER_ORDER_SOLICITED, MIN_SERVER_VER_PEGGED_TO_BENCHMARK, - MIN_SERVER_VER_PLACE_ORDER_CONID, MIN_SERVER_VER_POST_TO_ATS, MIN_SERVER_VER_PRICE_MGMT_ALGO, - MIN_SERVER_VER_PRIMARYEXCH, MIN_SERVER_VER_PROFESSIONAL_CUSTOMER, MIN_SERVER_VER_PROTOBUF, + MIN_SERVER_VER_MKT_DEPTH_PRIM_EXCHANGE, MIN_SERVER_VER_MODELS_SUPPORT, + MIN_SERVER_VER_NEWS_QUERY_ORIGINS, MIN_SERVER_VER_NOT_HELD, + MIN_SERVER_VER_OPT_OUT_SMART_ROUTING, MIN_SERVER_VER_ORDER_COMBO_LEGS_PRICE, + MIN_SERVER_VER_ORDER_CONTAINER, MIN_SERVER_VER_ORDER_SOLICITED, + MIN_SERVER_VER_PARAMETRIZED_DAYS_OF_EXECUTIONS, MIN_SERVER_VER_PEGBEST_PEGMID_OFFSETS, + MIN_SERVER_VER_PEGGED_TO_BENCHMARK, MIN_SERVER_VER_PLACE_ORDER_CONID, + MIN_SERVER_VER_POST_TO_ATS, MIN_SERVER_VER_PRICE_MGMT_ALGO, MIN_SERVER_VER_PRIMARYEXCH, + MIN_SERVER_VER_PROFESSIONAL_CUSTOMER, MIN_SERVER_VER_PROTOBUF, MIN_SERVER_VER_PROTOBUF_ACCOUNTS_POSITIONS, MIN_SERVER_VER_PROTOBUF_COMPLETED_ORDER, MIN_SERVER_VER_PROTOBUF_CONTRACT_DATA, MIN_SERVER_VER_PROTOBUF_HISTORICAL_DATA, MIN_SERVER_VER_PROTOBUF_MARKET_DATA, MIN_SERVER_VER_PROTOBUF_NEWS_DATA, MIN_SERVER_VER_PROTOBUF_PLACE_ORDER, MIN_SERVER_VER_PROTOBUF_REST_MESSAGES_1, MIN_SERVER_VER_PROTOBUF_REST_MESSAGES_2, MIN_SERVER_VER_PROTOBUF_REST_MESSAGES_3, MIN_SERVER_VER_PROTOBUF_SCAN_DATA, MIN_SERVER_VER_PTA_ORDERS, - MIN_SERVER_VER_RANDOMIZE_SIZE_AND_PRICE, MIN_SERVER_VER_REQ_CALC_IMPLIED_VOLAT, - MIN_SERVER_VER_REQ_MKT_DATA_CONID, MIN_SERVER_VER_REQ_SMART_COMPONENTS, - MIN_SERVER_VER_SCALE_ORDERS2, MIN_SERVER_VER_SCALE_ORDERS3, MIN_SERVER_VER_SCALE_TABLE, + MIN_SERVER_VER_RANDOMIZE_SIZE_AND_PRICE, MIN_SERVER_VER_REPLACE_FA_END, + MIN_SERVER_VER_REQ_CALC_IMPLIED_VOLAT, MIN_SERVER_VER_REQ_MKT_DATA_CONID, + MIN_SERVER_VER_REQ_SMART_COMPONENTS, MIN_SERVER_VER_RFQ_FIELDS, MIN_SERVER_VER_SCALE_ORDERS2, + MIN_SERVER_VER_SCALE_ORDERS3, MIN_SERVER_VER_SCALE_TABLE, MIN_SERVER_VER_SCANNER_GENERIC_OPTS, MIN_SERVER_VER_SEC_ID_TYPE, MIN_SERVER_VER_SMART_COMBO_ROUTING_PARAMS, - MIN_SERVER_VER_SOFT_DOLLAR_TIER, MIN_SERVER_VER_SSHORTX_OLD, MIN_SERVER_VER_SYNT_REALTIME_BARS, - MIN_SERVER_VER_TICK_BY_TICK, MIN_SERVER_VER_TICK_BY_TICK_IGNORE_SIZE, - MIN_SERVER_VER_TRADING_CLASS, MIN_SERVER_VER_TRAILING_PERCENT, + MIN_SERVER_VER_SMART_DEPTH, MIN_SERVER_VER_SOFT_DOLLAR_TIER, MIN_SERVER_VER_SSHORTX_OLD, + MIN_SERVER_VER_SYNT_REALTIME_BARS, MIN_SERVER_VER_TICK_BY_TICK, + MIN_SERVER_VER_TICK_BY_TICK_IGNORE_SIZE, MIN_SERVER_VER_TRADING_CLASS, + MIN_SERVER_VER_TRAILING_PERCENT, MIN_SERVER_VER_UNDO_RFQ_FIELDS, }; use crate::types::{ Contract, ExecutionFilter, Order, OrderCancel, ScannerSubscription, TagValue, TickerId, @@ -467,10 +473,18 @@ impl EncodableRequest for CancelMarketDepthRequest { } fn encode_fields(&self, fields: &mut FieldSink) -> TwsApiResult<()> { - fields - .push(1)? - .push(self.req_id)? - .push(self.is_smart_depth)?; + self.encode_fields_for_server_version(fields, MAX_CLIENT_VER) + } + + fn encode_fields_for_server_version( + &self, + fields: &mut FieldSink, + server_version: i32, + ) -> TwsApiResult<()> { + fields.push(1)?.push(self.req_id)?; + if server_version >= MIN_SERVER_VER_SMART_DEPTH { + fields.push(self.is_smart_depth)?; + } Ok(()) } @@ -525,11 +539,21 @@ impl EncodableRequest for GlobalCancelRequest { } fn encode_fields(&self, fields: &mut FieldSink) -> TwsApiResult<()> { - fields - .push(1)? - .push(&self.order_cancel.manual_order_cancel_time)? - .push(&self.order_cancel.ext_operator)? - .push(self.order_cancel.manual_order_indicator)?; + self.encode_fields_for_server_version(fields, MAX_CLIENT_VER) + } + + fn encode_fields_for_server_version( + &self, + fields: &mut FieldSink, + server_version: i32, + ) -> TwsApiResult<()> { + if server_version < MIN_SERVER_VER_CME_TAGGING_FIELDS { + fields.push(1)?; + } else { + fields + .push(&self.order_cancel.ext_operator)? + .push_empty(self.order_cancel.manual_order_indicator)?; + } Ok(()) } @@ -786,11 +810,22 @@ impl EncodableRequest for NewsArticleRequest { } fn encode_fields(&self, fields: &mut FieldSink) -> TwsApiResult<()> { + self.encode_fields_for_server_version(fields, MAX_CLIENT_VER) + } + + fn encode_fields_for_server_version( + &self, + fields: &mut FieldSink, + server_version: i32, + ) -> TwsApiResult<()> { fields .push(self.req_id)? .push(&self.provider_code)? .push(&self.article_id)?; - encode_tag_values(fields, &self.options) + if server_version >= MIN_SERVER_VER_NEWS_QUERY_ORIGINS { + fields.push(tag_values_to_tws_options(&self.options))?; + } + Ok(()) } fn encode_protobuf(&self) -> TwsApiResult>> { @@ -831,6 +866,14 @@ impl EncodableRequest for HistoricalNewsRequest { } fn encode_fields(&self, fields: &mut FieldSink) -> TwsApiResult<()> { + self.encode_fields_for_server_version(fields, MAX_CLIENT_VER) + } + + fn encode_fields_for_server_version( + &self, + fields: &mut FieldSink, + server_version: i32, + ) -> TwsApiResult<()> { fields .push(self.req_id)? .push(self.con_id)? @@ -838,7 +881,10 @@ impl EncodableRequest for HistoricalNewsRequest { .push(&self.start_date_time)? .push(&self.end_date_time)? .push(self.total_results)?; - encode_tag_values(fields, &self.options) + if server_version >= MIN_SERVER_VER_NEWS_QUERY_ORIGINS { + fields.push(tag_values_to_tws_options(&self.options))?; + } + Ok(()) } fn encode_protobuf(&self) -> TwsApiResult>> { @@ -1002,11 +1048,18 @@ impl EncodableRequest for ReplaceFinancialAdvisorRequest { } fn encode_fields(&self, fields: &mut FieldSink) -> TwsApiResult<()> { - fields - .push(1)? - .push(self.req_id)? - .push(self.fa_data_type)? - .push(&self.xml)?; + self.encode_fields_for_server_version(fields, MAX_CLIENT_VER) + } + + fn encode_fields_for_server_version( + &self, + fields: &mut FieldSink, + server_version: i32, + ) -> TwsApiResult<()> { + fields.push(1)?.push(self.fa_data_type)?.push(&self.xml)?; + if server_version >= MIN_SERVER_VER_REPLACE_FA_END { + fields.push(self.req_id)?; + } Ok(()) } @@ -1044,7 +1097,7 @@ impl EncodableRequest for HeadTimestampRequest { fn encode_fields(&self, fields: &mut FieldSink) -> TwsApiResult<()> { fields.push(self.req_id)?; - encode_contract_core(fields, &self.contract)?; + encode_contract_head_or_histogram(fields, &self.contract)?; fields .push(self.use_rth)? .push(&self.what_to_show)? @@ -1086,7 +1139,7 @@ impl EncodableRequest for HistogramDataRequest { fn encode_fields(&self, fields: &mut FieldSink) -> TwsApiResult<()> { fields.push(self.req_id)?; - encode_contract_core(fields, &self.contract)?; + encode_contract_head_or_histogram(fields, &self.contract)?; fields.push(self.use_rth)?.push(&self.time_period)?; Ok(()) } @@ -1127,13 +1180,24 @@ impl EncodableRequest for RealTimeBarsRequest { } fn encode_fields(&self, fields: &mut FieldSink) -> TwsApiResult<()> { + self.encode_fields_for_server_version(fields, MAX_CLIENT_VER) + } + + fn encode_fields_for_server_version( + &self, + fields: &mut FieldSink, + server_version: i32, + ) -> TwsApiResult<()> { fields.push(3)?.push(self.req_id)?; - encode_contract_core(fields, &self.contract)?; + encode_contract_real_time_bars(fields, &self.contract, server_version)?; fields .push(self.bar_size)? .push(&self.what_to_show)? .push(self.use_rth)?; - encode_tag_values(fields, &self.options) + if server_version >= MIN_SERVER_VER_LINKING { + fields.push(tag_values_to_tws_options(&self.options))?; + } + Ok(()) } fn encode_protobuf(&self) -> TwsApiResult>> { @@ -1980,10 +2044,43 @@ impl EncodableRequest for MarketDepthRequest { } fn encode_fields(&self, fields: &mut FieldSink) -> TwsApiResult<()> { + self.encode_fields_for_server_version(fields, MAX_CLIENT_VER) + } + + fn encode_fields_for_server_version( + &self, + fields: &mut FieldSink, + server_version: i32, + ) -> TwsApiResult<()> { fields.push(5)?.push(self.req_id)?; - encode_contract_core(fields, &self.contract)?; - fields.push(self.num_rows)?.push(self.is_smart_depth)?; - encode_tag_values(fields, &self.market_depth_options) + if server_version >= MIN_SERVER_VER_TRADING_CLASS { + fields.push(self.contract.con_id)?; + } + fields + .push(&self.contract.symbol)? + .push(&self.contract.sec_type)? + .push(&self.contract.last_trade_date_or_contract_month)? + .push_empty(self.contract.strike)? + .push(&self.contract.right)? + .push(&self.contract.multiplier)? + .push(&self.contract.exchange)?; + if server_version >= MIN_SERVER_VER_MKT_DEPTH_PRIM_EXCHANGE { + fields.push(&self.contract.primary_exchange)?; + } + fields + .push(&self.contract.currency)? + .push(&self.contract.local_symbol)?; + if server_version >= MIN_SERVER_VER_TRADING_CLASS { + fields.push(&self.contract.trading_class)?; + } + fields.push(self.num_rows)?; + if server_version >= MIN_SERVER_VER_SMART_DEPTH { + fields.push(self.is_smart_depth)?; + } + if server_version >= MIN_SERVER_VER_LINKING { + fields.push(tag_values_to_tws_options(&self.market_depth_options))?; + } + Ok(()) } fn encode_protobuf(&self) -> TwsApiResult>> { @@ -2061,12 +2158,29 @@ impl EncodableRequest for CancelOrderRequest { } fn encode_fields(&self, fields: &mut FieldSink) -> TwsApiResult<()> { - fields - .push(1)? - .push(self.order_id)? - .push(&self.order_cancel.manual_order_cancel_time)? - .push(&self.order_cancel.ext_operator)? - .push(self.order_cancel.manual_order_indicator)?; + self.encode_fields_for_server_version(fields, MAX_CLIENT_VER) + } + + fn encode_fields_for_server_version( + &self, + fields: &mut FieldSink, + server_version: i32, + ) -> TwsApiResult<()> { + if server_version < MIN_SERVER_VER_CME_TAGGING_FIELDS { + fields.push(1)?; + } + fields.push(self.order_id)?; + if server_version >= MIN_SERVER_VER_MANUAL_ORDER_TIME { + fields.push(&self.order_cancel.manual_order_cancel_time)?; + } + if (MIN_SERVER_VER_RFQ_FIELDS..MIN_SERVER_VER_UNDO_RFQ_FIELDS).contains(&server_version) { + fields.push("")?.push("")?.push_empty(UNSET_INTEGER)?; + } + if server_version >= MIN_SERVER_VER_CME_TAGGING_FIELDS { + fields + .push(&self.order_cancel.ext_operator)? + .push_empty(self.order_cancel.manual_order_indicator)?; + } Ok(()) } @@ -2094,9 +2208,19 @@ impl EncodableRequest for ExecutionRequest { } fn encode_fields(&self, fields: &mut FieldSink) -> TwsApiResult<()> { + self.encode_fields_for_server_version(fields, MAX_CLIENT_VER) + } + + fn encode_fields_for_server_version( + &self, + fields: &mut FieldSink, + server_version: i32, + ) -> TwsApiResult<()> { + fields.push(3)?; + if server_version >= MIN_SERVER_VER_EXECUTION_DATA_CHAIN { + fields.push(self.req_id)?; + } fields - .push(3)? - .push(self.req_id)? .push(self.filter.client_id)? .push(&self.filter.acct_code)? .push(&self.filter.time)? @@ -2104,6 +2228,14 @@ impl EncodableRequest for ExecutionRequest { .push(&self.filter.sec_type)? .push(&self.filter.exchange)? .push(&self.filter.side)?; + if server_version >= MIN_SERVER_VER_PARAMETRIZED_DAYS_OF_EXECUTIONS { + fields + .push(self.filter.last_n_days)? + .push(self.filter.specific_dates.len())?; + for date in &self.filter.specific_dates { + fields.push(*date)?; + } + } Ok(()) } @@ -2309,9 +2441,19 @@ impl EncodableRequest for ScannerSubscriptionRequest { } fn encode_fields(&self, fields: &mut FieldSink) -> TwsApiResult<()> { + self.encode_fields_for_server_version(fields, MAX_CLIENT_VER) + } + + fn encode_fields_for_server_version( + &self, + fields: &mut FieldSink, + server_version: i32, + ) -> TwsApiResult<()> { let sub = &self.subscription; + if server_version < MIN_SERVER_VER_SCANNER_GENERIC_OPTS { + fields.push(4)?; + } fields - .push(4)? .push(self.req_id)? .push(sub.number_of_rows)? .push(&sub.instrument)? @@ -2334,8 +2476,17 @@ impl EncodableRequest for ScannerSubscriptionRequest { .push_empty(sub.average_option_volume_above)? .push(&sub.scanner_setting_pairs)? .push(&sub.stock_type_filter)?; - encode_tag_values(fields, &self.scanner_subscription_options)?; - encode_tag_values(fields, &self.scanner_subscription_filter_options) + if server_version >= MIN_SERVER_VER_SCANNER_GENERIC_OPTS { + fields.push(tag_values_to_tws_options( + &self.scanner_subscription_filter_options, + ))?; + } + if server_version >= MIN_SERVER_VER_LINKING { + fields.push(tag_values_to_tws_options( + &self.scanner_subscription_options, + ))?; + } + Ok(()) } fn encode_protobuf(&self) -> TwsApiResult>> { @@ -2353,7 +2504,10 @@ impl EncodableRequest for ScannerSubscriptionRequest { } } -fn encode_contract_core(fields: &mut FieldSink, contract: &Contract) -> TwsApiResult<()> { +fn encode_contract_head_or_histogram( + fields: &mut FieldSink, + contract: &Contract, +) -> TwsApiResult<()> { fields .push(contract.con_id)? .push(&contract.symbol)? @@ -2367,9 +2521,32 @@ fn encode_contract_core(fields: &mut FieldSink, contract: &Contract) -> TwsApiRe .push(&contract.currency)? .push(&contract.local_symbol)? .push(&contract.trading_class)? - .push(contract.include_expired)? - .push(&contract.sec_id_type)? - .push(&contract.sec_id)?; + .push(contract.include_expired)?; + Ok(()) +} + +fn encode_contract_real_time_bars( + fields: &mut FieldSink, + contract: &Contract, + server_version: i32, +) -> TwsApiResult<()> { + if server_version >= MIN_SERVER_VER_TRADING_CLASS { + fields.push(contract.con_id)?; + } + fields + .push(&contract.symbol)? + .push(&contract.sec_type)? + .push(&contract.last_trade_date_or_contract_month)? + .push_empty(contract.strike)? + .push(&contract.right)? + .push(&contract.multiplier)? + .push(&contract.exchange)? + .push(&contract.primary_exchange)? + .push(&contract.currency)? + .push(&contract.local_symbol)?; + if server_version >= MIN_SERVER_VER_TRADING_CLASS { + fields.push(&contract.trading_class)?; + } Ok(()) } @@ -2532,7 +2709,7 @@ fn encode_place_order_fields( .push(&order.fa_group)? .push(&order.fa_method)? .push(&order.fa_percentage)?; - if server_version < 177 { + if server_version < MIN_SERVER_VER_FA_PROFILE_DESUPPORT { fields.push("")?; } if server_version >= MIN_SERVER_VER_MODELS_SUPPORT { @@ -2673,6 +2850,14 @@ fn encode_place_order_fields( .push(order.randomize_price)?; } if server_version >= MIN_SERVER_VER_PEGGED_TO_BENCHMARK { + if is_peg_benchmark_order(&order.order_type) { + fields + .push(order.reference_contract_id)? + .push(order.is_pegged_change_amount_decrease)? + .push(order.pegged_change_amount)? + .push(order.reference_change_amount)? + .push(&order.reference_exchange_id)?; + } fields.push(order.conditions.len())?; if !order.conditions.is_empty() { for condition in &order.conditions { @@ -2743,12 +2928,35 @@ fn encode_place_order_fields( if server_version >= MIN_SERVER_VER_MANUAL_ORDER_TIME { fields.push(&order.manual_order_time)?; } + if server_version >= MIN_SERVER_VER_PEGBEST_PEGMID_OFFSETS { + let mut send_mid_offsets = false; + if contract.exchange == "IBKRATS" { + fields.push_empty(order.min_trade_qty)?; + } + if is_peg_best_order(&order.order_type) { + fields + .push_empty(order.min_compete_size)? + .push_empty(order.compete_against_best_offset)?; + send_mid_offsets = order.compete_against_best_offset + == crate::constants::COMPETE_AGAINST_BEST_OFFSET_UP_TO_MID; + } else if is_peg_mid_order(&order.order_type) { + send_mid_offsets = true; + } + if send_mid_offsets { + fields + .push_empty(order.mid_offset_at_whole)? + .push_empty(order.mid_offset_at_half)?; + } + } if server_version >= MIN_SERVER_VER_CUSTOMER_ACCOUNT { fields.push(&order.customer_account)?; } if server_version >= MIN_SERVER_VER_PROFESSIONAL_CUSTOMER { fields.push(order.professional_customer)?; } + if (MIN_SERVER_VER_RFQ_FIELDS..MIN_SERVER_VER_UNDO_RFQ_FIELDS).contains(&server_version) { + fields.push("")?.push_empty(UNSET_INTEGER)?; + } if server_version >= MIN_SERVER_VER_INCLUDE_OVERNIGHT { fields.push(order.include_overnight)?; } @@ -2856,6 +3064,18 @@ fn encode_order_condition( Ok(()) } +fn is_peg_benchmark_order(order_type: &str) -> bool { + matches!(order_type, "PEG BENCH" | "PEGBENCH") +} + +fn is_peg_mid_order(order_type: &str) -> bool { + matches!(order_type, "PEG MID" | "PEGMID") +} + +fn is_peg_best_order(order_type: &str) -> bool { + matches!(order_type, "PEG BEST" | "PEGBEST") +} + fn encode_tag_values(fields: &mut FieldSink, values: &[TagValue]) -> TwsApiResult<()> { fields.push(values.len())?; for value in values { @@ -3268,8 +3488,8 @@ fn execution_filter_to_proto(filter: &ExecutionFilter) -> protobuf::ExecutionFil sec_type: non_empty(filter.sec_type.clone()), exchange: non_empty(filter.exchange.clone()), side: non_empty(filter.side.clone()), - last_n_days: None, - specific_dates: Vec::new(), + last_n_days: valid_i32(filter.last_n_days), + specific_dates: filter.specific_dates.clone(), } } diff --git a/crates/truefix-twsapi-client/src/types.rs b/crates/truefix-twsapi-client/src/types.rs index 676407a..d715406 100644 --- a/crates/truefix-twsapi-client/src/types.rs +++ b/crates/truefix-twsapi-client/src/types.rs @@ -54,6 +54,13 @@ pub struct PriceIncrement { pub increment: f64, } +/// A price/size bucket returned by `reqHistogramData`. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct HistogramEntry { + pub price: f64, + pub size: Decimal, +} + /// Account family-code mapping. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct FamilyCode { @@ -284,6 +291,18 @@ pub struct ContractDetails { pub trading_hours: String, /// Liquid hours. pub liquid_hours: String, + /// Economic value rule. + pub ev_rule: String, + /// Economic value multiplier. + pub ev_multiplier: f64, + /// Security identifiers returned by TWS. + pub sec_id_list: Vec, + /// Market-data aggregation group. + pub aggregate_group: i32, + /// Underlying symbol. + pub under_symbol: String, + /// Underlying security type. + pub under_sec_type: String, /// Market rule ids. pub market_rule_ids: String, /// CUSIP. @@ -897,7 +916,7 @@ pub struct OrderCancel { } /// Execution filter. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ExecutionFilter { /// Client id. pub client_id: i32, @@ -913,6 +932,26 @@ pub struct ExecutionFilter { pub exchange: String, /// Side. pub side: String, + /// Number of prior days to include. `UNSET_INTEGER` leaves it unspecified. + pub last_n_days: i32, + /// Specific execution dates, encoded when the server supports parametrized execution days. + pub specific_dates: Vec, +} + +impl Default for ExecutionFilter { + fn default() -> Self { + Self { + client_id: 0, + acct_code: String::new(), + time: String::new(), + symbol: String::new(), + sec_type: String::new(), + exchange: String::new(), + side: String::new(), + last_n_days: UNSET_INTEGER, + specific_dates: Vec::new(), + } + } } /// Execution details returned by TWS. diff --git a/crates/truefix-twsapi-client/tests/comm.rs b/crates/truefix-twsapi-client/tests/comm.rs index a2569c0..f9bc8e9 100644 --- a/crates/truefix-twsapi-client/tests/comm.rs +++ b/crates/truefix-twsapi-client/tests/comm.rs @@ -55,6 +55,9 @@ fn field_encoding_matches_python_bool_and_empty_sentinel_rules() { assert_eq!(comm::make_field(false).unwrap(), "0\0"); assert_eq!(comm::make_field_handle_empty(UNSET_INTEGER).unwrap(), "\0"); assert_eq!(comm::make_field_handle_empty(UNSET_DOUBLE).unwrap(), "\0"); + assert_eq!(comm::make_field(100.0_f64).unwrap(), "100.0\0"); + assert_eq!(comm::make_field(-0.0_f64).unwrap(), "-0.0\0"); + assert_eq!(comm::make_field(f64::INFINITY).unwrap(), "inf\0"); assert_eq!( comm::make_field_handle_empty(f64::INFINITY).unwrap(), "Infinity\0" diff --git a/crates/truefix-twsapi-client/tests/requests_decoder.rs b/crates/truefix-twsapi-client/tests/requests_decoder.rs index b73fdde..49dd446 100644 --- a/crates/truefix-twsapi-client/tests/requests_decoder.rs +++ b/crates/truefix-twsapi-client/tests/requests_decoder.rs @@ -6,10 +6,11 @@ use truefix_twsapi_client::message::{Incoming, Outgoing, PROTOBUF_MSG_ID}; use truefix_twsapi_client::protobuf; use truefix_twsapi_client::requests::{ AccountSummaryRequest, AccountUpdatesMultiRequest, CalculateImpliedVolatilityRequest, - CalculateOptionPriceRequest, CancelOrderRequest, CompletedOrdersRequest, - ContractDetailsRequest, EmptyRequest, EncodableRequest, ExecutionRequest, - ExerciseOptionsRequest, FieldSink, FinancialAdvisorRequest, HeadTimestampRequest, - HistoricalDataRequest, HistoricalTicksRequest, IdRequest, MarketDataRequest, + CalculateOptionPriceRequest, CancelMarketDepthRequest, CancelOrderRequest, + CompletedOrdersRequest, ContractDetailsRequest, EmptyRequest, EncodableRequest, + ExecutionRequest, ExerciseOptionsRequest, FieldSink, FinancialAdvisorRequest, + GlobalCancelRequest, HeadTimestampRequest, HistogramDataRequest, HistoricalDataRequest, + HistoricalNewsRequest, HistoricalTicksRequest, IdRequest, MarketDataRequest, MarketDepthRequest, NewsArticleRequest, PlaceOrderRequest, PnlSingleRequest, RealTimeBarsRequest, ReplaceFinancialAdvisorRequest, ScannerSubscriptionRequest, StartApiRequest, SubscribeToGroupEventsRequest, TickByTickRequest, UpdateDisplayGroupRequest, @@ -17,9 +18,15 @@ use truefix_twsapi_client::requests::{ VersionedRequest, WshEventDataRequest, encode_request_frame, encode_request_frame_with_protobuf, protobuf_min_server_version, }; -use truefix_twsapi_client::server_versions::{MAX_CLIENT_VER, MIN_SERVER_VER_PROTOBUF_MARKET_DATA}; +use truefix_twsapi_client::server_versions::{ + MAX_CLIENT_VER, MIN_SERVER_VER_LINKING, MIN_SERVER_VER_MANUAL_ORDER_TIME, + MIN_SERVER_VER_MKT_DEPTH_PRIM_EXCHANGE, MIN_SERVER_VER_NEWS_QUERY_ORIGINS, + MIN_SERVER_VER_PROTOBUF_MARKET_DATA, MIN_SERVER_VER_REPLACE_FA_END, MIN_SERVER_VER_RFQ_FIELDS, + MIN_SERVER_VER_SCANNER_GENERIC_OPTS, MIN_SERVER_VER_SMART_DEPTH, MIN_SERVER_VER_TRADING_CLASS, +}; use truefix_twsapi_client::types::{ - Contract, ExecutionFilter, Order, OrderCancel, Origin, ScannerSubscription, TagValue, + Contract, DepthMarketDataDescription, ExecutionFilter, FamilyCode, HistogramEntry, + NewsProvider, Order, OrderCancel, Origin, PriceIncrement, ScannerSubscription, TagValue, TickByTick as TickByTickPayload, }; @@ -267,14 +274,42 @@ fn typed_request_frames_use_protobuf_when_supported() { client_id: 2, acct_code: "DU123".to_owned(), symbol: "AAPL".to_owned(), + last_n_days: 5, + specific_dates: vec![20260701, 20260702], ..ExecutionFilter::default() }, }; let frame = encode_request_frame(&execution, MAX_CLIENT_VER).unwrap(); let decoded = protobuf::ExecutionRequest::decode(&frame[8..]).unwrap(); - assert_eq!( - decoded.execution_filter.and_then(|filter| filter.symbol), - Some("AAPL".to_owned()) + let filter = decoded.execution_filter.unwrap(); + assert_eq!(filter.symbol.as_deref(), Some("AAPL")); + assert_eq!(filter.last_n_days, Some(5)); + assert_eq!(filter.specific_dates, [20260701, 20260702]); + + let frame = encode_request_frame_with_protobuf(&execution, 200, false).unwrap(); + let fields = field_strings(&String::from_utf8_lossy(&frame[8..])); + assert!(fields.ends_with(&[ + "5".to_owned(), + "2".to_owned(), + "20260701".to_owned(), + "20260702".to_owned(), + ])); + + let mut fields = FieldSink::default(); + execution + .encode_fields_for_server_version(&mut fields, 41) + .unwrap(); + assert_eq!(field_strings(&fields.into_string())[0..2], ["3", "2"]); + + let mut fields = FieldSink::default(); + ExecutionRequest { + req_id: 9, + filter: ExecutionFilter::default(), + } + .encode_fields_for_server_version(&mut fields, 200) + .unwrap(); + assert!( + field_strings(&fields.into_string()).ends_with(&["2147483647".to_owned(), "0".to_owned(),]) ); } @@ -774,7 +809,10 @@ fn decoder_reads_more_protobuf_news_fa_and_verify_events() { assert_eq!( event, Event::NewsProviders { - providers: vec![("BRFG".to_owned(), "Briefing".to_owned())], + providers: vec![NewsProvider { + code: "BRFG".to_owned(), + name: "Briefing".to_owned(), + }], } ); @@ -824,7 +862,10 @@ fn decoder_reads_more_protobuf_rules_and_config_events() { event, Event::MarketRule { market_rule_id: 26, - price_increments: vec![(0.0, 0.01)], + price_increments: vec![PriceIncrement { + low_edge: 0.0, + increment: 0.01, + }], } ); @@ -1054,10 +1095,449 @@ fn place_order_request_encodes_extended_order_fields_to_field_protocol() { assert!(parts.contains(&"adaptivePriority".to_owned())); assert!(parts.contains(&"Normal".to_owned())); assert!(parts.contains(&"algo-1".to_owned())); - assert!(parts.contains(&"1000".to_owned())); + assert!(parts.contains(&"1000.0".to_owned())); assert!(parts.contains(&"1".to_owned())); } +#[test] +fn place_order_field_protocol_keeps_conditional_fields_in_wire_order() { + let encode = |contract: Contract, order: Order, server_version| { + let request = PlaceOrderRequest { + order_id: 1, + contract, + order, + extra_fields: String::new(), + }; + let mut fields = FieldSink::default(); + request + .encode_fields_for_server_version(&mut fields, server_version) + .unwrap(); + field_strings(&fields.into_string()) + }; + let has_sequence = |fields: &[String], expected: &[&str]| { + fields.windows(expected.len()).any(|window| { + window + .iter() + .map(String::as_str) + .eq(expected.iter().copied()) + }) + }; + + let benchmark = encode( + Contract { + symbol: "AAPL".to_owned(), + sec_type: "STK".to_owned(), + exchange: "SMART".to_owned(), + currency: "USD".to_owned(), + ..Contract::default() + }, + Order { + action: "BUY".to_owned(), + total_quantity: "1".parse().unwrap(), + order_type: "PEG BENCH".to_owned(), + reference_contract_id: 42, + is_pegged_change_amount_decrease: true, + pegged_change_amount: 1.25, + reference_change_amount: 2.5, + reference_exchange_id: "NYSE".to_owned(), + ..Order::default() + }, + MAX_CLIENT_VER, + ); + assert!(has_sequence( + &benchmark, + &["42", "1", "1.25", "2.5", "NYSE", "0"], + )); + + let peg_best = encode( + Contract { + symbol: "AAPL".to_owned(), + sec_type: "STK".to_owned(), + exchange: "IBKRATS".to_owned(), + currency: "USD".to_owned(), + ..Contract::default() + }, + Order { + action: "BUY".to_owned(), + total_quantity: "1".parse().unwrap(), + order_type: "PEG BEST".to_owned(), + min_trade_qty: 3, + min_compete_size: 4, + compete_against_best_offset: f64::INFINITY, + mid_offset_at_whole: 0.1, + mid_offset_at_half: 0.2, + customer_account: "customer".to_owned(), + professional_customer: true, + ..Order::default() + }, + MIN_SERVER_VER_RFQ_FIELDS, + ); + assert!(has_sequence( + &peg_best, + &["3", "4", "Infinity", "0.1", "0.2", "customer", "1", "", ""], + )); +} + +#[test] +fn legacy_field_encoders_follow_tws_version_gates() { + let cancel = CancelOrderRequest { + order_id: 7, + order_cancel: OrderCancel { + manual_order_cancel_time: "20260710-12:00:00".to_owned(), + ext_operator: "operator".to_owned(), + manual_order_indicator: 1, + }, + }; + let encode_cancel = |server_version| { + let mut fields = FieldSink::default(); + cancel + .encode_fields_for_server_version(&mut fields, server_version) + .unwrap(); + field_strings(&fields.into_string()) + }; + assert_eq!(encode_cancel(168), ["1", "7"]); + assert_eq!( + encode_cancel(MIN_SERVER_VER_MANUAL_ORDER_TIME), + ["1", "7", "20260710-12:00:00"] + ); + assert_eq!( + encode_cancel(MIN_SERVER_VER_RFQ_FIELDS), + ["1", "7", "20260710-12:00:00", "", "", ""] + ); + assert_eq!( + encode_cancel(192), + ["7", "20260710-12:00:00", "operator", "1"] + ); + + let global_cancel = GlobalCancelRequest { + order_cancel: cancel.order_cancel.clone(), + }; + let encode_global_cancel = |server_version| { + let mut fields = FieldSink::default(); + global_cancel + .encode_fields_for_server_version(&mut fields, server_version) + .unwrap(); + field_strings(&fields.into_string()) + }; + assert_eq!(encode_global_cancel(191), ["1"]); + assert_eq!(encode_global_cancel(192), ["operator", "1"]); + + let cancel_depth = CancelMarketDepthRequest { + req_id: 9, + is_smart_depth: true, + }; + let encode_cancel_depth = |server_version| { + let mut fields = FieldSink::default(); + cancel_depth + .encode_fields_for_server_version(&mut fields, server_version) + .unwrap(); + field_strings(&fields.into_string()) + }; + assert_eq!(encode_cancel_depth(145), ["1", "9"]); + assert_eq!( + encode_cancel_depth(MIN_SERVER_VER_SMART_DEPTH), + ["1", "9", "1"] + ); + + let replace = ReplaceFinancialAdvisorRequest { + req_id: 8, + fa_data_type: 3, + xml: "".to_owned(), + }; + let encode_replace = |server_version| { + let mut fields = FieldSink::default(); + replace + .encode_fields_for_server_version(&mut fields, server_version) + .unwrap(); + field_strings(&fields.into_string()) + }; + assert_eq!(encode_replace(156), ["1", "3", ""]); + assert_eq!( + encode_replace(MIN_SERVER_VER_REPLACE_FA_END), + ["1", "3", "", "8"] + ); + + let depth = MarketDepthRequest { + req_id: 9, + contract: Contract { + con_id: 265598, + symbol: "AAPL".to_owned(), + sec_type: "STK".to_owned(), + last_trade_date_or_contract_month: "202609".to_owned(), + strike: 175.0, + right: "C".to_owned(), + multiplier: "100".to_owned(), + exchange: "SMART".to_owned(), + primary_exchange: "NASDAQ".to_owned(), + currency: "USD".to_owned(), + local_symbol: "AAPL".to_owned(), + trading_class: "NMS".to_owned(), + include_expired: true, + sec_id_type: "ISIN".to_owned(), + sec_id: "US0378331005".to_owned(), + ..Contract::default() + }, + num_rows: 10, + is_smart_depth: true, + market_depth_options: vec![TagValue { + tag: "exchange".to_owned(), + value: "ISLAND".to_owned(), + }], + }; + let encode_depth = |server_version| { + let mut fields = FieldSink::default(); + depth + .encode_fields_for_server_version(&mut fields, server_version) + .unwrap(); + field_strings(&fields.into_string()) + }; + assert_eq!( + encode_depth(MIN_SERVER_VER_SMART_DEPTH - 1), + [ + "5", + "9", + "265598", + "AAPL", + "STK", + "202609", + "175.0", + "C", + "100", + "SMART", + "USD", + "AAPL", + "NMS", + "10", + "exchange=ISLAND;" + ] + ); + assert_eq!( + encode_depth(MIN_SERVER_VER_SMART_DEPTH), + [ + "5", + "9", + "265598", + "AAPL", + "STK", + "202609", + "175.0", + "C", + "100", + "SMART", + "USD", + "AAPL", + "NMS", + "10", + "1", + "exchange=ISLAND;" + ] + ); + assert_eq!( + encode_depth(MIN_SERVER_VER_MKT_DEPTH_PRIM_EXCHANGE), + [ + "5", + "9", + "265598", + "AAPL", + "STK", + "202609", + "175.0", + "C", + "100", + "SMART", + "NASDAQ", + "USD", + "AAPL", + "NMS", + "10", + "1", + "exchange=ISLAND;" + ] + ); +} + +#[test] +fn scanner_realtime_and_contract_field_encoders_match_python_layouts() { + fn encode_fields(request: &R) -> Vec { + let mut sink = FieldSink::default(); + request.encode_fields(&mut sink).unwrap(); + field_strings(&sink.into_string()) + } + + let contract = Contract { + con_id: 265598, + symbol: "AAPL".to_owned(), + sec_type: "STK".to_owned(), + last_trade_date_or_contract_month: "202609".to_owned(), + strike: 175.0, + right: "C".to_owned(), + multiplier: "100".to_owned(), + exchange: "SMART".to_owned(), + primary_exchange: "NASDAQ".to_owned(), + currency: "USD".to_owned(), + local_symbol: "AAPL".to_owned(), + trading_class: "NMS".to_owned(), + include_expired: true, + sec_id_type: "ISIN".to_owned(), + sec_id: "US0378331005".to_owned(), + ..Contract::default() + }; + + let head = HeadTimestampRequest { + req_id: 1, + contract: contract.clone(), + use_rth: true, + what_to_show: "TRADES".to_owned(), + format_date: 2, + }; + let histogram = HistogramDataRequest { + req_id: 2, + contract: contract.clone(), + use_rth: true, + time_period: "3 days".to_owned(), + }; + for fields in [encode_fields(&head), encode_fields(&histogram)] { + assert!(fields.contains(&"265598".to_owned())); + assert!(fields.contains(&"NMS".to_owned())); + assert!(fields.contains(&"1".to_owned())); + assert!(!fields.contains(&"ISIN".to_owned())); + assert!(!fields.contains(&"US0378331005".to_owned())); + } + + let realtime = RealTimeBarsRequest { + req_id: 3, + contract: contract.clone(), + bar_size: 5, + what_to_show: "TRADES".to_owned(), + use_rth: true, + options: vec![TagValue { + tag: "source".to_owned(), + value: "api".to_owned(), + }], + }; + let encode_realtime = |server_version| { + let mut sink = FieldSink::default(); + realtime + .encode_fields_for_server_version(&mut sink, server_version) + .unwrap(); + field_strings(&sink.into_string()) + }; + let before_trading_class = encode_realtime(MIN_SERVER_VER_TRADING_CLASS - 1); + assert_eq!(before_trading_class[0..3], ["3", "3", "AAPL"]); + assert!(!before_trading_class.contains(&"265598".to_owned())); + assert!(!before_trading_class.contains(&"NMS".to_owned())); + assert!(!before_trading_class.contains(&"source=api;".to_owned())); + let with_trading_class = encode_realtime(MIN_SERVER_VER_TRADING_CLASS); + assert_eq!(with_trading_class[0..4], ["3", "3", "265598", "AAPL"]); + assert!(with_trading_class.contains(&"NMS".to_owned())); + let with_options = encode_realtime(MIN_SERVER_VER_LINKING); + assert_eq!(with_options.last().map(String::as_str), Some("source=api;")); + + let scanner = ScannerSubscriptionRequest { + req_id: 4, + subscription: ScannerSubscription { + number_of_rows: 10, + instrument: "STK".to_owned(), + location_code: "STK.US".to_owned(), + scan_code: "TOP_PERC_GAIN".to_owned(), + ..ScannerSubscription::default() + }, + scanner_subscription_options: vec![TagValue { + tag: "option".to_owned(), + value: "value".to_owned(), + }], + scanner_subscription_filter_options: vec![TagValue { + tag: "filter".to_owned(), + value: "value".to_owned(), + }], + }; + let encode_scanner = |server_version| { + let mut sink = FieldSink::default(); + scanner + .encode_fields_for_server_version(&mut sink, server_version) + .unwrap(); + field_strings(&sink.into_string()) + }; + let before_generic_options = encode_scanner(MIN_SERVER_VER_SCANNER_GENERIC_OPTS - 1); + assert_eq!( + before_generic_options.first().map(String::as_str), + Some("4") + ); + assert!(!before_generic_options.contains(&"filter=value;".to_owned())); + assert_eq!( + before_generic_options.last().map(String::as_str), + Some("option=value;") + ); + let generic_options = encode_scanner(MIN_SERVER_VER_SCANNER_GENERIC_OPTS); + assert_eq!(generic_options.first().map(String::as_str), Some("4")); + assert!(generic_options.ends_with(&["filter=value;".to_owned(), "option=value;".to_owned()])); +} + +#[test] +fn news_field_options_are_versioned_single_fields() { + let options = vec![ + TagValue { + tag: "origin".to_owned(), + value: "BRFG".to_owned(), + }, + TagValue { + tag: "format".to_owned(), + value: "text".to_owned(), + }, + ]; + let article = NewsArticleRequest { + req_id: 1, + provider_code: "BRFG".to_owned(), + article_id: "A1".to_owned(), + options: options.clone(), + }; + let historical = HistoricalNewsRequest { + req_id: 2, + con_id: 265598, + provider_codes: "BRFG".to_owned(), + start_date_time: "20260701 00:00:00".to_owned(), + end_date_time: "20260710 00:00:00".to_owned(), + total_results: 10, + options, + }; + + let encode = |request: &dyn EncodableRequest, server_version| { + let mut sink = FieldSink::default(); + request + .encode_fields_for_server_version(&mut sink, server_version) + .unwrap(); + field_strings(&sink.into_string()) + }; + assert_eq!(encode(&article, 127), ["1", "BRFG", "A1"]); + assert_eq!( + encode(&article, MIN_SERVER_VER_NEWS_QUERY_ORIGINS), + ["1", "BRFG", "A1", "origin=BRFG;format=text;"] + ); + assert_eq!( + encode(&historical, 127), + [ + "2", + "265598", + "BRFG", + "20260701 00:00:00", + "20260710 00:00:00", + "10" + ] + ); + assert_eq!( + encode(&historical, MIN_SERVER_VER_NEWS_QUERY_ORIGINS), + [ + "2", + "265598", + "BRFG", + "20260701 00:00:00", + "20260710 00:00:00", + "10", + "origin=BRFG;format=text;" + ] + ); +} + #[test] fn place_order_request_encodes_extended_order_fields_to_protobuf() { let request = PlaceOrderRequest { @@ -1821,7 +2301,9 @@ fn decoder_reads_field_based_contract_details_callback() { "20260709:0930-1600", "ev", "1", - "0", + "1", + "ISIN", + "US0378331005", "0", "AAPL", "STK", @@ -1839,6 +2321,14 @@ fn decoder_reads_field_based_contract_details_callback() { assert_eq!(details.contract.con_id, 265598); assert_eq!(details.min_tick, 0.01); assert_eq!(details.long_name, "Apple Inc"); + assert_eq!(details.ev_rule, "ev"); + assert_eq!(details.ev_multiplier, 1.0); + assert_eq!(details.sec_id_list.len(), 1); + assert_eq!(details.sec_id_list[0].tag, "ISIN"); + assert_eq!(details.sec_id_list[0].value, "US0378331005"); + assert_eq!(details.aggregate_group, 0); + assert_eq!(details.under_symbol, "AAPL"); + assert_eq!(details.under_sec_type, "STK"); assert_eq!(details.market_rule_ids, "26"); } other => panic!("unexpected event: {other:?}"), @@ -2156,7 +2646,10 @@ fn decoder_reads_field_based_metadata_list_callbacks() { assert_eq!( decoder::decode_payload(false, &family).unwrap(), Event::FamilyCodes { - family_codes: vec![("DU123".to_owned(), "family".to_owned())], + family_codes: vec![FamilyCode { + account_id: "DU123".to_owned(), + family_code: "family".to_owned(), + }], } ); @@ -2205,7 +2698,16 @@ fn decoder_reads_field_based_metadata_list_callbacks() { ); match decoder::decode_payload(false, &depth).unwrap() { Event::MarketDepthExchanges { descriptions } => { - assert_eq!(descriptions[0], "ISLAND:STK:NASDAQ:Deep2:1"); + assert_eq!( + descriptions[0], + DepthMarketDataDescription { + exchange: "ISLAND".to_owned(), + security_type: "STK".to_owned(), + listing_exchange: "NASDAQ".to_owned(), + service_data_type: "Deep2".to_owned(), + aggregate_group: 1, + } + ); } other => panic!("unexpected event: {other:?}"), } @@ -2288,7 +2790,10 @@ fn decoder_reads_field_based_pnl_news_and_rule_callbacks() { decoder::decode_payload(false, &histogram).unwrap(), Event::HistogramData { req_id: 2, - items: vec![(175.5, "10".parse().unwrap())], + items: vec![HistogramEntry { + price: 175.5, + size: "10".parse().unwrap(), + }], } ); @@ -2297,7 +2802,10 @@ fn decoder_reads_field_based_pnl_news_and_rule_callbacks() { decoder::decode_payload(false, &market_rule).unwrap(), Event::MarketRule { market_rule_id: 26, - price_increments: vec![(0.0, 0.01)], + price_increments: vec![PriceIncrement { + low_edge: 0.0, + increment: 0.01, + }], } ); @@ -2334,7 +2842,10 @@ fn decoder_reads_field_based_pnl_news_and_rule_callbacks() { assert_eq!( decoder::decode_payload(false, &providers).unwrap(), Event::NewsProviders { - providers: vec![("BRFG".to_owned(), "Briefing".to_owned())], + providers: vec![NewsProvider { + code: "BRFG".to_owned(), + name: "Briefing".to_owned(), + }], } ); } @@ -2825,6 +3336,14 @@ fn decoder_reads_protobuf_contract_and_execution_events() { order_types: Some("LMT,MKT".to_owned()), valid_exchanges: Some("SMART,NASDAQ".to_owned()), long_name: Some("Apple Inc".to_owned()), + ev_rule: Some("ev-rule".to_owned()), + ev_multiplier: Some(2.5), + sec_id_list: [("ISIN".to_owned(), "US0378331005".to_owned())] + .into_iter() + .collect(), + agg_group: Some(7), + under_symbol: Some("AAPL".to_owned()), + under_sec_type: Some("STK".to_owned()), market_rule_ids: Some("26".to_owned()), ..protobuf::ContractDetails::default() }), @@ -2841,6 +3360,12 @@ fn decoder_reads_protobuf_contract_and_execution_events() { assert_eq!(details.contract.symbol, "AAPL"); assert_eq!(details.market_name, "NASDAQ"); assert_eq!(details.min_tick, 0.01); + assert_eq!(details.ev_rule, "ev-rule"); + assert_eq!(details.ev_multiplier, 2.5); + assert_eq!(details.sec_id_list[0].tag, "ISIN"); + assert_eq!(details.aggregate_group, 7); + assert_eq!(details.under_symbol, "AAPL"); + assert_eq!(details.under_sec_type, "STK"); assert_eq!(details.market_rule_ids, "26"); } other => panic!("unexpected event: {other:?}"), diff --git a/crates/truefix/Cargo.toml b/crates/truefix/Cargo.toml index 0e67843..c4fdcb4 100644 --- a/crates/truefix/Cargo.toml +++ b/crates/truefix/Cargo.toml @@ -39,6 +39,10 @@ tracing-subscriber = "0.3" time = { workspace = true } truefix-log = { workspace = true, features = ["redb"] } redb.workspace = true +# Official Binance Spot REST connector from crates.io, pinned for reproducible examples. +binance-sdk = { version = "60.0.0", features = ["spot", "margin_trading", "convert", "derivatives_trading_options"] } +http = "1" +serde_json.workspace = true [features] # `.cfg`-driven SQL/MSSQL backend selection (US3, feature 004, FR-003) needs the matching feature diff --git a/crates/truefix/examples/binance_cli.rs b/crates/truefix/examples/binance_cli.rs new file mode 100644 index 0000000..ee08e5c --- /dev/null +++ b/crates/truefix/examples/binance_cli.rs @@ -0,0 +1,554 @@ +//! Interactive CLI for the enabled Binance REST modules. +//! +//! It covers every REST endpoint exposed by the official SDK's Spot, Margin Trading, Options, +//! and Convert modules through their public raw-request APIs. Run the example without arguments +//! for the prompt, then type help. Credentials are read only at runtime and are never printed. + +use anyhow::{Context, Result, bail}; +use binance_sdk::{ + config::{ + ConfigurationRestApi, ConfigurationWebsocketApi, ConfigurationWebsocketStreams, PrivateKey, + }, + convert::ConvertRestApi, + derivatives_trading_options::{ + DerivativesTradingOptionsRestApi, DerivativesTradingOptionsWsStreams, + }, + margin_trading::{MarginTradingRestApi, MarginTradingWsStreams}, + models::RestApiResponse, + spot::{SpotRestApi, SpotWsApi, SpotWsStreams}, +}; +use http::Method; +use serde_json::Value; +use std::{ + collections::BTreeMap, + fs, + io::{self, Write}, + path::Path, +}; + +const DEFAULT_API_KEY_FILE: &str = + "/Users/jiayin/workspace/dev/dev/rust/truefix-account/binance/readme"; +const DEFAULT_PRIVATE_KEY_PATH: &str = + "/Users/jiayin/workspace/dev/dev/rust/truefix-account/binance/Ed25519/test-prv-key.pem"; + +#[derive(Clone, Copy)] +enum Module { + Spot, + Margin, + Options, + Convert, +} + +struct Request { + module: Module, + method: Method, + path: String, + query: BTreeMap, + body: BTreeMap, + signed: bool, + confirm_write: bool, +} + +fn api_key(path: &Path) -> Result { + let contents = + fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + let key = contents + .lines() + .skip_while(|line| !line.contains("API Key:")) + .skip(1) + .map(str::trim) + .find(|line| !line.is_empty()) + .context("no API key found after API Key:")?; + if key.chars().any(char::is_whitespace) { + bail!("the Binance API-key line must contain exactly one key"); + } + Ok(key.to_owned()) +} + +fn config() -> Result { + let key_file = + std::env::var("BINANCE_API_KEY_FILE").unwrap_or_else(|_| DEFAULT_API_KEY_FILE.to_owned()); + let private_key = std::env::var("BINANCE_PRIVATE_KEY_PATH") + .unwrap_or_else(|_| DEFAULT_PRIVATE_KEY_PATH.to_owned()); + Ok(ConfigurationRestApi::builder() + .api_key(api_key(Path::new(&key_file))?) + .private_key(PrivateKey::File(private_key)) + .build()?) +} + +fn websocket_config() -> Result { + let key_file = + std::env::var("BINANCE_API_KEY_FILE").unwrap_or_else(|_| DEFAULT_API_KEY_FILE.to_owned()); + let private_key = std::env::var("BINANCE_PRIVATE_KEY_PATH") + .unwrap_or_else(|_| DEFAULT_PRIVATE_KEY_PATH.to_owned()); + Ok(ConfigurationWebsocketApi::builder() + .api_key(api_key(Path::new(&key_file))?) + .private_key(PrivateKey::File(private_key)) + .build()?) +} + +#[tokio::main] +async fn main() -> Result<()> { + let arguments: Vec = std::env::args().skip(1).collect(); + if arguments.is_empty() { + repl().await + } else if matches!(arguments[0].as_str(), "help" | "h" | "?") { + help(); + Ok(()) + } else if matches!(arguments[0].as_str(), "catalog" | "commands") { + catalog(arguments.get(1).map(String::as_str)); + Ok(()) + } else if arguments + .first() + .is_some_and(|argument| argument == "stream") + { + stream(&arguments).await + } else if arguments.first().is_some_and(|argument| argument == "ws") { + websocket_api(&arguments).await + } else { + execute(parse(&arguments)?).await + } +} + +async fn repl() -> Result<()> { + println!("Binance CLI: Spot / Margin / Options / Convert. Type help for commands."); + let stdin = io::stdin(); + let mut line = String::new(); + loop { + print!("binance> "); + io::stdout().flush()?; + line.clear(); + if stdin.read_line(&mut line)? == 0 { + break; + } + let input = line.trim(); + if input.is_empty() { + continue; + } + if matches!(input, "exit" | "quit" | "q") { + break; + } + let arguments = match split_args(input) { + Ok(arguments) => arguments, + Err(error) => { + eprintln!("{error:#}"); + continue; + } + }; + if arguments + .first() + .is_some_and(|argument| matches!(argument.as_str(), "help" | "h" | "?")) + { + help(); + continue; + } + if arguments + .first() + .is_some_and(|argument| matches!(argument.as_str(), "catalog" | "commands")) + { + catalog(arguments.get(1).map(String::as_str)); + continue; + } + if arguments + .first() + .is_some_and(|argument| argument == "stream") + { + if let Err(error) = stream(&arguments).await { + eprintln!("{error:#}"); + } + continue; + } + if arguments.first().is_some_and(|argument| argument == "ws") { + if let Err(error) = websocket_api(&arguments).await { + eprintln!("{error:#}"); + } + continue; + } + match parse(&arguments) { + Ok(request) => { + if let Err(error) = execute(request).await { + eprintln!("{error:#}"); + } + } + Err(error) => eprintln!("{error:#}"), + } + } + Ok(()) +} + +async fn stream(arguments: &[String]) -> Result<()> { + if arguments.len() != 3 { + bail!("usage: stream "); + } + let configuration = ConfigurationWebsocketStreams::builder().build()?; + let stream_name = arguments[2].to_owned(); + match arguments[1].as_str() { + "spot" => { + let connection = SpotWsStreams::production(configuration).connect().await?; + let _events = connection.subscribe_on_ws_events(|event| println!("{event:?}")); + connection.subscribe(vec![stream_name], None); + wait_for_disconnect().await; + connection.disconnect().await?; + } + "margin" => { + let connection = MarginTradingWsStreams::production(configuration) + .connect() + .await?; + let _events = connection.subscribe_on_ws_events(|event| println!("{event:?}")); + connection.subscribe(vec![stream_name], None); + wait_for_disconnect().await; + connection.disconnect().await?; + } + "options" => { + let connection = DerivativesTradingOptionsWsStreams::production(configuration) + .connect() + .await?; + let _events = connection.subscribe_on_ws_events(|event| println!("{event:?}")); + connection.subscribe(vec![stream_name], None); + wait_for_disconnect().await; + connection.disconnect().await?; + } + _ => bail!("stream module must be spot, margin, or options"), + } + Ok(()) +} + +async fn wait_for_disconnect() { + println!("stream active; press Ctrl-C to disconnect"); + let _ = tokio::signal::ctrl_c().await; +} + +async fn websocket_api(arguments: &[String]) -> Result<()> { + if arguments.len() < 2 { + bail!("usage: ws [PARAMS_JSON] [--public] [--confirm-write]"); + } + let is_public = arguments.iter().any(|argument| argument == "--public"); + if !is_public && websocket_write(&arguments[1]) && !has_flag(arguments, "--confirm-write") { + bail!("this WebSocket API action requires --confirm-write"); + } + let payload = arguments + .iter() + .skip(2) + .find(|argument| !argument.starts_with("--")) + .map(|value| object(value, "params")) + .transpose()? + .unwrap_or_default(); + let config = if is_public { + ConfigurationWebsocketApi::builder().build()? + } else { + websocket_config()? + }; + let connection = SpotWsApi::production(config).connect().await?; + let response = if is_public { + connection + .send_message::(&arguments[1], payload) + .await? + } else { + connection + .send_signed_message::(&arguments[1], payload) + .await? + }; + println!("{}", serde_json::to_string_pretty(&response.data()?)?); + connection.disconnect().await?; + Ok(()) +} + +async fn execute(request: Request) -> Result<()> { + if request.method != Method::GET && !request.confirm_write { + bail!("write requests require --confirm-write"); + } + let configuration = if request.signed { + config()? + } else { + ConfigurationRestApi::builder().build()? + }; + let response = match request.module { + Module::Spot => send(SpotRestApi::production(configuration), &request).await?, + Module::Margin => send(MarginTradingRestApi::production(configuration), &request).await?, + Module::Options => { + send( + DerivativesTradingOptionsRestApi::production(configuration), + &request, + ) + .await? + } + Module::Convert => send(ConvertRestApi::production(configuration), &request).await?, + }; + println!("{}", serde_json::to_string_pretty(&response)?); + Ok(()) +} + +trait RawClient { + fn send<'a>( + &'a self, + signed: bool, + path: &'a str, + method: Method, + query: BTreeMap, + body: BTreeMap, + ) -> std::pin::Pin< + Box>> + Send + 'a>, + >; +} + +macro_rules! raw_client { + ($type:path) => { + impl RawClient for $type { + fn send<'a>( + &'a self, + signed: bool, + path: &'a str, + method: Method, + query: BTreeMap, + body: BTreeMap, + ) -> std::pin::Pin< + Box>> + Send + 'a>, + > { + Box::pin(async move { + if signed { + Ok(self.send_signed_request(path, method, query, body).await?) + } else { + Ok(self.send_request(path, method, query, body).await?) + } + }) + } + } + }; +} + +raw_client!(binance_sdk::spot::rest_api::RestApi); +raw_client!(binance_sdk::margin_trading::rest_api::RestApi); +raw_client!(binance_sdk::convert::rest_api::RestApi); +raw_client!(binance_sdk::derivatives_trading_options::rest_api::RestApi); + +async fn send(client: impl RawClient, request: &Request) -> Result { + Ok(client + .send( + request.signed, + &request.path, + request.method.clone(), + request.query.clone(), + request.body.clone(), + ) + .await? + .data() + .await?) +} + +fn parse(arguments: &[String]) -> Result { + let arguments = request_alias(arguments)?; + if arguments.len() < 4 || arguments[0] != "request" { + bail!( + "usage: request [QUERY_JSON] [BODY_JSON] [--public] [--confirm-write]" + ); + } + let module = match arguments[1].as_str() { + "spot" => Module::Spot, + "margin" => Module::Margin, + "options" => Module::Options, + "convert" => Module::Convert, + _ => bail!("unknown module; use spot, margin, options, or convert"), + }; + let values: Vec<&String> = arguments[4..] + .iter() + .filter(|argument| !argument.starts_with("--")) + .collect(); + if values.len() > 2 { + bail!("only QUERY_JSON and BODY_JSON may be supplied"); + } + Ok(Request { + module, + method: Method::from_bytes(arguments[2].to_ascii_uppercase().as_bytes())?, + path: arguments[3].clone(), + query: values + .first() + .map(|value| object(value, "query")) + .transpose()? + .unwrap_or_default(), + body: values + .get(1) + .map(|value| object(value, "body")) + .transpose()? + .unwrap_or_default(), + signed: !has_flag(&arguments, "--public"), + confirm_write: has_flag(&arguments, "--confirm-write"), + }) +} + +fn object(value: &str, label: &str) -> Result> { + serde_json::from_str(value).with_context(|| format!("{label} must be a JSON object")) +} + +fn request_alias(arguments: &[String]) -> Result> { + let Some(command) = arguments.first() else { + return Ok(Vec::new()); + }; + let method = match command.as_str() { + "get" => "GET", + "post" => "POST", + "put" => "PUT", + "delete" | "del" => "DELETE", + "request" => return Ok(arguments.to_vec()), + _ => return Ok(arguments.to_vec()), + }; + if arguments.len() < 3 { + bail!( + "usage: {command} [QUERY_JSON] [BODY_JSON] [--public] [--confirm-write]" + ); + } + Ok(std::iter::once("request".to_owned()) + .chain(std::iter::once(arguments[1].clone())) + .chain(std::iter::once(method.to_owned())) + .chain(arguments[2..].iter().cloned()) + .collect()) +} + +fn has_flag(arguments: &[String], flag: &str) -> bool { + arguments.iter().any(|argument| argument == flag) +} + +fn websocket_write(method: &str) -> bool { + method.starts_with("order.") + || method.starts_with("orderList.") + || method.starts_with("openOrders.") + || matches!( + method, + "session.logout" | "userDataStream.start" | "userDataStream.stop" + ) +} + +/// Split a command line while retaining JSON quotes. Quote a JSON document with single quotes +/// when it contains whitespace, e.g. `get spot /api/v3/klines '{"symbol": "BTCUSDT"}' --public`. +fn split_args(input: &str) -> Result> { + let mut out = Vec::new(); + let mut current = String::new(); + let mut quote = None; + let mut escaped = false; + let mut json_depth = 0usize; + for character in input.chars() { + if escaped { + current.push(character); + escaped = false; + } else if character == '\\' && (quote.is_some() || json_depth > 0) { + current.push(character); + escaped = true; + } else if Some(character) == quote { + quote = None; + } else if quote.is_none() && json_depth == 0 && matches!(character, '\'' | '"') { + quote = Some(character); + } else if quote.is_none() && matches!(character, '{' | '[') { + json_depth += 1; + current.push(character); + } else if quote.is_none() && matches!(character, '}' | ']') { + json_depth = json_depth + .checked_sub(1) + .context("unexpected JSON closing delimiter")?; + current.push(character); + } else if quote.is_none() && json_depth == 0 && character.is_whitespace() { + if !current.is_empty() { + out.push(std::mem::take(&mut current)); + } + } else { + current.push(character); + } + } + if escaped || quote.is_some() || json_depth != 0 { + bail!("unterminated quoted argument"); + } + if !current.is_empty() { + out.push(current); + } + Ok(out) +} + +fn help() { + println!( + "request [QUERY_JSON] [BODY_JSON] [--public] [--confirm-write]\n\ + [QUERY_JSON] [BODY_JSON] [--public] [--confirm-write]\n\ + catalog [spot|margin|options|convert]\n\ +\n\ +Every REST endpoint generated by the four enabled official SDK modules is available.\n\ +Examples:\n\ + get spot /api/v3/exchangeInfo {{\"symbol\":\"BTCUSDT\"}} --public\n\ + get spot /api/v3/account\n\ + get margin /sapi/v1/margin/account\n\ + get options /eapi/v1/exchangeInfo --public\n\ + get convert /sapi/v1/convert/exchangeInfo\n\ + stream spot btcusdt@trade\n\ + stream margin btcusdt@bookTicker\n\ + stream options btcusdt@trade\n\ + ws time --public\n\ + ws account.status\n\ +\n\ +Signed requests are the default. Public endpoints need --public. Every non-GET request requires --confirm-write.\n\ +The stream command accepts every raw stream name supported by its official SDK module.\n\ +The ws command accesses every Spot WebSocket API method. Use --confirm-write for order/session actions.\n\ +In the REPL, JSON may be written directly; quote it with single quotes if it contains whitespace." + ); +} + +fn catalog(module: Option<&str>) { + let requested = module.unwrap_or("all"); + if !matches!(requested, "all" | "spot" | "margin" | "options" | "convert") { + eprintln!("unknown module; use spot, margin, options, or convert"); + return; + } + println!( + "The generic request command covers every REST endpoint exposed by the enabled official SDK modules." + ); + if matches!(requested, "all" | "spot") { + println!( + "spot: /api/v3/exchangeInfo, /api/v3/ticker/price, /api/v3/depth, /api/v3/account, /api/v3/order" + ); + } + if matches!(requested, "all" | "margin") { + println!( + "margin: /sapi/v1/margin/account, /sapi/v1/margin/order, /sapi/v1/margin/allOrders, /sapi/v1/margin/isolated/account" + ); + } + if matches!(requested, "all" | "options") { + println!( + "options: /eapi/v1/exchangeInfo, /eapi/v1/ticker, /eapi/v1/depth, /eapi/v1/account, /eapi/v1/order" + ); + } + if matches!(requested, "all" | "convert") { + println!( + "convert: /sapi/v1/convert/exchangeInfo, /sapi/v1/convert/getQuote, /sapi/v1/convert/acceptQuote, /sapi/v1/convert/tradeFlow" + ); + } + println!( + "Use `help` for syntax. Verify endpoint parameters and permissions against Binance documentation before sending a request." + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_json_with_whitespace_without_losing_its_quotes() { + assert_eq!( + split_args("get spot /api/v3/ticker/price {\"symbol\": \"BTCUSDT\"} --public").unwrap(), + [ + "get", + "spot", + "/api/v3/ticker/price", + "{\"symbol\": \"BTCUSDT\"}", + "--public" + ] + ); + } + + #[test] + fn expands_short_request_alias() { + let request = parse(&[ + "get".to_owned(), + "spot".to_owned(), + "/api/v3/time".to_owned(), + "--public".to_owned(), + ]) + .unwrap(); + assert_eq!(request.method, Method::GET); + assert!(!request.signed); + } +} diff --git a/docs/getting-started.md b/docs/getting-started.md index 2a94a34..a53e2f4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,9 +1,9 @@ # TrueFix Getting Started -This guide covers the three current workspace entry points: the FIX engine, the Futu OpenD client, -and the Interactive Brokers TWS/Gateway client. The broker clients are independent crates. They do -not start OpenD or TWS/Gateway for you, and they do not bypass broker account, market-data, or trading -permissions. +This guide covers the current workspace entry points: the FIX engine, the Futu OpenD client, +the Interactive Brokers TWS/Gateway client, and the OKX and Binance exchange CLIs. The broker +and exchange clients are independent crates. They do not start OpenD or TWS/Gateway for you, and +they do not bypass broker account, market-data, or trading permissions. ## 1. Prerequisites @@ -13,11 +13,11 @@ Install or prepare: - a running Futu OpenD or IB TWS/Gateway instance; - the required account permissions for quotes, historical data, and trading. -Check the workspace and build both broker clients: +Check the workspace and build the client crates: ```bash cargo check --workspace -cargo build -p truefix-futu-client -p truefix-twsapi-client +cargo build -p truefix-futu-client -p truefix-twsapi-client -p truefix-okx-client ``` Run all examples from the repository root. @@ -254,7 +254,91 @@ TWS_MARKET_DATA_TYPE=3 Do not terminate the process immediately after sending a request. TWS responses are asynchronous; the CLI reads events for `TWS_WAIT_SECS` and then cancels continuous requests where appropriate. -## 4. Direct Rust API usage +## 4. Binance exchange CLI + +The Binance CLI covers the official connector's Spot, Margin Trading, Options, and Convert REST +modules. It also supports Spot WebSocket API calls and raw Spot, Margin, and Options stream names. + +Start the interactive prompt: + + cargo run -p truefix --example binance_cli + +The CLI reads the API key and Ed25519 PEM path only when a signed request is made. Its defaults +point to the local account directory; override them when needed: + +| Variable | Default | Purpose | +|----------|---------|---------| +| BINANCE_API_KEY_FILE | .../truefix-account/binance/readme | File containing the API key | +| BINANCE_PRIVATE_KEY_PATH | .../truefix-account/binance/Ed25519/test-prv-key.pem | Ed25519 private-key PEM | + +Use catalog to see the module groups. Public requests must include --public; signed requests +are the default. Every REST write requires --confirm-write. + +The same commands can run without entering the prompt. Put the command after Cargo's -- separator: + + cargo run -p truefix --example binance_cli -- get spot /api/v3/exchangeInfo '{"symbol":"BTCUSDT"}' --public + +Common REST examples: + + get spot /api/v3/exchangeInfo {"symbol":"BTCUSDT"} --public + get spot /api/v3/account + get margin /sapi/v1/margin/account + get options /eapi/v1/exchangeInfo --public + get convert /sapi/v1/convert/exchangeInfo + +WebSocket examples: + + stream spot btcusdt@trade + stream margin btcusdt@bookTicker + stream options btcusdt@trade + ws time --public + ws account.status + +Press Ctrl-C to disconnect an active stream. In the interactive prompt, JSON with whitespace may +be quoted with single quotes. Check endpoint-specific parameters and permissions in Binance +documentation before sending a request. + +## 5. OKX exchange CLI + +The OKX CLI exposes the complete 264-operation audited REST surface. It accepts an audited +domain/operation pair, rather than arbitrary URLs, and derives authentication and replay policy +from the operation manifest. + +Start it from the repository root: + + cargo run -p truefix-okx-client --example okx_cli + +Use catalog [domain] to list accepted operations. Get and post validate the verb against the +audited method. The default environment is Demo. Every POST requires a JSON body and +--confirm-write. + +The CLI also accepts one-shot commands after Cargo's -- separator: + + cargo run -p truefix-okx-client --example okx_cli -- get market_data get_ticker '{"instId":"BTC-USDT"}' + +Common REST examples: + + get market_data get_ticker {"instId":"BTC-USDT"} + get public_data get_instruments {"instType":"SPOT"} + get account get_account_balance + post trade place_order {} {"instId":"BTC-USDT","tdMode":"cash","side":"buy","ordType":"limit","sz":"0.001","px":"1"} --confirm-write + +Public operations need no credentials. To enable private operations, set OKX_PASSPHRASE; API key +and secret are read from the configured credential file unless overridden: + +| Variable | Default | Purpose | +|----------|---------|---------| +| OKX_CREDENTIAL_FILE | .../truefix-account/okx/okx | Credential file with API key and secret | +| OKX_API_KEY | unset | Overrides the file API key | +| OKX_SECRET | unset | Overrides the file secret | +| OKX_PASSPHRASE | unset | Enables private authentication | +| OKX_ENV | demo | Set to live only for production | +| OKX_CONFIRM_LIVE | unset | Must be 1 when OKX_ENV=live | + +Use Demo first. Live mode requires both OKX_ENV=live and OKX_CONFIRM_LIVE=1, in addition to +valid credentials. + +## 6. Direct Rust API usage Use the clients as workspace dependencies: @@ -262,6 +346,7 @@ Use the clients as workspace dependencies: [dependencies] truefix-futu-client = { path = "../truefix/crates/truefix-futu-client" } truefix-twsapi-client = { path = "../truefix/crates/truefix-twsapi-client" } +truefix-okx-client = { path = "../truefix/crates/truefix-okx-client" } ``` For Futu, call `FutuClient::connect`, then use `client.quote()` and `client.trade()`. Consume @@ -270,7 +355,7 @@ asynchronous push events through `client.subscribe_push()`. For TWS, call `TwsApiClient::connect(ClientConfig)`, send requests through concrete `req_*` methods or `send_request`, and consume callbacks through `read_event` as `Event` values. -## 5. Verification and troubleshooting +## 7. Verification and troubleshooting Recommended checks before submitting changes: @@ -279,7 +364,11 @@ cargo fmt --all -- --check cargo check --workspace cargo test -p truefix-futu-client --test mock_opend -- --test-threads=1 cargo test -p truefix-twsapi-client +cargo test -p truefix-okx-client +cargo test -p truefix --example binance_cli cargo clippy -p truefix-futu-client -p truefix-twsapi-client --all-targets -- -D warnings +cargo clippy -p truefix-okx-client -- -D warnings +cargo clippy -p truefix --example binance_cli -- -D warnings ``` Common failures: @@ -294,6 +383,17 @@ Common failures: 4. No quote updates: verify market-data permissions, market hours, symbol format, and market value. 5. Order failure: query accounts first and verify the trading environment, account permissions, unlock state, and trading market. +6. Binance missing API key or private key: public REST and WebSocket calls need --public. For + signed calls, set BINANCE_API_KEY_FILE and BINANCE_PRIVATE_KEY_PATH to readable files. Do not + place key material in shell history or this repository. +7. Binance write rejected before sending: add --confirm-write only after checking the endpoint, + request JSON, account permissions, and selected module. This guard is intentional. +8. OKX operation is unknown: run catalog or catalog and use the exact audited + domain/operation pair. The CLI deliberately rejects arbitrary REST paths. +9. OKX private request has missing credentials: set OKX_PASSPHRASE and either provide + OKX_API_KEY and OKX_SECRET or configure OKX_CREDENTIAL_FILE. +10. OKX live mode is rejected: the default is Demo. Set both OKX_ENV=live and + OKX_CONFIRM_LIVE=1 only after reviewing the command and credentials. The root README describes the FIX engine and workspace architecture. This document describes the current broker-client workflows. diff --git a/docs/news_vender.md b/docs/news_vender.md new file mode 100644 index 0000000..cb1c486 --- /dev/null +++ b/docs/news_vender.md @@ -0,0 +1,44 @@ +和 yahoo_finance / yfinance 类似、可以通过程序获取股票行情和财务数据的接口,主要有这些: +接口 免费额度 数据范围 实时性 适合场景 +Twelve Data 800 次/天,约 8 credits/分钟 美股、外汇、加密货币、ETF、指数,部分全球市场 免费版支持部分实时数据和试用 WebSocket 最接近 Yahoo Finance 的综合替代 +Finnhub 有免费开发额度 股票、外汇、加密货币、公司基本面、新闻、经济数据 REST + WebSocket,具体交易所权限有区别 实时行情、公司数据、新闻 +Alpha Vantage 免费版请求量较低 股票、外汇、加密货币、技术指标、财务数据、宏观数据 免费数据通常有限制或延迟 小型项目、技术指标计算 +Massive / 原 Polygon.io 免费版 5 次/分钟,约 2 年历史数据 主要是美股、期权、指数、外汇、加密货币 免费版主要用于历史和开发测试 美股专业行情、K线、公司行动 +Marketstack 100 次/月 全球股票、EOD、拆股、分红、交易所信息 免费版以日线为主 低频全球股票数据 +Nasdaq Data Link 部分数据集免费 股票、期货、宏观、另类数据、经济数据 取决于数据集 历史研究、量化数据集 +Stooq 免费下载 股票、指数、ETF、外汇、债券、加密货币 主要是历史数据 +免费历史回测数据 + + + + + +创建一个交易客户端 支持 + +https://github.com/fairwic/okx_rs +https://github.com/tensorchen/futu-rs +https://github.com/jonkarrer/alpaca_api_client +https://github.com/xemwebe/yahoo_finance_api + +https://github.com/bybit-exchange/bybit-rust-api +https://github.com/binance/binance-connector-rust +https://github.com/longbridge/openapi/tree/main/rust +https://github.com/tigerfintech/openapi-rust-sdk +/Users/jiayin/workspace/dev/dev/rust/truefix/crates/truefix-twsapi-client + + + +测试的账号地址等可以读取本地配置文件,可以配置读写有默认值有单独的设置界面。 + +行情的vender 和交易的vender单独抽象 +新闻数据的vender单独抽象 +instruments.单独抽象独立通用。 + +举个例子 ib账号的连接 可以支持 +ib 的行情provider +ib 的交易provider + 的新闻provider +有些可能只支持交易 或者只支持行情 + +有行情展示,交易操作,新闻展示, 使用tauri,标准交易窗口支持k线 + diff --git a/docs/roadmap.md b/docs/roadmap.md index ebfff1f..bd414a6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -326,7 +326,7 @@ paper = true | IB | 美股 Level 1 (NBBO) | 需在账户管理里订阅交易所数据包,区分"专业 / 非专业投资者"身份 | 非专业免费或每月数美元;专业投资者更高 | | IB | 美股期权行情 (OPRA) | 需订阅 OPRA 包 | 非专业约每月 $1.5 起,专业投资者显著更高,部分按连接数计费 | | 富途 OpenD | 港股 Level 2 十档 | 需综合账户资质 + 付费订阅 | 每月约 HK$100+ | -| 老虎 / 长桥 | 港 / 美股 Level 2 | 同上,需付费订阅 | 视套餐 | +| 老nn虎 / 长桥 | 港 / 美股 Level 2 | 同上,需付费订阅 | 视套餐 | | Binance | 加密现货 / 合约行情 | 免费,无需订阅 | 免费(仍受 API 限流约束,见 3.2.5) | **设计要点**: @@ -1415,3 +1415,23 @@ Phase 5: 插件化架构 | R-FUTURE-22 | 下单 / 结算路径禁止使用第三方估算汇率兜底,未连接对应券商时直接拒绝而非静默使用估算值 | 第三方汇率与券商实际结算价可能存在点差,静默使用会导致系统展示金额与实际扣款不一致;宁可拒绝下单,也不能让用户在不知情的情况下按错误汇率成交 | | R-FUTURE-23 | 行情数据默认按"未授权即拒绝"处理,而非静默降级为延迟数据 | 延迟行情若不被消费方(策略/风控/LLM Agent)感知,等同于让交易决策基于过期价格做出,是隐蔽的系统性风险;显式拒绝能倒逼开发者在配置阶段就处理授权问题 | | R-FUTURE-24 | STEP 从 Phase 2 移至 Phase 3,独立为 3.12 节,定位为"国内交易所适配器"而非二进制协议 | 本文档早前版本误将 STEP 描述为"FAST 之上的会话层,用于接入 CME/EUREX"。经核实标准原文(JR/T 0022—2020《证券交易数据交换协议》、JR/T 0182—2020《轻量级实时STEP消息传输协议》LFIXT,以及上交所/深交所各自的 STEP 接口规格书):STEP 的消息格式与标准 FIX 字节级同构(SOH 分隔 tag=value,`BeginString=STEP.x.yz`,校验和/重复组机制相同),会话机制引用"FIX 标准会话机制"或其轻量化变体 LFIXT,与 FAST/SBE 这类二进制编解码无技术交集;架构上应作为 Phase 3 的适配器(复用 `truefix-session`/`truefix-dict`),而非 Phase 2 的编解码器 | + + + + +Binance https://github.com/binance/binance-connector-rust +IB TWS /Users/jiayin/workspace/dev/dev/rust/truefix/crates/truefix-twsapi-client +富途 OpenD https://github.com/loadstarCN/nautilus-futu 不完全 https://github.com/tensorchen/futu-rs +长桥证券 https://github.com/longbridge/openapi/tree/main/rust +老虎证券 https://github.com/tigerfintech/openapi-rust-sdk +OKX /Users/jiayin/workspace/dev/dev/rust/truefix/crates/truefix-okx-client https://github.com/fairwic/okx_rs +Bybit https://github.com/bybit-exchange/bybit-rust-api + + +TradeStation https://github.com/antonio-hickey/tradestation-rs +Alpaca https://github.com/jonkarrer/alpaca_api_client +IG Markets https://github.com/joaquinbejar/ig-client +Charles Schwab https://github.com/bvelasquez/schwab-api-cli + + +https://github.com/nautechsystems/nautilus_trader/tree/develop diff --git a/docs/todo/008.md b/docs/todo/008.md new file mode 100644 index 0000000..d7fca65 --- /dev/null +++ b/docs/todo/008.md @@ -0,0 +1,70 @@ +1、使用tauri实现一个交易客户端 +2、依赖如下的的券商实现 +Binance https://github.com/binance/binance-connector-rust +IB TWS /Users/jiayin/workspace/dev/dev/rust/truefix/crates/truefix-twsapi-client +富途 OpenD https://github.com/loadstarCN/nautilus-futu 不完全 https://github.com/tensorchen/futu-rs +长桥证券 https://github.com/longbridge/openapi/tree/main/rust +老虎证券 https://github.com/tigerfintech/openapi-rust-sdk +OKX /Users/jiayin/workspace/dev/dev/rust/truefix/crates/truefix-okx-client https://github.com/fairwic/okx_rs +Bybit https://github.com/bybit-exchange/bybit-rust-api + + +TradeStation https://github.com/antonio-hickey/tradestation-rs +Alpaca https://github.com/jonkarrer/alpaca_api_client +IG Markets https://github.com/joaquinbejar/ig-client +Charles Schwab https://github.com/bvelasquez/schwab-api-cli + + +yahoo: https://github.com/xemwebe/yahoo_finance_api +Twelve Data: https://crates.io/crates/twelve-data-client +Finnhub: https://crates.io/crates/finnhub +motosan: https://crates.io/crates/motosan-finance +Alpha Vantage: https://crates.io/crates/alpha_vantage +Massive / 原 Polygon.io:https://github.com/inboard-ai/polygon +marketaux: https://www.marketaux.com/documentation +newsdata:https://github.com/raymondWai/newsdata-io-api +3. 抽象 通过配置添加 client 链接 +client -> 历史行情的vender +client -> 实时行情的vender +client -> 交易的vender +client -> 新闻的verner +client 对应的vender 分为不可用, 可用未启用,可用启用 几个状态,也在配置的时候可以选择,如果本身不支持行情或者交易 则为不可用不能配置。 +同一个类型的client可以配置多个按名字区分 + +不是每个client 都有这三类的vender 的支持的。 + +抽象对象 + +provider类型1 -> client1 + -> 新闻的vender + -> client2 + -> 新闻的vender +provider类型2 -> client1 + -> 交易vender + -> client2 + -> 行情的vender +provider类型3 -> client1 + -> 交易vender + -> 新闻的vender +provider类型4 -> client1 + -> 交易vender + -> 新闻的vender + -> 行情的vender + +4、client 的配置 在添加client后单独配置 填写对应client 的地址 ,key/密码之类的东西,根据provider 类型不一样配置不一样。 + + +5、有行情展示,交易操作,新闻展示,,标准交易窗口支持k线, 行情展示的vender 和交易vender 新闻vender 都独立切换 +6、行情展示可移动k线 显示更新或者更旧的行情。 +7、如果有实时行情 k线最后一部分会根据实时行情变化。 +8、支持配置多个交易agent 的 ai api配置 + + + +6、抽象出来 通用的 instruments 适配各个券商provider ,k线,tick 等 +7、需要 +ai-> 对应一个api key, +有实现量化策略的能力 +有决策交易的能力 +有实现风控的能力 + diff --git a/specs/015-okx-client-sdk/checklists/requirements.md b/specs/015-okx-client-sdk/checklists/requirements.md new file mode 100644 index 0000000..3f43c8d --- /dev/null +++ b/specs/015-okx-client-sdk/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: OKX Client SDK + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-07-10 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation passed on 2026-07-10. The source baseline is recorded as an auditable completeness boundary; architecture and module layout are intentionally deferred to planning. diff --git a/specs/015-okx-client-sdk/contracts/operation-inventory.md b/specs/015-okx-client-sdk/contracts/operation-inventory.md new file mode 100644 index 0000000..ab2cb19 --- /dev/null +++ b/specs/015-okx-client-sdk/contracts/operation-inventory.md @@ -0,0 +1,10 @@ +# Operation Inventory Contract + +The implementation maintains a machine-readable manifest independently derived from +`python-okx@fa8d738`. Each record contains `source_domain`, `source_operation`, `transport`, +`native_entrypoint`, `auth`, `rate_limit_class`, `write_safety`, `test_evidence`, and `status`. + +Coverage tests fail when any of the 264 REST baseline operations or real-time `order`, +`batch-orders`, `cancel-order`, `batch-cancel-orders`, `amend-order`, `batch-amend-orders`, or +`mass-cancel` commands lacks a manifest record, native entrypoint, or test evidence. Rust names +may differ; unapproved omissions may not. diff --git a/specs/015-okx-client-sdk/contracts/sdk-api.md b/specs/015-okx-client-sdk/contracts/sdk-api.md new file mode 100644 index 0000000..9f9209d --- /dev/null +++ b/specs/015-okx-client-sdk/contracts/sdk-api.md @@ -0,0 +1,42 @@ +# SDK Public Contract + +`OkxClient` is constructed from immutable `ClientConfig`; construction does not make a network +request. Demo Trading is the default. Production needs an explicit typed confirmation; custom +endpoints are separately explicit. A different credential context needs a different client. + +| Contract | Responsibility | +|---|---| +| `client.account()` | account, positions, risk, bills, leverage and loans | +| `client.trade()` | orders, fills, algorithmic orders, conversion and repayment | +| `client.market()` / `client.public_data()` | market data, instruments and public platform data | +| `client.funding()` / `client.subaccounts()` | assets, transfers and hierarchy | +| `client.finance()` / `client.strategy()` / `client.professional()` | long-tail product domains | +| `client.ws()` | public, private and business real-time sessions | + +Every operation has a typed request/result, documented auth/rate/pagination/retry semantics and +native OKX data preservation; arbitrary paths or mutable parameter maps are not the primary API. + +All failures use `OkxResult` and a named non-exhaustive `OkxError` for configuration, live +confirmation, signing/clock skew, transport/timeout, rate limit, authentication/permission, +exchange rejection, decoding, partial failure, connection loss and unknown completion. Errors keep +codes/request IDs but redact secrets. + +Safe reads may retry bounded transient failures. Writes are never blindly replayed. A caller may +authorize retry only with a stable client identity and status reconciliation proving safety. + +Real-time subscription handles expose a bounded event receiver and cancellation. Private/business +connections await authentication acknowledgement; reconnect replays desired subscriptions after +acknowledgement gates and never replays trade commands. + +## Gateway composition boundary + +`truefix-okx-client` is usable without `truefix-gateway` and does not depend on it. A consuming +application may adapt the optional projection types for common orders, balances, positions, fills, +and tickers into its gateway model. A projection is deliberately lossy only in scope, never by +mutation: retain the source native OKX value next to the projected value when the application may +need exchange-specific fields. + +The SDK does not project option Greeks, portfolio-margin and liquidation details, algorithm/grid +strategy state, funding/earn records, RFQ/spread/broker data, or other product-specific records. +Those remain native SDK data and must be handled by an OKX-aware caller. This boundary prevents a +cross-venue abstraction from silently changing or discarding OKX semantics. diff --git a/specs/015-okx-client-sdk/data-model.md b/specs/015-okx-client-sdk/data-model.md new file mode 100644 index 0000000..613dcad --- /dev/null +++ b/specs/015-okx-client-sdk/data-model.md @@ -0,0 +1,37 @@ +# Data Model: OKX Client SDK + +## Client and common responses + +| Entity | Fields | Rules | +|---|---|---| +| `ClientConfig` | environment, endpoint policy, credential reference, timeout, proxy, retry/limiter policy | Immutable; Demo default; live needs confirmation. | +| `Credentials` | key, secret, passphrase | One context/client; redacted in diagnostics. | +| `CanonicalRequest` | method, path, encoded query, exact body, metadata | The same bytes are signed and sent. | +| `ResponseEnvelope` | OKX code/message, data, request ID | Non-success maps to a typed exchange error. | +| `Page` | items, before/after cursors | Preserve server cursor semantics. | +| `BatchResult` | per-item successes/failures | Partial success remains visible per item. | + +## Trading, account and real-time entities + +| Entity | Fields / states | Rules | +|---|---|---| +| `Instrument` | ID, type/family, tick/lot rules, contract value | Requests must be compatible with product/trade mode. | +| Decimal amount/price | exact decimal value | Never use binary float; exact textual round trip. | +| `OrderRequest` / `Order` / `Fill` | client ID, exchange ID, state, values, timestamps | One order has zero or more fills; state-changing retry is unsafe until reconciled. | +| `Position` / `Balance` | instrument/currency, quantity, availability, margin/risk | Preserve native fields beyond gateway projection. | +| `AlgorithmicOrder` | algorithm IDs, trigger/execution parameters, state | Separate lifecycle with cross references to orders. | +| `RealtimeSession` | Disconnected, Connecting, Authenticating, Resubscribing, Active, Backoff | Private/business cannot subscribe before successful login acknowledgement. | +| `SubscriptionKey` / `Subscription` | endpoint, canonical args, correlation ID, event receiver | Desired and active sets are distinct; route only matching events. | +| `CompletionState` | confirmed, rejected, unknown | Connection loss before write acknowledgement is unknown, never auto-replayed. | + +## Domain services + +| Service | Scope | +|---|---| +| Account | balances, positions, bills, configuration, leverage, margin, loans, risk and fees | +| Trade | lifecycle, fills/history, algorithms, conversion and repayment | +| Market / Public Data | tickers, books, candles, trades, indexes, instruments, funding and platform data | +| Funding / Subaccount | deposits, withdrawals, transfers, valuation, bills and account hierarchy | +| Finance | savings, staking, flexible loans and dual investment | +| Strategy | grid, recurring buy and copy trading | +| Professional | block/spread trading, trading data, convert, broker rebate and status | diff --git a/specs/015-okx-client-sdk/plan.md b/specs/015-okx-client-sdk/plan.md new file mode 100644 index 0000000..f8e3de3 --- /dev/null +++ b/specs/015-okx-client-sdk/plan.md @@ -0,0 +1,108 @@ +# Implementation Plan: OKX Client SDK + +**Branch**: `015-okx-client-sdk` | **Date**: 2026-07-10 | **Spec**: [spec.md](./spec.md) + +## Summary + +Add an independent `truefix-okx-client` crate that fully covers the native OKX V5 surface in +`python-okx@fa8d738`: 264 REST operations across 20 domains plus public/private/business +WebSocket. It is a Rust domain API, not a translation of Python classes. `truefix-gateway` later +composes only mappable orders, accounts, positions, executions and market data. + +The design separates immutable client context, canonical signing/request bytes, HTTP/WebSocket +transport, scoped rate limits, domain services, rich types and an auditable operation manifest. +Demo is the default; live requires explicit typed confirmation. Only safe reads retry +automatically. Writes require reconciliation before any caller-authorized retry. + +## Technical Context + +**Language/Version**: Rust 2024; workspace MSRV Rust 1.96 + +**Primary Dependencies**: Tokio; Serde/serde_json; rust_decimal; time; tracing; metrics; +thiserror; reqwest 0.13 (rustls, HTTP/2); tokio-tungstenite 0.29 (rustls); hmac, sha2, base64, +URL/query serialization and futures-util. Every new dependency requires Apache-2.0 OR MIT review. + +**Storage**: N/A; callers own credentials, order persistence and caches. + +**Testing**: `cargo test`; table-driven unit tests; local HTTP/WebSocket fixtures; inventory, +decimal and environment-safety integration tests; opt-in Demo Trading smoke tests. + +**Target Platform**: Tokio-supported desktop/server platforms; no WASM commitment. + +**Project Type**: Workspace library crate plus examples. + +**Performance Goals**: Reuse pooled HTTP connections; bounded real-time event streams; reconnect +without duplicate subscriptions; obey server rate and connection limits. + +**Constraints**: Exact decimal preservation; no panic/unwrap/expect in non-test critical paths; +redacted diagnostics; immutable credential context; Demo default; explicit live confirmation; no +blind write replay. + +**Scale/Scope**: 264 baseline REST operations plus public/private/business real-time sessions and +their order commands; 100% manifest → native entrypoint → test traceability. + +## Constitution Check + +**Pre-design gate: PASS.** + +| Obligation | Evidence | +|---|---| +| Production readiness | Documented domain API, typed `OkxError`, redacted diagnostics, lifecycle/backpressure tests. | +| Protocol correctness | Canonical signing, environment, login/ack/heartbeat/recovery tests against official V5 behaviour. | +| License/provenance | `python-okx@fa8d738` is capability evidence only; independently write code, docs and fixtures. | +| Dual-track FIX dictionary | Not applicable: this SDK neither produces nor validates FIX. | +| Test discipline | Table-driven units and local fixture integration; no copied upstream tests. | +| Acceptor/initiator parity | Not applicable: this is an OKX client, not a FIX session endpoint. | +| Inventory completeness | Machine-readable manifest records each baseline operation, Rust entrypoint and test. | + +**Post-design gate: PASS.** The independent native-client boundary, named errors, provenance +discipline and inventory proof need no exception. + +## Project Structure + +### Documentation + +```text +specs/015-okx-client-sdk/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ ├── sdk-api.md +│ └── operation-inventory.md +└── tasks.md # created by speckit-tasks +``` + +### Source Code + +```text +crates/truefix-okx-client/ +├── Cargo.toml +├── examples/ +│ ├── public_market_data.rs +│ └── demo_order_lifecycle.rs +├── src/ +│ ├── lib.rs # facade and lint policy +│ ├── client.rs # OkxClient and domain accessors +│ ├── config.rs # immutable context and environment safety +│ ├── auth.rs # canonical V5 signer and clock source +│ ├── error.rs # OkxError / OkxResult +│ ├── request.rs # canonical request and retry classification +│ ├── response.rs # envelope, pagination, per-item failures +│ ├── limiter.rs # shared REST/WS scoped reservations +│ ├── inventory.rs # operation-manifest access +│ ├── transport/{http.rs,websocket.rs} +│ ├── services/{account.rs,trade.rs,market.rs,public_data.rs,funding.rs,subaccount.rs,finance.rs,strategy.rs,professional.rs} +│ ├── types/{common.rs,instrument.rs,order.rs,account.rs,websocket.rs} +│ └── ws/{session.rs,subscription.rs,event.rs,public.rs,private.rs,business.rs} +└── tests/{http_contract.rs,ws_session.rs,operation_inventory.rs,decimal_roundtrip.rs,environment_safety.rs} +``` + +**Structure Decision**: A single independent workspace library crate. Services model business +domains, not Python modules; transport and WS are shared infrastructure. A future gateway adapter +lives in `truefix-gateway` and composes this client. + +## Complexity Tracking + +No constitution violations or complexity exceptions. diff --git a/specs/015-okx-client-sdk/quickstart.md b/specs/015-okx-client-sdk/quickstart.md new file mode 100644 index 0000000..a7bdcee --- /dev/null +++ b/specs/015-okx-client-sdk/quickstart.md @@ -0,0 +1,41 @@ +# Quickstart Validation: OKX Client SDK + +## Prerequisites + +- Rust 1.96 and the workspace toolchain. +- Optional OKX Demo Trading credentials with only the permissions required by the scenario. Keep + them in a secure local secret source, never source control. + +## Local validation + +```sh +cargo test -p truefix-okx-client --test http_contract +cargo test -p truefix-okx-client --test ws_session +cargo test -p truefix-okx-client --test operation_inventory +cargo test -p truefix-okx-client --test decimal_roundtrip +cargo test -p truefix-okx-client --test environment_safety +``` + +Expected: fixtures validate signing/sending identity, redaction, Demo handling, login/subscription +acknowledgements, ping/pong, bounded reconnect/resubscribe and no write replay. Inventory tests +prove complete baseline coverage under [operation-inventory.md](./contracts/operation-inventory.md); +decimal/environment tests reject precision loss, live without confirmation and identity switching. + +## Demo smoke validation + +After securely configuring Demo credentials, run: + +```sh +cargo run -p truefix-okx-client --example public_market_data +cargo run -p truefix-okx-client --example demo_order_lifecycle +``` + +Expected: public data works without credentials; the Demo example completes the configured order +lifecycle and private subscription without duplicated writes. Live credentials are rejected unless +the [SDK contract](./contracts/sdk-api.md) confirmation is supplied. + +## Long-tail limitations + +Long-tail fixture validation is local and credential-free. Server-side permission, regional-product +and Demo availability restrictions remain typed exchange responses. Do not enable a write example +without the explicit Demo environment variables; all write paths are non-replaying. diff --git a/specs/015-okx-client-sdk/research.md b/specs/015-okx-client-sdk/research.md new file mode 100644 index 0000000..35cbf62 --- /dev/null +++ b/specs/015-okx-client-sdk/research.md @@ -0,0 +1,84 @@ +# Phase 0 Research: OKX Client SDK + +## Decisions + +### Native domain-oriented SDK + +- **Decision**: Add `truefix-okx-client` as an independent workspace crate with `OkxClient` + domain accessors and dedicated public/private/business real-time sessions. +- **Rationale**: The roadmap separates direct broker clients from `TradingGateway`; the source has + 264 operations in 20 business domains. Domain services keep native OKX meaning without Python + class or parameter-dictionary translation. +- **Alternatives considered**: A gateway-only adapter loses OKX-only products; one source-file- + shaped API preserves upstream organisation instead of a stable Rust API. + +### Auditable capability baseline + +- **Decision**: Maintain a machine-readable manifest for every REST operation and real-time + trading command from `thrdpty/clientapi/python-okx@fa8d738`, recording source domain, native + entrypoint, transport, authentication, retry safety and test evidence. +- **Rationale**: Source evidence is 262 V5 endpoint constants and 264 domain methods: Account + 52, Trade 28, PublicData 21, MarketData/Grid 19 each, Funding 18, BlockTrading 17 and remaining + account, finance and strategy domains. A manifest makes full migration measurable. +- **Alternatives considered**: Counting generated methods or copying names cannot prove omitted + functionality and violates provenance discipline. + +### Canonical request serialization and signing + +- **Decision**: One canonical request produces the encoded query, exact JSON body and signed path. + Private REST signs `timestamp + uppercase method + request path including query + body` with + HMAC-SHA256/Base64. The clock is injectable and time-skew is typed. +- **Rationale**: Signature validation is byte-sensitive. One serializer eliminates signing/sending + drift and improves on the source client's manual query construction. +- **Alternatives considered**: Separate sign/send serializers can change ordering or bytes; + copying upstream helpers is prohibited. + +### Immutable identity and safe environment selection + +- **Decision**: `ClientConfig` owns immutable credentials and endpoint policy. Demo defaults; + production requires distinct explicit confirmation. Separate clients are required for different + accounts, subaccounts or permissions. +- **Rationale**: Prevents identity leakage in concurrent HTTP and real-time work. Demo WebSocket + endpoints differ from live endpoints, so a REST header alone is insufficient. +- **Alternatives considered**: Mutable per-request credentials risk cross-account use; live by + default contradicts the clarified safety requirement. + +### Shared scoped limiter and read-only retries + +- **Decision**: REST and real-time operations reserve from a shared limiter keyed by documented + user/subaccount/instrument scope and operation class. Bounded jittered retries are only for safe + reads; writes need stable client identity plus reconciliation and explicit caller authorization. +- **Rationale**: OKX shares trade limits across REST/WS and applies endpoint-specific scopes. + This avoids duplicated asset changes while retaining read resiliency. +- **Alternatives considered**: A global bucket wastes capacity; per-transport buckets can jointly + exceed limits; retrying all writes risks duplicate orders. + +### Acknowledgement-gated real-time supervisor + +- **Decision**: Use `Disconnected → Connecting → Authenticating → Resubscribing → Active → + Backoff`; persist desired subscriptions separately from active socket subscriptions; await + login/subscribe acknowledgements; enforce ping/pong; replay subscriptions but never writes. +- **Rationale**: Official connection and request limits require liveness and controlled recovery. + The Python baseline overwrites one callback and lacks reliable reconnect/resubscribe. +- **Alternatives considered**: A single callback cannot safely multiplex; replaying unacknowledged + trade commands can duplicate execution. + +### Minimal native transport stack + +- **Decision**: Use async `reqwest` with rustls/HTTP2 and `tokio-tungstenite` with rustls, plus + minimal crypto/encoding crates after license review. +- **Rationale**: They fit the existing Tokio/rustls workspace. `reqwest` provides pooled async + HTTP/proxy/HTTP2 and is MIT OR Apache-2.0; `tokio-tungstenite` is Tokio-native and MIT. +- **Alternatives considered**: A hand-built HTTP/WebSocket stack increases maintenance and security + surface. + +## Sources + +- Local source inventory: `thrdpty/clientapi/python-okx` commit + `fa8d738249286b9b7ff8fed678218701f87bbb86`. +- [OKX V5 API documentation](https://www.okx.com/docs-v5/en/) — signing, Demo, rate limits and + WebSocket behaviour. +- [reqwest documentation](https://docs.rs/reqwest/latest/reqwest/) — reusable async HTTP client, + proxy/HTTP2 support and license. +- [tokio-tungstenite documentation](https://docs.rs/tokio-tungstenite/latest/tokio_tungstenite/) — + Tokio WebSocket support and license. diff --git a/specs/015-okx-client-sdk/spec.md b/specs/015-okx-client-sdk/spec.md new file mode 100644 index 0000000..74911c0 --- /dev/null +++ b/specs/015-okx-client-sdk/spec.md @@ -0,0 +1,148 @@ +# Feature Specification: OKX Client SDK + +**Feature Branch**: `015-okx-client-sdk` + +**Created**: 2026-07-10 + +**Status**: Draft + +**Input**: User description: "按照 docs/roadmap.md的计划,翻译 thrdpty/clientapi/python-okx,规划一下 按照rust的思路抽象,布局,不要直接翻译成rust版本的python.功能特性完全迁移过来" + +## Clarifications + +### Session 2026-07-10 + +- Q: 客户端的自动重试策略应如何区分只读与会改变状态的操作? → A: 只自动重试安全的只读请求;写操作仅在可证明幂等时重试。 +- Q: 客户端实例应如何管理主账户、子账户和不同权限的凭证上下文? → A: 每个客户端固定一个凭证上下文;切换账户创建新实例。 +- Q: Demo Trading 与生产环境的默认和选择策略应如何定义? → A: 默认 Demo Trading;生产环境需显式选择和额外确认。 + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - 以类型安全方式使用 OKX 交易能力 (Priority: P1) + +量化开发者可通过一套围绕 OKX 业务域组织的客户端,使用现货、杠杆、交割/永续合约及期权的账户、交易、资产和公开市场数据能力;请求与返回值表达领域含义,而非由调用方拼装通用参数表。 + +**Why this priority**: 下单、撤单、查询账户与行情是 OKX 接入和后续统一交易网关的基础价值。 + +**Independent Test**: 使用公开响应夹具及 Demo Trading 凭证分别完成只读查询、单笔/批量订单生命周期和账户查询,验证请求语义、签名、响应解码及 OKX 业务错误均正确呈现。 + +**Acceptance Scenarios**: + +1. **Given** 已配置 Demo Trading 凭证和一个有效合约,**When** 开发者提交订单、查询、修改或撤销订单,**Then** 每个操作均以对应的领域请求和结果完成,并保留 OKX 返回的订单标识、状态和逐项失败信息。 +2. **Given** 未配置凭证,**When** 开发者调用公开市场或公开合约数据能力,**Then** 请求可完成且不会发送私有凭证或签名。 +3. **Given** 请求被拒绝、鉴权失败或触发限额,**When** 客户端接收 OKX 的错误响应,**Then** 调用方能够区分本地传输失败、认证失败、限流、业务拒绝和部分成功。 + +--- + +### User Story 2 - 接收连续的实时市场与账户事件 (Priority: P1) + +开发者可建立公共、私有及业务实时连接,订阅多个频道,接收有序事件,并在断线后恢复连接、认证和既有订阅,不丢失连接状态或把不同订阅的事件混为一谈。 + +**Why this priority**: 实时行情、订单回报和账户风险变化是交易程序安全运行的必要输入。 + +**Independent Test**: 以本地 WebSocket 行为夹具模拟登录、订阅确认、数据事件、服务端错误、ping/pong 和断线,验证事件路由、恢复和订阅去重。 + +**Acceptance Scenarios**: + +1. **Given** 公共连接已建立,**When** 开发者订阅多个行情频道,**Then** 每个订阅获得可识别的确认和仅属于该频道的事件流。 +2. **Given** 私有或业务连接被服务端关闭,**When** 客户端重连成功,**Then** 它重新完成必要认证并恢复关闭前仍有效的订阅。 +3. **Given** 实时订单入口收到批量下单、改单或撤单请求,**When** OKX 返回逐项结果,**Then** 调用方可将每项结果与原请求关联。 + +--- + +### User Story 3 - 覆盖完整 OKX V5 业务面 (Priority: P2) + +运营、策略和资金管理用户可通过同一客户端访问 OKX V5 所覆盖的账户、资金、子账户、借贷、理财、转换、策略交易、跟单、价差、大宗和经纪业务,而无需为长尾业务回退到另一个 SDK。 + +**Why this priority**: 用户明确要求完整迁移;完整性是该 SDK 可作为 OKX 唯一集成入口的前提。 + +**Independent Test**: 针对来源清单中的每一个公开业务操作,以请求构造和响应/错误夹具验证其可达性、输入验证、权限要求和结果模型;以 Demo 或只读环境复验可安全调用的操作。 + +**Acceptance Scenarios**: + +1. **Given** 来源清单中的任一 REST 业务操作,**When** 开发者查阅客户端功能清单并调用对应能力,**Then** 该能力均有明确的领域入口、请求约束、结果模型和测试证据。 +2. **Given** 一个仅适用于特定账户等级、区域或权限的操作,**When** 调用方不满足前提,**Then** 客户端明确报告限制而不伪造成功或丢弃服务端信息。 + +--- + +### User Story 4 - 与统一网关协作而不失去 OKX 语义 (Priority: P3) + +TrueFix 使用者可将客户端组合进统一交易网关以执行通用下单、持仓、账户和行情流程,同时仍能直接使用 OKX 专属能力和原始业务字段。 + +**Why this priority**: 路线图要求独立 Client SDK 与多券商网关并存;两者都不能迫使另一方降级。 + +**Independent Test**: 以同一组 OKX 订单、账户和行情夹具验证通用工作流与直接 OKX 工作流可分别完成,并验证无法通用表示的 OKX 信息仍可被直接读取。 + +**Acceptance Scenarios**: + +1. **Given** 一个可映射到统一交易模型的 OKX 订单,**When** 上层网关通过客户端执行该订单,**Then** 可获得一致的订单确认和执行状态。 +2. **Given** OKX 专属的理财、跟单、策略或经纪操作,**When** 开发者直接使用 SDK,**Then** 该操作不因统一模型缺少对应概念而不可用或丢失数据。 + +### Edge Cases + +- 时钟偏差、失效凭证、错误口令或 Demo/实盘环境混用时,签名请求必须失败得可诊断,且不得向错误环境发送交易请求。 +- 单个批量请求中部分项目成功时,必须保留每个项目的成功或失败结果,不能将整体简化为单一成功状态。 +- 分页游标为空、越界、重复或出现相同时间戳时,历史查询必须保留 OKX 的游标语义,不重复或静默漏报记录。 +- 连接在认证、订阅确认或未完成请求期间中断时,恢复过程不得重复提交非幂等交易操作;未能安全恢复的请求必须以明确状态结束。 +- 收到未知频道、未知枚举值、新增字段或格式不完整的数据时,客户端必须在可安全兼容时保留可用数据,在不可安全兼容时返回结构化解码错误与原始上下文。 +- 货币精度、合约面值、最小下单量、价格档位、保证金模式及持仓方向不匹配时,客户端必须在可本地判定时提前拒绝,并完整传达交易所拒绝结果。 + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: 系统 MUST 提供独立的 OKX Client SDK,覆盖路线图 Phase 3k 的 Demo Trading、REST 与 WebSocket 接入,并可由统一交易网关组合使用。 +- **FR-002**: SDK MUST 按稳定的 OKX 业务域组织公共能力、请求、响应和事件模型;不得以 Python 类、可变字典或大量可选位置参数作为公共接口的直接映射。 +- **FR-003**: SDK MUST 将传输、认证签名、环境选择、限流/重试、业务服务、实时会话及领域模型的职责分离,使业务能力可独立测试和演进。 +- **FR-004**: SDK MUST 支持未认证公开请求及需认证私有请求,并正确处理 API key、secret、passphrase、时间戳、签名和模拟交易声明。 +- **FR-005**: SDK MUST 默认使用 Demo Trading,并允许调用方显式选择生产、Demo Trading 和自定义受控端点;生产环境还 MUST 要求额外确认,且环境选择在所有 HTTP 与实时连接中一致。 +- **FR-005a**: 每个客户端实例 MUST 固定绑定一个凭证上下文;切换主账户、子账户或权限 MUST 使用独立实例,防止并发请求、诊断信息和实时会话混用身份。 +- **FR-006**: SDK MUST 以具名、可匹配的错误表达网络、超时、解码、认证、权限、限流、交易所业务拒绝及批量部分失败,并保留 OKX 错误码和可安全公开的上下文。 +- **FR-007**: SDK MUST 覆盖所有来源 REST 操作;以 `thrdpty/clientapi/python-okx` 提交 `fa8d738` 的公开操作为基线,并维护可审计的操作清单、映射入口与测试证据。 +- **FR-008**: SDK MUST 覆盖账户域:余额、持仓、账单、配置、保证金和杠杆、借贷/还款、利率、风险、希腊值、费用、交易配置及固定/VIP 借贷相关操作。 +- **FR-009**: SDK MUST 覆盖交易域:单笔和批量下单、撤单、改单、平仓、订单/成交查询、条件单、转换、还款及其历史和详情操作。 +- **FR-010**: SDK MUST 覆盖公开市场与交易数据域:行情快照和历史、K 线、成交、订单簿、指数/标记/参考价格、资金费率、持仓量、交易规则及其他公开市场指标。 +- **FR-011**: SDK MUST 覆盖公开合约和平台数据域:现货、保证金、交割、永续、期权及指数相关合约元数据、交割/行权、价格限制、风险档位、系统时间、公告和状态信息。 +- **FR-012**: SDK MUST 覆盖资金与账户层级域:充值、提现、转账、闪电网络、资产估值、资金流水、子账户、API key/权限、子账户资金与借贷额度操作。 +- **FR-013**: SDK MUST 覆盖理财和借贷产品域:简单赚币、ETH/SOL 质押、DeFi 质押、灵活借贷、双币投资及其产品查询、申购、赎回、订单和历史操作。 +- **FR-014**: SDK MUST 覆盖策略和专业交易域:网格和定投、跟单交易、价差交易、大宗交易、转换、经纪返佣及相关查询、创建、调整、停止和历史操作。 +- **FR-015**: SDK MUST 为金额、价格、数量、货币、产品类别、交易模式、订单类型、持仓方向、时间、分页游标和标识符提供明确且可验证的领域表示,避免精度丢失和无效组合。 +- **FR-016**: SDK MUST 保留 OKX 响应中的业务数据、分页信息、逐项结果、服务端标识和新字段兼容信息;不得因转换为通用模型而丢失 OKX 专属数据。 +- **FR-017**: SDK MUST 提供公共、私有和业务实时会话,支持连接、认证、订阅、退订、请求关联、接收事件、心跳和受控关闭。 +- **FR-018**: 实时会话 MUST 支持多路订阅、订阅确认和事件路由,并为每个订阅或请求提供稳定关联标识。 +- **FR-019**: 实时私有交易入口 MUST 覆盖单笔/批量下单、撤单、改单及批量撤销,并以逐项结果报告成功、拒绝和未知完成状态。 +- **FR-020**: 实时会话 MUST 在可安全恢复时自动重连、重新认证并恢复有效订阅;对于不能安全重放的交易请求,MUST 通知调用方而非自动重发。 +- **FR-021**: SDK MUST 支持调用方配置代理、超时、并发/速率限制和结构化诊断;客户端仅可自动重试安全的只读请求,写操作仅在可证明幂等时重试,且不得记录 secret、passphrase、签名或其他敏感值。 +- **FR-022**: SDK MUST 为所有公开功能提供文档化前置条件、权限/环境限制、幂等性说明及最小可运行示例;会造成资产变动的示例默认指向 Demo Trading 或显式防护模式。 +- **FR-023**: SDK MUST 提供对可映射交易、账户、持仓和行情数据的网关集成边界,同时保持 SDK 本身可脱离网关独立使用。 +- **FR-024**: SDK MUST 以独立编写的测试、请求/响应/实时行为夹具和 Demo Trading 验证来证明行为正确性;不得复制或逐行翻译 `python-okx` 的源码、注释或测试实现。 + +### Key Entities + +- **Client configuration**: 一个固定的凭证上下文、端点环境、认证材料引用、代理、时间与重试/限额策略,以及安全诊断配置。 +- **Authenticated request**: 具有请求标识、时间戳、签名上下文、业务输入和幂等性语义的私有操作。 +- **OKX service domain**: 按账户、交易、市场、公开数据、资金、子账户、理财、策略与专业业务划分的能力集合。 +- **Instrument and trading rules**: OKX 合约、产品类别、价格/数量精度、交易单位、保证金和可交易约束。 +- **Order lifecycle**: 客户端订单标识、OKX 订单标识、订单状态、成交、算法单和逐项批量结果之间的关系。 +- **Realtime session and subscription**: 公共、私有或业务连接;其认证状态、订阅意图、关联标识、恢复状态和事件流。 +- **Gateway projection**: 可投影到 TrueFix 统一订单、账户、持仓、成交或行情模型的 OKX 数据,以及保持原生语义的补充数据。 + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 来源清单中 100% 的公开 REST 操作、公共实时操作及私有实时交易操作均有一个可调用的 SDK 入口、明确的请求/结果模型和至少一项自动化行为测试。 +- **SC-002**: 对清单中 100% 的操作,自动化测试能验证请求方法、路径/操作名、认证需求、关键输入序列化及成功和错误响应的解码;批量操作另须验证部分成功。 +- **SC-003**: 使用 Demo Trading 凭证时,核心交易生命周期(下单、查询、改/撤单)和至少一个私有实时订阅在 10 次连续端到端运行中均成功完成,且无重复提交。 +- **SC-004**: 在模拟的 100 次连接中断场景中,所有只读订阅均在连接恢复后重新建立;所有未确认的非幂等交易请求均被标记为需查询或未知,而非被自动重复执行。 +- **SC-005**: 对覆盖的现货、保证金、交割、永续和期权样本,100% 的价格与数量往返测试保持原始十进制值,不产生精度改变。 +- **SC-006**: 以文档化的源码基线审计时,不存在未说明的功能缺口;新增或移除 OKX 操作能在清单对比中被检测出来。 + +## Assumptions + +- 功能完整性的首个基线为本仓库中的 `thrdpty/clientapi/python-okx` 提交 `fa8d738`;其后上游或 OKX V5 的变化通过清单增量处理,而不是静默改变范围。 +- 该 SDK 面向 Rust stable 生态,但本规格仅定义用户可见能力;具体 crate、模块、异步运行时和依赖选型由规划阶段在项目宪法约束下确定。 +- SDK 是贴近 OKX 原生语义的独立客户端,而非跨券商抽象的替代品;统一网关只组合其可映射的能力。 +- OKX 账户权限、地区可用性和产品资格由服务端决定;SDK 会暴露限制和错误,但不绕过任何访问控制。 +- 客户端默认使用 OKX Demo Trading;生产环境须由调用方显式选择并额外确认。会改变资产或账户状态的验收默认使用 Demo Trading;无法在 Demo 环境使用的功能以协议行为夹具和安全的只读验证为证据。 +- 来源代码仅用于提取功能清单和理解外部行为;本项目实现、文档和测试均独立编写,以符合项目的许可与来源纪律。 diff --git a/specs/015-okx-client-sdk/tasks.md b/specs/015-okx-client-sdk/tasks.md new file mode 100644 index 0000000..39a78fd --- /dev/null +++ b/specs/015-okx-client-sdk/tasks.md @@ -0,0 +1,191 @@ +# Tasks: OKX Client SDK + +**Input**: Design documents in `/specs/015-okx-client-sdk/` + +**Tests**: Required by FR-024, SC-001–SC-006, the quickstart, and the constitution. Write the listed tests first and confirm they fail before their implementation tasks. + +**Organization**: Tasks are grouped by user story. The `python-okx@fa8d738` operation manifest is the completeness source of truth: every operation needs an intentional native entrypoint and test evidence. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Create the independent crate and controlled baseline. + +- [X] T001 Create `crates/truefix-okx-client/Cargo.toml` and register `truefix-okx-client` plus approved shared dependencies in `Cargo.toml`. +- [X] T002 Create the crate facade, public re-exports, package documentation, and critical-path lint policy in `crates/truefix-okx-client/src/lib.rs`. +- [X] T003 [P] Create the source-baseline manifest schema and seed source domain/operation identities from `python-okx@fa8d738` in `crates/truefix-okx-client/src/inventory.rs`. +- [X] T004 [P] Record new dependency license/provenance evidence and the source baseline in `crates/truefix-okx-client/THIRD_PARTY.md`. +- [X] T005 [P] Add credential-free public-data and Demo-order example skeletons in `crates/truefix-okx-client/examples/public_market_data.rs` and `crates/truefix-okx-client/examples/demo_order_lifecycle.rs`. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Implement shared safety, transport and domain primitives. + +**⚠️ CRITICAL**: Complete this phase before user-story work. + +- [X] T006 Define named `OkxError`, `OkxResult`, redacted context, exchange codes, partial failure, and unknown completion in `crates/truefix-okx-client/src/error.rs`. +- [X] T007 [P] Define exact decimal, identifier, instrument/product, time, pagination and envelope types in `crates/truefix-okx-client/src/types/common.rs` and `crates/truefix-okx-client/src/types/instrument.rs`. +- [X] T008 [P] Define immutable credentials, Demo/live/custom endpoint policy, typed live confirmation, timeout and proxy policy in `crates/truefix-okx-client/src/config.rs`. +- [X] T009 [P] Write configuration and redaction unit tests in `crates/truefix-okx-client/src/config.rs` and `crates/truefix-okx-client/src/error.rs`. +- [X] T010 Implement canonical URL/query/body construction, request metadata, and safe-read/write classification in `crates/truefix-okx-client/src/request.rs`. +- [X] T011 [P] Implement injected clock, REST/WS signing, and redacted authorization headers in `crates/truefix-okx-client/src/auth.rs`. +- [X] T012 [P] Write table-driven signer/request tests for encoded query, empty body, Demo header, time skew and redaction in `crates/truefix-okx-client/src/auth.rs` and `crates/truefix-okx-client/src/request.rs`. +- [X] T013 Implement response envelopes, pagination, per-item results, error-code and unknown-field decoding in `crates/truefix-okx-client/src/response.rs`. +- [X] T014 Implement shared scoped rate reservations, bounded read retry, and write-replay prohibition in `crates/truefix-okx-client/src/limiter.rs`. +- [X] T015 [P] Implement pooled HTTP execution, canonical sending, status mapping, proxy/timeout and redacted telemetry in `crates/truefix-okx-client/src/transport/http.rs`. +- [X] T016 Define `OkxClient`, its immutable shared core, and domain accessors in `crates/truefix-okx-client/src/client.rs` and `crates/truefix-okx-client/src/services/mod.rs`. +- [X] T017 Create local HTTP fixture helpers for method/path/query/signature/header/body validation in `crates/truefix-okx-client/tests/support/http.rs`. +- [X] T018 Create the manifest coverage harness in `crates/truefix-okx-client/tests/operation_inventory.rs`. + +**Checkpoint**: Signed HTTP operations, safe configuration, typed errors and manifest evidence are ready for every story. + +--- + +## Phase 3: User Story 1 - Type-safe OKX Trading (Priority: P1) 🎯 MVP + +**Goal**: Provide public market data, account inspection and a typed Demo Trading order lifecycle. + +**Independent Test**: Local HTTP fixtures verify unsigned public calls, signed Demo calls, order lifecycle, account queries, rejection and partial success; Demo smoke is opt-in. + +### Tests for User Story 1 + +- [X] T019 [P] [US1] Add public/auth/Demo/rejection/pagination HTTP contract tests in `crates/truefix-okx-client/tests/http_contract.rs`. +- [X] T020 [P] [US1] Add exact decimal request/response round-trip tests for spot, margin, swap and option samples in `crates/truefix-okx-client/tests/decimal_roundtrip.rs`. +- [X] T021 [P] [US1] Add Demo-default, live-confirmation and immutable-credential tests in `crates/truefix-okx-client/tests/environment_safety.rs`. + +### Implementation for User Story 1 + +- [X] T022 [P] [US1] Define account balance, position, bill, leverage, margin, risk and fee models in `crates/truefix-okx-client/src/types/account.rs`. +- [X] T023 [P] [US1] Define ordinary/algorithmic order, fill, batch-result, client ID and completion-state models in `crates/truefix-okx-client/src/types/order.rs`. +- [X] T024 [P] [US1] Define ticker, book, candle, trade, mark/index and funding-rate models in `crates/truefix-okx-client/src/types/market.rs`. +- [X] T025 [US1] Implement Account balance, position, bill, configuration, leverage, margin, risk and fee operations in `crates/truefix-okx-client/src/services/account.rs`. +- [X] T026 [US1] Implement Trade single/batch place, cancel, amend, close, query, fills and histories in `crates/truefix-okx-client/src/services/trade.rs`. +- [X] T027 [US1] Implement MarketData ticker, books, candles, trades, index/mark, funding and history operations in `crates/truefix-okx-client/src/services/market.rs`. +- [X] T028 [US1] Link Account, Trade and MarketData records to methods and fixture IDs in `crates/truefix-okx-client/src/inventory.rs`. +- [X] T029 [US1] Complete safe typed examples in `crates/truefix-okx-client/examples/public_market_data.rs` and `crates/truefix-okx-client/examples/demo_order_lifecycle.rs`. +- [X] T030 [US1] Verify the Phase 3 fixture suite and opt-in Demo scenario from `specs/015-okx-client-sdk/quickstart.md`. + +**Checkpoint**: Public data works without credentials and a typed Demo order lifecycle exposes account and result data. + +--- + +## Phase 4: User Story 2 - Continuous Market and Account Events (Priority: P1) + +**Goal**: Provide multiplexed public/private/business sessions with acknowledgement-gated authentication, liveness, reconnect/resubscribe and no write replay. + +**Independent Test**: A local WebSocket fixture drives login, subscription confirmation, events, ping/pong, error, disconnect and reconnect; subscriptions receive only their own events and no write is resent. + +### Tests for User Story 2 + +- [X] T031 [P] [US2] Create local WebSocket fixture helpers for login, acknowledgement, ping/pong, disconnect and scripted events in `crates/truefix-okx-client/tests/support/websocket.rs`. +- [X] T032 [P] [US2] Add public/private/business, correlation, deduplication, reconnect and no-write-replay tests in `crates/truefix-okx-client/tests/ws_session.rs`. + +### Implementation for User Story 2 + +- [X] T033 [P] [US2] Implement WebSocket connect/send/receive/close with endpoint selection and redacted diagnostics in `crates/truefix-okx-client/src/transport/websocket.rs`. +- [X] T034 [P] [US2] Define real-time login/ack/error/event/channel/command models in `crates/truefix-okx-client/src/types/websocket.rs`. +- [X] T035 [US2] Implement desired/active subscription keys, bounded IDs, correlation, routing, cancellation and deduplication in `crates/truefix-okx-client/src/ws/subscription.rs` and `crates/truefix-okx-client/src/ws/event.rs`. +- [X] T036 [US2] Implement lifecycle, login gate, ping/pong deadline, backoff and replay of desired subscriptions in `crates/truefix-okx-client/src/ws/session.rs`. +- [X] T037 [P] [US2] Implement public and private session entrypoints in `crates/truefix-okx-client/src/ws/public.rs` and `crates/truefix-okx-client/src/ws/private.rs`. +- [X] T038 [US2] Implement business session, optional login, mass-cancel limit class and upgrade recovery in `crates/truefix-okx-client/src/ws/business.rs`. +- [X] T039 [US2] Implement real-time order, batch-order, cancel, batch-cancel, amend, batch-amend and mass-cancel with unknown completion in `crates/truefix-okx-client/src/ws/private.rs` and `crates/truefix-okx-client/src/ws/business.rs`. +- [X] T040 [US2] Link all real-time commands and session operations to fixture evidence in `crates/truefix-okx-client/src/inventory.rs`. +- [X] T041 [US2] Verify the real-time quickstart scenario in `specs/015-okx-client-sdk/quickstart.md`. + +**Checkpoint**: Real-time sessions multiplex, recover read subscriptions and never replay asset-changing commands. + +--- + +## Phase 5: User Story 3 - Complete OKX V5 Business Surface (Priority: P2) + +**Goal**: Implement every remaining source-baseline REST operation as a typed native domain API. + +**Independent Test**: The manifest test has a record, native entrypoint and request/response fixture for all 264 REST operations; safe operations use Demo/read-only validation where available. + +### Tests for User Story 3 + +- [X] T042 [P] [US3] Extend per-domain request/error fixture assertions and manifest coverage in `crates/truefix-okx-client/tests/operation_inventory.rs` and `crates/truefix-okx-client/tests/http_contract.rs`. + +### Implementation for User Story 3 + +- [X] T043 [P] [US3] Implement PublicData instruments, delivery/exercise, interest, tiers, options, announcements and platform operations in `crates/truefix-okx-client/src/services/public_data.rs`. +- [X] T044 [P] [US3] Complete Account borrow/repay, fixed/VIP loans, Greeks, trading config and risk history operations in `crates/truefix-okx-client/src/services/account.rs`. +- [X] T045 [P] [US3] Complete Trade algorithmic orders, easy conversion, one-click repayment and histories in `crates/truefix-okx-client/src/services/trade.rs`. +- [X] T046 [P] [US3] Implement Funding deposit/withdrawal, transfer, lightning, dust, valuation and bills in `crates/truefix-okx-client/src/services/funding.rs`. +- [X] T047 [P] [US3] Implement SubAccount balance, bills, transfers, key/permission and loan operations in `crates/truefix-okx-client/src/services/subaccount.rs`. +- [X] T048 [P] [US3] Implement Grid and recurring-buy operations in `crates/truefix-okx-client/src/services/strategy.rs`. +- [X] T049 [US3] Implement CopyTrading operations in `crates/truefix-okx-client/src/services/strategy.rs`. +- [X] T050 [P] [US3] Implement Savings, ETH/SOL/DeFi staking, FlexibleLoan and DualInvest operations in `crates/truefix-okx-client/src/services/finance.rs`. +- [X] T051 [P] [US3] Implement BlockTrading RFQ, quote, trade and MMP operations in `crates/truefix-okx-client/src/services/professional.rs`. +- [X] T052 [US3] Implement SpreadTrading and TradingData operations in `crates/truefix-okx-client/src/services/professional.rs`. +- [X] T053 [US3] Implement Convert, FDBroker and Status operations in `crates/truefix-okx-client/src/services/professional.rs`. +- [X] T054 [US3] Add Phase 5 models, endpoint metadata, native entrypoints and fixture references in `crates/truefix-okx-client/src/types/` and `crates/truefix-okx-client/src/inventory.rs`. +- [X] T055 [US3] Make the inventory test fail for any missing baseline operation, auth class, safety class, native method or fixture evidence in `crates/truefix-okx-client/tests/operation_inventory.rs`. +- [X] T056 [US3] Run the long-tail fixture suite and document Demo/read-only limitations in `specs/015-okx-client-sdk/quickstart.md`. + +**Checkpoint**: The complete source baseline is typed, tested and auditable; server permission/region restrictions are typed errors. + +--- + +## Phase 6: User Story 4 - Gateway Composition Without Semantic Loss (Priority: P3) + +**Goal**: Provide a narrow projection boundary for a future gateway without losing native data. + +**Independent Test**: Mapping fixtures prove common order/account/position/execution/market projections are consistent while original native fields remain accessible. + +### Tests for User Story 4 + +- [X] T057 [P] [US4] Add native-to-gateway projection and native-field-preservation fixtures in `crates/truefix-okx-client/tests/gateway_projection.rs`. + +### Implementation for User Story 4 + +- [X] T058 [US4] Define optional projection traits/types without a gateway dependency in `crates/truefix-okx-client/src/types/gateway.rs` and `crates/truefix-okx-client/src/types/mod.rs`. +- [X] T059 [US4] Implement projections while retaining native extension data in `crates/truefix-okx-client/src/types/order.rs`, `crates/truefix-okx-client/src/types/account.rs`, and `crates/truefix-okx-client/src/types/market.rs`. +- [X] T060 [US4] Document composition and non-projectable product boundaries in `crates/truefix-okx-client/src/lib.rs` and `specs/015-okx-client-sdk/contracts/sdk-api.md`. +- [X] T061 [US4] Verify no `truefix-gateway` dependency and run projection fixtures in `crates/truefix-okx-client/Cargo.toml` and `crates/truefix-okx-client/tests/gateway_projection.rs`. + +**Checkpoint**: A future adapter can consume common data while direct callers retain the complete native SDK. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +- [X] T062 Add rustdoc for all public types, traits, services and operations in `crates/truefix-okx-client/src/`. +- [X] T063 Add connection/request/retry/rate-limit/subscription telemetry and redaction assertions in `crates/truefix-okx-client/src/` and `crates/truefix-okx-client/tests/telemetry.rs`. +- [X] T064 [P] Add manifest maintenance and upstream-drift instructions in `crates/truefix-okx-client/README.md`. +- [X] T065 Run `cargo fmt --check`, `cargo clippy -p truefix-okx-client -- -D warnings`, and `cargo test -p truefix-okx-client` from the repository root. +- [X] T066 Run every scenario in `specs/015-okx-client-sdk/quickstart.md`, running Demo smoke only when credentials are supplied. +- [X] T067 Audit `crates/truefix-okx-client/` for copied Python source/tests, panic-prone paths, secret exposure, missing inventory records and license evidence. + +## Dependencies & Execution Order + +- **Phase 1**: No dependencies. +- **Phase 2**: Depends on Phase 1 and blocks all stories. +- **US1 / US2 (P1)**: Start after Phase 2; US1 is the recommended MVP. +- **US3 (P2)**: Starts after Phase 2 and follows US1 service conventions; its manifest test independently proves completeness. +- **US4 (P3)**: Depends on US1 core models and remains independent from `truefix-gateway`. +- **Polish**: Follows all desired stories. + +## Parallel Opportunities + +- T003–T005; T007–T009; T011–T012; T015; and T017–T018 can proceed in parallel after stated prerequisites. +- In US1, T019–T021 and T022–T024 are parallel; separate service files may proceed concurrently after shared types land. +- In US2, T031–T034 and T037 are parallel once their interfaces stabilize. +- In US3, T043–T053 are deliberately split by service file; coordinate shared model additions through T054. + +## Implementation Strategy + +### MVP First + +1. Complete Phases 1–2. +2. Complete US1 through T030. +3. Validate its fixture suite and use only supplied Demo credentials for the smoke path. +4. Demonstrate public market data and a typed Demo order lifecycle. + +### Incremental Delivery + +1. Add US2 for safe real-time observation and recovery. +2. Add US3 by service domain; the manifest makes omissions failing work. +3. Add US4 as a projection boundary, not a replacement for native semantics. +4. Pass Phase 7 before declaring baseline parity. diff --git a/specs/016-okx-parity-remediation/checklists/requirements.md b/specs/016-okx-parity-remediation/checklists/requirements.md new file mode 100644 index 0000000..501e675 --- /dev/null +++ b/specs/016-okx-parity-remediation/checklists/requirements.md @@ -0,0 +1,28 @@ +# Specification Quality Checklist: OKX Parity Remediation + +**Purpose**: Validate specification completeness and quality before planning +**Created**: 2026-07-10 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [X] No implementation details leak into requirements +- [X] Focused on user value and business needs +- [X] All mandatory sections completed + +## Requirement Completeness + +- [X] No clarification markers remain +- [X] Requirements are testable and unambiguous +- [X] Success criteria are measurable +- [X] Acceptance scenarios and edge cases are defined +- [X] Scope, dependencies, and assumptions are identified + +## Feature Readiness + +- [X] Functional requirements have acceptance evidence +- [X] User stories cover primary remediation flows + +## Notes + +Validated against the local source baseline. Newer official-only endpoints are intentionally outside baseline parity until separately approved. diff --git a/specs/016-okx-parity-remediation/contracts/parity-inventory.md b/specs/016-okx-parity-remediation/contracts/parity-inventory.md new file mode 100644 index 0000000..5d6f425 --- /dev/null +++ b/specs/016-okx-parity-remediation/contracts/parity-inventory.md @@ -0,0 +1,8 @@ +# Parity Inventory Contract + +Each Python-baseline operation has exactly one record with source identity, current protocol method/path, authentication class, replay classification, Rust entrypoint, and local fixture ID. A record cannot be complete when any field is missing. Rust-only server endpoints are invalid; convenience methods must map only to approved records. + +Authenticated REST requests use UTC timestamps with exactly three fractional millisecond digits. +Every request declares JSON content type and explicitly sends `x-simulated-trading` as `1` for +Demo or `0` for live. Empty query values are omitted before signing. Safe reads may retry one +transient failure after rate-limit throttling; writes are never automatically replayed. diff --git a/specs/016-okx-parity-remediation/data-model.md b/specs/016-okx-parity-remediation/data-model.md new file mode 100644 index 0000000..e39a9c3 --- /dev/null +++ b/specs/016-okx-parity-remediation/data-model.md @@ -0,0 +1,8 @@ +# Data Model + +| Entity | Required fields | Rules | +|---|---|---| +| BaselineOperation | source identity, method, path, auth, replay class, entrypoint, fixture | One unique record per Python operation; all 264 classified. | +| CanonicalRequest | timestamp, method, path/query, headers, body | Timestamp is UTC milliseconds; empty query values omitted; bytes signed and sent identically. | +| ParityFinding | baseline identity, observed behavior, disposition, evidence | Unsupported server endpoint is removed or explicitly classified as a convenience abstraction. | +| RetryDecision | request safety, failure class, attempt count | Read only; at most one retry; writes never retry. | diff --git a/specs/016-okx-parity-remediation/plan.md b/specs/016-okx-parity-remediation/plan.md new file mode 100644 index 0000000..bca264e --- /dev/null +++ b/specs/016-okx-parity-remediation/plan.md @@ -0,0 +1,84 @@ +# Implementation Plan: OKX Parity Remediation + +**Branch**: `feature/twsapi-client-python-port` | **Date**: 2026-07-10 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` + +**Note**: This template is filled in by the `/speckit-plan` command. See `.specify/templates/plan-template.md` for the execution workflow. + +## Summary + +Make `truefix-okx-client` protocol-correct against all 264 operations in the local Python baseline. Python defines capability scope; current official OKX documentation resolves protocol details. Correct signing/header/query behavior, replace invalid endpoint metadata, remove unsupported server operations, and prove every baseline record through fixture evidence. + +## Technical Context + + + +**Language/Version**: Rust 2024, MSRV 1.96 + +**Primary Dependencies**: Tokio, reqwest, tokio-tungstenite, Serde, rust_decimal, time, tracing + +**Storage**: N/A + +**Testing**: cargo test; local HTTP/WebSocket contract fixtures; table-driven inventory tests + +**Target Platform**: Tokio-supported desktop/server platforms + +**Project Type**: Workspace library crate + +**Performance Goals**: Reuse HTTP connections; bounded one-retry read recovery; no duplicate writes + +**Constraints**: Exact milliseconds for signed timestamps; JSON content type; explicit live/Demo intent; no write replay; no production panic paths + +**Scale/Scope**: 264 baseline REST operations plus existing WS behavior + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +PASS: inventory-backed parity, independent implementation/provenance, typed errors, test-first fixture coverage, redacted diagnostics. FIX-specific dictionary and acceptor/initiator gates do not apply to this external SDK. + +## Project Structure + +### Documentation (this feature) + +```text +specs/[###-feature]/ +├── plan.md # This file (/speckit-plan command output) +├── research.md # Phase 0 output (/speckit-plan command) +├── data-model.md # Phase 1 output (/speckit-plan command) +├── quickstart.md # Phase 1 output (/speckit-plan command) +├── contracts/ # Phase 1 output (/speckit-plan command) +└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan) +``` + +### Source Code (repository root) + + +```text +crates/truefix-okx-client/ +├── src/{auth,request,limiter,inventory}.rs +├── src/services/ +├── src/types/ +└── tests/{http_contract,operation_inventory}.rs +``` + +**Structure Decision**: Extend the existing standalone client crate; endpoint facts live in the inventory and domain services, while shared signing/retry behavior remains centralized. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/specs/016-okx-parity-remediation/quickstart.md b/specs/016-okx-parity-remediation/quickstart.md new file mode 100644 index 0000000..659f43e --- /dev/null +++ b/specs/016-okx-parity-remediation/quickstart.md @@ -0,0 +1,28 @@ +# Validation Quickstart + +Run from repository root: + +```sh +python3 crates/truefix-okx-client/scripts/generate_operation_inventory.py +cargo test -p truefix-okx-client --test operation_inventory +cargo test -p truefix-okx-client --test http_contract +cargo test -p truefix-okx-client +cargo fmt --check +cargo clippy -p truefix-okx-client -- -D warnings +``` + +The generator reads the pinned `thrdpty/clientapi/python-okx` capability source and refuses to +write the Rust manifest unless it extracts exactly 264 operations and every approved path resolves +to a native Rust service method. Commit the generated file together with source/API changes. + +Expected inventory failures are explicit: a changed count reports `WrongCount`, repeated source +identity reports `DuplicateSourceIdentity`, absent method/path/auth/replay/fixture evidence reports +`Unclassified`, and a missing Rust entrypoint reports `Unsupported`. + +The HTTP contract fixture checks the exact signed millisecond timestamp, JSON and simulation +headers, empty-query omission, corrected path/verb behavior, and retry safety. Its scripted 429 +scenario must capture two identical GETs separated by the server's `Retry-After`; the corresponding +POST scenario must capture exactly one request and return `RateLimited` without replay. + +Demo/live smoke runs only with securely supplied credentials. Never place API keys, secrets, or +passphrases in source, fixtures, logs, command history, or generated inventory. diff --git a/specs/016-okx-parity-remediation/research.md b/specs/016-okx-parity-remediation/research.md new file mode 100644 index 0000000..183a274 --- /dev/null +++ b/specs/016-okx-parity-remediation/research.md @@ -0,0 +1,8 @@ +# Research: OKX Parity Remediation + +## Decisions + +- **Baseline scope**: Use `python-okx@fa8d738` to enumerate all 264 operations. **Rationale**: measurable parity. **Alternative**: repair only listed findings; rejected because it leaves unclassified gaps. +- **Protocol authority**: Use official OKX documentation for path, verb, headers, signing, and response details. **Rationale**: Python can contain historical defects. **Alternative**: mirror Python exactly; rejected for protocol risk. +- **Retries**: Retry a safe read at most once after transient failure; never replay writes. **Rationale**: bounded recovery without duplicate asset changes. +- **Non-baseline operations**: Remove service endpoints absent from Python; retain only pure convenience mappings. **Rationale**: no unverified server capabilities. diff --git a/specs/016-okx-parity-remediation/spec.md b/specs/016-okx-parity-remediation/spec.md new file mode 100644 index 0000000..f25a05f --- /dev/null +++ b/specs/016-okx-parity-remediation/spec.md @@ -0,0 +1,88 @@ +# Feature Specification: OKX Parity Remediation + +**Feature Branch**: `016-okx-parity-remediation` +**Created**: 2026-07-10 +**Status**: Draft +**Input**: Correct the reviewed differences between the native client and the local `python-okx@fa8d738` capability baseline. + +## Clarifications + +### Session 2026-07-10 + +- Q: Should remediation cover only reviewed findings or the full Python baseline? → A: Complete and verify all 264 Python baseline REST operations. +- Q: How should Rust operations outside the Python baseline be handled? → A: Remove unsupported server endpoints; retain only convenience abstractions that introduce no server capability. +- Q: What bounded retry policy applies to safe reads? → A: At most one automatic retry for transient read failures only. +- Q: Which source resolves a baseline-versus-protocol conflict? → A: Python defines the capability set; official OKX documentation defines current protocol details. + +## User Scenarios & Testing + +### User Story 1 - Submit interoperable authenticated requests (Priority: P1) + +SDK users can send private requests whose signed bytes and headers match the baseline contract. + +**Independent Test**: Local fixtures verify a millisecond UTC timestamp, JSON content type, simulation flag for both environments, and byte-identical signing input. + +**Acceptance Scenarios**: + +1. **Given** an authenticated request, **When** it is sent, **Then** its timestamp always has exactly three fractional-second digits and its signature covers that exact value. +2. **Given** a JSON request, **When** it is sent in Demo or live mode, **Then** it includes JSON content type and the appropriate simulation declaration. + +### User Story 2 - Use baseline-correct product operations (Priority: P1) + +SDK users can invoke supported funding, loan, finance, broker, and trading operations without an invented or stale endpoint path. + +**Independent Test**: A local contract fixture validates each corrected method/path/verb pairing against the source-baseline inventory. + +**Acceptance Scenarios**: + +1. **Given** a corrected operation, **When** it is constructed, **Then** its path and HTTP method match the approved baseline identity. +2. **Given** an operation absent from the baseline, **When** callers inspect the inventory, **Then** it is not represented as a supported baseline operation. + +### User Story 3 - Trust coverage and retry behavior (Priority: P2) + +SDK users can rely on documented read retry behavior and an auditable report of remaining parity gaps. + +**Independent Test**: Transient read failures retry within the configured bound; writes never retry; inventory tests fail for missing endpoint metadata or fixture evidence. + +## Edge Cases + +- Empty query values are omitted consistently with the approved baseline. +- A connection loss after a write remains an unknown completion and is never automatically retried. +- Server permission, regional availability, and product retirement errors remain visible to callers. + +## Requirements + +### Functional Requirements + +- **FR-001**: Private request timestamps MUST use UTC with exactly millisecond precision. +- **FR-002**: JSON requests MUST declare their media type; simulated-trading intent MUST be explicit for Demo and live environments. +- **FR-003**: Empty query values MUST be omitted from canonical query serialization. +- **FR-004**: Corrected operations MUST use the baseline-approved path and request method, including funding, fixed/VIP loan, flexible loan, staking, dual investment, broker rebate, and manual borrow/repay operations. +- **FR-005**: The operation inventory MUST distinguish approved baseline operations from unsupported or newly discovered operations and retain native entrypoint and fixture evidence. +- **FR-006**: Safe read requests MUST perform bounded transient retry; asset-changing requests MUST never be automatically replayed. +- **FR-006a**: A safe read MAY retry at most once after a timeout, connection interruption, rate limit, or server failure; writes MUST never be automatically replayed. +- **FR-007**: The remediation MUST add inventory-backed coverage for every one of the 264 Python-baseline REST operations across account, trade, market, public data, funding, subaccount, professional, strategy, and finance domains. +- **FR-008**: The remediation MUST remove any Rust endpoint not present in the Python baseline; convenience abstractions may remain only when they map exclusively to approved baseline operations. +- **FR-009**: Python defines the 264-operation capability set; current official OKX documentation MUST resolve any path, method, header, authentication, or response-semantics conflict. + +### Key Entities + +- **Baseline operation record**: Source identity, method, path, auth class, replay class, native entrypoint, and fixture evidence. +- **Canonical authenticated request**: Timestamp, headers, query, body, and exact signing bytes. +- **Parity finding**: Verified mismatch, its disposition, and regression evidence. + +## Success Criteria + +### Measurable Outcomes + +- **SC-001**: 100% of reviewed critical authentication/header cases have deterministic fixture coverage. +- **SC-002**: 100% of corrected path/verb findings have a baseline inventory record and contract test. +- **SC-003**: Inventory validation reports zero unclassified operations across all 264 baseline REST operations. +- **SC-004**: Safe reads recover from one transient fixture failure without replaying any write command. + +## Assumptions + +- The local `python-okx@fa8d738` source is the capability baseline; it is used only to record behavior and endpoint facts. +- Official OKX documentation is the authority for current protocol details when it differs from the Python baseline. +- Operations that exist only in newer official documentation are tracked separately and are not treated as baseline parity until reviewed. +- Demo credentials are optional; live smoke tests remain opt-in. diff --git a/specs/016-okx-parity-remediation/tasks.md b/specs/016-okx-parity-remediation/tasks.md new file mode 100644 index 0000000..40e3f24 --- /dev/null +++ b/specs/016-okx-parity-remediation/tasks.md @@ -0,0 +1,104 @@ +# Tasks: OKX Parity Remediation + +**Input**: Design documents in `/specs/016-okx-parity-remediation/` + +**Tests**: Required by the specification and constitution. Write contract/inventory tests before their corresponding corrections. + +## Phase 1: Setup + +- [X] T001 Create the 264-row baseline operation manifest schema and source extractor in `crates/truefix-okx-client/src/inventory.rs`. +- [X] T002 [P] Record reviewed parity findings and protocol authority in `crates/truefix-okx-client/README.md`. + +## Phase 2: Foundational Protocol Correctness + +- [X] T003 Add failing timestamp/header/query/retry contract cases in `crates/truefix-okx-client/tests/http_contract.rs`. +- [X] T004 [P] Add 264-operation completeness assertions in `crates/truefix-okx-client/tests/operation_inventory.rs`. +- [X] T005 Implement fixed-millisecond signing timestamps in `crates/truefix-okx-client/src/auth.rs`. +- [X] T006 Implement JSON content type, explicit Demo/live simulation headers, and transient status mapping in `crates/truefix-okx-client/src/transport/http.rs`. +- [X] T007 Implement empty-query filtering and canonical serialization in `crates/truefix-okx-client/src/request.rs`. +- [X] T008 Implement one bounded safe-read retry and limiter throttling in `crates/truefix-okx-client/src/client.rs` and `crates/truefix-okx-client/src/limiter.rs`. + +## Phase 3: User Story 1 - Interoperable Authentication (P1) + +**Goal**: Signed requests match the approved protocol contract. + +**Independent Test**: Local HTTP fixtures verify millisecond timestamp, headers, query omission, one read retry, and no write replay. + +- [X] T009 [US1] Extend table-driven signing coverage in `crates/truefix-okx-client/src/auth.rs`. +- [X] T010 [US1] Extend request/transport fixture assertions in `crates/truefix-okx-client/tests/http_contract.rs`. +- [X] T011 [US1] Document authentication and retry semantics in `specs/016-okx-parity-remediation/contracts/parity-inventory.md`. + +## Phase 4: User Story 2 - Correct Baseline Operations (P1) + +**Goal**: Every baseline operation maps to a current, approved method and path. + +**Independent Test**: Inventory plus per-domain fixtures reject incorrect verbs, paths, and Rust-only server endpoints. + +- [X] T012 [P] [US2] Correct account/fixed/VIP loan/manual borrow paths and add missing account operations in `crates/truefix-okx-client/src/services/account.rs`. +- [X] T013 [P] [US2] Correct funding currencies/lightning/dust paths and methods in `crates/truefix-okx-client/src/services/funding.rs`. +- [X] T014 [P] [US2] Correct flexible loan, staking, dual-investment, and savings operations in `crates/truefix-okx-client/src/services/finance.rs`. +- [X] T015 [P] [US2] Complete missing market/public-data operations in `crates/truefix-okx-client/src/services/market.rs` and `crates/truefix-okx-client/src/services/public_data.rs`. +- [X] T016 [P] [US2] Complete trade, subaccount, strategy, and copy-trading baseline operations in `crates/truefix-okx-client/src/services/trade.rs`, `crates/truefix-okx-client/src/services/subaccount.rs`, and `crates/truefix-okx-client/src/services/strategy.rs`. +- [X] T017 [P] [US2] Correct broker, RFQ, spread, trading-data, and convert operations in `crates/truefix-okx-client/src/services/professional.rs`. +- [X] T018 [US2] Remove or recast non-baseline server endpoints as pure convenience methods in `crates/truefix-okx-client/src/services/`. +- [X] T019 [US2] Add per-domain path/verb fixtures in `crates/truefix-okx-client/tests/http_contract.rs`. + +## Phase 5: User Story 3 - Auditable Complete Coverage (P2) + +**Goal**: Users can trust the exact parity status and retry guarantees. + +**Independent Test**: The inventory fails for any absent, duplicate, unsupported, or unevidenced baseline record. + +- [X] T020 [US3] Populate all 264 records with auth, replay, entrypoint, and fixture evidence in `crates/truefix-okx-client/src/inventory.rs`. +- [X] T021 [US3] Add duplicate/unsupported/unclassified inventory failure tests in `crates/truefix-okx-client/tests/operation_inventory.rs`. +- [X] T022 [US3] Add read-retry/write-no-replay and 429 throttle fixtures in `crates/truefix-okx-client/tests/http_contract.rs`. +- [X] T023 [US3] Update parity validation scenarios in `specs/016-okx-parity-remediation/quickstart.md`. + +## Phase 6: Polish & Validation + +- [X] T024 Add rustdoc for corrected public operations in `crates/truefix-okx-client/src/`. +- [X] T025 Audit production paths for copied source, secret exposure, and panic-prone code in `crates/truefix-okx-client/`. +- [X] T026 Run `cargo fmt --check`, `cargo clippy -p truefix-okx-client -- -D warnings`, and `cargo test -p truefix-okx-client` from the repository root. + +## Phase 7: Post-Review Protocol Remediation + +- [X] T027 Correct generated-inventory path overrides and make regeneration retain canonical OKX paths. +- [X] T028 Model account-balance summaries and OKX empty numeric sentinels with response fixtures. +- [X] T029 Require private WebSocket login and route events despite server-added subscription metadata. +- [X] T030 Add explicit clock-offset and `expTime` support for signed order/amend requests, including command-level WebSocket expiry. +- [X] T031 Run full OKX-client formatting, tests, clippy, and generated-inventory validation. +- [X] T032 Integrate session-aware WebSocket heartbeat receive/send helpers and timeout recovery tests. + +## Phase 8: Second-Review Correctness Remediation + +- [X] T033 [P] Decode native WebSocket `event` acknowledgements and invalidate sessions on transport failure. +- [X] T034 [P] Measure the OKX server-time offset through the public-time endpoint before client construction. +- [X] T035 [P] Surface per-item trade command failures instead of returning an unconditional success. +- [X] T036 Restrict WebSocket `expTime` to supported order/amend commands and add protocol fixtures. +- [X] T037 Run generated-inventory, formatting, full tests, clippy, and diff validation. + +## Phase 9: Third-Review Session and Baseline Remediation + +- [X] T038 [P] Match broad WebSocket subscriptions and apply acknowledgements to session/subscription state. +- [X] T039 [P] Preserve Python no-parameter POST semantics in generic baseline execution and RFQ MMP reset. +- [X] T040 [P] Disconnect a session after heartbeat-send failure and encapsulate WS expiry fields. +- [X] T041 Run generated-inventory, formatting, full tests, clippy, and diff validation. + +## Dependencies & Execution Order + +- Phase 2 blocks all stories. +- US1 can complete after T003–T008. +- US2 domain tasks T012–T017 can run in parallel after T004–T008; T018–T019 follow them. +- US3 depends on corrected domain methods and fixtures. + +## Parallel Opportunities + +- T002, T004, and T012–T017 use separate files and can run in parallel. +- T009 and T010 can run together after foundational protocol tasks. + +## Implementation Strategy + +1. Establish exact signing, header, query, retry, and inventory behavior. +2. Correct endpoint domains in parallel, with fixture evidence before each correction. +3. Populate and enforce all 264 inventory records. +4. Run the complete validation suite and provenance audit.