diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index bf7d22798..000000000 --- a/Cargo.lock +++ /dev/null @@ -1,829 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "allocator-api2" -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 = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] - -[[package]] -name = "auto_ops" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7460f7dd8e100147b82a63afca1a20eb6c231ee36b90ba7272e14951cb58af59" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "bincode" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" -dependencies = [ - "bincode_derive", - "serde", - "unty", -] - -[[package]] -name = "bincode_derive" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" -dependencies = [ - "virtue", -] - -[[package]] -name = "bumpalo" -version = "3.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" - -[[package]] -name = "bytemuck" -version = "1.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" - -[[package]] -name = "cc" -version = "1.2.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "find-msvc-tools" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "iana-time-zone" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -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 = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "internment" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "636d4b0f6a39fd684effe2a73f5310df16a3fa7954c26d36833e98f44d1977a2" -dependencies = [ - "hashbrown 0.15.5", - "serde", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "js-sys" -version = "0.3.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.180" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" - -[[package]] -name = "libm" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "matrixmultiply" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" -dependencies = [ - "autocfg", - "rawpointer", -] - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "nalgebra" -version = "0.33.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26aecdf64b707efd1310e3544d709c5c0ac61c13756046aaaba41be5c4f66a3b" -dependencies = [ - "approx", - "matrixmultiply", - "num-complex", - "num-rational", - "num-traits", - "rand", - "rand_distr", - "simba", - "typenum", -] - -[[package]] -name = "ndarray" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" -dependencies = [ - "matrixmultiply", - "num-complex", - "num-integer", - "num-traits", - "portable-atomic", - "portable-atomic-util", - "rawpointer", - "serde", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "numpy" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778da78c64ddc928ebf5ad9df5edf0789410ff3bdbf3619aed51cd789a6af1e2" -dependencies = [ - "libc", - "ndarray", - "num-complex", - "num-integer", - "num-traits", - "pyo3", - "pyo3-build-config", - "rustc-hash", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "portable-atomic" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" - -[[package]] -name = "portable-atomic-util" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "pyo3" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c738662e2181be11cb82487628404254902bb3225d8e9e99c31f3ef82a405c" -dependencies = [ - "chrono", - "indexmap", - "libc", - "once_cell", - "portable-atomic", - "pyo3-build-config", - "pyo3-ffi", - "pyo3-macros", -] - -[[package]] -name = "pyo3-build-config" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9ca0864a7dd3c133a7f3f020cbff2e12e88420da854c35540fd20ce2d60e435" -dependencies = [ - "target-lexicon", -] - -[[package]] -name = "pyo3-ffi" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dfc1956b709823164763a34cc42bbfd26b8730afa77809a3df8b94a3ae3b059" -dependencies = [ - "libc", - "pyo3-build-config", -] - -[[package]] -name = "pyo3-macros" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29dc660ad948bae134d579661d08033fbb1918f4529c3bbe3257a68f2009ddf2" -dependencies = [ - "proc-macro2", - "pyo3-macros-backend", - "quote", - "syn", -] - -[[package]] -name = "pyo3-macros-backend" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e78cd6c6d718acfcedf26c3d21fe0f053624368b0d44298c55d7138fde9331f7" -dependencies = [ - "heck", - "proc-macro2", - "pyo3-build-config", - "quote", - "syn", -] - -[[package]] -name = "quote" -version = "1.0.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] - -[[package]] -name = "rand_distr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand", -] - -[[package]] -name = "rateslib" -version = "2.6.0" -dependencies = [ - "auto_ops", - "bincode", - "chrono", - "indexmap", - "internment", - "itertools", - "ndarray", - "num-traits", - "numpy", - "pyo3", - "serde", - "serde_json", - "statrs", -] - -[[package]] -name = "rawpointer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "safe_arch" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "simba" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" -dependencies = [ - "approx", - "num-complex", - "num-traits", - "paste", - "wide", -] - -[[package]] -name = "statrs" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" -dependencies = [ - "approx", - "nalgebra", - "num-traits", - "rand", -] - -[[package]] -name = "syn" -version = "2.0.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "target-lexicon" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1dd07eb858a2067e2f3c7155d54e929265c264e6f37efe3ee7a8d1b5a1dd0ba" - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "unty" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" - -[[package]] -name = "virtue" -version = "0.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasm-bindgen" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wide" -version = "0.7.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" -dependencies = [ - "bytemuck", - "safe_arch", -] - -[[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", -] - -[[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", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "zerocopy" -version = "0.8.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" diff --git a/Cargo.toml b/Cargo.toml index 54c29b301..010c8066b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rateslib" -version = "2.6.0" +version = "2.7.0" edition = "2021" exclude = [ ".github/*", @@ -33,7 +33,7 @@ chrono = { version = "0.4", features = ["serde"] } indexmap = { version = "2.7", features = ["serde"] } ndarray = { version = "0.17", features = ["serde"] } internment = { version = "0.8", features = ["serde"] } -pyo3 = "=0.28.1" +pyo3 = "0.28" num-traits = "0.2" auto_ops = "0.3" numpy = "0.28" diff --git a/docs/source/c_ir_smile.rst b/docs/source/c_ir_smile.rst new file mode 100644 index 000000000..b3ac65a3a --- /dev/null +++ b/docs/source/c_ir_smile.rst @@ -0,0 +1,359 @@ +.. _c-ir-smile-doc: + +.. ipython:: python + :suppress: + + from rateslib.volatility import IRSabrSmile + import matplotlib.pyplot as plt + from datetime import datetime as dt + import numpy as np + from pandas import DataFrame + +********************************* +IR Vol Smiles & Cubes +********************************* + +The ``rateslib.volatility`` module includes classes for *Smiles* and *Cubes* +which can be used to price *IR Options* and *IR Option Strategies*. + +.. autosummary:: + rateslib.volatility.IRSabrSmile + rateslib.volatility.IRSabrCube + +Introduction and IR Volatility Smiles +************************************* + +A standard for interest rate (IR) options is to utilise a volatility based on SABR parameters. +These are the elements provided in the object's ``nodes``. +An :class:`~rateslib.volatility.IRSabrSmile` is a *Smile* parametrised by the +conventional :math:`\alpha, \beta, \rho, \nu` variables of the SABR model. The parameter +:math:`\beta` is considered a hyper-parameter and will not be varied by a +:class:`~rateslib.solver.Solver` but :math:`\alpha, \rho, \nu` will be varied. + +This object must also be initialised with: + +- An ``eval_date`` which serves the same purpose as the initial node point on a *Curve*, + and indicates *'today'* or *'horizon'*. It may be used to determine time to expiry. +- An ``expiry``, for which options priced with this *Smile* must have an equivalent + expiry or errors will be raised. +- A ``tenor`` indicating the maturity of the underlying :class:`~rateslib.instruments.IRS` that + this *Smile* will derive. +- An ``irs_series``, which contains the :class:`~rateslib.data.fixings.IRSSeries` conventions for + defining the mid-market rate on the underlying :class:`~rateslib.instruments.IRS`. + +An example of an *IRSabrSmile* is shown below. + +.. ipython:: python + + smile = IRSabrSmile( + eval_date=dt(2000, 1, 1), + expiry=dt(2000, 7, 1), + tenor="1y", + irs_series="usd_irs", + nodes={ + "alpha": 0.20, + "beta": 0.5, + "rho": -0.05, + "nu": 0.65, + }, + ) + # --> smile.plot(x_axis="strike", f=2.25) + # --> smile.plot(x_axis="moneyness", f=2.25) + +.. container:: twocol + + .. container:: leftside50 + + **Strike vs Vol Plot** + + .. plot:: + + from rateslib.volatility import IRSabrSmile + from datetime import datetime as dt + smile = IRSabrSmile( + eval_date=dt(2000, 1, 1), + expiry=dt(2000, 7, 1), + tenor="1y", + irs_series="usd_irs", + nodes={ + "alpha": 0.20, + "beta": 0.5, + "rho": -0.05, + "nu": 0.65, + }, + ) + fig, ax, lines = smile.plot(x_axis="strike", f=2.25) + plt.show() + plt.close() + + .. container:: rightside50 + + **Moneyness vs Vol Plot** + + .. plot:: + + from rateslib.volatility import IRSabrSmile + from datetime import datetime as dt + smile = IRSabrSmile( + eval_date=dt(2000, 1, 1), + expiry=dt(2000, 7, 1), + tenor="1y", + irs_series="usd_irs", + nodes={ + "alpha": 0.20, + "beta": 0.5, + "rho": -0.05, + "nu": 0.65, + }, + ) + fig, ax, lines = smile.plot(x_axis="moneyness", f=2.25) + plt.show() + plt.close() + +.. _c-ir-smile-constructing-doc: + +Constructing a Smile +********************* + +It is expected that *Smiles* will typically be calibrated to market prices, similar to +interest rate curves. + +.. + The following data describes *Instruments* to calibrate the EURUSD FX volatility surface on 7th May 2024. + We will take a cross-section of this data, at the 3-week expiry (28th May 2024), and create + both an *FXDeltaVolSmile* and *FXSabrSmile*. + + FX Options are **multi-currency derivative** *Instruments* and require an :class:`~rateslib.fx.FXForwards` + framework for pricing. We will do this first using other prevailing market data, + i.e. local currency interest rates at 3.90% and 5.32%, and an FX Swap rate at 8.85 points. + + .. ipython:: python + + # Define the interest rate curves for EUR, USD and X-Ccy basis + usdusd = Curve({dt(2024, 5, 7): 1.0, dt(2024, 5, 30): 1.0}, calendar="nyc", id="usdusd") + eureur = Curve({dt(2024, 5, 7): 1.0, dt(2024, 5, 30): 1.0}, calendar="tgt", id="eureur") + eurusd = Curve({dt(2024, 5, 7): 1.0, dt(2024, 5, 30): 1.0}, id="eurusd") + + # Create an FX Forward market with spot FX rate data + fxf = FXForwards( + fx_rates=FXRates({"eurusd": 1.0760}, settlement=dt(2024, 5, 9)), + fx_curves={"eureur": eureur, "usdusd": usdusd, "eurusd": eurusd}, + ) + + pre_solver = Solver( + curves=[eureur, eurusd, usdusd], + instruments=[ + IRS(dt(2024, 5, 9), "3W", spec="eur_irs", curves="eureur"), + IRS(dt(2024, 5, 9), "3W", spec="usd_irs", curves="usdusd"), + FXSwap(dt(2024, 5, 9), "3W", pair="eurusd", curves=["eurusd", "usdusd"]), + ], + s=[3.90, 5.32, 8.85], + fx=fxf, + id="rates_sv", + ) + + Since EURUSD *Options* are **not** premium adjusted and the premium currency is USD we will match + the *FXDeltaVolSmile* with this definition and set it to a ``delta_type`` of *'spot'*, matching + the market convention of these quoted instruments. + Since we have 5 calibrating instruments we can safely utilise 5 degrees of freedom. + + .. ipython:: python + + dv_smile = FXDeltaVolSmile( + nodes={ + 0.10: 10.0, + 0.25: 10.0, + 0.50: 10.0, + 0.75: 10.0, + 0.90: 10.0, + }, + eval_date=dt(2024, 5, 7), + expiry=dt(2024, 5, 28), + delta_type="spot", + id="eurusd_3w_smile" + ) + + sabr_smile = FXSabrSmile( + nodes={ + "alpha": 0.10, # default vol level set to 10% + "beta": 1.0, # model is fully lognormal + "rho": 0.10, + "nu": 1.0, # initialised with curvature + }, + eval_date=dt(2024, 5, 7), + expiry=dt(2024, 5, 28), + id="eurusd_3w_smile" + ) + + The above *FXDeltaVolSmile* is initialised as a flat vol at 10%, whilst the *FXSabrSmile* + is initialised with around 10% with some shallow curvature. In order to calibrate + these, we need to create the pricing + instruments, given in the market prices data table. + + .. ipython:: python + + # Setup the Solver instrument calibration for FXOptions and vol Smiles + option_args=dict( + pair="eurusd", expiry=dt(2024, 5, 28), calendar="tgt|fed", delta_type="spot", + curves=["eurusd", "usdusd"], vol="eurusd_3w_smile" + ) + dv_solver = Solver( + pre_solvers=[pre_solver], + curves=[dv_smile], + instruments=[ + FXStraddle(strike="atm_delta", **option_args), + FXRiskReversal(strike=("-25d", "25d"), **option_args), + FXRiskReversal(strike=("-10d", "10d"), **option_args), + FXBrokerFly(strike=(("-25d", "25d"), "atm_delta"), **option_args), + FXBrokerFly(strike=(("-10d", "10d"), "atm_delta"), **option_args), + ], + s=[5.493, -0.157, -0.289, 0.071, 0.238], + fx=fxf, + id="dv_solver", + ) + + The *FXSabrSmile* can be similarly calibrated. + + .. ipython:: python + + sabr_solver = Solver( + pre_solvers=[pre_solver], + curves=[sabr_smile], + instruments=[ + FXStraddle(strike="atm_delta", **option_args), + FXRiskReversal(strike=("-25d", "25d"), **option_args), + FXRiskReversal(strike=("-10d", "10d"), **option_args), + FXBrokerFly(strike=(("-25d", "25d"), "atm_delta"), **option_args), + FXBrokerFly(strike=(("-10d", "10d"), "atm_delta"), **option_args), + ], + s=[5.493, -0.157, -0.289, 0.071, 0.238], + fx=fxf, + id="sabr_solver", + ) + + dv_smile.plot(f=fxf.rate("eurusd", dt(2024, 5, 30)), x_axis="delta", labels=["DeltaVol", "Sabr"]) + + + .. plot:: + :caption: Rateslib Vol Smile: 'delta index' + + from rateslib.curves import Curve + from rateslib.instruments import * + from rateslib.volatility import FXDeltaVolSmile, FXSabrSmile + from rateslib.fx import FXRates, FXForwards + from rateslib.solver import Solver + import matplotlib.pyplot as plt + from datetime import datetime as dt + dv_smile = FXDeltaVolSmile( + nodes={ + 0.10: 10.0, + 0.25: 10.0, + 0.50: 10.0, + 0.75: 10.0, + 0.90: 10.0, + }, + eval_date=dt(2024, 5, 7), + expiry=dt(2024, 5, 28), + delta_type="spot", + id="eurusd_3w_smile" + ) + sabr_smile = FXSabrSmile( + nodes={ + "alpha": 0.10, + "beta": 1.0, + "rho": 0.10, + "nu": 1.0, + }, + eval_date=dt(2024, 5, 7), + expiry=dt(2024, 5, 28), + id="eurusd_3w_smile" + ) + # Define the interest rate curves for EUR, USD and X-Ccy basis + eureur = Curve({dt(2024, 5, 7): 1.0, dt(2024, 5, 30): 1.0}, calendar="tgt", id="eureur") + eurusd = Curve({dt(2024, 5, 7): 1.0, dt(2024, 5, 30): 1.0}, id="eurusd") + usdusd = Curve({dt(2024, 5, 7): 1.0, dt(2024, 5, 30): 1.0}, calendar="nyc", id="usdusd") + # Create an FX Forward market with spot FX rate data + fxf = FXForwards( + fx_rates=FXRates({"eurusd": 1.0760}, settlement=dt(2024, 5, 9)), + fx_curves={"eureur": eureur, "usdusd": usdusd, "eurusd": eurusd}, + ) + # Setup the Solver instrument calibration for rates Curves and vol Smiles + option_args=dict( + pair="eurusd", expiry=dt(2024, 5, 28), calendar="tgt", delta_type="spot", + curves=["eurusd", "usdusd"], vol="eurusd_3w_smile" + ) + pre_solver = Solver( + curves=[eureur, eurusd, usdusd], + instruments=[ + IRS(dt(2024, 5, 9), "3W", spec="eur_irs", curves="eureur"), + IRS(dt(2024, 5, 9), "3W", spec="usd_irs", curves="usdusd"), + FXSwap(dt(2024, 5, 9), "3W", pair="eurusd", curves=["eurusd", "usdusd"]), + ], + s=[3.90, 5.32, 8.85], + fx=fxf, + ) + sabr_solver = Solver( + pre_solvers=[pre_solver], + curves=[sabr_smile], + instruments=[ + FXStraddle(strike="atm_delta", **option_args), + FXRiskReversal(strike=("-25d", "25d"), **option_args), + FXRiskReversal(strike=("-10d", "10d"), **option_args), + FXBrokerFly(strike=(("-25d", "25d"), "atm_delta"), **option_args), + FXBrokerFly(strike=(("-10d", "10d"), "atm_delta"), **option_args), + ], + s=[5.493, -0.157, -0.289, 0.071, 0.238], + fx=fxf, + id="sabr_solver", + ) + dv_solver = Solver( + pre_solvers=[pre_solver], + curves=[dv_smile], + instruments=[ + FXStraddle(strike="atm_delta", **option_args), + FXRiskReversal(strike=("-25d", "25d"), **option_args), + FXRiskReversal(strike=("-10d", "10d"), **option_args), + FXBrokerFly(strike=(("-25d", "25d"), "atm_delta"), **option_args), + FXBrokerFly(strike=(("-10d", "10d"), "atm_delta"), **option_args), + ], + s=[5.493, -0.157, -0.289, 0.071, 0.238], + fx=fxf, + id="dv_solver", + ) + fig, ax, line = dv_smile.plot(f=fxf.rate("eurusd", dt(2024, 5, 30)), x_axis="delta", comparators=[sabr_smile], labels=["DeltaVol", "Sabr"]) + plt.show() + plt.close() + + + FX Volatility Surfaces + ********************** + + An :class:`~rateslib.volatility.FXDeltaVolSurface` in *rateslib* is a collection of + multiple, cross-sectional :class:`~rateslib.volatility.FXDeltaVolSmile` where: + + - each cross-sectional *Smile* will represent a *DeltaVolSmile* at that explicit *expiry*, + - the *delta type* and the *delta indexes* on each cross-sectional *Smile* are the same, + - each *Smile* has its own calibrated node values, + - *Smiles* for *expiries* that do not pre-exist are generated with an interpolation + scheme that uses linear total variance, which is equivalent to flat-forward volatility, + measured relative to the delta indexes. + + An :class:`~rateslib.volatility.FXSabrSurface` is a collection of multiple, + cross-sectional :class:`~rateslib.volatility.FXSabrSmile` where: + + - each cross-sectional *Smile* will represent a *SabrSmile* at that explicit *expiry*, + - each cross-sectional *Smile* is defined by its own :math:`\alpha, \beta, \rho, \nu` + parameters, + - *Smiles* for *expiries* that do not pre-exist are **not** generated. Volatility values + for a given *strike* are interpolated with linear total variance between the volatility + on neighboring *Smiles* for the same *strike*. + + **Further Information** + + Examples of the differences between each *Surface* type, temporal interpolation and using + **volatility weights** and calibrating an entire EURUSD surface to all given market data + is included in three separate notebooks available in the :ref:`Cookbook `. + + - Comparing Surface Interpolation for FX Options. + - FX Volatility Surface Temporal Interpolation. + - A EURUSD market for IRS, cross-currency and FX volatility. diff --git a/pyproject.toml b/pyproject.toml index 5b9030d11..42adf5bac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ features = ["pyo3/extension-module"] [project] name = "rateslib" -version = "2.6.0" +version = "2.7.0" description = "A fixed income library for trading interest rates" readme = "README.md" authors = [{ name = "J H M Darbyshire"}] @@ -151,7 +151,7 @@ max-complexity = 14 files = ["python/"] exclude = [ "python/tests", - # "/instruments/bonds/bond_future.py", + # "/periods/ir_volatility.py", ] strict = true #packages = [ diff --git a/python/rateslib/__init__.py b/python/rateslib/__init__.py index 437571d97..9df4b6da5 100644 --- a/python/rateslib/__init__.py +++ b/python/rateslib/__init__.py @@ -96,7 +96,6 @@ def __exit__(self, *args) -> None: # type: ignore[no-untyped-def] from rateslib.dual import ADOrder, Dual, Dual2, Variable, dual_exp, dual_log, dual_solve, gradient from rateslib.enums import FloatFixingMethod, NoInput from rateslib.fx import FXForwards, FXRates -from rateslib.fx_volatility import FXDeltaVolSmile, FXDeltaVolSurface, FXSabrSmile, FXSabrSurface from rateslib.instruments import ( CDS, FRA, @@ -125,7 +124,10 @@ def __exit__(self, *args) -> None: # type: ignore[no-untyped-def] FXSwap, FXVolValue, IndexFixedRateBond, + IRVolValue, + PayerSwaption, Portfolio, + ReceiverSwaption, Spread, STIRFuture, Value, @@ -179,6 +181,14 @@ def __exit__(self, *args) -> None: # type: ignore[no-untyped-def] bspldnev_single, bsplev_single, ) +from rateslib.volatility import ( + FXDeltaVolSmile, + FXDeltaVolSurface, + FXSabrSmile, + FXSabrSurface, + IRSabrCube, + IRSabrSmile, +) # module level doc-string __doc__ = """ @@ -253,11 +263,14 @@ def __exit__(self, *args) -> None: # type: ignore[no-untyped-def] "FXIndex", "FloatRateIndex", "FloatRateSeries", - # fx_volatility.py + # volatility/fx "FXDeltaVolSmile", "FXDeltaVolSurface", "FXSabrSmile", "FXSabrSurface", + # volatility/ir + "IRSabrSmile", + "IRSabrCube", # solver.py "Solver", # fx.py @@ -293,6 +306,7 @@ def __exit__(self, *args) -> None: # type: ignore[no-untyped-def] "FRA", "Value", "FXVolValue", + "IRVolValue", "Bill", "BillCalcMode", "IRS", @@ -316,4 +330,6 @@ def __exit__(self, *args) -> None: # type: ignore[no-untyped-def] "FXStraddle", "FXStrangle", "FXBrokerFly", + "PayerSwaption", + "ReceiverSwaption", ] diff --git a/python/rateslib/_spec_loader.py b/python/rateslib/_spec_loader.py index 4fa883e24..415968ef9 100644 --- a/python/rateslib/_spec_loader.py +++ b/python/rateslib/_spec_loader.py @@ -22,7 +22,7 @@ Any, ) -DEVELOPMENT = os.environ.get("RATESLIB_DEVELOPMENT", "False") +DEVELOPMENT = os.environ.get("RATESLIB_DEVELOPMENT", "True") # This is output from a development version and hard coded before a release for performance. INSTRUMENT_SPECS: dict[str, dict[str, Any]] = { diff --git a/python/rateslib/curves/interpolation.py b/python/rateslib/curves/interpolation.py index 8ad5fff4e..02210ce57 100644 --- a/python/rateslib/curves/interpolation.py +++ b/python/rateslib/curves/interpolation.py @@ -21,7 +21,13 @@ from rateslib.scheduling import Convention, dcf if TYPE_CHECKING: - from rateslib.local_types import Any, DualTypes, _BaseCurve, datetime # pragma: no cover + from rateslib.local_types import ( # pragma: no cover + Any, + DualTypes, + Sequence, + _BaseCurve, + datetime, + ) UTC = timezone.utc @@ -149,7 +155,7 @@ def _get_posix(date: datetime, curve: _BaseCurve) -> tuple[float, float, float, def index_left( - list_input: list[Any], + list_input: Sequence[Any], list_length: int, value: Any, left_count: int = 0, diff --git a/python/rateslib/data/fixings.py b/python/rateslib/data/fixings.py index fed157afb..74b36cb14 100644 --- a/python/rateslib/data/fixings.py +++ b/python/rateslib/data/fixings.py @@ -33,9 +33,11 @@ from rateslib.enums.parameters import ( FloatFixingMethod, SpreadCompoundMethod, + SwaptionSettlementMethod, _get_float_fixing_method, _get_index_method, _get_spread_compound_method, + _get_swaption_settlement_method, ) from rateslib.rs import Adjuster from rateslib.scheduling.adjuster import _get_adjuster @@ -43,17 +45,20 @@ from rateslib.scheduling.convention import Convention, _get_convention from rateslib.scheduling.dcfs import dcf from rateslib.scheduling.frequency import _get_frequency, _get_tenor_from_frequency, add_tenor -from rateslib.scheduling.schedule import _get_stub_inference +from rateslib.scheduling.schedule import Schedule, _get_stub_inference from rateslib.utils.calendars import _get_first_bus_day if TYPE_CHECKING: from rateslib.local_types import ( # pragma: no cover + IRS, Any, Arr1dF64, Arr1dObj, + Cal, CalInput, CalTypes, CurveOption_, + CurvesT_, DualTypes, DualTypes_, Frequency, @@ -62,9 +67,11 @@ FXIndex_, IndexMethod, LegFixings, + NamedCal, PeriodFixings, Result, StubInference, + UnionCal, _BaseCurve_, bool_, datetime_, @@ -1270,258 +1277,447 @@ def reset(self, state: int_ = NoInput(0)) -> None: self._fx_fixing3.reset(state) -# class FXFixing_Legacy(_BaseFixing): -# """ -# An FX fixing value for cross currency settlement. -# -# .. role:: red -# -# .. role:: green -# -# Parameters -# ---------- -# fx_index: FXIndex, str, :red:`required` -# The :class:`~rateslib.data.fixings.FXIndex` defining the FX pair and its conventions. -# publication: datetime, :green:`optional` -# The publication date of the fixing. If not given, must provide ``delivery`` in order to -# derive the *publication date*. -# delivery: datetime, :green:`optional` -# The settlement delivery date of the cashflow. Can be used to derive the -# *publication date*. If not given is derived from the ``publication``. -# value: float, Dual, Dual2, Variable, optional -# The initial value for the fixing to adopt. Most commonly this is not given and it is -# determined from a timeseries of published FX rates. -# identifier: str, optional -# The string name of the series to be loaded by the *Fixings* object. Will be -# appended with "_{pair}" to derive the full timeseries key. -# -# Examples -# -------- -# -# .. ipython:: python -# :suppress: -# -# from rateslib.data.fixings import FXFixing, FXIndex -# from rateslib import fixings, dt -# from pandas import Series -# -# .. ipython:: python -# -# fixings.add("WMRPSPOT01_USDJPY", Series(index=[dt(1999, 12, 29)], data=[155.00])) -# fixings.add("WMRPSPOT01_AUDUSD", Series(index=[dt(1999, 12, 29)], data=[1.260])) -# fxfix = FXFixing( -# delivery=dt(2000, 1, 4), -# fx_index=FXIndex( -# pair="audjpy", -# calendar="syd,tyo|fed", -# settle=2, -# isda_mtm_calendar="syd,tyo,ldn", -# isda_mtm_settle=-2, -# ), -# identifier="WMRPSPOT01" -# ) -# fxfix.publication # <-- derived from isda attributes -# fxfix.value # <-- should be 1.26 * 155 = 202.5 -# -# .. ipython:: python -# :suppress: -# -# fixings.pop("WMRPSPOT01_USDJPY") -# fixings.pop("WMRPSPOT01_AUDUSD") -# -# """ -# -# def __init__( -# self, -# fx_index: FXIndex | str, -# publication: datetime_ = NoInput(0), -# delivery: datetime_ = NoInput(0), -# value: DualTypes_ = NoInput(0), -# identifier: str_ = NoInput(0), -# ) -> None: -# fx_index_: FXIndex = _get_fx_index(fx_index) -# del fx_index -# if isinstance(delivery, NoInput) and isinstance(publication, NoInput): -# raise ValueError( -# "At least one date; a `delivery` or `publication` is required to derive the " -# "`date` used for the FX fixing." -# ) -# elif isinstance(publication, NoInput) and isinstance(delivery, datetime): -# # then derive it under conventions -# date_ = fx_index_.isda_fixing_date(delivery) -# self._delivery = delivery -# self._publication = date_ -# elif isinstance(publication, datetime): -# date_ = publication -# self._publication = date_ -# if isinstance(delivery, NoInput): -# self._delivery = fx_index_.delivery(date_) -# -# super().__init__(date=date_, value=value, identifier=identifier) -# self._fx_index = fx_index_ -# self._is_cross = "usd" not in self.fx_index.pair -# -# @property -# def fx_index(self) -> FXIndex: -# """The :class:`FXIndex` for the FX fixing.""" -# return self._fx_index -# -# @property -# def is_cross(self) -> bool: -# """Whether the fixing is a cross rate derived from other USD dominated fixings.""" -# return self._is_cross -# -# def _value_from_possible_inversion(self, identifier: str) -> DualTypes_: -# direct, inverted = self.pair, f"{self.pair[3:6]}{self.pair[0:3]}" -# try: -# state, timeseries, bounds = fixings.__getitem__(identifier + "_" + direct) -# exponent = 1.0 -# except ValueError as e: -# try: -# state, timeseries, bounds = fixings.__getitem__(identifier + "_" + inverted) -# exponent = -1.0 -# except ValueError: -# raise e -# -# if state == self._state: -# return NoInput(0) -# else: -# self._state = state -# v = self._lookup_and_calculate(timeseries, bounds) -# if isinstance(v, NoInput): -# return NoInput(0) -# self._value = v**exponent -# return self._value -# -# def _value_from_cross(self, identifier: str) -> DualTypes_: -# lhs1, lhs2 = "usd" + self.pair[:3], self.pair[:3] + "usd" -# try: -# state_l, timeseries_l, bounds_l = fixings.__getitem__(identifier + "_" + lhs1) -# exponent_l = -1.0 -# except ValueError: -# try: -# state_l, timeseries_l, bounds_l = fixings.__getitem__(identifier + "_" + lhs2) -# exponent_l = 1.0 -# except ValueError: -# raise ValueError( -# "The LHS cross currency has no available fixing series, either " -# f"{identifier + '_' + lhs1} or {identifier + '_' + lhs2}" -# ) -# -# rhs1, rhs2 = "usd" + self.pair[3:], self.pair[3:] + "usd" -# try: -# state_r, timeseries_r, bounds_r = fixings.__getitem__(identifier + "_" + rhs1) -# exponent_r = 1.0 -# except ValueError: -# try: -# state_r, timeseries_r, bounds_r = fixings.__getitem__(identifier + "_" + rhs2) -# exponent_r = -1.0 -# except ValueError: -# raise ValueError( -# "The RHS cross currency has no available fixing series, either " -# f"{identifier + '_' + lhs1} or {identifier + '_' + lhs2}" -# ) -# -# if hash(state_l + state_r) == self._state: -# return NoInput(0) -# else: -# self._state = hash(state_l + state_r) -# v_l = self._lookup_and_calculate(timeseries_l, bounds_l) -# v_r = self._lookup_and_calculate(timeseries_r, bounds_r) -# if isinstance(v_l, NoInput) or isinstance(v_r, NoInput): -# return NoInput(0) -# self._value = v_l**exponent_l * v_r**exponent_r -# return self._value -# -# @property -# def publication(self) -> datetime: -# return self._publication -# -# @property -# def delivery(self) -> datetime: -# return self._delivery -# -# @property -# def value(self) -> DualTypes_: -# if not isinstance(self._value, NoInput): -# return self._value -# else: -# if isinstance(self._identifier, NoInput): -# return NoInput(0) -# else: -# if self.is_cross: -# return self._value_from_cross(identifier=self._identifier) -# else: -# return self._value_from_possible_inversion(identifier=self._identifier) -# -# def _lookup_and_calculate( -# self, timeseries: Series, bounds: tuple[datetime, datetime] | None -# ) -> DualTypes_: -# return self._lookup(timeseries=timeseries, date=self.date, bounds=bounds) -# -# @classmethod -# def _lookup( -# cls, -# timeseries: Series[DualTypes], # type: ignore[type-var] -# date: datetime, -# bounds: tuple[datetime, datetime] | None = None, -# ) -> DualTypes_: -# result = fixings.__base_lookup__( -# fixing_series=timeseries, -# lookup_date=date, -# bounds=bounds, -# ) -# if isinstance(result, Err): -# if isinstance(result._exception, FixingRangeError): -# return NoInput(0) -# result.unwrap() -# else: -# return result.unwrap() -# -# @property -# def pair(self) -> str: -# """The currency pair related to the FX fixing.""" -# return self.fx_index.pair -# -# def value_or_forecast(self, fx: FXForwards_) -> DualTypes: -# """ -# Return the determined value of the fixing or forecast it if not available. -# -# Parameters -# ---------- -# fx: FXForwards, optional -# The :class:`~rateslib.fx.FXForwards` object to forecast the forward FX rate. -# -# Returns -# ------- -# float, Dual, Dual2, Variable -# """ -# if isinstance(self.value, NoInput): -# fx_: FXForwards = _validate_obj_not_no_input(fx, "FXForwards") -# return fx_.rate(pair=self.pair, settlement=self.delivery) -# else: -# return self.value -# -# def try_value_or_forecast(self, fx: FXForwards_) -> Result[DualTypes]: -# """ -# Return the determined value of the fixing or forecast it if not available. -# -# Parameters -# ---------- -# fx: FXForwards, optional -# The :class:`~rateslib.fx.FXForwards` object to forecast the forward FX rate. -# -# Returns -# ------- -# Result[float, Dual, Dual2, Variable] -# """ -# if isinstance(self.value, NoInput): -# if isinstance(fx, NoInput): -# return Err(ValueError("Must provide `fx` argument to forecast FXFixing.")) -# else: -# return Ok(fx.rate(pair=self.pair, settlement=self.delivery)) -# else: -# return Ok(self.value) +class IRSSeries: + """ + Define the parameters of a specific IRS series. + + This object acts as a container to store local conventions for different IRS markets. + + .. rubric:: Examples + + .. ipython:: python + :suppress: + + from rateslib.data.fixings import IRSSeries + + .. ipython:: python + + fxi = FXIndex( + pair="eurusd", + calendar="tgt|fed", # <- Spot FX measures settlement dates according to this calendar + settle=2, + isda_mtm_calendar="ldn", # <- MTM XCS FX fixing dates are determined according to this calendar + isda_mtm_settle=-2, + ) + fxi.delivery(dt(2025, 7, 3)) + fxi.isda_fixing_date(dt(2025, 7, 3)) + + .. role:: red + + .. role:: green + + Parameters + ---------- + currency: str, :red:`required` + The currency of the fixing. 3-digit iso code. + settle: Adjuster, int, str :green:`optional (set by 'defaults')` + The effective date lag from the fixing date to arrive at the swap effective date, + under the given ``calendar``. If int is assumed to be settleable business days. + calendar: Calendar, str, :red:`required` + The calendar passed to the :class:`~rateslib.instruments.IRS` + convention: str, :green:`optional (set by 'defaults')` + The convention passed to the :class:`~rateslib.instruments.IRS` + leg2_convention: str, :green:`optional (set by 'defaults')` + The leg2_convention passed to the :class:`~rateslib.instruments.IRS` + frequency: str, :green:`optional (set by 'defaults')` + The frequency passed to the :class:`~rateslib.instruments.IRS` + leg2_frequency: str, :green:`optional (set by 'defaults')` + The leg2_frequency passed to the :class:`~rateslib.instruments.IRS` + leg2_fixing_method: FloatFixingMethod, str, :green:`optional (set by 'defaults')` + The :class:`~rateslib.enums.parameters.FloatFixingMethod` describing the determination + of the floating rate for each period. + eom : bool, :green:`optional` + The eom passed to the :class:`~rateslib.instruments.IRS` + modifier : Adjuster, str in {"NONE", "F", "MF", "P", "MP"}, :green:`optional (set by Default)` + The eom passed to the :class:`~rateslib.instruments.IRS` + payment_lag: Adjuster, int, :green:`optional` + The payment_lag passed to the :class:`~rateslib.instruments.IRS` + """ # noqa: E501 + + def __init__( + self, + currency: str, + settle: int | Adjuster | str, + frequency: Frequency | str, + convention: str, + calendar: Cal | UnionCal | NamedCal | str, + leg2_fixing_method: str | FloatFixingMethod, + *, + eom: bool_ = NoInput(0), + modifier: Adjuster | str_ = NoInput(0), + payment_lag: Adjuster | str | int_ = NoInput(0), + leg2_frequency: Frequency | str_ = NoInput(1), + leg2_convention: str_ = NoInput(1), + ) -> None: + self._currency = currency.lower() + self._settle = _get_adjuster(settle) + self._calendar = get_calendar(calendar) + self._frequency = _get_frequency(frequency, roll=NoInput(0), calendar=self.calendar) + self._leg2_frequency = _get_frequency( + _drb(self.frequency, leg2_frequency), roll=NoInput(0), calendar=self.calendar + ) + self._convention = _get_convention(convention) + self._leg2_convention = _get_convention(_drb(self.convention, leg2_convention)) + self._eom: bool = _drb(defaults.eom, eom) + self._modifier = _get_adjuster(_drb(defaults.modifier, modifier)) + self._payment_lag = payment_lag + self._leg2_fixing_method = _get_float_fixing_method(leg2_fixing_method) + + @property + def currency(self) -> str: + """The currency of the associated :class:`~rateslib.instruments.IRS`""" + return self._currency + + @property + def settle(self) -> Adjuster: + """The :class:`~rateslib.scheduling.Adjuster` for effective date determination of the + associated :class:`~rateslib.instruments.IRS`""" + return self._settle + + @property + def calendar(self) -> Cal | NamedCal | UnionCal: + """The calendar of the associated :class:`~rateslib.instruments.IRS`""" + return self._calendar + + @property + def frequency(self) -> Frequency: + """The :class:`~rateslib.scheduling.Frequency` of leg1 of + the associated :class:`~rateslib.instruments.IRS`""" + return self._frequency + + @property + def leg2_frequency(self) -> Frequency: + """The :class:`~rateslib.scheduling.Frequency` of leg2 of + the associated :class:`~rateslib.instruments.IRS`""" + return self._leg2_frequency + + @property + def convention(self) -> Convention: + """The :class:`rateslib.scheduling.Convention` of leg1 of + the associated :class:`~rateslib.instruments.IRS`""" + return self._convention + + @property + def leg2_convention(self) -> Convention: + """The :class:`rateslib.scheduling.Convention` of leg2 of + the associated :class:`~rateslib.instruments.IRS`""" + return self._leg2_convention + + @property + def modifier(self) -> Adjuster: + """The :class:`rateslib.scheduling.Adjuster` for accrual modification + of the associated :class:`~rateslib.instruments.IRS`""" + return self._modifier + + @property + def payment_lag(self) -> Adjuster | int | str_: + """The :class:`rateslib.scheduling.Adjuster` for payment date modification + of the associated :class:`~rateslib.instruments.IRS`""" + return self._payment_lag + + @property + def eom(self) -> bool: + """Whether the roll-day tends to EoM or not.""" + return self._eom + + @property + def leg2_fixing_method(self) -> FloatFixingMethod: + """The :class:`~rateslib.enums.FloatFixingMethod` of the + :class:`~rateslib.legs.FloatLeg`.""" + return self._leg2_fixing_method + + def __repr__(self) -> str: + return f"" + + +def _get_irs_series(val: IRSSeries | str) -> IRSSeries: + if isinstance(val, IRSSeries): + return val + else: + return IRSSeries(**defaults.irs_series[val.lower()]) + + +class IRSFixing(_BaseFixing): + """ + An IRS fixing value for the determination of IR Swaptions. + + .. rubric:: Examples + + .. ipython:: python + :suppress: + + from rateslib.data.fixings import IRSFixing + from rateslib import fixings, dt, Curve + from pandas import Series + + .. ipython:: python + + fixings.add("ISDA_USD_2Y", Series(index=[dt(2000, 1, 4)], data=[2.543])) + irs_fix = IRSFixing( + publication=dt(2000, 1, 4), + irs_series="usd_irs", + tenor="2Y", + identifier="ISDA_USD_2Y", + ) + irs_fix.publication + irs_fix.value # <-- determined from Series + + .. ipython:: python + + curve = Curve({dt(2000, 1, 4): 1.0, dt(2003, 1, 4): 0.91}, convention="Act360") + irs_fix = IRSFixing( + publication=dt(2000, 1, 11), + irs_series="usd_irs", + tenor="2Y", + identifier="ISDA_USD_2Y", + ) + irs_fix.publication + irs_fix.value_or_forecast(curves=[curve, curve]) # <-- no Series index available - use Curve + + .. ipython:: python + :suppress: + + fixings.pop("ISDA_USD_2Y") + + .. role:: red + + .. role:: green + + Parameters + ---------- + irs_series: IRSSeries, str, :red:`required` + The :class:`~rateslib.data.fixings.IRSSeries` defining the IRS conventions. + publication: datetime, :red:`required` + The publication date of the fixing. + tenor: str, :red:`required` + The standard tenor of the underlying :class:`~rateslib.instruments.IRS` of the fixing. + value: float, Dual, Dual2, Variable, :green:`optional` + The initial value for the fixing to adopt. Most commonly this is not given and it is + determined from a timeseries of published rates. + identifier: str, :green:`optional` + The string name of the series to be loaded by the *Fixings* object. + + """ # noqa: E501 + + def __init__( + self, + irs_series: IRSSeries | str, + publication: datetime, + tenor: str | datetime, + value: DualTypes_ = NoInput(0), + identifier: str_ = NoInput(0), + ) -> None: + self._publication = publication + self._tenor = tenor + self._irs_series = _get_irs_series(irs_series) + super().__init__(identifier=identifier, value=value, date=self.publication) + + @property + def tenor(self) -> datetime | str: + """The tenor of the IRSFixing""" + return self._tenor + + @property + def irs_series(self) -> IRSSeries: + """The :class:`~rateslib.enums.IRSSeries` for the fixing.""" + return self._irs_series + + @cached_property + def irs(self) -> IRS: + """The :class:`~rateslib.instruments.IRS` underlying for the swaptions priced + by this *Smile*.""" + from rateslib.instruments.irs import IRS + + return IRS( + effective=self.effective, + termination=self.tenor, + frequency=self.irs_series.frequency, + leg2_frequency=self.irs_series.leg2_frequency, + convention=self.irs_series.convention, + leg2_convention=self.irs_series.leg2_convention, + calendar=self.irs_series.calendar, + payment_lag=self.irs_series.payment_lag, + modifier=self.irs_series.modifier, + eom=self.irs_series.eom, + notional=1e6, # default notional to a sized paid IRS + ) + + def annuity( + self, + settlement_method: SwaptionSettlementMethod | str, + index_curve: _BaseCurve, + rate_curve: CurveOption_, + ) -> DualTypes: + r""" + Return the annuity value used in the determination of the cashflow settlement, scaled to + match 1mm notional per bp. + + .. role:: red + + .. role:: green + + Parameters + ---------- + settlement_method: SwaptionSettlementMethod, str, :red:`required` + The :class:`~rateslib.enums.SwaptionSettlementMethod` defining the settlement method. + index_curve: _BaseCurve, :green:`optional` + The price alignment index (PAI) curve, colloquially known as the discount factor + curve for the *IRS* that determines the PV. Required for certain methods. + rate_curve: _BaseCurve or dict of such, :green:`optional` + The curve used to forecast the floating leg of the + underlying :class:`~rateslib.instruments.IRS`. + + + Returns + ------- + float, Dual, Dual2 + + Notes + ----- + This method branches based on the SwaptionSettlementMethod: + + - **Physical**: only the ``index_curve`` need be provided. In the case of physical + settlement this curve is the discount factor curve used to discount the resultant + :class:`~rateslib.instruments.IRS`, which is likely to be cleared and hence should + typically be a single currency RFR curve, e.g. SOFR or ESTR. + - **CashParTenor**: the annuity factor is derived from the *IRSFixing* value itself, using + the formula: + + .. math:: + + A_R = \sum_{i=1}^N \frac{1/f}{(1 + R / f)^{i}} + + - **CashCollateralized**: very similar to the *Physical* settlement, only the + ``index_curve`` needs to be provided to derive the annuity. In practice, this *Curve* + should be constructed according the ISDA cash collateralized method using the published + rates at each tenor for the collateralization, e.g. SOFR swaps or ESTR swaps. + + .. math:: + + A_R = \sum_{i=1}^N d_i v_i + + """ + settlement_method_ = _get_swaption_settlement_method(settlement_method) + del settlement_method + if settlement_method_ == SwaptionSettlementMethod.Physical: + a_r: DualTypes = self.irs.leg1.analytic_delta( # type: ignore[assignment] + disc_curve=index_curve, forward=self.effective, local=False + ) + elif settlement_method_ == SwaptionSettlementMethod.CashParTenor: + R = self.value_or_forecast( + curves=dict( # type: ignore[arg-type] + rate_curve=NoInput(0), + disc_curve=index_curve, + leg2_rate_curve=rate_curve, + leg2_disc_curve=index_curve, + ) + ) + a_r, f = 0.0, self.irs.leg1.schedule.frequency_obj.periods_per_annum() + for i, _period in enumerate(self.irs.leg1._regular_periods): + a_r += (1 / f) * (1 + R / (f * 100.0)) ** (-i - 1) * 100.0 + else: # settlement_method == SwaptionSettlementMethod.CashCollaterized: + a_r = self.irs.leg1.analytic_delta( # type: ignore[assignment] + disc_curve=index_curve, + forward=self.effective, + local=False, + ) + return a_r + + @property + def publication(self) -> datetime: + """The publication date of the fixing.""" + return self._publication + + @cached_property + def effective(self) -> datetime: + """The effective date of the underlying :class:`~rateslib.instruments.IRS`.""" + return self.irs_series.calendar.adjust(self.publication, self.irs_series.settle) + + @cached_property + def termination(self) -> datetime: + """The termination date of the underlying :class:`~rateslib.instruments.IRS`.""" + if isinstance(self.tenor, datetime): + return self.tenor + else: + schedule = Schedule( + effective=self.effective, + termination=self.tenor, + frequency=self.irs_series.frequency, + calendar=self.irs_series.calendar, + modifier=self.irs_series.modifier, + eom=self.irs_series.eom, + ) + return schedule.aschedule[-1] + + def value_or_forecast(self, curves: CurvesT_) -> DualTypes: + """ + Return the determined value of the fixing or forecast it if not available. + + Parameters + ---------- + curves: optional + Curves in the pricing format required by :class:`~rateslib.instruments.IRS`. + + Returns + ------- + float, Dual, Dual2, Variable + """ + if isinstance(self.value, NoInput): + rate = self.irs.rate(curves=curves) + return rate + else: + return self.value + + def try_value_or_forecast(self, curves: CurvesT_) -> Result[DualTypes]: + """ + Return the determined value of the fixing or forecast it if not available. + + Parameters + ---------- + curves: _Curves, + Pricing objects. See **Pricing** on :class:`~rateslib.instruments.IRS` + for details of allowed inputs. + + Returns + ------- + Result[float, Dual, Dual2, Variable] + """ + if isinstance(self.value, NoInput): + try: + return Ok(self.irs.rate(curves=curves)) + except Exception as e: + return Err(e) + else: + return Ok(self.value) + + def _lookup_and_calculate( + self, + timeseries: Series[DualTypes], # type: ignore[type-var] + bounds: tuple[datetime, datetime] | None, + ) -> DualTypes_: + return self._lookup(timeseries=timeseries, bounds=bounds, date=self.date) + + @classmethod + def _lookup( + cls, + timeseries: Series[DualTypes], # type: ignore[type-var] + date: datetime, + bounds: tuple[datetime, datetime] | None = None, + ) -> DualTypes_: + result = fixings.__base_lookup__( + fixing_series=timeseries, + lookup_date=date, + bounds=bounds, + ) + if isinstance(result, Err): + if isinstance(result._exception, FixingRangeError): + return NoInput(0) + result.unwrap() + else: + return result.unwrap() + + def __repr__(self) -> str: + return f"" def _maybe_get_fx_index(val: FXIndex | str_) -> FXIndex_: @@ -3579,11 +3775,13 @@ def _leg_fixings_to_list(rate_fixings: LegFixings, n_periods: int) -> list[Perio __all__ = [ "FloatRateSeries", "FloatRateIndex", + "IRSSeries", + "FXIndex", "RFRFixing", "IBORFixing", "IBORStubFixing", "IndexFixing", - "FXIndex", + "IRSFixing", "FXFixing", "_FXFixingMajor", "_UnitFixing", diff --git a/python/rateslib/default.py b/python/rateslib/default.py index d4686f156..22d8d68f4 100644 --- a/python/rateslib/default.py +++ b/python/rateslib/default.py @@ -21,7 +21,7 @@ from rateslib._spec_loader import INSTRUMENT_SPECS from rateslib.enums.generics import NoInput, _drb -from rateslib.rs import Adjuster, Convention, NamedCal +from rateslib.rs import Adjuster, Convention, Frequency, NamedCal PlotOutput = tuple[plt.Figure, plt.Axes, list[plt.Line2D]] # type: ignore[name-defined] @@ -79,6 +79,8 @@ fx_delivery_lag=2, fx_delta_type="spot", fx_option_metric="pips", + ir_option_metric="log_normal_vol", + ir_option_settlement="physical", cds_premium_accrued=True, cds_recovery_rate=0.40, cds_protection_discretization=23, @@ -327,6 +329,32 @@ allow_cross=False, ), }, + irs_series={ + "eur_irs6": dict( + currency="eur", + settle=Adjuster.BusDaysLagSettle(2), + calendar="tgt", + modifier=Adjuster.ModifiedFollowing(), + convention=Convention.ThirtyE360, + leg2_convention=Convention.Act360, + frequency=Frequency.Months(12, None), + leg2_frequency=Frequency.Months(6, None), + leg2_fixing_method="ibor(2)", + eom=False, + payment_lag=Adjuster.BusDaysLagSettle(0), + ), + "usd_irs": dict( + currency="usd", + settle=Adjuster.BusDaysLagSettle(2), + calendar="nyc", + modifier=Adjuster.ModifiedFollowing(), + convention=Convention.Act360, + frequency=Frequency.Months(12, None), + leg2_fixing_method="rfr_payment_delay", + eom=False, + payment_lag=Adjuster.BusDaysLagSettle(2), + ), + }, float_series={ "usd_ibor": dict( lag=2, @@ -528,6 +556,8 @@ class Defaults: fx_delivery_lag: int fx_delta_type: str fx_option_metric: str + ir_option_metric: str + ir_option_settlement: str cds_premium_accrued: bool cds_recovery_rate: float cds_protection_discretization: int @@ -561,6 +591,7 @@ class Defaults: # Contact rateslib at gmail.com if this code is observed outside its intended sphere. spec: dict[str, dict[str, Any]] fx_index: dict[str, Any] + irs_series: dict[str, Any] float_series: dict[str, Any] def __new__(cls) -> Defaults: diff --git a/python/rateslib/enums/__init__.py b/python/rateslib/enums/__init__.py index b703bb8bb..bc78cd38a 100644 --- a/python/rateslib/enums/__init__.py +++ b/python/rateslib/enums/__init__.py @@ -16,9 +16,11 @@ FXDeltaMethod, FXOptionMetric, IndexMethod, + IROptionMetric, LegIndexBase, LegMtm, SpreadCompoundMethod, + SwaptionSettlementMethod, ) __all__ = [ @@ -26,7 +28,9 @@ "SpreadCompoundMethod", "IndexMethod", "FXDeltaMethod", + "SwaptionSettlementMethod", "FXOptionMetric", + "IROptionMetric", "LegMtm", "LegIndexBase", "NoInput", diff --git a/python/rateslib/enums/parameters.py b/python/rateslib/enums/parameters.py index 0172494fd..cbc17e7f1 100644 --- a/python/rateslib/enums/parameters.py +++ b/python/rateslib/enums/parameters.py @@ -13,40 +13,14 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Never -import rateslib.rs -from rateslib.rs import FloatFixingMethod, LegIndexBase +from rateslib.rs import FloatFixingMethod, IROptionMetric, LegIndexBase if TYPE_CHECKING: pass -class PicklingContainer: - pass - - -rateslib.rs.PyFloatFixingMethod = PicklingContainer() # type: ignore[attr-defined] - -rateslib.rs.PyFloatFixingMethod.RFRPaymentDelay = rateslib.rs.FloatFixingMethod.RFRPaymentDelay # type: ignore[attr-defined] -rateslib.rs.PyFloatFixingMethod.RFRObservationShift = ( # type: ignore[attr-defined] - rateslib.rs.FloatFixingMethod.RFRObservationShift -) -rateslib.rs.PyFloatFixingMethod.RFRLockout = rateslib.rs.FloatFixingMethod.RFRLockout # type: ignore[attr-defined] -rateslib.rs.PyFloatFixingMethod.RFRLookback = rateslib.rs.FloatFixingMethod.RFRLookback # type: ignore[attr-defined] -rateslib.rs.PyFloatFixingMethod.RFRPaymentDelayAverage = ( # type: ignore[attr-defined] - rateslib.rs.FloatFixingMethod.RFRPaymentDelayAverage -) -rateslib.rs.PyFloatFixingMethod.RFRObservationShiftAverage = ( # type: ignore[attr-defined] - rateslib.rs.FloatFixingMethod.RFRObservationShiftAverage -) -rateslib.rs.PyFloatFixingMethod.RFRLockoutAverage = rateslib.rs.FloatFixingMethod.RFRLockoutAverage # type: ignore[attr-defined] -rateslib.rs.PyFloatFixingMethod.RFRLookbackAverage = ( # type: ignore[attr-defined] - rateslib.rs.FloatFixingMethod.RFRLookbackAverage -) -rateslib.rs.PyFloatFixingMethod.IBOR = rateslib.rs.FloatFixingMethod.IBOR # type: ignore[attr-defined] - - class OptionType(float, Enum): """ Enumerable type to define option directions. @@ -65,6 +39,16 @@ class FXOptionMetric(Enum): Percent = 1 +class SwaptionSettlementMethod(Enum): + """ + Enumerable type for swaption settlement methods. + """ + + Physical = 0 + CashParTenor = 1 + CashCollateralized = 2 + + class LegMtm(Enum): """ Enumerable type to define :class:`~rateslib.data.fixings.FXFixing` dates for non-deliverable @@ -78,25 +62,6 @@ class LegMtm(Enum): Payment = 2 -_LEG_MTM_MAP = { - "initial": LegMtm.Initial, - "xcs": LegMtm.XCS, - "payment": LegMtm.Payment, -} - - -def _get_leg_mtm(leg_mtm: str | LegMtm) -> LegMtm: - if isinstance(leg_mtm, LegMtm): - return leg_mtm - else: - try: - return _LEG_MTM_MAP[leg_mtm.lower()] - except KeyError: - raise ValueError( - f"`mtm` as string: '{leg_mtm}' is not a valid option. Please consult docs." - ) - - class IndexMethod(Enum): """ Enumerable type to define determining the index value on some reference value date. @@ -142,6 +107,50 @@ def __str__(self) -> str: return self.name +_SWAPTION_SETTLEMENT_MAP = { + "physical": SwaptionSettlementMethod.Physical, + "cash_par_tenor": SwaptionSettlementMethod.CashParTenor, + "cash_collateralized": SwaptionSettlementMethod.CashCollateralized, + # aliases + "cashcollateralized": SwaptionSettlementMethod.CashCollateralized, + "cashpartenor": SwaptionSettlementMethod.CashParTenor, +} + + +def _get_swaption_settlement_method( + method: str | SwaptionSettlementMethod, +) -> SwaptionSettlementMethod: + if isinstance(method, SwaptionSettlementMethod): + return method + else: + try: + return _SWAPTION_SETTLEMENT_MAP[method.lower()] + except KeyError: + raise ValueError( + f"`swaption_settlement_method` as string: '{method}' is not a valid option. " + f"Please consult docs." + ) + + +_LEG_MTM_MAP = { + "initial": LegMtm.Initial, + "xcs": LegMtm.XCS, + "payment": LegMtm.Payment, +} + + +def _get_leg_mtm(leg_mtm: str | LegMtm) -> LegMtm: + if isinstance(leg_mtm, LegMtm): + return leg_mtm + else: + try: + return _LEG_MTM_MAP[leg_mtm.lower()] + except KeyError: + raise ValueError( + f"`mtm` as string: '{leg_mtm}' is not a valid option. Please consult docs." + ) + + _INDEX_METHOD_MAP = { "daily": IndexMethod.Daily, "monthly": IndexMethod.Monthly, @@ -275,11 +284,53 @@ def _get_fx_option_metric(method: str | FXOptionMetric) -> FXOptionMetric: ) +_IR_METRIC_MAP: dict[str, type[IROptionMetric]] = { + "normal_vol": IROptionMetric.NormalVol, + "log_normal_vol": IROptionMetric.LogNormalVol, + "cash": IROptionMetric.Cash, + "percent_notional": IROptionMetric.PercentNotional, + "black_vol_shift": IROptionMetric.BlackVolShift, + # aliases + "normalvol": IROptionMetric.NormalVol, + "lognormalvol": IROptionMetric.LogNormalVol, + "percentnotional": IROptionMetric.PercentNotional, + "blackvolshift": IROptionMetric.BlackVolShift, +} + + +def _get_ir_option_metric(method: str | IROptionMetric) -> IROptionMetric: + if isinstance(method, IROptionMetric): + return method + else: + method = method.lower() + if "shift" in method: + idx = method.rfind("_") + if idx < 0: + raise ValueError( + "The 'BlackVolShift' metric must have an underscore and shift, e.g. " + "'black_vol_shift_100" + ) + else: + args: tuple[Never, ...] | tuple[int]= (int(method[idx + 1 :]),) + method = method[:idx] + else: + args = tuple() + + try: + return _IR_METRIC_MAP[method](*args) + except KeyError: + raise ValueError( + f"IROption `metric` as string: '{method}' is not a valid option. Please consult " + f"documentation." + ) + + __all__ = [ "SpreadCompoundMethod", "FloatFixingMethod", "FXDeltaMethod", "FXOptionMetric", + "IROptionMetric", "LegMtm", "LegIndexBase", "OptionType", diff --git a/python/rateslib/instruments/__init__.py b/python/rateslib/instruments/__init__.py index a796e3ba9..26e40fb08 100644 --- a/python/rateslib/instruments/__init__.py +++ b/python/rateslib/instruments/__init__.py @@ -36,6 +36,11 @@ from rateslib.instruments.fx_swap import FXSwap from rateslib.instruments.fx_vol_value import FXVolValue from rateslib.instruments.iirs import IIRS +from rateslib.instruments.ir_options import ( + PayerSwaption, + ReceiverSwaption, +) +from rateslib.instruments.ir_vol_value import IRVolValue from rateslib.instruments.irs import IRS from rateslib.instruments.ndf import NDF from rateslib.instruments.ndxcs import NDXCS @@ -82,12 +87,16 @@ "FXStraddle", "FXStrangle", "FXBrokerFly", + # ir options + "ReceiverSwaption", + "PayerSwaption", # generics "Portfolio", "Fly", "Spread", "Value", "FXVolValue", + "IRVolValue", "BondCalcMode", "BillCalcMode", "_BaseInstrument", diff --git a/python/rateslib/instruments/fx_options/call_put.py b/python/rateslib/instruments/fx_options/call_put.py index 25738be7c..2a1e53d18 100644 --- a/python/rateslib/instruments/fx_options/call_put.py +++ b/python/rateslib/instruments/fx_options/call_put.py @@ -18,14 +18,13 @@ from pandas import DataFrame -from rateslib import FXDeltaVolSmile, FXDeltaVolSurface, defaults +from rateslib import defaults from rateslib.curves._parsers import _validate_obj_not_no_input from rateslib.data.fixings import _fx_index_set_cross, _get_fx_index from rateslib.default import PlotOutput, plot from rateslib.dual.utils import _dual_float from rateslib.enums.generics import NoInput, _drb from rateslib.enums.parameters import FXOptionMetric, _get_fx_delta_type -from rateslib.fx_volatility import FXSabrSmile, FXSabrSurface from rateslib.instruments.protocols import _BaseInstrument, _KWArgs from rateslib.instruments.protocols.pricing import ( _Curves, @@ -38,6 +37,8 @@ from rateslib.periods import Cashflow, FXCallPeriod, FXPutPeriod from rateslib.periods.utils import _validate_fx_as_forwards from rateslib.scheduling.frequency import _get_fx_expiry_and_delivery_and_payment +from rateslib.volatility import FXDeltaVolSmile, FXDeltaVolSurface, FXSabrSmile, FXSabrSurface +from rateslib.volatility.ir import IRVolObj if TYPE_CHECKING: from typing import NoReturn # pragma: no cover @@ -167,6 +168,8 @@ def _parse_vol(cls, vol: VolT_) -> _Vol: """ if isinstance(vol, _Vol): return vol + elif isinstance(vol, IRVolObj): + raise TypeError("`vol` cannot be an IR type vol object and must be FX type vol object.") else: return _Vol(fx_vol=vol) diff --git a/python/rateslib/instruments/fx_options/strangle.py b/python/rateslib/instruments/fx_options/strangle.py index 022c99bb3..7b87d9a1f 100644 --- a/python/rateslib/instruments/fx_options/strangle.py +++ b/python/rateslib/instruments/fx_options/strangle.py @@ -19,7 +19,6 @@ from rateslib.dual.utils import _set_ad_order_objects from rateslib.enums.generics import NoInput, _drb from rateslib.enums.parameters import FXDeltaMethod -from rateslib.fx_volatility import FXDeltaVolSmile, FXDeltaVolSurface, FXSabrSmile, FXSabrSurface from rateslib.instruments.fx_options.call_put import FXCall, FXPut from rateslib.instruments.fx_options.risk_reversal import _BaseFXOptionStrat from rateslib.instruments.protocols.pricing import ( @@ -30,6 +29,7 @@ ) from rateslib.periods.utils import _validate_fx_as_forwards from rateslib.splines import evaluate +from rateslib.volatility import FXDeltaVolSmile, FXDeltaVolSurface, FXSabrSmile, FXSabrSurface if TYPE_CHECKING: from rateslib.local_types import ( # pragma: no cover diff --git a/python/rateslib/instruments/fx_vol_value.py b/python/rateslib/instruments/fx_vol_value.py index c2726cdcb..b90be48b8 100644 --- a/python/rateslib/instruments/fx_vol_value.py +++ b/python/rateslib/instruments/fx_vol_value.py @@ -15,7 +15,6 @@ from rateslib import defaults from rateslib.enums.generics import NoInput, _drb -from rateslib.fx_volatility import FXDeltaVolSmile, FXDeltaVolSurface, FXSabrSmile, FXSabrSurface from rateslib.instruments.protocols import _BaseInstrument from rateslib.instruments.protocols.kwargs import _KWArgs from rateslib.instruments.protocols.pricing import ( @@ -24,6 +23,8 @@ _Vol, ) from rateslib.periods.utils import _validate_fx_as_forwards +from rateslib.volatility import FXDeltaVolSmile, FXDeltaVolSurface, FXSabrSmile, FXSabrSurface +from rateslib.volatility.ir import IRVolObj if TYPE_CHECKING: from rateslib.local_types import ( # pragma: no cover @@ -47,13 +48,13 @@ class FXVolValue(_BaseInstrument): Examples -------- - The below :class:`~rateslib.fx_volatility.FXDeltaVolSmile` is solved directly + The below :class:`~rateslib.volatility.FXDeltaVolSmile` is solved directly from calibrating volatility values. .. ipython:: python :suppress: - from rateslib.fx_volatility import FXDeltaVolSmile + from rateslib.volatility import FXDeltaVolSmile from rateslib.instruments import FXVolValue from rateslib.solver import Solver @@ -134,7 +135,12 @@ def __init__( def _parse_vol(self, vol: VolT_) -> _Vol: if isinstance(vol, _Vol): return vol - return _Vol(fx_vol=vol) + elif isinstance(vol, IRVolObj): + raise TypeError( + f"`vol` must be suitable object for FX vol pricing. Got {type(vol).__name__}" + ) + else: + return _Vol(fx_vol=vol) def rate( self, diff --git a/python/rateslib/instruments/ir_options/__init__.py b/python/rateslib/instruments/ir_options/__init__.py new file mode 100644 index 000000000..2256f495e --- /dev/null +++ b/python/rateslib/instruments/ir_options/__init__.py @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: LicenseRef-Rateslib-Dual +# +# Copyright (c) 2026 Siffrorna Technology Limited +# +# Dual-licensed: Free Educational Licence or Paid Commercial Licence (commercial/professional use) +# Source-available, not open source. +# +# See LICENSE and https://rateslib.com/py/en/latest/i_licence.html for details, +# and/or contact info (at) rateslib (dot) com +#################################################################################################### + +from rateslib.instruments.ir_options.call_put import PayerSwaption, ReceiverSwaption, _BaseIROption + +__all__ = [ + "PayerSwaption", + "ReceiverSwaption", + "_BaseIROption", +] diff --git a/python/rateslib/instruments/ir_options/call_put.py b/python/rateslib/instruments/ir_options/call_put.py new file mode 100644 index 000000000..9ab149f17 --- /dev/null +++ b/python/rateslib/instruments/ir_options/call_put.py @@ -0,0 +1,998 @@ +# SPDX-License-Identifier: LicenseRef-Rateslib-Dual +# +# Copyright (c) 2026 Siffrorna Technology Limited +# +# Dual-licensed: Free Educational Licence or Paid Commercial Licence (commercial/professional use) +# Source-available, not open source. +# +# See LICENSE and https://rateslib.com/py/en/latest/i_licence.html for details, +# and/or contact info (at) rateslib (dot) com +#################################################################################################### + +from __future__ import annotations + +from abc import ABCMeta +from dataclasses import dataclass +from datetime import datetime +from typing import TYPE_CHECKING + +from rateslib import defaults +from rateslib.curves._parsers import _validate_obj_not_no_input +from rateslib.dual.utils import _dual_float +from rateslib.enums.generics import NoInput, _drb +from rateslib.enums.parameters import ( + IROptionMetric, + SwaptionSettlementMethod, + _get_ir_option_metric, +) +from rateslib.instruments.irs import IRS +from rateslib.instruments.protocols import _BaseInstrument, _KWArgs +from rateslib.instruments.protocols.pricing import ( + _Curves, + _maybe_get_curve_maybe_from_solver, + _maybe_get_ir_vol_maybe_from_solver, + _Vol, +) +from rateslib.legs import CustomLeg +from rateslib.periods import Cashflow, IRSCallPeriod, IRSPutPeriod +from rateslib.periods.utils import ( + _get_ir_vol_value_and_forward_maybe_from_obj, +) +from rateslib.volatility.fx import FXVolObj +from rateslib.volatility.ir import IRSabrSmile +from rateslib.volatility.ir.utils import _get_ir_expiry_and_payment + +if TYPE_CHECKING: + from rateslib.local_types import ( # pragma: no cover + Any, + CurveOption_, + CurvesT_, + DataFrame, + DualTypes, + DualTypes_, + FXForwards_, + IROptionMetric, + IRSSeries, + Sequence, + Solver_, + VolT_, + _BaseCurve, + _BaseCurve_, + _BaseIRSOptionPeriod, + _BaseLeg, + _IRVolOption_, + datetime_, + str_, + ) + + +@dataclass +class _IRVolPricingMetrics: + """None elements are used as flags to indicate an element is not yet set.""" + + vol: DualTypes + k: DualTypes + t_e: DualTypes + f: DualTypes + shift: DualTypes + + +class _BaseIROption(_BaseInstrument, metaclass=ABCMeta): + """ + Abstract base class for implementing *IROptions*. + + See :class:`~rateslib.instruments.PayerSwaption` and + :class:`~rateslib.instruments.ReceiverSwaption`. + """ + + _rate_scalar: float = 1.0 + _pricing: _IRVolPricingMetrics + + @property + def leg1(self) -> CustomLeg: + """The :class:`~rateslib.legs.CustomLeg` of the *Instrument* containing the + :class:`~rateslib.periods.FXOptionPeriod`.""" + return self._leg1 + + @property + def leg2(self) -> CustomLeg: + """The :class:`~rateslib.legs.CustomLeg` of the *Instrument* containing the + premium :class:`~rateslib.periods.Cashflow`.""" + return self._leg2 + + @property + def legs(self) -> Sequence[_BaseLeg]: + """A list of the *Legs* of the *Instrument*.""" + return self._legs + + @property + def _option(self) -> _BaseIRSOptionPeriod: + return self.leg1.periods[0] # type: ignore[return-value] + + @property + def _irs(self) -> IRS: + return self._option.ir_option_params.option_fixing.irs + + @property + def _premium(self) -> Cashflow: + return self.leg2.periods[0] # type: ignore[return-value] + + @classmethod + def _parse_curves(cls, curves: CurvesT_) -> _Curves: + """ + A Swaption has 3 curve requirements. See **Pricing**. + """ + if isinstance(curves, NoInput): + return _Curves() + elif isinstance(curves, list | tuple): + if len(curves) == 1: + return _Curves( + rate_curve=curves[0], + index_curve=curves[0], + disc_curve=curves[0], + leg2_disc_curve=curves[0], + ) + elif len(curves) == 2: + return _Curves( + rate_curve=curves[0], + disc_curve=curves[1], + index_curve=curves[1], + leg2_disc_curve=curves[1], + ) + elif len(curves) == 3: + return _Curves( + rate_curve=curves[0], + disc_curve=curves[1], + index_curve=curves[2], + leg2_disc_curve=curves[1], + ) + else: + raise ValueError( + f"{type(cls).__name__} requires only 2 curve types. Got {len(curves)}." + ) + elif isinstance(curves, dict): + return _Curves( + rate_curve=curves.get("rate_curve", NoInput(0)), + disc_curve=curves.get("disc_curve", NoInput(0)), + index_curve=curves.get("index_curve", NoInput(0)), + leg2_disc_curve=_drb( + curves.get("disc_curve", NoInput(0)), + curves.get("leg2_disc_curve", NoInput(0)), + ), + ) + elif isinstance(curves, _Curves): + return curves + else: # `curves` is just a single input which is copied across all curves + return _Curves( + rate_curve=curves, # type: ignore[arg-type] + disc_curve=curves, # type: ignore[arg-type] + index_curve=curves, # type: ignore[arg-type] + leg2_disc_curve=curves, # type: ignore[arg-type] + ) + + @classmethod + def _parse_vol(cls, vol: VolT_) -> _Vol: + """ + FXoptions requires only a single FXVolObj or a scalar. + """ + if isinstance(vol, _Vol): + return vol + elif isinstance(vol, FXVolObj): + raise TypeError("`vol` cannot be an FX type vol object and must be IR type vol object.") + else: + return _Vol(ir_vol=vol) + + def __init__( + self, + expiry: datetime | str, + tenor: datetime | str, + strike: DualTypes | str, + irs_series: IRSSeries | str, + *, + notional: DualTypes_ = NoInput(0), + eval_date: datetime | NoInput = NoInput(0), + premium: DualTypes_ = NoInput(0), + payment_lag: str | datetime_ = NoInput(0), + option_fixings: DualTypes_ = NoInput(0), + settlement_method: SwaptionSettlementMethod | str_ = NoInput(0), + metric: IROptionMetric | str_ = NoInput(0), + curves: CurvesT_ = NoInput(0), + vol: VolT_ = NoInput(0), + spec: str_ = NoInput(0), + call: bool = True, + ): + user_args = dict( + tenor=tenor, + expiry=expiry, + notional=notional, + strike=strike, + irs_series=irs_series, + option_fixings=option_fixings, + settlement_method=settlement_method, + leg2_payment_lag=payment_lag, + leg2_premium=premium, + metric=metric, + curves=self._parse_curves(curves), + vol=self._parse_vol(vol), + ) + # instrument_args: dict[str, Any] = dict() + default_args = dict( + notional=defaults.notional, + metric=defaults.ir_option_metric, + settlement_method=defaults.ir_option_settlement, + ) + self._kwargs = _KWArgs( + user_args=user_args, + default_args=default_args, + spec=spec, + meta_args=["curves", "vol", "metric"], + ) + + # determine the `expiry` and `delivery` as datetimes if derived from other combinations + (self.kwargs.leg1["expiry"], self.kwargs.leg2["payment"]) = _get_ir_expiry_and_payment( + eval_date=eval_date, + expiry=self.kwargs.leg1["expiry"], + irs_series=self.kwargs.leg1["irs_series"], + payment_lag=self.kwargs.leg2["payment_lag"], + ) + + self._leg1 = CustomLeg( + [ + IRSCallPeriod( # type: ignore[abstract] + expiry=self.kwargs.leg1["expiry"], + tenor=self.kwargs.leg1["tenor"], + irs_series=self.kwargs.leg1["irs_series"], + strike=NoInput(0) + if isinstance(self.kwargs.leg1["strike"], str) + else self.kwargs.leg1["strike"], + notional=self.kwargs.leg1["notional"], + option_fixings=self.kwargs.leg1["option_fixings"], + metric=self.kwargs.meta["metric"], + settlement_method=self.kwargs.leg1["settlement_method"], + ) + if call + else IRSPutPeriod( # type: ignore[abstract] + expiry=self.kwargs.leg1["expiry"], + tenor=self.kwargs.leg1["tenor"], + irs_series=self.kwargs.leg1["irs_series"], + strike=NoInput(0) + if isinstance(self.kwargs.leg1["strike"], str) + else self.kwargs.leg1["strike"], + notional=self.kwargs.leg1["notional"], + option_fixings=self.kwargs.leg1["option_fixings"], + metric=self.kwargs.meta["metric"], + settlement_method=self.kwargs.leg1["settlement_method"], + ) + ] + ) + self._leg2 = CustomLeg( + [ + Cashflow( + notional=_drb(0.0, self.kwargs.leg2["premium"]), + payment=self.kwargs.leg2["payment"], + currency=self._leg1.settlement_params.currency, + ), + ] + ) + self._legs = [self._leg1, self._leg2] + + def __repr__(self) -> str: + return f"" + + def _set_strike_and_vol( + self, + rate_curve: CurveOption_, + disc_curve: _BaseCurve_, + index_curve: _BaseCurve_, + vol: _IRVolOption_, + ) -> None: + """ + Set the strike, if necessary, and determine pricing metrics from the volatility objects. + + The strike for the *OptionPeriod* is either; string or numeric. + + If it is string, then a numeric strike must be determined with an associated vol. + + If it is numeric then the volatility must be determined for the given strike. + + Pricing elements are captured and cached so they can be used later by subsequent methods. + """ + _ir_price_params = _get_ir_vol_value_and_forward_maybe_from_obj( + rate_curve=rate_curve, + index_curve=index_curve, + strike=self.kwargs.leg1["strike"], + ir_vol=vol, + irs=self._irs, + tenor=self._option.ir_option_params.option_fixing.termination, + expiry=self._option.ir_option_params.expiry, + ) + + if isinstance(vol, IRSabrSmile): + eval_date = vol.meta.eval_date + else: + _ = _validate_obj_not_no_input(disc_curve, "disc_curve") + eval_date = _.nodes.initial + t_e_ = self._option.ir_option_params.time_to_expiry(eval_date) + + _pricing = _IRVolPricingMetrics( + vol=_ir_price_params.vol, + k=_ir_price_params.k, + t_e=t_e_, + f=_ir_price_params.f, + shift=_ir_price_params.shift, + ) + + # Review section in book regarding Hyper-parameters and Solver interaction + self._option.ir_option_params.strike = _pricing.k + self._pricing = _pricing + # self._option_periods[0].strike = _dual_float(self._pricing.k) + + def _set_premium( + self, + rate_curve: CurveOption_, + disc_curve: _BaseCurve_, + index_curve: _BaseCurve_, + pricing: _IRVolPricingMetrics, + ) -> None: + """ + Set an unspecified premium on the Option to be equal to the mid-market premium. + """ + if isinstance(self.kwargs.leg2["premium"], NoInput): + # then set the CashFlow to mid-market + npv: DualTypes = self._option.npv( # type: ignore[assignment] + rate_curve=rate_curve, + disc_curve=disc_curve, + index_curve=index_curve, + ir_vol=pricing.vol, + local=False, + forward=self.kwargs.leg2["payment"], + ) + self._premium.settlement_params._notional = _dual_float(npv) + + def rate( + self, + *, + curves: CurvesT_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + vol: VolT_ = NoInput(0), + base: str_ = NoInput(0), + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + metric: IROptionMetric | str_ = NoInput(0), + ) -> DualTypes: + _curves = self._parse_curves(curves) + _vol = self._parse_vol(vol) + rate_curve = _maybe_get_curve_maybe_from_solver( + curves=_curves, curves_meta=self.kwargs.meta["curves"], solver=solver, name="rate_curve" + ) + disc_curve: _BaseCurve = _validate_obj_not_no_input( + _maybe_get_curve_maybe_from_solver( + curves=_curves, + curves_meta=self.kwargs.meta["curves"], + solver=solver, + name="disc_curve", + ), + name="disc_curve", + ) + index_curve: _BaseCurve = _validate_obj_not_no_input( + _maybe_get_curve_maybe_from_solver( + curves=_curves, + curves_meta=self.kwargs.meta["curves"], + solver=solver, + name="index_curve", + ), + name="index_curve", + ) + ir_vol = _maybe_get_ir_vol_maybe_from_solver( + vol=_vol, vol_meta=self.kwargs.meta["vol"], solver=solver + ) + self._set_strike_and_vol( + rate_curve=rate_curve, disc_curve=disc_curve, index_curve=index_curve, vol=ir_vol + ) + + # Premium is not required for rate and also sets as float + # Review section: "Hyper-parameters and Solver interaction" before enabling. + # self._set_premium(curves, fx) + + metric_ = _get_ir_option_metric(_drb(self.kwargs.meta["metric"], metric)) + del metric + + value = self._option.rate( + rate_curve=rate_curve, + disc_curve=disc_curve, + index_curve=index_curve, + ir_vol=ir_vol, + metric=metric_, + ) + if ( + metric_ in [IROptionMetric.Cash(), IROptionMetric.PercentNotional()] + and self.leg2.settlement_params.payment != self.leg1.settlement_params.payment + ): + disc_curve_ = _validate_obj_not_no_input(disc_curve, name="disc_curve") + del disc_curve + return ( + value + * disc_curve_[self.leg2.settlement_params.payment] + / disc_curve_[self.leg1.settlement_params.payment] + ) + else: + return value + + def npv( + self, + *, + curves: CurvesT_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + vol: VolT_ = NoInput(0), + base: str_ = NoInput(0), + local: bool = False, + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DualTypes | dict[str, DualTypes]: + _curves = self._parse_curves(curves) + _vol = self._parse_vol(vol) + rate_curve = _maybe_get_curve_maybe_from_solver( + curves=_curves, + curves_meta=self.kwargs.meta["curves"], + solver=solver, + name="rate_curve", + ) + disc_curve = _maybe_get_curve_maybe_from_solver( + curves=_curves, + curves_meta=self.kwargs.meta["curves"], + solver=solver, + name="disc_curve", + ) + index_curve = _maybe_get_curve_maybe_from_solver( + curves=_curves, + curves_meta=self.kwargs.meta["curves"], + solver=solver, + name="index_curve", + ) + ir_vol = _maybe_get_ir_vol_maybe_from_solver( + vol=_vol, vol_meta=self.kwargs.meta["vol"], solver=solver + ) + self._set_strike_and_vol( + rate_curve=rate_curve, disc_curve=disc_curve, index_curve=index_curve, vol=ir_vol + ) + + self._set_premium( + rate_curve=rate_curve, + disc_curve=disc_curve, + index_curve=index_curve, + pricing=self._pricing, + ) + + if not local: + base_ = _drb(self.legs[0].settlement_params.currency, base) + else: + base_ = base + + opt_npv = self._option.npv( + rate_curve=rate_curve, # _validate_obj_not_no_input(rate_curve, "rate curve"), + disc_curve=disc_curve, + index_curve=index_curve, + fx=fx, + base=base_, + local=local, + ir_vol=self._pricing.vol, + settlement=settlement, + forward=forward, + ) + prem_npv = self._premium.npv( + disc_curve=_maybe_get_curve_maybe_from_solver( + curves=_curves, + curves_meta=self.kwargs.meta["curves"], + solver=solver, + name="leg2_disc_curve", + ), + fx=fx, + base=base_, + local=local, + settlement=settlement, + forward=forward, + ) + if local: + return {k: opt_npv.get(k, 0) + prem_npv.get(k, 0) for k in set(opt_npv) | set(prem_npv)} # type:ignore[union-attr, arg-type] + else: + return opt_npv + prem_npv # type: ignore[operator] + + def cashflows( + self, + *, + curves: CurvesT_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + vol: VolT_ = NoInput(0), + base: str_ = NoInput(0), + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DataFrame: + try: + _curves = self._parse_curves(curves) + _vol = self._parse_vol(vol) + rate_curve = _maybe_get_curve_maybe_from_solver( + curves=_curves, + curves_meta=self.kwargs.meta["curves"], + solver=solver, + name="rate_curve", + ) + disc_curve = _maybe_get_curve_maybe_from_solver( + curves=_curves, + curves_meta=self.kwargs.meta["curves"], + solver=solver, + name="disc_curve", + ) + index_curve = _maybe_get_curve_maybe_from_solver( + curves=_curves, + curves_meta=self.kwargs.meta["curves"], + solver=solver, + name="index_curve", + ) + ir_vol = _maybe_get_ir_vol_maybe_from_solver( + vol=_vol, vol_meta=self.kwargs.meta["vol"], solver=solver + ) + self._set_strike_and_vol( + rate_curve=rate_curve, + disc_curve=disc_curve, + index_curve=index_curve, + vol=ir_vol, + ) + self._set_premium( + rate_curve=rate_curve, + disc_curve=disc_curve, + index_curve=index_curve, + pricing=self._pricing, + ) + except Exception: # noqa: S110 + pass # `cashflows` proceed without pricing determined values + + return self._cashflows_from_legs( + curves=curves, + solver=solver, + fx=fx, + base=base, + settlement=settlement, + forward=forward, + vol=vol, + ) + + # def analytic_greeks( + # self, + # curves: CurvesT_ = NoInput(0), + # solver: Solver_ = NoInput(0), + # fx: FXForwards_ = NoInput(0), + # vol: FXVol_ = NoInput(0), + # ) -> dict[str, Any]: + # """ + # Return various pricing metrics of the *FX Option*. + # + # .. rubric:: Examples + # + # .. ipython:: python + # :suppress: + # + # from rateslib import Curve, FXCall, dt, FXForwards, FXRates, FXDeltaVolSmile + # + # .. ipython:: python + # + # eur = Curve({dt(2020, 1, 1): 1.0, dt(2021, 1, 1): 0.98}) + # usd = Curve({dt(2020, 1, 1): 1.0, dt(2021, 1, 1): 0.96}) + # fxf = FXForwards( + # fx_rates=FXRates({"eurusd": 1.10}, settlement=dt(2020, 1, 3)), + # fx_curves={"eureur": eur, "eurusd": eur, "usdusd": usd}, + # ) + # fxvs = FXDeltaVolSmile( + # nodes={0.25: 11.0, 0.5: 9.8, 0.75: 10.7}, + # delta_type="forward", + # eval_date=dt(2020, 1, 1), + # expiry=dt(2020, 4, 1) + # ) + # fxc = FXCall( + # expiry="3m", + # strike=1.10, + # eval_date=dt(2020, 1, 1), + # spec="eurusd_call", + # ) + # fxc.analytic_greeks(fx=fxf, curves=[eur, usd], vol=fxvs) + # + # Parameters + # ---------- + # curves: _Curves, :green:`optional` + # Pricing objects. See **Pricing** on each *Instrument* for details of allowed inputs. + # solver: Solver, :green:`optional` + # A :class:`~rateslib.solver.Solver` object containing *Curve*, *Smile*, *Surface*, or + # *Cube* mappings for pricing. + # fx: FXForwards, :green:`optional` + # The :class:`~rateslib.fx.FXForwards` object used for forecasting FX rates, if necessary. + # vol: _Vol, :green:`optional` + # Pricing objects. See **Pricing** on each *Instrument* for details of allowed inputs. + # + # Returns + # ------- + # dict + # """ + # return self._analytic_greeks_set_metrics( + # curves=curves, + # solver=solver, + # fx=fx, + # vol=vol, + # set_metrics=True, + # ) + # + # def _analytic_greeks_set_metrics( + # self, + # curves: CurvesT_ = NoInput(0), + # solver: Solver_ = NoInput(0), + # fx: FXForwards_ = NoInput(0), + # vol: FXVol_ = NoInput(0), + # set_metrics: bool_ = True, + # ) -> dict[str, Any]: + # """ + # Return various pricing metrics of the *FX Option*. + # + # Returns + # ------- + # float, Dual, Dual2 + # """ + # _curves = self._parse_curves(curves) + # _vol = self._parse_vol(vol) + # rate_curve = _maybe_get_curve_maybe_from_solver( + # curves=_curves, curves_meta=self.kwargs.meta["curves"], solver=solver, name="rate_curve" + # ) + # disc_curve = _maybe_get_curve_maybe_from_solver( + # curves=_curves, curves_meta=self.kwargs.meta["curves"], solver=solver, name="disc_curve" + # ) + # fx_vol = _maybe_get_fx_vol_maybe_from_solver( + # vol=_vol, vol_meta=self.kwargs.meta["vol"], solver=solver + # ) + # fx_ = _get_fx_forwards_maybe_from_solver(solver=solver, fx=fx) + # + # if set_metrics: + # self._set_strike_and_vol( + # rate_curve=rate_curve, disc_curve=disc_curve, fx=fx_, vol=fx_vol + # ) + # # self._set_premium(curves, fx) + # + # return self._option.analytic_greeks( + # rate_curve=_validate_obj_not_no_input(rate_curve, "rate curve"), + # disc_curve=_validate_obj_not_no_input(disc_curve, "disc curve"), + # fx=_validate_fx_as_forwards(fx_), + # fx_vol=fx_vol, + # premium=NoInput(0), + # premium_payment=self.kwargs.leg2["payment"], + # ) + # + # def _analytic_greeks_reduced( + # self, + # curves: CurvesT_ = NoInput(0), + # solver: Solver_ = NoInput(0), + # fx: FXForwards_ = NoInput(0), + # base: str_ = NoInput(0), + # vol: FXVol_ = NoInput(0), + # set_metrics: bool_ = True, + # ) -> dict[str, Any]: + # """ + # Return various pricing metrics of the *FX Option*. + # """ + # _curves = self._parse_curves(curves) + # _vol = self._parse_vol(vol) + # rate_curve = _maybe_get_curve_maybe_from_solver( + # curves=_curves, curves_meta=self.kwargs.meta["curves"], solver=solver, name="rate_curve" + # ) + # disc_curve = _maybe_get_curve_maybe_from_solver( + # curves=_curves, curves_meta=self.kwargs.meta["curves"], solver=solver, name="disc_curve" + # ) + # fx_vol = _maybe_get_fx_vol_maybe_from_solver( + # vol=_vol, vol_meta=self.kwargs.meta["vol"], solver=solver + # ) + # fx_ = _get_fx_forwards_maybe_from_solver(solver=solver, fx=fx) + # + # if set_metrics: + # self._set_strike_and_vol( + # rate_curve=rate_curve, disc_curve=disc_curve, fx=fx_, vol=fx_vol + # ) + # # self._set_premium(curves, fx) + # + # return self._option._base_analytic_greeks( + # rate_curve=_validate_obj_not_no_input(rate_curve, "rate_curve"), + # disc_curve=_validate_obj_not_no_input(disc_curve, "disc_curve"), + # fx=_validate_fx_as_forwards(fx_), + # fx_vol=self._pricing.vol, # type: ignore[arg-type] # vol is set and != None + # premium=NoInput(0), + # _reduced=True, + # ) # none of the reduced greeks need a VolObj - faster to reuse from _pricing.vol + + # def analytic_delta(self, *args: Any, leg: int = 1, **kwargs: Any) -> NoReturn: + # """Not implemented for Option types. + # Use :meth:`~rateslib.instruments._BaseFXOption.analytic_greeks`. + # """ + # raise NotImplementedError("For Option types use `analytic_greeks`.") + # + # def _plot_payoff( + # self, + # window: tuple[float, float] | NoInput = NoInput(0), + # curves: CurvesT_ = NoInput(0), + # solver: Solver_ = NoInput(0), + # fx: FXForwards_ = NoInput(0), + # vol: IRVol_ = NoInput(0), + # ) -> tuple[ + # np.ndarray[tuple[int], np.dtype[np.float64]], np.ndarray[tuple[int], np.dtype[np.float64]] + # ]: + # """ + # Mechanics to determine (x,y) coordinates for payoff at expiry plot. + # """ + # + # _curves = self._parse_curves(curves) + # _vol = self._parse_vol(vol) + # rate_curve = _validate_obj_not_no_input( + # _maybe_get_curve_maybe_from_solver( + # curves=_curves, + # curves_meta=self.kwargs.meta["curves"], + # solver=solver, + # name="rate_curve", + # ), + # "rate_curve", + # ) + # disc_curve = _validate_obj_not_no_input( + # _maybe_get_curve_maybe_from_solver( + # curves=_curves, + # curves_meta=self.kwargs.meta["curves"], + # solver=solver, + # name="disc_curve", + # ), + # "disc curve", + # ) + # fx_vol = _maybe_get_fx_vol_maybe_from_solver( + # vol=_vol, vol_meta=self.kwargs.meta["vol"], solver=solver + # ) + # fx_ = _get_fx_forwards_maybe_from_solver(solver=solver, fx=fx) + # self._set_strike_and_vol(rate_curve=rate_curve, disc_curve=disc_curve, fx=fx_, vol=fx_vol) + # # self._set_premium(curves, fx) + # + # x, y = self._option._payoff_at_expiry(window) + # return x, y + # + # def plot_payoff( + # self, + # range: tuple[float, float] | NoInput = NoInput(0), # noqa: A002 + # curves: CurvesT_ = NoInput(0), + # solver: Solver_ = NoInput(0), + # fx: FXForwards_ = NoInput(0), + # base: str_ = NoInput(0), + # vol: float_ = NoInput(0), + # ) -> PlotOutput: + # """ + # Return a plot of the payoff at expiry, indexed by the *FXFixing* value. + # + # Parameters + # ---------- + # range: list of float, :green:`optional` + # A range of values for the *FXFixing* value at expiry to use as the x-axis. + # curves: _Curves, :green:`optional` + # Pricing objects. See **Pricing** on each *Instrument* for details of allowed inputs. + # solver: Solver, :green:`optional` + # A :class:`~rateslib.solver.Solver` object containing *Curve*, *Smile*, *Surface*, or + # *Cube* mappings for pricing. + # fx: FXForwards, :green:`optional` + # The :class:`~rateslib.fx.FXForwards` object used for forecasting FX rates, if necessary. + # vol: _Vol, :green:`optional` + # Pricing objects. See **Pricing** on each *Instrument* for details of allowed inputs. + # + # Returns + # ------- + # (Figure, Axes, list[Lines2D]) + # """ + # + # x, y = self._plot_payoff(window=range, curves=curves, solver=solver, fx=fx, vol=vol) + # return plot([x], [y]) # type: ignore + # + # def local_analytic_rate_fixings( + # self, + # *, + # curves: CurvesT_ = NoInput(0), + # solver: Solver_ = NoInput(0), + # fx: FXForwards_ = NoInput(0), + # vol: VolT_ = NoInput(0), + # settlement: datetime_ = NoInput(0), + # forward: datetime_ = NoInput(0), + # ) -> DataFrame: + # return DataFrame() + # + # def spread( + # self, + # *, + # curves: CurvesT_ = NoInput(0), + # solver: Solver_ = NoInput(0), + # fx: FXForwards_ = NoInput(0), + # vol: VolT_ = NoInput(0), + # base: str_ = NoInput(0), + # settlement: datetime_ = NoInput(0), + # forward: datetime_ = NoInput(0), + # ) -> DualTypes: + # """ + # Not implemented for Option types. Use :meth:`~rateslib.instruments._BaseFXOption.rate`. + # """ + # raise NotImplementedError(f"`spread` is not implemented for type: {type(self).__name__}") + + +class PayerSwaption(_BaseIROption): + """ + An *IR Payer* swaption. + + .. warning:: + + *Swaptions* are in Beta status introduced in v2.7.0 + + .. rubric:: Examples + + .. ipython:: python + :suppress: + + from rateslib import dt, Curve, PayerSwaption + + .. ipython:: python + + iro = PayerSwaption( + expiry=dt(2027, 2, 16), + tenor="6m", + strike=3.02, + notional=100e6, + irs_series="usd_irs", + premium=10000.0, + ) + # iro.cashflows() + + .. rubric:: Pricing + + A *Swaption* requires from one to three *Curves*; + + - a ``rate_curve`` used to forecast the rates on the :class:`~rateslib.legs.FloatLeg` of the + underlying :class:`~rateslib.instruments.IRS`. + - a ``disc_curve`` used to discount the value of the *Swaption* and the premium under the + terms of its bilateral collateral agreement. + - an ``index_curve`` used as the price alignment index rate for the discounting of the + underlying :class:`~rateslib.instruments.IRS`. This does not necessarily need to equal the + ``disc_curve``. + + Allowable inputs are: + + .. code-block:: python + + curves = rate_curve | [rate_curve] # one curve is used as all curves + curves = [rate_curve, disc_curve] # two curves are applied in the given order, index_curve is set equal to disc_curve + curves = [rate_curve, disc_curve, index_curve] # three curves applied in the given order + curves = { + "rate_curve": rate_curve, + "disc_curve": disc_curve + "index_curve": index_curve + } # dict form is explicit + + A *Swaption* also requires an *IRVolatility* object or numeric value for the ``vol`` argument. + If a numeric value is given it is assumed to be a Black (log-normal) volatility without shift. + Allowed inputs are: + + .. code-block:: python + + vol = 12.0 # a specific Black (log-normal) calendar-day annualized vol until expiry + vol = vol_obj # an explicit volatility object, e.g. IRSabrSmile + + The following pricing ``metric`` are available, with examples: + + .. ipython:: python + + curve = Curve( + nodes={dt(2026, 2, 16): 1.0, dt(2028, 2, 16): 0.941024343401225}, calendar="nyc" + ) + + - **"BlackVol" or "BlackVolShift100", "BlackVolShift200", "BlackVolShift300"**: + The *rate* method will make the necessary conversions between the different volatility + representations. + + .. ipython:: python + + iro.rate(curves=[curve], vol=25.16, metric="BlackVol") + iro.rate(curves=[curve], vol=25.16, metric="BlackVolShift100") + iro.rate(curves=[curve], vol=25.16, metric="BlackVolShift200") + iro.rate(curves=[curve], vol=25.16, metric="BlackVolShift300") + + - **"NormalVol"**: the equivalent number of basis point volatility used in the Bachelier + formula: + + .. ipython:: python + + iro.rate(curves=[curve], vol=25.16, metric="NormalVol") + + - **"Cash"**: the cash premium amount applicable to the 'payment' date, expressed in the + premium currency. + + .. ipython:: python + + iro.rate(curves=[curve], vol=25.16, metric="Cash") + + - **"PercentNotional"**: the cash premium amount expressed as a percentage of the + notional. + + .. ipython:: python + + iro.rate(curves=[curve], vol=25.16, metric="PercentNotional") + + .. role:: red + + .. role:: green + + Parameters + ---------- + . + + .. note:: + + The following define **ir option** and generalised **settlement** parameters. + + expiry: datetime, str, :red:`required` + The expiry of the option. If given in string tenor format, e.g. "1M" requires an + ``eval_date``. See **Notes**. + tenor: datetime, str, :red:`required` + The parameter defining the maturity of the underlying :class:`~rateslib.instruments.IRS`. + irs_series: IRSSeries, str, :red:`required` + The standard conventions applied to the underlying :class:`~rateslib.instruments.IRS`. + strike: float, Variable, str, :red:`required` + The strike value of the option. + If str, there are two possibilities; {"atm", "{}bps"}. "atm" will produce a strike equal + to the mid-market *IRS* rate, whilst "20bps" or "-50bps" will yield a strike that number + of basis points different to the mid-market rate. + notional: float, :green:`optional (set by 'defaults')` + The notional amount expressed in units of LHS of ``pair``. + eval_date: datetime, :green:`optional` + Only required if ``expiry`` is given as string tenor. + Should be entered as today (also called horizon) and **not** spot. + payment_lag: int or datetime, :green:`optional (set as IRS effective)` + The number of business days after expiry to pay premium. If a *datetime* is given this will + set the premium date explicitly. + settlement_method: SwaptionSettlementMethod, str, :green:`optional (set by 'default')` + The method for deriving the settlement cashflow or underlying value. + + .. note:: + + The following define additional **rate** parameters. + + premium: float, :green:`optional` + The amount paid for the option. If not given assumes an unpriced *Option* and sets this as + mid-market premium during pricing. + option_fixings: float, Dual, Dual2, Variable, Series, str, :green:`optional` + The value of the option :class:`~rateslib.data.fixings.IRSFixing`. If a scalar, is used + directly. If a string identifier, links to the central ``fixings`` object and data loader. + + .. note:: + + The following are **meta parameters**. + + metric : str, :green:`optional (set as "pips_or_%")` + The pricing metric returned by the ``rate`` method. See **Pricing**. + curves : _BaseCurve, str, dict, _Curves, Sequence, :green:`optional` + Pricing objects passed directly to the *Instrument's* methods' ``curves`` argument. See + **Pricing**. + vol: str, Smile, Surface, float, Dual, Dual2, Variable + Pricing objects passed directly to the *Instrument's* methods' ``vol`` argument. See + **Pricing**. + spec : str, optional + An identifier to pre-populate many field with conventional values. See + :ref:`here` for more info and available values. + + """ # noqa: E501 + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, call=True, **kwargs) + + +class ReceiverSwaption(_BaseIROption): + """ + An *IR Receiver* swaption. + + For parameters and examples see :class:`~rateslib.instruments.PayerSwaption`. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, call=False, **kwargs) diff --git a/python/rateslib/instruments/ir_vol_value.py b/python/rateslib/instruments/ir_vol_value.py new file mode 100644 index 000000000..69dfd6b9b --- /dev/null +++ b/python/rateslib/instruments/ir_vol_value.py @@ -0,0 +1,189 @@ +# SPDX-License-Identifier: LicenseRef-Rateslib-Dual +# +# Copyright (c) 2026 Siffrorna Technology Limited +# +# Dual-licensed: Free Educational Licence or Paid Commercial Licence (commercial/professional use) +# Source-available, not open source. +# +# See LICENSE and https://rateslib.com/py/en/latest/i_licence.html for details, +# and/or contact info (at) rateslib (dot) com +#################################################################################################### + +from __future__ import annotations + +from typing import TYPE_CHECKING, NoReturn + +from rateslib import defaults +from rateslib.enums.generics import NoInput, _drb +from rateslib.instruments.irs import IRS +from rateslib.instruments.protocols import _BaseInstrument +from rateslib.instruments.protocols.kwargs import _KWArgs +from rateslib.instruments.protocols.pricing import ( + _maybe_get_ir_vol_maybe_from_solver, + _Vol, +) +from rateslib.volatility.fx import FXVolObj +from rateslib.volatility.ir import IRSabrSmile + +if TYPE_CHECKING: + from rateslib.local_types import ( # pragma: no cover + Any, + CurvesT_, + DualTypes, + FXForwards_, + Solver_, + VolT_, + datetime_, + str_, + ) + + +class IRVolValue(_BaseInstrument): + """ + A pseudo *Instrument* used to calibrate an *IR Vol Object* within a + :class:`~rateslib.solver.Solver`. + + .. rubric:: Examples + + Examples + -------- + The below :class:`~rateslib.volatility.FXDeltaVolSmile` is solved directly + from calibrating volatility values. + + .. ipython:: python + :suppress: + + from rateslib.volatility import IRSabrSmile + from rateslib.instruments import IRVolValue + from rateslib.solver import Solver + + .. + .. ipython:: python + + smile = IRSabrSmile( + nodes={"alpha": 0.20, "beta": 0.5, "rho": 0.05, "nu": 0.60}, + eval_date=dt(2026, 2, 12), + tenor="1y", + expiry=dt(2027, 2, 12), + irs_series="usd_irs", + id="VolSmile", + ) + instruments = [ + IRVolValue(2.5, vol="VolSmile"), + IRVolValue(3.5, vol=smile) + ] + solver = Solver(curves=[smile], instruments=instruments, s=[8.9, 7.8]) + smile[2.1] + smile[2.5] + smile[3.5] + smile[3.9] + + .. rubric:: Pricing + + An *IR Vol Value* requires, and will calibrate, just one *IR Vol Object*. + + Allowable inputs are: + + .. code-block:: python + + vol = ir_vol_obj | [ir_vol_obj] # a single object is detected + vol = {"ir_vol": ir_vol_obj} # dict form is explicit + + The ``curves`` must match the pricing for an :class:`~rateslib.instruments.IRS`, since the + atm-rate is determined directly from an *IRS* instance. + + Currently the only available ``metric`` is *'vol'* which returns the specific volatility value + for the index value, i.e. a strike for an :class:`~rateslib.instruments.IRS`. + + .. role:: red + + .. role:: green + + Parameters + ---------- + index_value : float, Dual, Dual2, :red:`required` + The value of some index to the *IRVolSmile* or *IRVolSurface*. + expiry: datetime, :green:`optional` + The expiry at which to evaluate. This will only be used with *Surfaces*, not *Smiles*. + metric: str, :green:`optional (set as 'vol')` + The default metric to return from the ``rate`` method. + vol: str, IRSabrSmile, :green:`optional` + The associated object from which to determine the ``rate``. + curves : _BaseCurve, str, dict, _Curves, Sequence, :green:`optional` + Pricing objects passed directly to the *Instrument's* methods' ``curves`` argument. See + **Pricing**. + + """ + + _rate_scalar = 1.0 + + def __init__( + self, + index_value: DualTypes, + expiry: datetime_ = NoInput(0), + metric: str_ = NoInput(0), + vol: VolT_ = NoInput(0), + curves: CurvesT_ = NoInput(0), + ): + user_args = dict( + expiry=expiry, + index_value=index_value, + vol=self._parse_vol(vol), + metric=metric, + curves=IRS._parse_curves(curves), + ) + default_args = dict(convention=defaults.convention, metric="vol", curves=NoInput(0)) + self._kwargs = _KWArgs( + spec=NoInput(0), + user_args=user_args, + default_args=default_args, + meta_args=["curves", "metric", "vol", "curves"], + ) + + def _parse_vol(self, vol: VolT_) -> _Vol: + if isinstance(vol, _Vol): + return vol + elif isinstance(vol, FXVolObj): + raise TypeError( + f"`vol` must be suitable object for IR vol pricing. Got {type(vol).__name__}" + ) + return _Vol(ir_vol=vol) + + def rate( + self, + *, + curves: CurvesT_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + vol: VolT_ = NoInput(0), + base: str_ = NoInput(0), + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + metric: str_ = NoInput(0), + ) -> DualTypes: + _vol: _Vol = self._parse_vol(vol) + metric_ = _drb(self.kwargs.meta["metric"], metric).lower() + + if metric_ == "vol": + vol_ = _maybe_get_ir_vol_maybe_from_solver( + vol_meta=self.kwargs.meta["vol"], solver=solver, vol=_vol + ) + + if isinstance(vol_, IRSabrSmile): + return vol_.get_from_strike( + k=self.kwargs.leg1["index_value"], + curves=curves, + ).vol + else: + raise ValueError("`vol` as an object must be provided for VolValue.") + + raise ValueError("`metric` must be in {'vol'}.") + + def npv(self, *args: Any, **kwargs: Any) -> NoReturn: + raise NotImplementedError("`VolValue` instrument has no concept of NPV.") + + def cashflows(self, *args: Any, **kwargs: Any) -> NoReturn: + raise NotImplementedError("`VolValue` instrument has no concept of cashflows.") + + def analytic_delta(self, *args: Any, **kwargs: Any) -> NoReturn: + raise NotImplementedError("`VolValue` instrument has no concept of analytic delta.") diff --git a/python/rateslib/instruments/irs.py b/python/rateslib/instruments/irs.py index c390f918f..ffcd890da 100644 --- a/python/rateslib/instruments/irs.py +++ b/python/rateslib/instruments/irs.py @@ -30,7 +30,9 @@ if TYPE_CHECKING: from rateslib.local_types import ( # pragma: no cover + Adjuster, CalInput, + Convention, CurvesT_, DataFrame, DualTypes, @@ -66,6 +68,7 @@ class IRS(_BaseInstrument): from rateslib.data.fixings import FXIndex from datetime import datetime as dt from rateslib import fixings + from pandas import Series .. ipython:: python @@ -90,6 +93,8 @@ class IRS(_BaseInstrument): curves = [None, disc_curve, rate_curve, disc_curve] # four curves applied to each leg curves = {"leg2_rate_curve": rate_curve, "disc_curve": disc_curve} # dict form is explicit + ``metric`` is unused by *IRS* and is always fixed '*rate*'. + .. role:: red .. role:: green @@ -151,9 +156,9 @@ class IRS(_BaseInstrument): The :class:`~rateslib.scheduling.Adjuster` to use to map adjusted schedule dates into additional dates, which may be used, for example by fixings schedules. If given as integer will define the number of business days to lag dates by. - convention: str, :green:`optional (set by 'defaults')` - The day count convention applied to calculations of period accrual dates. - See :meth:`~rateslib.scheduling.dcf`. + convention: Convention, str, :green:`optional (set by 'defaults')` + The :class:`~rateslib.scheduling.Convention` applied to calculations of period accrual + dates. See :meth:`~rateslib.scheduling.dcf`. leg2_effective : datetime, :green:`optional (inherited from leg1)` leg2_termination : datetime, str, :green:`optional (inherited from leg1)` leg2_frequency : Frequency, str, :green:`optional (inherited from leg1)` @@ -167,7 +172,7 @@ class IRS(_BaseInstrument): leg2_payment_lag: Adjuster, int, :green:`optional (inherited from leg1)` leg2_payment_lag_exchange: Adjuster, int, :green:`optional (inherited from leg1)` leg2_ex_div: Adjuster, int, :green:`optional (inherited from leg1)` - leg2_convention: str, :green:`optional (inherited from leg1)` + leg2_convention: Convention, str, :green:`optional (inherited from leg1)` .. note:: @@ -192,7 +197,7 @@ class IRS(_BaseInstrument): The fixed rate applied to the :class:`~rateslib.legs.FixedLeg`. If `None` will be set to mid-market when curves are provided. leg2_fixing_method: FloatFixingMethod, str, :green:`optional (set by 'defaults')` - The :class:`~rateslib.enums.parameters.FloatFixingMethod` describing the determination + The :class:`~rateslib.enums.FloatFixingMethod` describing the determination of the floating rate for each period. leg2_fixing_frequency: Frequency, str, :green:`optional (set by 'frequency' or '1B')` The :class:`~rateslib.scheduling.Frequency` as a component of the @@ -339,12 +344,12 @@ def __init__( back_stub: datetime_ = NoInput(0), roll: int | RollDay | str_ = NoInput(0), eom: bool_ = NoInput(0), - modifier: str_ = NoInput(0), + modifier: Adjuster | str_ = NoInput(0), calendar: CalInput = NoInput(0), - payment_lag: int_ = NoInput(0), - payment_lag_exchange: int_ = NoInput(0), - ex_div: int_ = NoInput(0), - convention: str_ = NoInput(0), + payment_lag: Adjuster | str | int_ = NoInput(0), + payment_lag_exchange: Adjuster | str | int_ = NoInput(0), + ex_div: Adjuster | str | int_ = NoInput(0), + convention: Convention | str_ = NoInput(0), leg2_effective: datetime_ = NoInput(1), leg2_termination: datetime | str_ = NoInput(1), leg2_frequency: Frequency | str_ = NoInput(1), @@ -353,12 +358,12 @@ def __init__( leg2_back_stub: datetime_ = NoInput(1), leg2_roll: int | RollDay | str_ = NoInput(1), leg2_eom: bool_ = NoInput(1), - leg2_modifier: str_ = NoInput(1), + leg2_modifier: Adjuster | str_ = NoInput(1), leg2_calendar: CalInput = NoInput(1), - leg2_payment_lag: int_ = NoInput(1), - leg2_payment_lag_exchange: int_ = NoInput(1), - leg2_ex_div: int_ = NoInput(1), - leg2_convention: str_ = NoInput(1), + leg2_payment_lag: Adjuster | str | int_ = NoInput(1), + leg2_payment_lag_exchange: Adjuster | str | int_ = NoInput(1), + leg2_ex_div: Adjuster | str | int_ = NoInput(1), + leg2_convention: Convention | str_ = NoInput(1), # settlement parameters currency: str_ = NoInput(0), notional: float_ = NoInput(0), @@ -594,7 +599,8 @@ def _set_pricing_mid( def _parse_vol(self, vol: VolT_) -> _Vol: return _Vol() - def _parse_curves(self, curves: CurvesT_) -> _Curves: + @classmethod + def _parse_curves(cls, curves: CurvesT_) -> _Curves: """ An IRS has two curve requirements: a leg2_rate_curve and a disc_curve used by both legs. @@ -626,7 +632,7 @@ def _parse_curves(self, curves: CurvesT_) -> _Curves: ) else: raise ValueError( - f"{type(self).__name__} requires only 2 curve types. Got {len(curves)}." + f"{type(cls).__name__} requires only 2 curve types. Got {len(curves)}." ) elif isinstance(curves, dict): return _Curves( diff --git a/python/rateslib/instruments/protocols/cashflows.py b/python/rateslib/instruments/protocols/cashflows.py index e95f299c9..a3b265ec5 100644 --- a/python/rateslib/instruments/protocols/cashflows.py +++ b/python/rateslib/instruments/protocols/cashflows.py @@ -24,6 +24,7 @@ _maybe_get_curve_object_maybe_from_solver, _maybe_get_curve_or_dict_object_maybe_from_solver, _maybe_get_fx_vol_maybe_from_solver, + _maybe_get_ir_vol_maybe_from_solver, _WithPricingObjs, ) @@ -154,6 +155,7 @@ def _cashflows_from_legs( _fx_maybe_from_solver = _get_fx_maybe_from_solver(fx=fx, solver=solver) fx_vol = _maybe_get_fx_vol_maybe_from_solver(_vol_meta, _vol, solver) + ir_vol = _maybe_get_ir_vol_maybe_from_solver(_vol_meta, _vol, solver) legs_df = [ self.legs[0].cashflows( rate_curve=_maybe_get_curve_or_dict_object_maybe_from_solver( @@ -167,6 +169,7 @@ def _cashflows_from_legs( ), fx=_fx_maybe_from_solver, fx_vol=fx_vol, + ir_vol=ir_vol, settlement=settlement, forward=forward, base=base, diff --git a/python/rateslib/instruments/protocols/pricing.py b/python/rateslib/instruments/protocols/pricing.py index 948aa1876..5495c6d79 100644 --- a/python/rateslib/instruments/protocols/pricing.py +++ b/python/rateslib/instruments/protocols/pricing.py @@ -20,12 +20,13 @@ from rateslib.enums.generics import NoInput, _drb if TYPE_CHECKING: - from rateslib.local_types import ( + from rateslib.local_types import ( # pragma: no cover FX_, Any, CurvesT_, FXForwards_, FXVol_, + IRVol_, Solver, Solver_, VolT_, @@ -39,6 +40,8 @@ _BaseCurveOrIdOrIdDict_, _FXVolObj, _FXVolOption_, + _IRVolObj, + _IRVolOption_, ) @@ -145,19 +148,26 @@ def __init__( self, *, fx_vol: FXVol_ = NoInput(0), + ir_vol: IRVol_ = NoInput(0), ): self._fx_vol = fx_vol + self._ir_vol = ir_vol @property def fx_vol(self) -> FXVol_: """The FX vol object used for modelling FX volatility.""" return self._fx_vol + @property + def ir_vol(self) -> IRVol_: + """The IR vol object used for modelling IR volatility.""" + return self._ir_vol + def __eq__(self, other: Any) -> bool: if not isinstance(other, _Vol): return False else: - return self.fx_vol == other.fx_vol + return self.fx_vol == other.fx_vol and self.ir_vol == other.ir_vol # Solver and Curve mapping @@ -383,7 +393,7 @@ def _parse_curve_or_id_from_solver_(curve: _BaseCurveOrId, solver: Solver) -> _B raise ValueError("`curve` must be in `solver`.") -# Solver and Vol mapping +# Solver and FX Vol mapping def _maybe_get_fx_vol_maybe_from_solver( @@ -447,6 +457,70 @@ def _validate_fx_vol_is_not_id(fx_vol: _FXVolObj | str) -> _FXVolObj: return fx_vol +# Solver and IR Vol mapping + + +def _maybe_get_ir_vol_maybe_from_solver( + vol_meta: _Vol, + vol: _Vol, + # name: str, = "fx_vol" + solver: Solver_, +) -> _IRVolOption_: + ir_vol_ = _drb(vol_meta.ir_vol, vol.ir_vol) + if isinstance(ir_vol_, NoInput | float | Dual | Dual2 | Variable): + return ir_vol_ + elif isinstance(solver, NoInput): + return _validate_ir_vol_is_not_id(ir_vol=ir_vol_) + else: + return _get_ir_vol_from_solver(ir_vol=ir_vol_, solver=solver) + + +def _get_ir_vol_from_solver(ir_vol: _IRVolObj | str, solver: Solver) -> _IRVolObj: + if isinstance(ir_vol, str): + return solver._get_pre_irvol(ir_vol) + + try: + # it is a safeguard to load curves from solvers when a solver is + # provided and multiple curves might have the same id + __: _IRVolObj = solver._get_pre_irvol(ir_vol.id) + if id(__) != id(ir_vol): # Python id() is a memory id, not a string label id. + raise ValueError( + "An IRVol object has been supplied, as part of ``vol``, which has the same " + f"`id` ('{ir_vol.id}'),\nas one of the curves available as part of the " + "Solver's collection but is not the same object.\n" + "This is ambiguous and cannot price.\n" + "Either refactor the arguments as follows:\n" + "1) remove the conflicting object: [vol=[..], solver=] -> " + "[vol=None, solver=]\n" + "2) change the `id` of the supplied IRVol object and ensure the rateslib.defaults " + "option 'curve_not_in_solver' is set to 'ignore'.\n" + " This will remove the ability to accurately price risk metrics.", + ) + return __ + except AttributeError: + raise AttributeError( + "IRVol object has no attribute `id`, likely it is not a valid object, got: " + f"{ir_vol}.\nSince a solver is provided have you missed labelling the `curves` " + f"of the instrument or supplying `curves` directly?", + ) + except KeyError: + if defaults.curve_not_in_solver == "ignore": + return ir_vol + elif defaults.curve_not_in_solver == "warn": + warnings.warn("FXVol object not found in `solver`.", UserWarning) + return ir_vol + else: + raise ValueError("FXVol object must be in `solver`.") + + +def _validate_ir_vol_is_not_id(ir_vol: _IRVolObj | str) -> _IRVolObj: + if isinstance(ir_vol, str): # curve is a str ID + raise ValueError( + f"`vol` must contain IRVol object, not str, if `solver` not given. Got id: '{ir_vol}'" + ) + return ir_vol + + # FX and Solver mapping diff --git a/python/rateslib/legs/protocols/cashflows.py b/python/rateslib/legs/protocols/cashflows.py index 6786d7bfa..cceebb27f 100644 --- a/python/rateslib/legs/protocols/cashflows.py +++ b/python/rateslib/legs/protocols/cashflows.py @@ -27,6 +27,7 @@ _BaseCurve_, _BasePeriod, _FXVolOption_, + _IRVolOption_, datetime, datetime_, str_, @@ -50,6 +51,7 @@ def cashflows( index_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), base: str_ = NoInput(0), settlement: datetime_ = NoInput(0), forward: datetime_ = NoInput(0), @@ -64,27 +66,34 @@ def cashflows( should be avoided. It is more efficent to source relevant parameters or calculations from object attributes or other methods directly. + .. role:: red + + .. role:: green + Parameters ---------- - rate_curve: _BaseCurve or dict of such indexed by string tenor, optional + rate_curve: _BaseCurve or dict of such indexed by string tenor, :green:`optional` Used to forecast floating period rates, if necessary. - index_curve: _BaseCurve, optional + index_curve: _BaseCurve, :green:`optional` Used to forecast index values for indexation, if necessary. - disc_curve: _BaseCurve, optional + disc_curve: _BaseCurve, :green:`optional` Used to discount cashflows. - fx: FXForwards, optional + fx: FXForwards, :green:`optional` The :class:`~rateslib.fx.FXForwards` object used for forecasting the ``fx_fixing`` for deliverable cashflows, if necessary. Or, an :class:`~rateslib.fx.FXRates` object purely for immediate currency conversion. - fx_vol: FXDeltaVolSmile, FXSabrSmile, FXDeltaVolSurface, FXSabrSurface, optional + fx_vol: FXDeltaVolSmile, FXSabrSmile, FXDeltaVolSurface, FXSabrSurface, :green:`optional` The FX volatility *Smile* or *Surface* object used for determining Black calendar day implied volatility values. - base: str, optional + ir_vol: IRSabrSmile, :green:`optional` + The IR volatility *Smile* or *Surface* object used for determining Black calendar + day implied volatility values. + base: str, :green:`optional` The currency to convert relevant values into. - settlement: datetime, optional + settlement: datetime, :green:`optional` The assumed settlement date of the *PV* determination. Used only to evaluate *ex-dividend* status. - forward: datetime, optional + forward: datetime, :green:`optional` The future date to project the *PV* to using the ``disc_curve``. Returns @@ -98,6 +107,7 @@ def cashflows( index_curve=index_curve, fx=fx, fx_vol=fx_vol, + ir_vol=ir_vol, base=base, settlement=settlement, forward=forward, diff --git a/python/rateslib/local_types.py b/python/rateslib/local_types.py index f3a0fb956..2d22cbac2 100644 --- a/python/rateslib/local_types.py +++ b/python/rateslib/local_types.py @@ -35,6 +35,7 @@ from rateslib.data.fixings import IBORFixing as IBORFixing from rateslib.data.fixings import IBORStubFixing as IBORStubFixing from rateslib.data.fixings import IndexFixing as IndexFixing +from rateslib.data.fixings import IRSSeries as IRSSeries from rateslib.data.fixings import RFRFixing as RFRFixing from rateslib.data.loader import Fixings as Fixings from rateslib.data.loader import _BaseFixingsLoader as _BaseFixingsLoader @@ -43,15 +44,14 @@ from rateslib.enums.generics import Result as Result from rateslib.enums.parameters import FloatFixingMethod as FloatFixingMethod from rateslib.enums.parameters import FXDeltaMethod as FXDeltaMethod +from rateslib.enums.parameters import FXOptionMetric as FXOptionMetric from rateslib.enums.parameters import IndexMethod as IndexMethod +from rateslib.enums.parameters import IROptionMetric as IROptionMetric from rateslib.enums.parameters import OptionType as OptionType from rateslib.enums.parameters import SpreadCompoundMethod as SpreadCompoundMethod +from rateslib.enums.parameters import SwaptionSettlementMethod as SwaptionSettlementMethod from rateslib.fx import FXForwards as FXForwards from rateslib.fx import FXRates as FXRates -from rateslib.fx_volatility import FXDeltaVolSmile as FXDeltaVolSmile -from rateslib.fx_volatility import FXDeltaVolSurface as FXDeltaVolSurface -from rateslib.fx_volatility import FXSabrSmile as FXSabrSmile -from rateslib.fx_volatility import FXSabrSurface as FXSabrSurface from rateslib.instruments import CDS as CDS from rateslib.instruments import FRA as FRA from rateslib.instruments import IIRS as IIRS @@ -96,6 +96,7 @@ from rateslib.periods import FXPutPeriod as FXPutPeriod from rateslib.periods import ZeroFloatPeriod as ZeroFloatPeriod from rateslib.periods import _BaseFXOptionPeriod as _BaseFXOptionPeriod +from rateslib.periods import _BaseIRSOptionPeriod as _BaseIRSOptionPeriod from rateslib.periods.parameters import _FloatRateParams as _FloatRateParams from rateslib.periods.parameters import _IndexParams as _IndexParams from rateslib.periods.parameters import _NonDeliverableParams as _NonDeliverableParams @@ -104,29 +105,35 @@ from rateslib.periods.protocols import _BasePeriod as _BasePeriod from rateslib.rs import Adjuster as Adjuster from rateslib.rs import ( - Cal, FlatBackwardInterpolator, FlatForwardInterpolator, LinearInterpolator, LinearZeroRateInterpolator, LogLinearInterpolator, - NamedCal, NullInterpolator, - UnionCal, ) from rateslib.rs import StubInference as StubInference +from rateslib.volatility import FXDeltaVolSmile as FXDeltaVolSmile +from rateslib.volatility import FXDeltaVolSurface as FXDeltaVolSurface +from rateslib.volatility import FXSabrSmile as FXSabrSmile +from rateslib.volatility import FXSabrSurface as FXSabrSurface +from rateslib.volatility import IRSabrCube as IRSabrCube +from rateslib.volatility import IRSabrSmile as IRSabrSmile CurveInterpolator: TypeAlias = "FlatBackwardInterpolator | FlatForwardInterpolator | LinearInterpolator | LogLinearInterpolator | LinearZeroRateInterpolator | NullInterpolator" +from rateslib.rs import Cal as Cal from rateslib.rs import Convention as Convention from rateslib.rs import Dual as Dual from rateslib.rs import Dual2 as Dual2 from rateslib.rs import Frequency as Frequency from rateslib.rs import LegIndexBase as LegIndexBase +from rateslib.rs import NamedCal as NamedCal from rateslib.rs import PPSplineDual as PPSplineDual from rateslib.rs import PPSplineDual2 as PPSplineDual2 from rateslib.rs import PPSplineF64 as PPSplineF64 from rateslib.rs import RollDay as RollDay +from rateslib.rs import UnionCal as UnionCal from rateslib.scheduling import Schedule as Schedule from rateslib.solver import Solver as Solver @@ -147,6 +154,7 @@ Arr2dF64: TypeAlias = "np.ndarray[tuple[int, int], np.dtype[np.float64]]" Arr1dObj: TypeAlias = "np.ndarray[tuple[int], np.dtype[np.object_]]" Arr2dObj: TypeAlias = "np.ndarray[tuple[int, int], np.dtype[np.object_]]" +Arr3dObj: TypeAlias = "np.ndarray[tuple[int, int, int], np.dtype[np.object_]]" PeriodFixings: TypeAlias = "DualTypes | Series[DualTypes] | str | NoInput" LegFixings: TypeAlias = "PeriodFixings | list[PeriodFixings] | tuple[PeriodFixings, PeriodFixings]" @@ -187,7 +195,14 @@ FXVol: TypeAlias = "_FXVolOption | str" FXVol_: TypeAlias = "FXVol | NoInput" -VolT: TypeAlias = "FXVol | _Vol" +_IRVolObj: TypeAlias = "IRSabrSmile | IRSabrCube" +_IRVolOption: TypeAlias = "_IRVolObj | DualTypes" +_IRVolOption_: TypeAlias = "_IRVolOption | NoInput" + +IRVol: TypeAlias = "_IRVolOption | str" +IRVol_: TypeAlias = "IRVol | NoInput" + +VolT: TypeAlias = "IRVol | FXVol | _Vol" VolT_: TypeAlias = "VolT | NoInput" FXVolStrat_: TypeAlias = "Sequence[FXVolStrat_] | VolT | NoInput" SeqVolT_: TypeAlias = "Sequence[VolT_]" diff --git a/python/rateslib/periods/__init__.py b/python/rateslib/periods/__init__.py index 1c028eed4..e1b655c08 100644 --- a/python/rateslib/periods/__init__.py +++ b/python/rateslib/periods/__init__.py @@ -34,6 +34,7 @@ ZeroFloatPeriod, ) from rateslib.periods.fx_volatility import FXCallPeriod, FXPutPeriod, _BaseFXOptionPeriod +from rateslib.periods.ir_volatility import IRSCallPeriod, IRSPutPeriod, _BaseIRSOptionPeriod from rateslib.periods.protocols import _BasePeriod, _BasePeriodStatic __all__ = [ @@ -47,7 +48,10 @@ "CreditProtectionPeriod", "FXCallPeriod", "FXPutPeriod", + "IRSCallPeriod", + "IRSPutPeriod", "_BasePeriod", "_BasePeriodStatic", "_BaseFXOptionPeriod", + "_BaseIRSOptionPeriod", ] diff --git a/python/rateslib/periods/credit.py b/python/rateslib/periods/credit.py index 2f80d021f..0edf272a8 100644 --- a/python/rateslib/periods/credit.py +++ b/python/rateslib/periods/credit.py @@ -45,6 +45,7 @@ _BaseCurve, _BaseCurve_, _FXVolOption_, + _IRVolOption_, bool_, datetime, datetime_, @@ -221,6 +222,7 @@ def immediate_local_npv( disc_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> DualTypes: rate_curve_, disc_curve_ = _validate_credit_curves(rate_curve, disc_curve).unwrap() @@ -235,6 +237,7 @@ def try_immediate_local_analytic_delta( disc_curve: _BaseCurve_ = NoInput(0), fx: FXRevised_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> Result[DualTypes]: c = 0.0001 * self.period_params.dcf * self.settlement_params.notional @@ -254,6 +257,7 @@ def cashflow( index_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> DualTypes: if isinstance(self.rate_params.fixed_rate, NoInput): raise ValueError(err.VE_NEEDS_FIXEDRATE) @@ -272,6 +276,7 @@ def try_cashflow( index_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> Result[DualTypes]: r""" Replicate :meth:`~rateslib.periods.protocols._WithNPVStatic.cashflow` @@ -508,6 +513,7 @@ def cashflow( index_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> DualTypes: rate_curve_ = _try_validate_base_curve(rate_curve).unwrap() return -self.settlement_params.notional * (1 - rate_curve_.meta.credit_recovery_rate) @@ -520,6 +526,7 @@ def try_cashflow( index_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> Result[DualTypes]: r""" Replicate :meth:`~rateslib.periods.protocols._WithNPVStatic.cashflow` @@ -550,6 +557,7 @@ def immediate_local_npv( disc_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> DualTypes: rate_curve_, disc_curve_ = _validate_credit_curves(rate_curve, disc_curve).unwrap() quadrature = self._quadrature(rate_curve_, disc_curve_) @@ -591,6 +599,7 @@ def try_immediate_local_analytic_delta( disc_curve: _BaseCurve_ = NoInput(0), fx: FXRevised_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> Result[DualTypes]: return Ok(0.0) diff --git a/python/rateslib/periods/fixed_period.py b/python/rateslib/periods/fixed_period.py index 7a48d870e..7236d8f8b 100644 --- a/python/rateslib/periods/fixed_period.py +++ b/python/rateslib/periods/fixed_period.py @@ -49,6 +49,7 @@ Series, _BaseCurve_, _FXVolOption_, + _IRVolOption_, bool_, datetime, datetime_, @@ -660,6 +661,7 @@ def cashflows( index_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), base: str_ = NoInput(0), settlement: datetime_ = NoInput(0), forward: datetime_ = NoInput(0), diff --git a/python/rateslib/periods/float_period.py b/python/rateslib/periods/float_period.py index 97915f2c5..dba8951dd 100644 --- a/python/rateslib/periods/float_period.py +++ b/python/rateslib/periods/float_period.py @@ -69,6 +69,7 @@ _BaseCurve_, _FloatRateParams, _FXVolOption_, + _IRVolOption_, bool_, datetime, datetime_, @@ -1055,6 +1056,7 @@ def cashflows( index_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), base: str_ = NoInput(0), settlement: datetime_ = NoInput(0), forward: datetime_ = NoInput(0), diff --git a/python/rateslib/periods/fx_volatility.py b/python/rateslib/periods/fx_volatility.py index 41b5960d5..54bc61c31 100644 --- a/python/rateslib/periods/fx_volatility.py +++ b/python/rateslib/periods/fx_volatility.py @@ -32,40 +32,41 @@ _get_fx_option_metric, ) from rateslib.fx import FXForwards -from rateslib.fx_volatility import ( +from rateslib.periods.parameters import ( + _FXOptionParams, + _IndexParams, + _NonDeliverableParams, + _SettlementParams, +) +from rateslib.periods.protocols import _BasePeriodStatic, _WithAnalyticFXOptionGreeks +from rateslib.periods.utils import ( + _get_fx_vol_value_maybe_from_obj, + _get_vol_delta_type, + _get_vol_smile_or_raise, + _get_vol_smile_or_value, + _validate_fx_as_forwards, +) +from rateslib.volatility import ( FXDeltaVolSmile, FXDeltaVolSurface, FXSabrSmile, FXSabrSurface, - FXVolObj, ) -from rateslib.fx_volatility.delta_vol import ( +from rateslib.volatility.fx import FXVolObj +from rateslib.volatility.fx.delta_vol import ( _moneyness_from_atm_delta_one_dimensional, _moneyness_from_atm_delta_two_dimensional, _moneyness_from_delta_one_dimensional, _moneyness_from_delta_two_dimensional, ) -from rateslib.fx_volatility.utils import ( - _black76, - _d_plus_min_u, +from rateslib.volatility.fx.utils import ( _delta_type_constants, _moneyness_from_atm_delta_closed_form, _moneyness_from_delta_closed_form, - _surface_index_left, ) -from rateslib.periods.parameters import ( - _FXOptionParams, - _IndexParams, - _NonDeliverableParams, - _SettlementParams, -) -from rateslib.periods.protocols import _BasePeriodStatic, _WithAnalyticFXOptionGreeks -from rateslib.periods.utils import ( - _get_vol_delta_type, - _get_vol_maybe_from_obj, - _get_vol_smile_or_raise, - _get_vol_smile_or_value, - _validate_fx_as_forwards, +from rateslib.volatility.utils import ( + _OptionModelBlack76, + _surface_index_left, ) if TYPE_CHECKING: @@ -279,7 +280,7 @@ def unindexed_reference_cashflow( # type: ignore[override] # value is expressed in reference currency (i.e. pair[3:]) fx_ = _validate_fx_as_forwards(fx) - vol_ = _get_vol_maybe_from_obj( + vol_ = _get_fx_vol_value_maybe_from_obj( fx_vol=fx_vol, fx=fx_, rate_curve=rate_curve, @@ -302,11 +303,10 @@ def unindexed_reference_cashflow( # type: ignore[override] "Use one of `disc_curve`, `fx_vol`, or `rate_curve`." ) - expected = _black76( + expected = _OptionModelBlack76._value( F=fx_.rate(self.fx_option_params.pair, self.fx_option_params.delivery), K=k, t_e=t_e, - v1=NoInput(0), # not required v2=1.0, # disc_curve_[delivery] / disc_curve_[payment], vol=vol_ / 100.0, phi=self.fx_option_params.direction.value, # controls calls or put price @@ -325,7 +325,7 @@ def try_rate( """ Return the pricing metric of the *FXOption*, with lazy error handling. - See :meth:`~rateslib.periods.FXOptionPeriod.rate`. + See :meth:`~rateslib.periods._BaseFXOptionPeriod.rate`. """ if not isinstance(metric, NoInput): metric_ = _get_fx_option_metric(metric) @@ -466,9 +466,9 @@ def implied_vol( def root( vol: DualTypes, f_d: DualTypes, k: DualTypes, t_e: float, v2: DualTypes, phi: float ) -> tuple[DualTypes, DualTypes]: - f0 = _black76(f_d, k, t_e, NoInput(0), v2, vol, phi) * 10000.0 - imm_premium + f0 = _OptionModelBlack76._value(f_d, k, t_e, v2, vol, phi) * 10000.0 - imm_premium sqrt_t = t_e**0.5 - d_plus = _d_plus_min_u(k / f_d, vol * sqrt_t, 0.5) + d_plus = _OptionModelBlack76._d_plus_min_u(k / f_d, vol * sqrt_t, 0.5) f1 = v2 * dual_norm_pdf(phi * d_plus) * f_d * sqrt_t * 10000.0 return f0, f1 @@ -523,7 +523,7 @@ def _index_vol_and_strike_from_atm( return self._index_vol_and_strike_from_atm_sabr(f, eta_0, vol) else: # DualTypes | FXDeltaVolSmile | FXDeltaVolSurface f_: DualTypes = f # type: ignore[assignment] - vol_: DualTypes | FXDeltaVolSmile | FXDeltaVolSurface = vol # type: ignore[assignment] + vol_: DualTypes | FXDeltaVolSmile | FXDeltaVolSurface = vol return self._index_vol_and_strike_from_atm_dv( f_, eta_0, @@ -567,7 +567,7 @@ def root1d( if isinstance(vol, FXSabrSmile): alpha = vol.nodes.alpha else: # FXSabrSurface - vol_: FXSabrSurface = vol # type: ignore[assignment] + vol_: FXSabrSurface = vol expiry_posix = self.fx_option_params.expiry.replace(tzinfo=UTC).timestamp() e_idx, _ = _surface_index_left(vol_.meta.expiries_posix, expiry_posix) alpha = vol_.smiles[e_idx].nodes.alpha @@ -822,7 +822,7 @@ def root1d( if isinstance(vol, FXSabrSmile): alpha = vol.nodes.alpha else: # FXSabrSurface - vol_: FXSabrSurface = vol # type: ignore[assignment] + vol_: FXSabrSurface = vol expiry_posix = self.fx_option_params.expiry.replace(tzinfo=UTC).timestamp() e_idx, _ = _surface_index_left(vol_.meta.expiries_posix, expiry_posix) alpha = vol_.smiles[e_idx].nodes.alpha @@ -926,9 +926,9 @@ class FXCallPeriod(_BaseFXOptionPeriod): The notional of the option expressed in units of LHS currency of `pair`. delta_type: FXDeltaMethod, str, :green:`optional (set by 'default')` The definition of the delta for the option. - metric: FXDeltaMethod, str, :green:`optional` (set by 'default')` + metric: FXOptionMetric, str, :green:`optional` (set by 'default')` The metric used by default in the - :meth:`~rateslib.periods.fx_volatility.FXOptionPeriod.rate` method. + :meth:`~rateslib.periods.fx_volatility._BaseFXOptionPeriod.rate` method. option_fixings: float, Dual, Dual2, Variable, Series, str, :green:`optional` The value of the option :class:`~rateslib.data.fixings.FXFixing`. If a scalar, is used directly. If a string identifier, links to the central ``fixings`` object and data loader. @@ -1028,9 +1028,9 @@ class FXPutPeriod(_BaseFXOptionPeriod): The notional of the option expressed in units of LHS currency of `pair`. delta_type: FXDeltaMethod, str, :green:`optional (set by 'default')` The definition of the delta for the option. - metric: FXDeltaMethod, str, :green:`optional` (set by 'default')` + metric: FXOptionMetric, str, :green:`optional` (set by 'default')` The metric used by default in the - :meth:`~rateslib.periods.fx_volatility.FXOptionPeriod.rate` method. + :meth:`~rateslib.periods.fx_volatility._BaseFXOptionPeriod.rate` method. option_fixings: float, Dual, Dual2, Variable, Series, str, :green:`optional` The value of the option :class:`~rateslib.data.fixings.FXFixing`. If a scalar, is used directly. If a string identifier, links to the central ``fixings`` object and data loader. diff --git a/python/rateslib/periods/ir_volatility.py b/python/rateslib/periods/ir_volatility.py new file mode 100644 index 000000000..b777ba964 --- /dev/null +++ b/python/rateslib/periods/ir_volatility.py @@ -0,0 +1,773 @@ +# SPDX-License-Identifier: LicenseRef-Rateslib-Dual +# +# Copyright (c) 2026 Siffrorna Technology Limited +# +# Dual-licensed: Free Educational Licence or Paid Commercial Licence (commercial/professional use) +# Source-available, not open source. +# +# See LICENSE and https://rateslib.com/py/en/latest/i_licence.html for details, +# and/or contact info (at) rateslib (dot) com +#################################################################################################### + +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import TYPE_CHECKING + +import rateslib.errors as err +from rateslib import defaults +from rateslib.curves._parsers import ( + _disc_required_maybe_from_curve, + _validate_curve_not_no_input, +) +from rateslib.data.fixings import _get_irs_series +from rateslib.dual import ift_1dim +from rateslib.dual.utils import _dual_float +from rateslib.enums.generics import Err, NoInput, Ok, _drb +from rateslib.enums.parameters import ( + IROptionMetric, + OptionType, + SwaptionSettlementMethod, + _get_ir_option_metric, +) +from rateslib.instruments.protocols.pricing import _Curves +from rateslib.periods.parameters import ( + _IndexParams, + _IROptionParams, + _NonDeliverableParams, + _SettlementParams, +) +from rateslib.periods.protocols import _BasePeriodStatic, _WithAnalyticIROptionGreeks +from rateslib.periods.utils import ( + _get_ir_vol_value_and_forward_maybe_from_obj, +) +from rateslib.volatility.ir.utils import _IRVolPricingParams +from rateslib.volatility.utils import ( + _OptionModelBachelier, + _OptionModelBlack76, +) + +if TYPE_CHECKING: + from rateslib.local_types import ( # pragma: no cover + Any, + CurveOption, + CurveOption_, + DualTypes, + DualTypes_, + FXForwards_, + IRSSeries, + Result, + Series, + _BaseCurve, + _BaseCurve_, + _IRVolOption_, + datetime, + datetime_, + str_, + ) + + +class _BaseIRSOptionPeriod(_BasePeriodStatic, _WithAnalyticIROptionGreeks, metaclass=ABCMeta): + r""" + Abstract base class for *IROptionPeriods* types. + + **See Also**: :class:`~rateslib.periods.IRCallPeriod`, + :class:`~rateslib.periods.IRPutPeriod` + + """ + + def analytic_greeks( + self, + rate_curve: CurveOption, + disc_curve: _BaseCurve, + index_curve: _BaseCurve, + fx: FXForwards_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), + premium: DualTypes_ = NoInput(0), # expressed in the payment currency + premium_payment: datetime_ = NoInput(0), + ) -> dict[str, Any]: + return super()._base_analytic_greeks( + rate_curve=rate_curve, + disc_curve=disc_curve, + index_curve=index_curve, + ir_vol=ir_vol, + fx=fx, + premium=premium, + premium_payment=premium_payment, + ) + + @property + def period_params(self) -> None: + """This *Period* type has no + :class:`~rateslib.periods.parameters._PeriodParams`.""" + return self._period_params # type: ignore[return-value] # pragma: no cover + + @property + def settlement_params(self) -> _SettlementParams: + """The :class:`~rateslib.periods.parameters._SettlementParams` + of the *Period*.""" + return self._settlement_params + + @property + def index_params(self) -> _IndexParams | None: + """The :class:`~rateslib.periods.parameters._IndexParams` of + the *Period*, if any.""" + return self._index_params + + @property + def non_deliverable_params(self) -> _NonDeliverableParams | None: + """The :class:`~rateslib.periods.parameters._NonDeliverableParams` of the + *Period*., if any.""" + return self._non_deliverable_params + + @property + def rate_params(self) -> None: + """This *Period* type has no rate parameters.""" + return self._rate_params # type: ignore[return-value] # pragma: no cover + + @property + def ir_option_params(self) -> _IROptionParams: + """The :class:`~rateslib.periods.parameters._IROptionParams` of the + *Period*.""" + return self._ir_option_params + + @abstractmethod + def __init__( + self, + *, + # option params: + direction: OptionType, + expiry: datetime, + tenor: datetime | str, + irs_series: IRSSeries | str, + strike: DualTypes_ = NoInput(0), + notional: DualTypes_ = NoInput(0), + metric: IROptionMetric | str_ = NoInput(0), + option_fixings: DualTypes | Series[DualTypes] | str_ = NoInput(0), # type: ignore[type-var] + # currency args: + settlement_method: SwaptionSettlementMethod | str_ = NoInput(0), + ex_dividend: datetime_ = NoInput(0), + ) -> None: + self._index_params = None + self._rate_params = None + self._period_params = None + + self._ir_option_params = _IROptionParams( + _direction=direction, + _expiry=expiry, + _tenor=tenor, + _irs_series=_get_irs_series(irs_series), + _strike=strike, + _metric=_drb(defaults.ir_option_metric, metric), + _option_fixings=option_fixings, + _settlement_method=_drb(defaults.ir_option_settlement, settlement_method), + ) + + nd_pair = NoInput(0) + if isinstance(nd_pair, NoInput): + # then option is directly deliverable + self._non_deliverable_params: _NonDeliverableParams | None = None + self._settlement_params = _SettlementParams( + _notional=_drb(defaults.notional, notional), + _payment=self.ir_option_params.option_fixing.effective, + _currency=self.ir_option_params.option_fixing.irs_series.currency, + _notional_currency=self.ir_option_params.option_fixing.irs_series.currency, + _ex_dividend=ex_dividend, + ) + else: + raise NotImplementedError("ND IR Options not implement") # pragma: no cover + + def __repr__(self) -> str: + return f"" + + def _unindexed_reference_cashflow_elements( + self, + *, + rate_curve: CurveOption_ = NoInput(0), + disc_curve: _BaseCurve_ = NoInput(0), + index_curve: _BaseCurve_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), + ) -> tuple[DualTypes, DualTypes | None, _IRVolPricingParams | None]: + """ + Perform the unindexed_reference_cashflow calculations but return calculation + components. + + Returns + ------- + (cashflow, analytic_delta, pricing params) + """ + # The unindexed_reference_cashflow is the value of the IRS after expiry. + # This may be based on number numerous different settlement methods: physical / cash etc. + # Currently we only offer 1 form of valuation which is "physical or physical simulation". + if isinstance(self.ir_option_params.strike, NoInput): + raise ValueError(err.VE_NEEDS_STRIKE) + k = self.ir_option_params.strike + r = self.ir_option_params.option_fixing.value + if not isinstance(r, NoInput): + # the presence of fixing value here is used purely as an indicator of exercise status. + + phi: OptionType = self.ir_option_params.direction + + if (phi == OptionType.Call and k < r) or (phi == OptionType.Put and k > r): + if self.ir_option_params.settlement_method is SwaptionSettlementMethod.Physical: + local_npv_pay_dt: DualTypes = self.ir_option_params.option_fixing.irs.npv( # type: ignore[assignment] + curves=_Curves( + disc_curve=index_curve, + leg2_rate_curve=rate_curve, + leg2_disc_curve=index_curve, + ), + forward=self.settlement_params.payment, + local=False, + ) + value = ( + local_npv_pay_dt + * self.settlement_params.notional + / 1e6 + * self.ir_option_params.direction.value + ) + return value, None, None + else: + # in [ + # SwaptionSettlementMethod.CashParTenor, + # SwaptionSettlementMethod.CashCollateralized + # ] + index_curve_ = _validate_curve_not_no_input(index_curve) + del index_curve + a_r = self.ir_option_params.option_fixing.annuity( + settlement_method=self.ir_option_params.settlement_method, + rate_curve=rate_curve, + index_curve=index_curve_, + ) + value = ( + (r - k) + * 100.0 + * a_r + * self.settlement_params.notional + / 1e6 + * self.ir_option_params.direction.value + ) + return value, None, None + else: + # no exercise + return 0.0, None, None + + else: + disc_curve_ = _disc_required_maybe_from_curve(curve=rate_curve, disc_curve=disc_curve) + del disc_curve + index_curve_ = _validate_curve_not_no_input(index_curve) + del index_curve + + pricing_ = _get_ir_vol_value_and_forward_maybe_from_obj( + ir_vol=ir_vol, + index_curve=index_curve_, + rate_curve=rate_curve, + strike=k, + irs=self.ir_option_params.option_fixing.irs, + expiry=self.ir_option_params.expiry, + tenor=self.ir_option_params.option_fixing.termination, + ) + + t_e = self.ir_option_params.time_to_expiry(disc_curve_.nodes.initial) # time to expiry + expected = ( + _OptionModelBlack76._value( + F=pricing_.f + pricing_.shift, + K=pricing_.k + pricing_.shift, + t_e=t_e, + v2=1.0, # not required + vol=pricing_.vol / 100.0, + phi=self.ir_option_params.direction.value, # controls calls or put price + ) + * 100.0 + ) # bps + a_r = self.ir_option_params.option_fixing.annuity( + settlement_method=self.ir_option_params.settlement_method, + rate_curve=rate_curve, + index_curve=index_curve_, + ) + return ( + expected * self.settlement_params.notional / 1e6 * a_r, + a_r, + pricing_, + ) + + def unindexed_reference_cashflow( # type: ignore[override] + self, + *, + rate_curve: _BaseCurve_ = NoInput(0), + disc_curve: _BaseCurve_ = NoInput(0), + index_curve: _BaseCurve_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), + **kwargs: Any, + ) -> DualTypes: + return self._unindexed_reference_cashflow_elements( + rate_curve=rate_curve, + disc_curve=disc_curve, + index_curve=index_curve, + ir_vol=ir_vol, + )[0] + + def try_rate( + self, + rate_curve: CurveOption_, + disc_curve: _BaseCurve, + index_curve: _BaseCurve, + fx: FXForwards_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), + metric: IROptionMetric | str_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> Result[DualTypes]: + """ + Return the pricing metric of the *FXOption*, with lazy error handling. + + See :meth:`~rateslib.periods.FXOptionPeriod.rate`. + """ + try: + return Ok( + self.rate( + rate_curve=rate_curve, + disc_curve=disc_curve, + index_curve=index_curve, + fx=fx, + ir_vol=ir_vol, + metric=metric, + forward=forward, + ) + ) + except Exception as e: + return Err(e) + + def rate( + self, + *, + rate_curve: CurveOption_, + disc_curve: _BaseCurve, + index_curve: _BaseCurve, + fx: FXForwards_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), + metric: IROptionMetric | str_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DualTypes: + """ + Return the pricing metric of the *IRSOption*. + + This is priced according to the ``payment`` date of the *OptionPeriod*. + + Parameters + ---------- + rate_curve: Curve + The curve used for forecasting rates on the underlying + :class:`~rateslib.instruments.IRS`. + disc_curve: Curve + The discount *Curve* according to the collateral agreement of the option. + index_curve: Curve + The curve used for price alignment indexing according to the + :class:`~rateslib.enums.SwaptionSettlementMethod`. I.e. the discount curve used on the + underlying :class:`~rateslib.instruments.IRS`. + fx: float, FXRates, FXForwards, optional + The object to project the currency pair FX rate at delivery. + ir_vol: IRSabrSmile, float, Dual, Dual2 + The volatility object to price the option. If given as numeric, it is assumed to be + Black (log-normal) volatility with zero shift. + metric: IROptionMetric, + The metric to return. See examples. + forward: datetime, optional (set as payment date of option) + Not currently used by IRSOptionPeriod.rate. + + Returns + ------- + float, Dual, Dual2 or dict of such. + """ + if not isinstance(metric, NoInput): + metric_ = _get_ir_option_metric(metric) + else: # use metric associated with self + metric_ = self.ir_option_params.metric + del metric + + cash, anal_delta, pricing = self._unindexed_reference_cashflow_elements( + rate_curve=rate_curve, + disc_curve=disc_curve, + index_curve=index_curve, + ir_vol=ir_vol, + ) + + if metric_ == IROptionMetric.Cash(): + return cash + elif metric_ == IROptionMetric.PercentNotional(): + return cash / self.settlement_params.notional * 100.0 + + disc_curve_ = _disc_required_maybe_from_curve(curve=rate_curve, disc_curve=disc_curve) + del disc_curve + + if pricing is None: + pricing_ = _get_ir_vol_value_and_forward_maybe_from_obj( + ir_vol=ir_vol, + index_curve=index_curve, + rate_curve=rate_curve, + strike=self.ir_option_params.strike, # type: ignore[arg-type] + irs=self.ir_option_params.option_fixing.irs, + expiry=self.ir_option_params.expiry, + tenor=self.ir_option_params.option_fixing.termination, + ) + else: + pricing_ = pricing + del pricing + + if metric_ == IROptionMetric.NormalVol(): + # use a root finder to reverse engineer the _Bachelier model. + if anal_delta is None: + anal_delta_: DualTypes = self.ir_option_params.option_fixing.irs.analytic_delta( # type: ignore[assignment] + curves=_Curves(disc_curve=disc_curve_), + forward=self.settlement_params.payment, + local=False, + ) + else: + anal_delta_ = anal_delta + del anal_delta + + def s(g: DualTypes) -> DualTypes: + return _OptionModelBachelier._value( + F=pricing_.f, + K=pricing_.k, + t_e=self.ir_option_params.time_to_expiry(disc_curve_.nodes.initial), + v2=1.0, + vol=g, + phi=self.ir_option_params.direction.value, + ) + + result = ift_1dim( + s=s, + s_tgt=1e4 * cash / (anal_delta_ * self.settlement_params.notional), + h="modified_brent", + ini_h_args=(0.0001, 10.0), + ) + g: DualTypes = result["g"] + return g * 100.0 + else: + # metric_ in [BlackVol types] + # might need to resolve a volatility value depending upon the required shift + # and the expected shift + required_shift = metric_.shift() + provided_shift = int(_dual_float(pricing_.shift)) + if required_shift == provided_shift: + return pricing_.vol + else: + # use a root finder to reverse engineer the shifted vol value. + if anal_delta is None: + anal_delta_ = self.ir_option_params.option_fixing.irs.analytic_delta( # type: ignore[assignment] + curves=_Curves(disc_curve=disc_curve_), + forward=self.settlement_params.payment, + local=False, + ) + else: + anal_delta_ = anal_delta + del anal_delta + + def s(g: DualTypes) -> DualTypes: + return _OptionModelBlack76._value( + F=pricing_.f + float(required_shift) / 100.0, + K=pricing_.k + float(required_shift) / 100.0, + t_e=self.ir_option_params.time_to_expiry(disc_curve_.nodes.initial), + v2=1.0, + vol=g, + phi=self.ir_option_params.direction.value, + ) + + result = ift_1dim( + s=s, + s_tgt=1e4 * cash / (anal_delta_ * self.settlement_params.notional), + h="modified_brent", + ini_h_args=(0.0001, 10.0), + ) + g = result["g"] + return g * 100.0 + + # + # def implied_vol( + # self, + # rate_curve: _BaseCurve, + # disc_curve: _BaseCurve, + # fx: FXForwards, + # premium: DualTypes, + # metric: FXOptionMetric | str_ = NoInput(0), + # ) -> Number: + # """ + # Calculate the implied volatility of the FX option. + # + # Parameters + # ---------- + # rate_curve: Curve + # Not used by `implied_vol`. + # disc_curve: Curve + # The discount *Curve* for the RHS currency. + # fx: FXForwards + # The object to project the currency pair FX rate at delivery. + # premium: float, Dual, Dual2 + # The premium value of the option paid at the appropriate payment date. Expressed + # either in *'pips'* or *'percent'* of notional. Must align with ``metric``. + # metric: str in {"pips", "percent"}, optional + # The manner in which the premium is expressed. + # + # Returns + # ------- + # float, Dual or Dual2 + # """ + # if isinstance(self.ir_option_params.strike, NoInput): + # raise ValueError(err.VE_NEEDS_STRIKE) + # k = self.ir_option_params.strike + # phi = self.ir_option_params.direction + # metric_ = _get_ir_option_metric(_drb(self.ir_option_params.metric, metric)) + # + # # This function uses newton_1d and is AD safe. + # + # # convert the premium to a standardised immediate pips value. + # if metric_ == FXOptionMetric.Percent: + # # convert premium to pips form + # premium = ( + # premium + # * fx.rate(self.ir_option_params.pair, self.settlement_params.payment) + # * 100.0 + # ) + # # convert to immediate pips form + # imm_premium = premium * disc_curve[self.settlement_params.payment] + # + # t_e = self.ir_option_params.time_to_expiry(disc_curve.nodes.initial) + # v2 = disc_curve[self.ir_option_params.delivery] + # f_d = fx.rate(self.ir_option_params.pair, self.ir_option_params.delivery) + # + # def root( + # vol: DualTypes, f_d: DualTypes, k: DualTypes, t_e: float, v2: DualTypes, phi: float + # ) -> tuple[DualTypes, DualTypes]: + # f0 = _OptionModelBlack76._value(f_d, k, t_e, NoInput(0), v2, vol, phi) * 10000.0 - imm_premium + # sqrt_t = t_e**0.5 + # d_plus = _d_plus_min_u(k / f_d, vol * sqrt_t, 0.5) + # f1 = v2 * dual_norm_pdf(phi * d_plus) * f_d * sqrt_t * 10000.0 + # return f0, f1 + # + # result = newton_1dim(root, 0.10, args=(f_d, k, t_e, v2, phi)) + # _: Number = result["g"] * 100.0 + # return _ + # + # def _payoff_at_expiry( + # self, rng: tuple[float, float] | NoInput = NoInput(0) + # ) -> tuple[Arr1dF64, Arr1dF64]: + # # used by plotting methods + # if isinstance(self.ir_option_params.strike, NoInput): + # raise ValueError( + # "Cannot return payoff for option without a specified `strike`.", + # ) # pragma: no cover + # if isinstance(rng, NoInput): + # x = np.linspace(0, 20, 1001) + # else: + # x = np.linspace(rng[0], rng[1], 1001) + # k: float = _dual_float(self.ir_option_params.strike) + # _ = (x - k) * self.ir_option_params.direction + # __ = np.zeros(1001) + # if self.ir_option_params.direction > 0: # call + # y = np.where(x < k, __, _) * self.settlement_params.notional + # else: # put + # y = np.where(x > k, __, _) * self.settlement_params.notional + # return x, y + + +class IRSCallPeriod(_BaseIRSOptionPeriod): + r""" + A *Period* defined by a European call option on an IRS. + + The expected unindexed reference cashflow is given by, + + .. math:: + + \mathbb{E^Q}[\bar{C}_t] = \left \{ \begin{matrix} \max(f_d - K, 0) & \text{after expiry} \\ B76(f_d, K, t, \sigma) & \text{before expiry} \end{matrix} \right . + + where :math:`B76(.)` is the Black-76 option pricing formula, using log-normal volatility + calculations with calendar day time reference. + + .. rubric:: Examples + + .. ipython:: python + :suppress: + + from rateslib.periods import FXCallPeriod + from datetime import datetime as dt + + .. ipython:: python + + fxo = FXCallPeriod( + delivery=dt(2000, 3, 1), + pair="eurusd", + expiry=dt(2000, 2, 28), + strike=1.10, + delta_type="forward", + ) + fxo.cashflows() + + .. role:: red + + .. role:: green + + Parameters + ---------- + . + .. note:: + + The following define **ir option** and generalised **settlement** parameters. + + expiry: datetime, :red:`required` + The expiry date of the option, when the option fixing is determined. + irs_series: IRSSeries, str :red:`required` + This defines the conventions of the underlying :class:`~rateslib.instruments.IRS`. + tenor: datetime, str :red:`required` + The tenor of the underlying :class:`~rateslib.instruments.IRS`. + strike: float, Dual, Dual2, Variable, :green:`optional` + The strike fixed rate of the option. Can be set after initialisation. + notional: float, Dual, Dual2, Variable, :green:`optional (set by 'defaults')` + The notional of the option expressed in reference currency. + metric: IROptionMetric, str, :green:`optional` (set by 'default')` + The metric used by default in the + :meth:`~rateslib.periods.ir_volatility._BaseIRSOptionPeriod.rate` method. + option_fixings: float, Dual, Dual2, Variable, Series, str, :green:`optional` + The value of the option :class:`~rateslib.data.fixings.IRSFixing`. If a scalar, is used + directly. If a string identifier, links to the central ``fixings`` object and data loader. + See :ref:`fixings `. + settlement_method: SwaptionSettlementMethod, str, :green:`optional` (set by 'default')` + The method for deriving the settlement cashflow or underlying value. + ex_dividend: datetime, :green:`optional (set as 'delivery')` + The ex-dividend date of the settled cashflow. + + .. note:: + + This *Period* type has not implemented **indexation** or **non-deliverability**. + + """ # noqa: E501 + + def __init__( + self, + *, + # option params: + expiry: datetime, + tenor: datetime | str, + irs_series: IRSSeries | str, + strike: DualTypes_ = NoInput(0), + notional: DualTypes_ = NoInput(0), + metric: IROptionMetric | str_ = NoInput(0), + option_fixings: DualTypes | Series[DualTypes] | str_ = NoInput(0), # type: ignore[type-var] + # currency args: + settlement_method: SwaptionSettlementMethod | str_ = NoInput(0), + ex_dividend: datetime_ = NoInput(0), + ) -> None: + super().__init__( + direction=OptionType.Call, + tenor=tenor, + irs_series=irs_series, + expiry=expiry, + strike=strike, + notional=notional, + metric=metric, + option_fixings=option_fixings, + settlement_method=settlement_method, + ex_dividend=ex_dividend, + ) + + +class IRSPutPeriod(_BaseIRSOptionPeriod): + r""" + A *Period* defined by a European FX put option. + + The expected unindexed reference cashflow is given by, + + .. math:: + + \mathbb{E^Q}[\bar{C}_t] = \left \{ \begin{matrix} \max(K - f_d, 0) & \text{after expiry} \\ B76(f_d, K, t, \sigma) & \text{before expiry} \end{matrix} \right . + + where :math:`B76(.)` is the Black-76 option pricing formula, using log-normal volatility + calculations with calendar day time reference. + + .. rubric:: Examples + + .. ipython:: python + :suppress: + + from rateslib.periods import FXPutPeriod + from datetime import datetime as dt + + .. ipython:: python + + fxo = FXPutPeriod( + delivery=dt(2000, 3, 1), + pair="eurusd", + expiry=dt(2000, 2, 28), + strike=1.10, + delta_type="forward", + ) + fxo.cashflows() + + .. role:: red + + .. role:: green + + Parameters + ---------- + . + .. note:: + + The following define **fx option** and generalised **settlement** parameters. + + delivery: datetime, :red:`required` + The settlement date of the underlying FX rate of the option. Also used as the implied + payment date of the cashflow valuation date. + pair: str, :red:`required` + The currency pair of the :class:`~rateslib.data.fixings.FXFixing` against which the option + will settle. + expiry: datetime, :red:`required` + The expiry date of the option, when the option fixing is determined. + strike: float, Dual, Dual2, Variable, :green:`optional` + The strike price of the option. Can be set after initialisation. + notional: float, Dual, Dual2, Variable, :green:`optional (set by 'defaults')` + The notional of the option expressed in units of LHS currency of `pair`. + delta_type: FXDeltaMethod, str, :green:`optional (set by 'default')` + The definition of the delta for the option. + metric: FXDeltaMethod, str, :green:`optional` (set by 'default')` + The metric used by default in the + :meth:`~rateslib.periods.fx_volatility.FXOptionPeriod.rate` method. + option_fixings: float, Dual, Dual2, Variable, Series, str, :green:`optional` + The value of the option :class:`~rateslib.data.fixings.FXFixing`. If a scalar, is used + directly. If a string identifier, links to the central ``fixings`` object and data loader. + See :ref:`fixings `. + settlement_method: SwaptionSettlementMethod, str, :green:`optional` (set by 'default')` + The method for deriving the settlement cashflow or underlying value. + ex_dividend: datetime, :green:`optional (set as 'delivery')` + The ex-dividend date of the settled cashflow. + + .. note:: + + This *Period* type has not implemented **indexation** or **non-deliverability**. + + """ # noqa: E501 + + def __init__( + self, + *, + # option params: + expiry: datetime, + tenor: datetime | str, + irs_series: IRSSeries | str, + strike: DualTypes_ = NoInput(0), + notional: DualTypes_ = NoInput(0), + metric: IROptionMetric | str_ = NoInput(0), + option_fixings: DualTypes | Series[DualTypes] | str_ = NoInput(0), # type: ignore[type-var] + # currency args: + settlement_method: SwaptionSettlementMethod | str_ = NoInput(0), + ex_dividend: datetime_ = NoInput(0), + ) -> None: + super().__init__( + direction=OptionType.Put, + tenor=tenor, + irs_series=irs_series, + expiry=expiry, + strike=strike, + notional=notional, + metric=metric, + option_fixings=option_fixings, + settlement_method=settlement_method, + ex_dividend=ex_dividend, + ) diff --git a/python/rateslib/periods/parameters/__init__.py b/python/rateslib/periods/parameters/__init__.py index fc3499345..d13663b30 100644 --- a/python/rateslib/periods/parameters/__init__.py +++ b/python/rateslib/periods/parameters/__init__.py @@ -15,6 +15,7 @@ _IndexParams, _init_or_none_IndexParams, ) +from rateslib.periods.parameters.ir_volatility import _IROptionParams from rateslib.periods.parameters.mtm import ( _init_MtmParams, _MtmParams, @@ -47,4 +48,5 @@ "_CreditParams", "_MtmParams", "_FXOptionParams", + "_IROptionParams", ] diff --git a/python/rateslib/periods/parameters/ir_volatility.py b/python/rateslib/periods/parameters/ir_volatility.py new file mode 100644 index 000000000..4c667ea0f --- /dev/null +++ b/python/rateslib/periods/parameters/ir_volatility.py @@ -0,0 +1,130 @@ +# SPDX-License-Identifier: LicenseRef-Rateslib-Dual +# +# Copyright (c) 2026 Siffrorna Technology Limited +# +# Dual-licensed: Free Educational Licence or Paid Commercial Licence (commercial/professional use) +# Source-available, not open source. +# +# See LICENSE and https://rateslib.com/py/en/latest/i_licence.html for details, +# and/or contact info (at) rateslib (dot) com +#################################################################################################### + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pandas import Series + +from rateslib.data.fixings import IRSFixing +from rateslib.enums.generics import NoInput +from rateslib.enums.parameters import _get_ir_option_metric, _get_swaption_settlement_method + +if TYPE_CHECKING: + from rateslib.local_types import ( + DualTypes, + DualTypes_, + IROptionMetric, + IRSSeries, + OptionType, + SwaptionSettlementMethod, + datetime, + str_, + ) + + +class _IROptionParams: + """ + Parameters for *IR Option Period* cashflows. + """ + + _expiry: datetime + _metric: IROptionMetric + _option_fixing: IRSFixing + _strike: DualTypes_ + _currency: str + _direction: OptionType + + def __init__( + self, + _direction: OptionType, + _expiry: datetime, + _tenor: str | datetime, + _irs_series: IRSSeries, + _metric: str | IROptionMetric, + _option_fixings: DualTypes | Series[DualTypes] | str_, # type: ignore[type-var] + _strike: DualTypes_, + _settlement_method: SwaptionSettlementMethod | str, + ): + self._direction = _direction + self._expiry = _expiry + self._metric = _get_ir_option_metric(_metric) + self._strike = _strike + self._settlement_method = _get_swaption_settlement_method(_settlement_method) + + if isinstance(_option_fixings, Series): + value = IRSFixing._lookup(timeseries=_option_fixings, date=self.expiry) + self._option_fixing = IRSFixing( + tenor=_tenor, + value=value, + irs_series=_irs_series, + publication=_expiry, + identifier=NoInput(0), + ) + elif isinstance(_option_fixings, str): + self._option_fixing = IRSFixing( + tenor=_tenor, + value=NoInput(0), + irs_series=_irs_series, + publication=_expiry, + identifier=_option_fixings, + ) + else: + self._option_fixing = IRSFixing( + tenor=_tenor, + value=_option_fixings, + publication=_expiry, + irs_series=_irs_series, + identifier=NoInput(0), + ) + + self._option_fixing.irs.fixed_rate = self.strike + + @property + def settlement_method(self) -> SwaptionSettlementMethod: + """The settlement method of the option.""" + return self._settlement_method + + @property + def expiry(self) -> datetime: + """The expiry date of the option.""" + return self._expiry + + @property + def direction(self) -> OptionType: + """The direction of the option.""" + return self._direction + + @property + def strike(self) -> DualTypes_: + """The strike price of the option.""" + return self._strike + + @strike.setter + def strike(self, val: DualTypes_) -> None: + self.option_fixing.irs.fixed_rate = val + self._strike = val + + @property + def option_fixing(self) -> IRSFixing: + """The FX fixing related to settlement of the option.""" + return self._option_fixing + + @property + def metric(self) -> IROptionMetric: + """The default pricing quoting of the option.""" + return self._metric + + def time_to_expiry(self, now: datetime) -> float: + """The time to expiry of the option in years measured by calendar days from ``now``.""" + # TODO make this a dual, associated with theta + return (self.expiry - now).days / 365.0 diff --git a/python/rateslib/periods/protocols/__init__.py b/python/rateslib/periods/protocols/__init__.py index 8fb7cf386..e7e267c16 100644 --- a/python/rateslib/periods/protocols/__init__.py +++ b/python/rateslib/periods/protocols/__init__.py @@ -27,7 +27,10 @@ _WithAnalyticRateFixings, _WithAnalyticRateFixingsStatic, ) -from rateslib.periods.protocols.analytic_greeks import _WithAnalyticFXOptionGreeks +from rateslib.periods.protocols.analytic_greeks import ( + _WithAnalyticFXOptionGreeks, + _WithAnalyticIROptionGreeks, +) from rateslib.periods.protocols.cashflows import ( _WithCashflows, _WithCashflowsStatic, @@ -70,6 +73,7 @@ class _BasePeriodStatic( "_WithAnalyticDelta", "_WithAnalyticRateFixings", "_WithAnalyticFXOptionGreeks", + "_WithAnalyticIROptionGreeks", "_WithNPVStatic", "_WithCashflowsStatic", "_WithAnalyticDeltaStatic", diff --git a/python/rateslib/periods/protocols/analytic_greeks.py b/python/rateslib/periods/protocols/analytic_greeks.py index 21c222cc0..855b60b88 100644 --- a/python/rateslib/periods/protocols/analytic_greeks.py +++ b/python/rateslib/periods/protocols/analytic_greeks.py @@ -16,24 +16,31 @@ from rateslib.dual import dual_log, dual_norm_cdf, dual_norm_pdf from rateslib.enums.generics import NoInput, _drb from rateslib.enums.parameters import FXDeltaMethod -from rateslib.fx_volatility import FXDeltaVolSmile, FXDeltaVolSurface, FXSabrSmile, FXSabrSurface -from rateslib.fx_volatility.utils import ( - _d_plus_min_u, - _delta_type_constants, -) from rateslib.periods.parameters.fx_volatility import _FXOptionParams +from rateslib.periods.parameters.ir_volatility import _IROptionParams from rateslib.periods.parameters.settlement import _SettlementParams +from rateslib.periods.utils import _get_ir_vol_value_and_forward_maybe_from_obj from rateslib.splines import evaluate +from rateslib.volatility import FXDeltaVolSmile, FXDeltaVolSurface, FXSabrSmile, FXSabrSurface +from rateslib.volatility.fx.utils import ( + _delta_type_constants, +) +from rateslib.volatility.utils import ( + _OptionModelBlack76, +) if TYPE_CHECKING: from rateslib.local_types import ( # pragma: no cover Any, + CurveOption, DualTypes, DualTypes_, FXForwards, + FXForwards_, _BaseCurve, _FXVolOption, _FXVolOption_, + _IRVolOption_, datetime, datetime_, ) @@ -243,9 +250,9 @@ def _base_analytic_greeks( z_v_0 = v_deli / v_spot else: z_v_0 = 1.0 - d_eta_0 = _d_plus_min_u(u, vol_sqrt_t, eta_0) - d_plus = _d_plus_min_u(u, vol_sqrt_t, 0.5) - d_min = _d_plus_min_u(u, vol_sqrt_t, -0.5) + d_eta_0 = _OptionModelBlack76._d_plus_min_u(u, vol_sqrt_t, eta_0) + d_plus = _OptionModelBlack76._d_plus_min_u(u, vol_sqrt_t, 0.5) + d_min = _OptionModelBlack76._d_plus_min_u(u, vol_sqrt_t, -0.5) _: dict[str, Any] = dict() @@ -518,3 +525,400 @@ def _analytic_bs76( d_min: DualTypes, ) -> DualTypes: return phi * v_deli * (f_d * dual_norm_cdf(phi * d_plus) - k * dual_norm_cdf(phi * d_min)) + + +class _WithAnalyticIROptionGreeks(Protocol): + """ + Protocol to derive analytic *IROption* greeks. + """ + + @property + def ir_option_params(self) -> _IROptionParams: ... + + @property + def settlement_params(self) -> _SettlementParams: ... + + def analytic_greeks( + self, + rate_curve: CurveOption, + disc_curve: _BaseCurve, + index_curve: _BaseCurve, + fx: FXForwards, + ir_vol: _IRVolOption_ = NoInput(0), + premium: DualTypes_ = NoInput(0), # expressed in the payment currency + premium_payment: datetime_ = NoInput(0), + ) -> dict[str, Any]: + r""" + Return the different greeks for the *FX Option*. + + Parameters + ---------- + rate_curve: _BaseCurve + The discount *Curve* for the LHS currency of ``pair``. + disc_curve: _BaseCurve + The discount *Curve* for the RHS currency of ``pair``. + fx: FXForwards, optional + The :class:`~rateslib.fx.FXForward` object used for forecasting the + ``fx_fixing`` for deliverable cashflows, if necessary. + fx_vol: FXDeltaVolSmile, FXSabrSmile, FXDeltaVolSurface, FXSabrSurface, optional + The FX volatility *Smile* or *Surface* object used for determining Black calendar + day implied volatility values. + premium: float, Dual, Dual2, optional + The premium value of the option paid at the appropriate payment date. + Premium should be expressed in domestic currency. + If not given calculates and assumes a mid-market premium. + premium_payment: datetime, optional + The date that the premium is paid. If not given is assumed to be equal to the + *payment* associated with the option period *settlement_params*. + + Returns + ------- + dict + + Notes + ----- + **Delta** :math:`\Delta` + + This is the percentage value of the domestic notional in either the *forward* or *spot* + FX rate. The choice of which is defined by the option's ``delta_type``. + + Delta is also expressed in nominal domestic currency amount. + + **Gamma** :math:`\Gamma` + + This defines by how much *delta* will change for a 1.0 increase in either the *forward* + or *spot* FX rate. Which rate is determined by the option's ``delta_type``. + + Gamma is also expressed in nominal domestic currency amount for a +1% change in FX rates. + + **Vanna** :math:`\Delta_{\nu}` + + This defines by how much *delta* will change for a 1.0 increase (i.e. 100 log-vols) in + volatility. The additional + + **Vega** :math:`\nu` + + This defines by how much the PnL of the option will change for a 1.0 increase in + volatility for a nominal of 1 unit of domestic currency. + + Vega is also expressed in foreign currency for a 0.01 (i.e. 1 log-vol) move higher in vol. + + **Vomma (Volga)** :math:`\nu_{\nu}` + + This defines by how much *vega* will change for a 1.0 increase in volatility. + + These values can be used to estimate PnL for a change in the *forward* or + *spot* FX rate and the volatility according to, + + .. math:: + + \delta P \approx v_{deli} N^{dom} \left ( \Delta \delta f + \frac{1}{2} \Gamma \delta f^2 + \Delta_{\nu} \delta f \delta \sigma \right ) + N^{dom} \left ( \nu \delta \sigma + \frac{1}{2} \nu_{\nu} \delta \sigma^2 \right ) + + where :math:`v_{deli}` is the date of FX settlement for *forward* or *spot* rate. + + **Kappa** :math:`\kappa` + + This defines by how much the PnL of the option will change for a 1.0 increase in + strike for a nominal of 1 unit of domestic currency. + + **Kega** :math:`\left . \frac{dK}{d\sigma} \right|_{\Delta}` + + This defines the rate of change of strike with respect to volatility for a constant delta. + + Raises + ------ + ValueError: if the ``strike`` is not set on the *Option*. + """ # noqa: E501 + raise NotImplementedError( + "Type {type(self).__name__} has not implmented `anlaytic_greeks`." + ) + + def _base_analytic_greeks( + self, + rate_curve: CurveOption, + disc_curve: _BaseCurve, + index_curve: _BaseCurve, + fx: FXForwards_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), + premium: DualTypes_ = NoInput(0), # expressed in the payment currency + premium_payment: datetime_ = NoInput(0), + _reduced: bool = False, + ) -> dict[str, Any]: + """Calculates `analytic_greeks`, if _reduced only calculates those necessary for + Strange single_vol calculation. + + _reduced calculates: + + __vol, vega, __bs76, _kappa, _kega, _delta_index, gamma, __strike, __forward, __sqrt_t + """ + _drb(self.settlement_params.payment, premium_payment) + + if isinstance(self.ir_option_params.strike, NoInput): + raise ValueError("`strike` must be set to value IROption.") + + # v_deli = rate_curve[self.ir_option_params.option_fixing.effective] + sqrt_t = self.ir_option_params.time_to_expiry(disc_curve.nodes.initial) ** 0.5 + pricing_ = _get_ir_vol_value_and_forward_maybe_from_obj( + ir_vol=ir_vol, + index_curve=index_curve, + rate_curve=rate_curve, + strike=self.ir_option_params.strike, + irs=self.ir_option_params.option_fixing.irs, + expiry=self.ir_option_params.expiry, + tenor=self.ir_option_params.option_fixing.termination, + ) + vol_sqrt_t = pricing_.vol / 100.0 * sqrt_t + d_plus = _OptionModelBlack76._d_plus_min_u(pricing_.k / pricing_.f, vol_sqrt_t, 0.5) + # d_min = _OptionModelBlack76._d_plus_min_u(u, vol_sqrt_t, -0.5) + + _: dict[str, Any] = dict() + _["__forward"] = pricing_.f + _["__sqrt_t"] = sqrt_t + _["__vol"] = pricing_.vol / 100.0 + _["__strike"] = pricing_.k + _["delta"] = self._analytic_delta( + self.ir_option_params.direction, + d_plus, + ) + # _[f"delta_{self.fx_option_params.pair[:3]}"] = ( + # abs(self.settlement_params.notional) * _["delta"] + # ) + + # + # _["gamma"] = self._analytic_gamma( + # _is_spot, + # v_deli, + # v_spot, + # z_w_0, + # self.fx_option_params.direction, + # d_plus, + # f_d, + # vol_sqrt_t, + # ) + # _["vega"] = self._analytic_vega( + # v_deli, f_d, sqrt_t, self.fx_option_params.direction, d_plus + # ) + # _["_kega"] = self._analytic_kega( + # z_u_0, + # z_w_0, + # eta_0, + # fx_vol_, + # sqrt_t, + # f_d, + # self.fx_option_params.direction, + # self.fx_option_params.strike, + # d_eta_0, + # ) + # _["_kappa"] = self._analytic_kappa(v_deli, self.fx_option_params.direction, d_min) + + # _["__bs76"] = self._analytic_bs76( + # self.fx_option_params.direction, + # v_deli, + # f_d, + # d_plus, + # self.fx_option_params.strike, + # d_min, + # ) + # _["__notional"] = self.settlement_params.notional + # if self.fx_option_params.direction > 0: + # _["__class"] = "FXCallPeriod" + # else: + # _["__class"] = "FXPutPeriod" + # + # if not _reduced: + + # + # _[f"gamma_{self.fx_option_params.pair[:3]}_1%"] = ( + # _["gamma"] + # * abs(self.settlement_params.notional) + # * (f_t if _is_spot else f_d) + # * 0.01 + # ) + # + # _[f"vega_{self.fx_option_params.pair[3:]}"] = ( + # _["vega"] * abs(self.settlement_params.notional) * 0.01 + # ) + # + # _["delta_sticky"] = self._analytic_sticky_delta( + # _["delta"], + # _["vega"], + # v_deli, + # fx_vol, + # sqrt_t, + # fx_vol_, + # self.fx_option_params.expiry, + # f_d, + # delta_idx, + # u, + # z_v_0, + # z_w_0, + # z_w_1, + # eta_1, + # d_plus, + # self.fx_option_params.strike, + # fx, + # ) + # _["vomma"] = self._analytic_vomma(_["vega"], d_plus, d_min, fx_vol_) + # _["vanna"] = self._analytic_vanna( + # z_w_0, self.fx_option_params.direction, d_plus, d_min, fx_vol_ + # ) + # # _["vanna"] = self._analytic_vanna(_["vega"], _is_spot, f_t, f_d, d_plus, vol_sqrt_t) + + return _ + + # + # @staticmethod + # def _analytic_vega( + # v_deli: DualTypes, f_d: DualTypes, sqrt_t: DualTypes, phi: float, d_plus: DualTypes + # ) -> DualTypes: + # return v_deli * f_d * sqrt_t * dual_norm_pdf(phi * d_plus) + # + # @staticmethod + # def _analytic_vomma( + # vega: DualTypes, + # d_plus: DualTypes, + # d_min: DualTypes, + # vol: DualTypes, + # ) -> DualTypes: + # return vega * d_plus * d_min / vol + # + # @staticmethod + # def _analytic_gamma( + # spot: DualTypes, + # v_deli: DualTypes, + # v_spot: DualTypes, + # z_w: DualTypes, + # phi: float, + # d_plus: DualTypes, + # f_d: DualTypes, + # vol_sqrt_t: DualTypes, + # ) -> DualTypes: + # ret = z_w * dual_norm_pdf(phi * d_plus) / (f_d * vol_sqrt_t) + # if spot: + # return ret * z_w * v_spot / v_deli + # return ret + # + @staticmethod + def _analytic_delta( + phi: float, + d_plus: DualTypes, + ) -> DualTypes: + return phi * dual_norm_cdf(phi * d_plus) + + # + # @staticmethod + # def _analytic_sticky_delta( + # delta: DualTypes, + # vega: DualTypes, + # v_deli: DualTypes, + # vol: _FXVolOption, + # sqrt_t: DualTypes, + # vol_: DualTypes, + # expiry: datetime, + # f_d: DualTypes, + # delta_idx: DualTypes | None, + # u: DualTypes, + # z_v_0: DualTypes, + # z_w_0: DualTypes, + # z_w_1: DualTypes, + # eta_1: float, + # d_plus: DualTypes, + # k: DualTypes, + # fxf: FXForwards, + # ) -> DualTypes: + # dvol_df: DualTypes + # if isinstance(vol, FXSabrSmile): + # _, dvol_df = vol._d_sabr_d_k_or_f( # type: ignore[assignment] + # k=k, + # f=f_d, + # expiry=expiry, + # as_float=False, + # derivative=2, # with respect to f + # ) + # elif isinstance(vol, FXSabrSurface): + # _, dvol_df = vol._d_sabr_d_k_or_f( # type: ignore[assignment] + # k=k, + # f=fxf, # use FXForwards to derive multiple rates + # expiry=expiry, + # as_float=False, + # derivative=2, # with respect to f + # ) + # elif isinstance(vol, FXDeltaVolSmile | FXDeltaVolSurface): + # if isinstance(vol, FXDeltaVolSurface): + # smile: FXDeltaVolSmile = vol.get_smile(expiry) + # else: + # smile = vol + # # d sigma / d delta_idx + # _B = evaluate(smile.nodes.spline.spline, delta_idx, 1) / 100.0 # type: ignore[arg-type] + # + # if vol.meta.delta_type in [ + # FXDeltaMethod.ForwardPremiumAdjusted, + # FXDeltaMethod.SpotPremiumAdjusted, + # ]: + # # then smile is adjusted: + # ddelta_idx_df_d: DualTypes = -delta_idx / f_d # type: ignore[operator] + # else: + # ddelta_idx_df_d = 0.0 + # _A = z_w_1 * dual_norm_pdf(-d_plus) + # ddelta_idx_df_d -= _A / (f_d * vol_ * sqrt_t) + # ddelta_idx_df_d /= 1 + _A * ((dual_log(u) / (vol_**2 * sqrt_t) + eta_1 * sqrt_t) * _B) + # + # dvol_df = _B * z_w_0 / z_v_0 * ddelta_idx_df_d + # + # else: + # dvol_df = 0.0 + # + # return delta + vega / v_deli * z_v_0 * dvol_df + # + # @staticmethod + # def _analytic_vanna( + # z_w: DualTypes, + # phi: float, + # d_plus: DualTypes, + # d_min: DualTypes, + # vol: DualTypes, + # ) -> DualTypes: + # return -z_w * dual_norm_pdf(phi * d_plus) * d_min / vol + # + # # @staticmethod + # # def _analytic_vanna(vega, spot, f_t, f_d, d_plus, vol_sqrt_t): # Alternative monetary def. + # # if spot: + # # return vega / f_t * (1 - d_plus / vol_sqrt_t) + # # else: + # # return vega / f_d * (1 - d_plus / vol_sqrt_t) + # + # @staticmethod + # def _analytic_kega( + # z_u: DualTypes, + # z_w: DualTypes, + # eta: float, + # vol: DualTypes, + # sqrt_t: float, + # f_d: DualTypes, + # phi: float, + # k: DualTypes, + # d_eta: DualTypes, + # ) -> DualTypes: + # if eta < 0: + # # dz_u_du = 1.0 + # x = vol * phi * dual_norm_cdf(phi * d_eta) / (f_d * z_u * dual_norm_pdf(phi * d_eta)) + # else: + # x = 0.0 + # + # ret = (d_eta - 2.0 * eta * sqrt_t * vol) / (-1 / (k * sqrt_t) + x) + # return ret + # + # @staticmethod + # def _analytic_kappa(v_deli: DualTypes, phi: float, d_min: DualTypes) -> DualTypes: + # return -v_deli * phi * dual_norm_cdf(phi * d_min) + # + # @staticmethod + # def _analytic_bs76( + # phi: float, + # v_deli: DualTypes, + # f_d: DualTypes, + # d_plus: DualTypes, + # k: DualTypes, + # d_min: DualTypes, + # ) -> DualTypes: + # return phi * v_deli * (f_d * dual_norm_cdf(phi * d_plus) - k * dual_norm_cdf(phi * d_min)) diff --git a/python/rateslib/periods/protocols/cashflows.py b/python/rateslib/periods/protocols/cashflows.py index b37e0ddef..c46dd3145 100644 --- a/python/rateslib/periods/protocols/cashflows.py +++ b/python/rateslib/periods/protocols/cashflows.py @@ -43,6 +43,7 @@ Result, _BaseCurve_, _FXVolOption_, + _IRVolOption_, datetime_, str_, ) @@ -80,6 +81,7 @@ def try_cashflow( index_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> Result[DualTypes]: """ Calculate the cashflow for the *Period* with any non-deliverable currency adjustment @@ -119,6 +121,7 @@ def cashflows( index_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), base: str_ = NoInput(0), settlement: datetime_ = NoInput(0), forward: datetime_ = NoInput(0), @@ -209,6 +212,7 @@ def _index_elements( index_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> dict[str, Any]: # indexing parameters index_elements: dict[str, Any] = {} @@ -226,6 +230,7 @@ def _index_elements( disc_curve=disc_curve, fx=fx, fx_vol=fx_vol, + ir_vol=ir_vol, ) index_elements = { @@ -277,6 +282,7 @@ def cashflows( index_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), base: str_ = NoInput(0), settlement: datetime_ = NoInput(0), forward: datetime_ = NoInput(0), @@ -327,6 +333,7 @@ def cashflows( index_curve=index_curve, fx=fx, fx_vol=fx_vol, + ir_vol=ir_vol, base=base, forward=forward, settlement=settlement, @@ -449,6 +456,7 @@ def _cashflow_elements( index_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), base: str_ = NoInput(0), settlement: datetime_ = NoInput(0), forward: datetime_ = NoInput(0), @@ -460,6 +468,7 @@ def _cashflow_elements( index_curve=index_curve, fx=fx, fx_vol=fx_vol, + ir_vol=ir_vol, ) disc_curve_result = _try_disc_required_maybe_from_curve(curve=rate_curve, disc_curve=disc_curve) @@ -479,6 +488,7 @@ def _cashflow_elements( disc_curve=disc_curve, fx=fx, fx_vol=fx_vol, + ir_vol=ir_vol, settlement=settlement, forward=forward, ) diff --git a/python/rateslib/periods/protocols/npv.py b/python/rateslib/periods/protocols/npv.py index 6bee914d3..b831302b3 100644 --- a/python/rateslib/periods/protocols/npv.py +++ b/python/rateslib/periods/protocols/npv.py @@ -37,6 +37,7 @@ Result, _BaseCurve_, _FXVolOption_, + _IRVolOption_, datetime, datetime_, str_, @@ -155,6 +156,7 @@ def immediate_local_npv( disc_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> DualTypes: r""" Calculate the immediate NPV of the *Period* in local settlement currency. @@ -180,6 +182,9 @@ def immediate_local_npv( fx_vol: FXDeltaVolSmile, FXSabrSmile, FXDeltaVolSurface, FXSabrSurface, optional The FX volatility *Smile* or *Surface* object used for determining Black calendar day implied volatility values. + ir_vol: IRSabrSmile, optional + The IR volatility *smile* or *Cube* object used for determining Black calendar + day implied volatility values. Returns ------- @@ -197,6 +202,7 @@ def try_immediate_local_npv( disc_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> Result[DualTypes]: r""" Replicate :meth:`~rateslib.periods.protocols._WithNPV.immediate_local_npv` with @@ -212,6 +218,7 @@ def try_immediate_local_npv( index_curve=index_curve, disc_curve=disc_curve, fx_vol=fx_vol, + ir_vol=ir_vol, fx=fx, ) except Exception as e: @@ -229,6 +236,7 @@ def local_npv( disc_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), settlement: datetime_ = NoInput(0), forward: datetime_ = NoInput(0), ) -> DualTypes: @@ -259,6 +267,9 @@ def local_npv( fx_vol: FXDeltaVolSmile, FXSabrSmile, FXDeltaVolSurface, FXSabrSurface, optional The FX volatility *Smile* or *Surface* object used for determining Black calendar day implied volatility values. + ir_vol: IRSabrSmile, optional + The IR volatility *Smile* or *Cube* object used for determining Black calendar + day implied volatility values. settlement: datetime, optional (set as immediate date) The assumed settlement date of the *PV* determination. Used only to evaluate *ex-dividend* status. @@ -275,6 +286,7 @@ def local_npv( disc_curve=disc_curve, fx=fx, fx_vol=fx_vol, + ir_vol=ir_vol, ) return _screen_ex_div_and_forward( local_value=Ok(local_immediate_npv), @@ -293,6 +305,7 @@ def try_local_npv( disc_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), settlement: datetime_ = NoInput(0), forward: datetime_ = NoInput(0), ) -> Result[DualTypes]: @@ -312,6 +325,7 @@ def try_local_npv( settlement=settlement, forward=forward, fx_vol=fx_vol, + ir_vol=ir_vol, fx=fx, ) except Exception as e: @@ -327,6 +341,7 @@ def npv( disc_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), base: str_ = NoInput(0), local: bool = False, settlement: datetime_ = NoInput(0), @@ -363,6 +378,9 @@ def npv( fx_vol: FXDeltaVolSmile, FXSabrSmile, FXDeltaVolSurface, FXSabrSurface, optional The FX volatility *Smile* or *Surface* object used for determining Black calendar day implied volatility values. + ir_vol: IRSabrSmile, optional + The IR volatility *smile* or *Cube* object used for determining Black calendar + day implied volatility values. base: str, optional The currency to convert the *local settlement* NPV to. local: bool, optional @@ -393,6 +411,7 @@ def npv( disc_curve=disc_curve, fx=fx, fx_vol=fx_vol, + ir_vol=ir_vol, settlement=settlement, forward=forward, ) @@ -609,6 +628,7 @@ def unindexed_reference_cashflow( index_curve: _BaseCurve_ = NoInput(0), fx: FX_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> DualTypes: r""" Calculate the cashflow for the *Static Period* before settlement currency and @@ -633,6 +653,9 @@ def unindexed_reference_cashflow( fx_vol: FXDeltaVolSmile, FXSabrSmile, FXDeltaVolSurface, FXSabrSurface, optional The FX volatility *Smile* or *Surface* object used for determining Black calendar day implied volatility values. + ir_vol: IRSabrSmile, optional + The IR volatility *smile* or *Cube* object used for determining Black calendar + day implied volatility values. Returns ------- @@ -652,6 +675,7 @@ def try_unindexed_reference_cashflow( index_curve: _BaseCurve_ = NoInput(0), fx: FX_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> Result[DualTypes]: r""" Replicate :meth:`~rateslib.periods.protocols._WithNPVStatic.unindexed_reference_cashflow` @@ -667,6 +691,7 @@ def try_unindexed_reference_cashflow( index_curve=index_curve, disc_curve=disc_curve, fx_vol=fx_vol, + ir_vol=ir_vol, fx=fx, ) except Exception as e: @@ -682,6 +707,7 @@ def reference_cashflow( index_curve: _BaseCurve_ = NoInput(0), fx: FX_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> DualTypes: r""" Calculate the cashflow for the *Static Period* before settlement currency adjustment @@ -706,6 +732,9 @@ def reference_cashflow( fx_vol: FXDeltaVolSmile, FXSabrSmile, FXDeltaVolSurface, FXSabrSurface, optional The FX volatility *Smile* or *Surface* object used for determining Black calendar day implied volatility values. + ir_vol: IRSabrSmile, optional + The IR volatility *smile* or *Cube* object used for determining Black calendar + day implied volatility values. Returns ------- @@ -717,6 +746,7 @@ def reference_cashflow( index_curve=index_curve, fx=fx, fx_vol=fx_vol, + ir_vol=ir_vol, ) return self.index_up(value=urc, index_curve=index_curve) @@ -728,6 +758,7 @@ def try_reference_cashflow( index_curve: _BaseCurve_ = NoInput(0), fx: FX_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> Result[DualTypes]: r""" Replicate :meth:`~rateslib.periods.protocols._WithNPVStatic.reference_cashflow` @@ -743,6 +774,7 @@ def try_reference_cashflow( index_curve=index_curve, disc_curve=disc_curve, fx_vol=fx_vol, + ir_vol=ir_vol, fx=fx, ) except Exception as e: @@ -758,6 +790,7 @@ def unindexed_cashflow( index_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> DualTypes: r""" Calculate the cashflow for the *Static Period* with settlement currency adjustment @@ -782,6 +815,9 @@ def unindexed_cashflow( fx_vol: FXDeltaVolSmile, FXSabrSmile, FXDeltaVolSurface, FXSabrSurface, optional The FX volatility *Smile* or *Surface* object used for determining Black calendar day implied volatility values. + ir_vol: IRSabrSmile, optional + The IR volatility *smile* or *Cube* object used for determining Black calendar + day implied volatility values. Returns ------- @@ -793,6 +829,7 @@ def unindexed_cashflow( index_curve=index_curve, fx=fx, fx_vol=fx_vol, + ir_vol=ir_vol, ) return self.convert_deliverable(value=urc, fx=fx) @@ -804,6 +841,7 @@ def try_unindexed_cashflow( index_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> Result[DualTypes]: r""" Replicate :meth:`~rateslib.periods.protocols._WithNPVStatic.unindexed_cashflow` @@ -819,6 +857,7 @@ def try_unindexed_cashflow( index_curve=index_curve, disc_curve=disc_curve, fx_vol=fx_vol, + ir_vol=ir_vol, fx=fx, ) except Exception as e: @@ -834,6 +873,7 @@ def cashflow( index_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> DualTypes: r""" Calculate the cashflow for the *Period* with settlement currency adjustment @@ -858,6 +898,9 @@ def cashflow( fx_vol: FXDeltaVolSmile, FXSabrSmile, FXDeltaVolSurface, FXSabrSurface, optional The FX volatility *Smile* or *Surface* object used for determining Black calendar day implied volatility values. + ir_vol: IRSabrSmile, optional + The IR volatility *smile* or *Cube* object used for determining Black calendar + day implied volatility values. Returns ------- @@ -869,6 +912,7 @@ def cashflow( disc_curve=disc_curve, fx=fx, fx_vol=fx_vol, + ir_vol=ir_vol, ) return self.convert_deliverable(value=rc, fx=fx) @@ -880,6 +924,7 @@ def try_cashflow( index_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> Result[DualTypes]: r""" Replicate :meth:`~rateslib.periods.protocols._WithNPVStatic.cashflow` @@ -895,6 +940,7 @@ def try_cashflow( index_curve=index_curve, disc_curve=disc_curve, fx_vol=fx_vol, + ir_vol=ir_vol, fx=fx, ) except Exception as e: @@ -910,6 +956,7 @@ def immediate_local_npv( disc_curve: _BaseCurve_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: _FXVolOption_ = NoInput(0), + ir_vol: _IRVolOption_ = NoInput(0), ) -> DualTypes: r""" Calculate the NPV of the *Period* in local settlement currency. @@ -937,6 +984,9 @@ def immediate_local_npv( fx_vol: FXDeltaVolSmile, FXSabrSmile, FXDeltaVolSurface, FXSabrSurface, optional The FX volatility *Smile* or *Surface* object used for determining Black calendar day implied volatility values. + ir_vol: IRSabrSmile, optional + The IR volatility *smile* or *Cube* object used for determining Black calendar + day implied volatility values. Returns ------- @@ -957,6 +1007,7 @@ def immediate_local_npv( index_curve=index_curve, disc_curve=disc_curve_, fx_vol=fx_vol, + ir_vol=ir_vol, fx=fx, ) return c * disc_curve_[self.settlement_params.payment] diff --git a/python/rateslib/periods/utils.py b/python/rateslib/periods/utils.py index a74ebb114..0f9a30492 100644 --- a/python/rateslib/periods/utils.py +++ b/python/rateslib/periods/utils.py @@ -21,18 +21,29 @@ from rateslib.enums.generics import Err, NoInput, Ok, Result from rateslib.enums.parameters import FXDeltaMethod from rateslib.fx import FXForwards, FXRates -from rateslib.fx_volatility import FXDeltaVolSmile, FXDeltaVolSurface, FXSabrSmile, FXSabrSurface +from rateslib.instruments.protocols.pricing import _Curves +from rateslib.volatility import ( + FXDeltaVolSmile, + FXDeltaVolSurface, + FXSabrSmile, + FXSabrSurface, + IRSabrCube, + IRSabrSmile, +) +from rateslib.volatility.ir.utils import _IRVolPricingParams if TYPE_CHECKING: from rateslib.local_types import ( FX_, + IRS, Any, CurveOption_, DualTypes, FXForwards_, - _BaseCurve, + IRSabrCube, _BaseCurve_, _FXVolOption_, + _IRVolOption_, datetime_, str_, ) @@ -137,7 +148,54 @@ def _get_immediate_fx_scalar_and_base( return fx, base # type: ignore[return-value] -def _get_vol_maybe_from_obj( +def _get_ir_vol_value_and_forward_maybe_from_obj( + ir_vol: _IRVolOption_, + rate_curve: CurveOption_, + index_curve: _BaseCurve_, + strike: DualTypes | str, + irs: IRS, + expiry: datetime, + tenor: datetime, +) -> _IRVolPricingParams: + """ + Return the following pring requirements: + + Returns + ------- + output: tuple[DualTypes, DualTypes, DualTypes] + The forward IRS rate exc. shift, the Black shifted vol, the shift to add to `f` and `k`. + """ + # IROption can have a `strike` that is NoInput, however this internal function should + # only be performed after a `strike` has been set to number, temporarily or otherwise. + f_ = irs.rate( + curves=_Curves( + disc_curve=index_curve, leg2_rate_curve=rate_curve, leg2_disc_curve=index_curve + ) + ) + + if isinstance(strike, NoInput): + k_: DualTypes = f_ + elif isinstance(strike, str): + if strike.lower() == "atm": + k_ = f_ + elif "bps" in strike: + k_ = f_ + float(strike[:-3]) + else: + raise ValueError("`strike` as string must be either 'atm' or '{}bps'.") + else: + k_ = strike + + if isinstance(ir_vol, IRSabrSmile | IRSabrCube): + # ir_vol is a Vol object + return ir_vol.get_from_strike(k=k_, f=f_, expiry=expiry, tenor=tenor) + elif isinstance(ir_vol, NoInput): + raise ValueError("`ir_vol` cannot be NoInput when provided to pricing function.") + else: + # vol given as scalar interpolated as Black Vol Zero shifted + return _IRVolPricingParams(vol=ir_vol, f=f_, k=k_, shift=0.0) + + +def _get_fx_vol_value_maybe_from_obj( fx_vol: _FXVolOption_, fx: FXForwards, rate_curve: _BaseCurve_, diff --git a/python/rateslib/rs.pyi b/python/rateslib/rs.pyi index a3712e32b..d0eea5fa9 100644 --- a/python/rateslib/rs.pyi +++ b/python/rateslib/rs.pyi @@ -250,6 +250,21 @@ class FloatFixingMethod(_MethodParam): def to_json(self) -> str: ... +class _Shift: + def shift(self) -> int: ... + +class IROptionMetric(_Shift): + class NormalVol(IROptionMetric): ... + class LogNormalVol(IROptionMetric): ... + class PercentNotional(IROptionMetric): ... + class Cash(IROptionMetric): ... + + class BlackVolShift(IROptionMetric): + param: int + def __init__(self, param: int) -> None: ... + + def to_json(self) -> str: ... + class CalendarManager: def add(self, name: str, calendar: Cal) -> None: ... def pop(self, name: str) -> Cal | UnionCal: ... diff --git a/python/rateslib/scheduling/__init__.py b/python/rateslib/scheduling/__init__.py index 069ff1ef1..290819112 100644 --- a/python/rateslib/scheduling/__init__.py +++ b/python/rateslib/scheduling/__init__.py @@ -11,7 +11,6 @@ from __future__ import annotations -import rateslib.rs from rateslib.rs import ( Adjuster, Cal, @@ -30,37 +29,6 @@ from rateslib.scheduling.imm import get_imm, next_imm from rateslib.scheduling.schedule import Schedule -# Patch the namespace for pyo3 pickling: see https://github.com/PyO3/pyo3/discussions/5226 -# rateslib.rs.RollDay_Day = rateslib.rs.RollDay.Day # type: ignore[attr-defined] -# rateslib.rs.RollDay_IMM = rateslib.rs.RollDay.IMM # type: ignore[attr-defined] - - -class PicklingContainer: - pass - - -rateslib.rs.PyAdjuster = PicklingContainer() # type: ignore[attr-defined] - -rateslib.rs.PyAdjuster.Actual = rateslib.rs.Adjuster.Actual # type: ignore[attr-defined] -rateslib.rs.PyAdjuster.Following = rateslib.rs.Adjuster.Following # type: ignore[attr-defined] -rateslib.rs.PyAdjuster.ModifiedFollowing = rateslib.rs.Adjuster.ModifiedFollowing # type: ignore[attr-defined] -rateslib.rs.PyAdjuster.Previous = rateslib.rs.Adjuster.Previous # type: ignore[attr-defined] -rateslib.rs.PyAdjuster.ModifiedPrevious = rateslib.rs.Adjuster.ModifiedPrevious # type: ignore[attr-defined] -rateslib.rs.PyAdjuster.FollowingSettle = rateslib.rs.Adjuster.FollowingSettle # type: ignore[attr-defined] -rateslib.rs.PyAdjuster.ModifiedFollowingSettle = rateslib.rs.Adjuster.ModifiedFollowingSettle # type: ignore[attr-defined] -rateslib.rs.PyAdjuster.PreviousSettle = rateslib.rs.Adjuster.PreviousSettle # type: ignore[attr-defined] -rateslib.rs.PyAdjuster.ModifiedPreviousSettle = rateslib.rs.Adjuster.ModifiedPreviousSettle # type: ignore[attr-defined] -rateslib.rs.PyAdjuster.BusDaysLagSettle = rateslib.rs.Adjuster.BusDaysLagSettle # type: ignore[attr-defined] -rateslib.rs.PyAdjuster.CalDaysLagSettle = rateslib.rs.Adjuster.CalDaysLagSettle # type: ignore[attr-defined] -rateslib.rs.PyAdjuster.FollowingExLast = rateslib.rs.Adjuster.FollowingExLast # type: ignore[attr-defined] -rateslib.rs.PyAdjuster.FollowingExLastSettle = rateslib.rs.Adjuster.FollowingExLastSettle # type: ignore[attr-defined] -rateslib.rs.PyAdjuster.BusDaysLagSettleInAdvance = rateslib.rs.Adjuster.BusDaysLagSettleInAdvance # type: ignore[attr-defined] -# -# rateslib.rs.Frequency_CalDays = rateslib.rs.Frequency.CalDays # type: ignore[attr-defined] -# rateslib.rs.Frequency_BusDays = rateslib.rs.Frequency.BusDays # type: ignore[attr-defined] -# rateslib.rs.Frequency_Months = rateslib.rs.Frequency.Months # type: ignore[attr-defined] -# rateslib.rs.Frequency_Zero = rateslib.rs.Frequency.Zero # type: ignore[attr-defined] - Imm.__doc__ = """ Enumerable type for International Money-Market (IMM) date definitions. diff --git a/python/rateslib/scheduling/frequency.py b/python/rateslib/scheduling/frequency.py index 0bd5a8442..aaafb3928 100644 --- a/python/rateslib/scheduling/frequency.py +++ b/python/rateslib/scheduling/frequency.py @@ -23,7 +23,12 @@ from rateslib.utils.calendars import _get_first_bus_day if TYPE_CHECKING: - from rateslib.local_types import CalInput, datetime_, int_, str_ + from rateslib.local_types import ( # pragma: no cover + CalInput, + datetime_, + int_, + str_, + ) def _get_frequency( diff --git a/python/rateslib/solver.py b/python/rateslib/solver.py index 6705493be..f16639663 100644 --- a/python/rateslib/solver.py +++ b/python/rateslib/solver.py @@ -38,17 +38,14 @@ from rateslib.dual.utils import _dual_float from rateslib.enums.generics import NoInput, _drb from rateslib.fx import FXForwards, FXRates - -# Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International -# Commercial use of this code, and/or copying and redistribution is prohibited. -# Contact rateslib at gmail.com if this code is observed outside its intended sphere. -from rateslib.fx_volatility import FXVols from rateslib.mutability import ( _new_state_post, _no_interior_validation, _validate_states, _WithState, ) +from rateslib.volatility.fx import FXVols +from rateslib.volatility.ir import IRVolObj, IRVols P = ParamSpec("P") @@ -1384,7 +1381,7 @@ def _reset_properties_(self, dual2_only: bool = False) -> None: @_validate_states def _get_pre_curve(self, obj: str) -> Curve: - ret: Curve | FXVols = self.pre_curves[obj] + ret: Curve | FXVols | IRVols = self.pre_curves[obj] if isinstance(ret, _BaseCurve): return ret else: @@ -1395,7 +1392,7 @@ def _get_pre_curve(self, obj: str) -> Curve: @_validate_states def _get_pre_fxvol(self, obj: str) -> FXVols: - _: Curve | FXVols = self.pre_curves[obj] + _: Curve | FXVols | IRVols = self.pre_curves[obj] if isinstance(_, FXVols): return _ else: @@ -1404,6 +1401,17 @@ def _get_pre_fxvol(self, obj: str) -> FXVols: f"type object was returned:'{type(_)}'." ) + @_validate_states + def _get_pre_irvol(self, obj: str) -> IRVols: + _: Curve | FXVols | IRVols = self.pre_curves[obj] + if isinstance(_, IRVolObj): + return _ + else: + raise ValueError( + f"A type of `IRVol` object was sought with id:'{obj}' from Solver but another " + f"type object was returned:'{type(_)}'." + ) + @_validate_states def _get_fx(self) -> FXForwards_: return self.fx @@ -1669,10 +1677,6 @@ def _set_ad_order(self, order: int) -> None: self.fx._set_ad_order(order) self._reset_properties_() - # Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International - # Commercial use of this code, and/or copying and redistribution is prohibited. - # Contact rateslib at gmail.com if this code is observed outside its intended sphere. - @_validate_states @_no_interior_validation def delta( @@ -2345,9 +2349,4 @@ def exo_delta( return _ -# Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International -# Commercial use of this code, and/or copying and redistribution is prohibited. -# Contact rateslib at gmail.com if this code is observed outside its intended sphere. - - __all__ = ["Gradients", "Solver"] diff --git a/python/rateslib/verify.py b/python/rateslib/verify.py index 5fca6d93e..537a76127 100644 --- a/python/rateslib/verify.py +++ b/python/rateslib/verify.py @@ -28,7 +28,7 @@ Any, ) -VERSION = "2.6.0" +VERSION = "2.7.0" class LicenceNotice(UserWarning): diff --git a/python/rateslib/volatility/__init__.py b/python/rateslib/volatility/__init__.py new file mode 100644 index 000000000..79123517c --- /dev/null +++ b/python/rateslib/volatility/__init__.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: LicenseRef-Rateslib-Dual +# +# Copyright (c) 2026 Siffrorna Technology Limited +# +# Dual-licensed: Free Educational Licence or Paid Commercial Licence (commercial/professional use) +# Source-available, not open source. +# +# See LICENSE and https://rateslib.com/py/en/latest/i_licence.html for details, +# and/or contact info (at) rateslib (dot) com +#################################################################################################### + +from rateslib.volatility.fx import ( + FXDeltaVolSmile, + FXDeltaVolSurface, + FXSabrSmile, + FXSabrSurface, + _BaseFXSmile, + _FXDeltaVolSmileNodes, + _FXDeltaVolSpline, + _FXDeltaVolSurfaceMeta, + _FXSabrSurfaceMeta, + _FXSmileMeta, + _SabrSmileNodes, +) +from rateslib.volatility.ir import ( + IRSabrCube, + IRSabrSmile, + _BaseIRSmile, + _IRSabrCubeMeta, + _IRSmileMeta, +) + +__all__ = [ + "FXSabrSmile", + "FXSabrSurface", + "FXDeltaVolSurface", + "FXDeltaVolSmile", + "IRSabrSmile", + "IRSabrCube", + "_BaseFXSmile", + "_BaseIRSmile", + "_FXDeltaVolSurfaceMeta", + "_FXSmileMeta", + "_FXDeltaVolSpline", + "_FXDeltaVolSmileNodes", + "_FXSabrSurfaceMeta", + "_SabrSmileNodes", + "_IRSabrCubeMeta", + "_IRSmileMeta", +] diff --git a/python/rateslib/fx_volatility/__init__.py b/python/rateslib/volatility/fx/__init__.py similarity index 75% rename from python/rateslib/fx_volatility/__init__.py rename to python/rateslib/volatility/fx/__init__.py index e209bfef7..400c1db51 100644 --- a/python/rateslib/fx_volatility/__init__.py +++ b/python/rateslib/volatility/fx/__init__.py @@ -10,30 +10,32 @@ #################################################################################################### -from rateslib.fx_volatility.base import _BaseSmile -from rateslib.fx_volatility.delta_vol import FXDeltaVolSmile, FXDeltaVolSurface -from rateslib.fx_volatility.sabr import FXSabrSmile, FXSabrSurface -from rateslib.fx_volatility.utils import ( +from rateslib.volatility.fx.base import _BaseFXSmile +from rateslib.volatility.fx.delta_vol import FXDeltaVolSmile, FXDeltaVolSurface +from rateslib.volatility.fx.sabr import FXSabrSmile, FXSabrSurface +from rateslib.volatility.fx.utils import ( _FXDeltaVolSmileNodes, _FXDeltaVolSpline, _FXDeltaVolSurfaceMeta, - _FXSabrSmileNodes, _FXSabrSurfaceMeta, _FXSmileMeta, ) +from rateslib.volatility.utils import ( + _SabrSmileNodes, +) __all__ = [ "FXSabrSmile", "FXSabrSurface", "FXDeltaVolSurface", "FXDeltaVolSmile", - "_BaseSmile", + "_BaseFXSmile", "_FXDeltaVolSurfaceMeta", "_FXSmileMeta", "_FXDeltaVolSpline", "_FXDeltaVolSmileNodes", "_FXSabrSurfaceMeta", - "_FXSabrSmileNodes", + "_SabrSmileNodes", ] FXVols = FXDeltaVolSmile | FXDeltaVolSurface | FXSabrSmile | FXSabrSurface diff --git a/python/rateslib/fx_volatility/base.py b/python/rateslib/volatility/fx/base.py similarity index 86% rename from python/rateslib/fx_volatility/base.py rename to python/rateslib/volatility/fx/base.py index 19c0ac4d5..99aef4f9e 100644 --- a/python/rateslib/fx_volatility/base.py +++ b/python/rateslib/volatility/fx/base.py @@ -17,16 +17,18 @@ from rateslib.default import PlotOutput, plot from rateslib.dual import Dual, Dual2, Variable from rateslib.enums.generics import NoInput, _drb -from rateslib.fx_volatility.utils import _FXSmileMeta from rateslib.mutability import _WithCache, _WithState +from rateslib.volatility.fx.utils import _FXSmileMeta if TYPE_CHECKING: - from rateslib.local_types import FXForwards + from rateslib.local_types import FXForwards # pragma: no cover DualTypes: TypeAlias = "float | Dual | Dual2 | Variable" # if not defined causes _WithCache failure -class _BaseSmile(_WithState, _WithCache[float, DualTypes]): +class _BaseFXSmile(_WithState, _WithCache[float, DualTypes]): + """Abstract base class for implementing *FX Smiles*.""" + _ad: int _default_plot_x_axis: str meta: _FXSmileMeta @@ -41,7 +43,7 @@ def __iter__(self) -> NoReturn: def plot( self, - comparators: list[_BaseSmile] | NoInput = NoInput(0), + comparators: list[_BaseFXSmile] | NoInput = NoInput(0), labels: list[str] | NoInput = NoInput(0), x_axis: str | NoInput = NoInput(0), f: DualTypes | FXForwards | NoInput = NoInput(0), @@ -53,14 +55,14 @@ def plot( The *'delta'* ``x_axis`` type for a *SabrSmile* is calculated based on a **forward, unadjusted** delta and is expressed as a negated put option delta - consistent with the definition for a :class:`~rateslib.fx_volatility.FXDeltaVolSmile`. + consistent with the definition for a :class:`~rateslib.volatility.FXDeltaVolSmile`. Parameters ---------- comparators: list[Smile] A list of Smiles which to include on the same plot as comparators. Note the comments on - :meth:`FXDeltaVolSmile.plot `. + :meth:`FXDeltaVolSmile.plot `. labels : list[str] A list of strings associated with the plot and comparators. Must be same length as number of plots. @@ -87,7 +89,7 @@ def plot( y = [y_] if not isinstance(comparators, NoInput): for smile in comparators: - if not isinstance(smile, _BaseSmile): + if not isinstance(smile, _BaseFXSmile): raise ValueError("A `comparator` must be a valid FX Smile type.") x_, y_ = smile._plot(x_axis_, f) # type: ignore[attr-defined] x.append(x_) diff --git a/python/rateslib/fx_volatility/delta_vol.py b/python/rateslib/volatility/fx/delta_vol.py similarity index 97% rename from python/rateslib/fx_volatility/delta_vol.py rename to python/rateslib/volatility/fx/delta_vol.py index c394900be..c9b310f16 100644 --- a/python/rateslib/fx_volatility/delta_vol.py +++ b/python/rateslib/volatility/fx/delta_vol.py @@ -41,27 +41,29 @@ from rateslib.dual.utils import _dual_float from rateslib.enums.generics import NoInput, _drb from rateslib.enums.parameters import FXDeltaMethod, _get_fx_delta_type -from rateslib.fx_volatility.base import _BaseSmile -from rateslib.fx_volatility.utils import ( - _d_plus_min_u, +from rateslib.mutability import ( + _clear_cache_post, + _new_state_post, + _validate_states, + _WithCache, + _WithState, +) +from rateslib.scheduling import get_calendar +from rateslib.splines.evaluate import evaluate +from rateslib.volatility.fx.base import _BaseFXSmile +from rateslib.volatility.fx.utils import ( _delta_type_constants, _FXDeltaVolSmileNodes, _FXDeltaVolSurfaceMeta, _FXSmileMeta, _moneyness_from_delta_closed_form, +) +from rateslib.volatility.utils import ( + _OptionModelBlack76, _surface_index_left, _t_var_interp, _validate_weights, ) -from rateslib.mutability import ( - _clear_cache_post, - _new_state_post, - _validate_states, - _WithCache, - _WithState, -) -from rateslib.scheduling import get_calendar -from rateslib.splines import evaluate if TYPE_CHECKING: from rateslib.local_types import DualTypes, DualTypes_, Sequence # pragma: no cover @@ -70,7 +72,7 @@ UTC = timezone.utc -class FXDeltaVolSmile(_BaseSmile): +class FXDeltaVolSmile(_BaseFXSmile): r""" Create an *FX Volatility Smile* at a given expiry indexed by delta percent. @@ -162,12 +164,12 @@ def id(self) -> str: @property def meta(self) -> _FXSmileMeta: # type: ignore[override] - """An instance of :class:`~rateslib.fx_volatility.utils._FXSmileMeta`.""" + """An instance of :class:`~rateslib.volatility.fx._FXSmileMeta`.""" return self.nodes.meta @property def nodes(self) -> _FXDeltaVolSmileNodes: - """An instance of :class:`~rateslib.fx_volatility.utils._FXDeltaVolSmileNodes`.""" + """An instance of :class:`~rateslib.volatility.fx._FXDeltaVolSmileNodes`.""" return self._nodes @property @@ -463,7 +465,7 @@ def update( """ Update a *Smile* with new, manually passed nodes. - For arguments see :class:`~rateslib.fx_volatility.FXDeltaVolSmile` + For arguments see :class:`~rateslib.volatility.FXDeltaVolSmile` Returns ------- @@ -572,7 +574,7 @@ class FXDeltaVolSurface(_WithState, _WithCache[datetime, FXDeltaVolSmile]): Notes ----- - See :class:`~rateslib.fx_volatility.FXDeltaVolSmile` for a description of delta indexes and + See :class:`~rateslib.volatility.FXDeltaVolSmile` for a description of delta indexes and *Smile* construction. **Temporal Interpolation** @@ -673,7 +675,7 @@ def id(self) -> str: @property def meta(self) -> _FXDeltaVolSurfaceMeta: - """An instance of :class:`~rateslib.fx_volatility.utils._FXDeltaVolSurfaceMeta`.""" + """An instance of :class:`~rateslib.volatility.fx._FXDeltaVolSurfaceMeta`.""" return self._meta @property @@ -935,7 +937,7 @@ def root1d( vol_sqrt_t = vol_ * sqrt_t_e # Calculate function values - d0 = _d_plus_min_u(u, vol_sqrt_t, eta_0) + d0 = _OptionModelBlack76._d_plus_min_u(u, vol_sqrt_t, eta_0) _phi0 = dual_norm_cdf(phi * d0) f0 = phi * z_w_0 * z_u_0 * (0.5 - _phi0) @@ -1011,7 +1013,7 @@ def root1d( vol_sqrt_t = vol_ * sqrt_t_e # Calculate function values - d0 = _d_plus_min_u(u, vol_sqrt_t, eta_0) + d0 = _OptionModelBlack76._d_plus_min_u(u, vol_sqrt_t, eta_0) _phi0 = dual_norm_cdf(phi * d0) f0 = delta - z_w_0 * z_u_0 * phi * _phi0 @@ -1086,11 +1088,11 @@ def root2d( vol_sqrt_t = vol_ * sqrt_t_e # Calculate function values - d0 = _d_plus_min_u(u, vol_sqrt_t, eta_0) + d0 = _OptionModelBlack76._d_plus_min_u(u, vol_sqrt_t, eta_0) _phi0 = dual_norm_cdf(phi * d0) f0_0 = phi * z_w_0 * z_u_0 * (0.5 - _phi0) - d1 = _d_plus_min_u(u, vol_sqrt_t, eta_1) + d1 = _OptionModelBlack76._d_plus_min_u(u, vol_sqrt_t, eta_1) _phi1 = dual_norm_cdf(-d1) f0_1 = delta_idx - z_w_1 * z_u_1 * _phi1 @@ -1163,11 +1165,11 @@ def root2d( vol_sqrt_t = vol_ * sqrt_t_e # Calculate function values - d0 = _d_plus_min_u(u, vol_sqrt_t, eta_0) + d0 = _OptionModelBlack76._d_plus_min_u(u, vol_sqrt_t, eta_0) _phi0 = dual_norm_cdf(phi * d0) f0_0: DualTypes = delta - z_w_0 * z_u_0 * phi * _phi0 - d1 = _d_plus_min_u(u, vol_sqrt_t, eta_1) + d1 = _OptionModelBlack76._d_plus_min_u(u, vol_sqrt_t, eta_1) _phi1 = dual_norm_cdf(-d1) f0_1: DualTypes = delta_idx - z_w_1 * z_u_1 * _phi1 @@ -1258,11 +1260,11 @@ def root3d( vol_sqrt_t = vol_ * sqrt_t_e # Calculate function values - d0 = _d_plus_min_u(u, vol_sqrt_t, eta_0) + d0 = _OptionModelBlack76._d_plus_min_u(u, vol_sqrt_t, eta_0) _phi0 = dual_norm_cdf(phi * d0) f0_0 = delta - z_w_0 * z_u_0 * phi * _phi0 - d1 = _d_plus_min_u(u, vol_sqrt_t, eta_1) + d1 = _OptionModelBlack76._d_plus_min_u(u, vol_sqrt_t, eta_1) _phi1 = dual_norm_cdf(-d1) f0_1 = delta_idx - z_w_1 * z_u_1 * _phi1 diff --git a/python/rateslib/fx_volatility/sabr.py b/python/rateslib/volatility/fx/sabr.py similarity index 96% rename from python/rateslib/fx_volatility/sabr.py rename to python/rateslib/volatility/fx/sabr.py index 0df4449b2..113dccb75 100644 --- a/python/rateslib/fx_volatility/sabr.py +++ b/python/rateslib/volatility/fx/sabr.py @@ -34,16 +34,6 @@ from rateslib.enums.generics import NoInput, _drb from rateslib.enums.parameters import FXDeltaMethod from rateslib.fx import FXForwards -from rateslib.fx_volatility.base import _BaseSmile -from rateslib.fx_volatility.utils import ( - _d_sabr_d_k_or_f, - _FXSabrSmileNodes, - _FXSabrSurfaceMeta, - _FXSmileMeta, - _surface_index_left, - _t_var_interp_d_sabr_d_k_or_f, - _validate_weights, -) from rateslib.mutability import ( _clear_cache_post, _new_state_post, @@ -52,6 +42,18 @@ _WithState, ) from rateslib.scheduling import get_calendar +from rateslib.volatility.fx.base import _BaseFXSmile +from rateslib.volatility.fx.utils import ( + _FXSabrSurfaceMeta, + _FXSmileMeta, +) +from rateslib.volatility.utils import ( + _SabrModel, + _SabrSmileNodes, + _surface_index_left, + _t_var_interp_d_sabr_d_k_or_f, + _validate_weights, +) if TYPE_CHECKING: from rateslib.local_types import ( # pragma: no cover @@ -69,7 +71,7 @@ UTC = timezone.utc -class FXSabrSmile(_BaseSmile): +class FXSabrSmile(_BaseFXSmile): r""" Create an *FX Volatility Smile* at a given expiry indexed by strike using SABR parameters. @@ -132,7 +134,7 @@ class FXSabrSmile(_BaseSmile): _ini_solve = 1 _meta: _FXSmileMeta _id: str - _nodes: _FXSabrSmileNodes + _nodes: _SabrSmileNodes @_new_state_post def __init__( @@ -168,7 +170,7 @@ def __init__( raise ValueError( f"'{_}' is a required SABR parameter that must be included in ``nodes``" ) - self._nodes: _FXSabrSmileNodes = _FXSabrSmileNodes( + self._nodes: _SabrSmileNodes = _SabrSmileNodes( _alpha=_to_number(nodes["alpha"]), _beta=nodes["beta"], # type: ignore[arg-type] _rho=_to_number(nodes["rho"]), @@ -190,12 +192,12 @@ def id(self) -> str: @property def meta(self) -> _FXSmileMeta: # type: ignore[override] - """An instance of :class:`~rateslib.fx_volatility.utils._FXSmileMeta`.""" + """An instance of :class:`~rateslib.volatility.fx._FXSmileMeta`.""" return self._meta @property - def nodes(self) -> _FXSabrSmileNodes: - """An instance of :class:`~rateslib.fx_volatility.utils._FXSabrSmileNodes`.""" + def nodes(self) -> _SabrSmileNodes: + """An instance of :class:`~rateslib.volatility.fx._FXSabrSmileNodes`.""" return self._nodes def get_from_strike( @@ -233,7 +235,7 @@ def get_from_strike( Notes ----- This function returns a tuple consistent with an - :class:`~rateslib.fx_volatility.FXDeltaVolSmile`, however since the *FXSabrSmile* has no + :class:`~rateslib.volatility.FXDeltaVolSmile`, however since the *FXSabrSmile* has no concept of a `delta index` the first element returned is always zero and can be effectively ignored. """ @@ -256,7 +258,7 @@ def get_from_strike( else: raise ValueError("`f` (ATM-forward FX rate) must be a value or FXForwards object.") - vol_ = _d_sabr_d_k_or_f( + vol_ = _SabrModel._d_sabr_d_k_or_f( _to_number(k), _to_number(f_), self._meta.t_expiry, @@ -305,7 +307,7 @@ def _d_sabr_d_k_or_f( p_ = self.nodes.rho v_ = self.nodes.nu - return _d_sabr_d_k_or_f(k_, f_, t_e, a_, b_, p_, v_, derivative) + return _SabrModel._d_sabr_d_k_or_f(k_, f_, t_e, a_, b_, p_, v_, derivative) def _get_node_vector(self) -> np.ndarray[tuple[int, ...], np.dtype[np.object_]]: """Get a 1d array of variables associated with nodes of this object updated by Solver""" @@ -331,7 +333,7 @@ def _set_node_vector( base_obj = DualType(0.0, [f"{self.id}{i}" for i in range(3)], *DualArgs) ident = np.eye(3) - self._nodes = _FXSabrSmileNodes( + self._nodes = _SabrSmileNodes( _beta=self.nodes.beta, _alpha=DualType.vars_from( base_obj, # type: ignore[arg-type] @@ -368,7 +370,7 @@ def _set_ad_order(self, order: int) -> None: self._ad = order - self._nodes = _FXSabrSmileNodes( + self._nodes = _SabrSmileNodes( _beta=self.nodes.beta, _alpha=set_order_convert(self.nodes.alpha, order, [f"{self.id}0"]), _rho=set_order_convert(self.nodes.rho, order, [f"{self.id}1"]), @@ -409,7 +411,7 @@ def update_node(self, key: str, value: DualTypes) -> None: raise KeyError("`key` is not in ``nodes``.") kwargs = {f"_{_}": getattr(self.nodes, _) for _ in params if _ != key} kwargs.update({f"_{key}": value}) - self._nodes = _FXSabrSmileNodes(**kwargs) + self._nodes = _SabrSmileNodes(**kwargs) self._set_ad_order(self.ad) # Plotting @@ -500,7 +502,7 @@ class FXSabrSurface(_WithState, _WithCache[datetime, FXSabrSmile]): Notes ----- - See :class:`~rateslib.fx_volatility.FXSabrSmile` for a description of SABR parameters for + See :class:`~rateslib.volatility.FXSabrSmile` for a description of SABR parameters for *Smile* construction. **Temporal Interpolation** @@ -523,7 +525,7 @@ class FXSabrSurface(_WithState, _WithCache[datetime, FXSabrSmile]): one wishes to obtian values for. When seeking an ``expiry`` beyond the final expiry, a new - :class:`~rateslib.fx_volatility.SabrSmile` is created at that specific *expiry* using the + :class:`~rateslib.volatility.SabrSmile` is created at that specific *expiry* using the same SABR parameters as matching the final parametrised *Smile*. This will capture the evolution of ATM-forward rates through time. @@ -595,7 +597,7 @@ def id(self) -> str: @property def meta(self) -> _FXSabrSurfaceMeta: - """An instance of :class:`~rateslib.fx_volatility.utils._FXSabrSurfaceMeta`.""" + """An instance of :class:`~rateslib.volatility.fx._FXSabrSurfaceMeta`.""" return self._meta @property @@ -679,7 +681,7 @@ def get_from_strike( Notes ----- This function returns a tuple consistent with an - :class:`~rateslib.fx_volatility.FXDeltaVolSmile`, however since the *FXSabrSmile* has no + :class:`~rateslib.volatility.FXDeltaVolSmile`, however since the *FXSabrSmile* has no concept of a `delta index` the first element returned is always zero and can be effectively ignored. """ diff --git a/python/rateslib/fx_volatility/utils.py b/python/rateslib/volatility/fx/utils.py similarity index 56% rename from python/rateslib/fx_volatility/utils.py rename to python/rateslib/volatility/fx/utils.py index a7763a76b..71cf91b59 100644 --- a/python/rateslib/fx_volatility/utils.py +++ b/python/rateslib/volatility/fx/utils.py @@ -14,7 +14,7 @@ import json from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from functools import cached_property from typing import TYPE_CHECKING, TypeAlias @@ -26,24 +26,17 @@ Variable, dual_exp, dual_inv_norm_cdf, - dual_log, - dual_norm_cdf, set_order_convert, ) -from rateslib.dual.utils import _dual_float, _to_number +from rateslib.dual.utils import _dual_float from rateslib.enums.generics import ( NoInput, ) from rateslib.enums.parameters import FXDeltaMethod -from rateslib.rs import _sabr_x0 as _rs_sabr_x0 -from rateslib.rs import _sabr_x1 as _rs_sabr_x1 -from rateslib.rs import _sabr_x2 as _rs_sabr_x2 -from rateslib.rs import index_left_f64 -from rateslib.scheduling import get_calendar from rateslib.splines import PPSplineDual, PPSplineDual2, PPSplineF64 if TYPE_CHECKING: - from rateslib.local_types import Any, CalTypes, Number + from rateslib.local_types import Any, CalTypes DualTypes: TypeAlias = "float | Dual | Dual2 | Variable" # if not defined causes _WithCache failure @@ -54,6 +47,9 @@ @dataclass class _FXSmileMeta: + """A container of meta data associated with a :class:`~rateslib.volatility._BaseFXSmile` + used to make calculations.""" + _eval_date: datetime _expiry: datetime _plot_x_axis: str @@ -76,7 +72,7 @@ def expiry(self) -> datetime: @property def plot_x_axis(self) -> str: """The default ``x_axis`` parameter passed to - :meth:`~rateslib.fx_volatility._BaseSmile.plot`""" + :meth:`~rateslib.volatility._BaseSmile.plot`""" return self._plot_x_axis @property @@ -119,7 +115,7 @@ def delivery_lag(self) -> int: class _FXDeltaVolSmileNodes: """ A container for data relating to interpolating the `nodes` of a - :class:`~rateslib.fx_volatility.FXDeltaVolSmile`. + :class:`~rateslib.volatility.FXDeltaVolSmile`. """ _nodes: dict[float, DualTypes] @@ -171,7 +167,7 @@ def plot_upper_bound(self) -> float: @property def meta(self) -> _FXSmileMeta: - """An instance of :class:`~rateslib.fx_volatility.utils._FXSmileMeta`.""" + """An instance of :class:`~rateslib.volatility.fx._FXSmileMeta`.""" return self._meta @property @@ -196,14 +192,14 @@ def n(self) -> int: @property def spline(self) -> _FXDeltaVolSpline: - """An instance of :class:`~rateslib.fx_volatility.utils._FXDeltaVolSpline`.""" + """An instance of :class:`~rateslib.volatility.fx._FXDeltaVolSpline`.""" return self._spline class _FXDeltaVolSpline: """ A container for data relating to interpolating the `nodes` of - a :class:`~rateslib.fx_volatility.FXDeltaVolSmile` using a cubic PPSpline. + a :class:`~rateslib.volatility.FXDeltaVolSmile` using a cubic PPSpline. """ _t: list[float] @@ -324,7 +320,7 @@ def __eq__(self, other: Any) -> bool: class _FXDeltaVolSurfaceMeta: """ An immutable container of meta data associated with a - :class:`~rateslib.fx_volatility.FXDeltaVolSurface` used to make calculations. + :class:`~rateslib.volatility.FXDeltaVolSurface` used to make calculations. """ _eval_date: datetime @@ -342,13 +338,13 @@ def __post_init__(self) -> None: @property def delta_indexes(self) -> list[float]: """A list of delta indexes associated with each cross-sectional - :class:`~rateslib.fx_volatility.FXDeltaVolSmile`.""" + :class:`~rateslib.volatility.FXDeltaVolSmile`.""" return self._delta_indexes @property def expiries(self) -> list[datetime]: """A list of the expiries of each cross-sectional - :class:`~rateslib.fx_volatility.FXDeltaVolSmile`.""" + :class:`~rateslib.volatility.FXDeltaVolSmile`.""" return self._expiries @cached_property @@ -389,52 +385,15 @@ def delta_type(self) -> FXDeltaMethod: @property def plot_x_axis(self) -> str: """The default ``x_axis`` parameter passed to - :meth:`~rateslib.fx_volatility._BaseSmile.plot`""" + :meth:`~rateslib.volatility._BaseSmile.plot`""" return self._plot_x_axis -@dataclass(frozen=True) -class _FXSabrSmileNodes: - """ - A container for data relating to the SABR parameters of a - :class:`~rateslib.fx_volatility.FXSabrSmile`. - """ - - _alpha: Number - _beta: float | Variable - _rho: Number - _nu: Number - - @property - def alpha(self) -> Number: - """The :math:`\\alpha` parameter of the SABR function.""" - return self._alpha - - @property - def beta(self) -> float | Variable: - """The :math:`\\beta` parameter of the SABR function.""" - return self._beta - - @property - def rho(self) -> Number: - """The :math:`\\rho` parameter of the SABR function.""" - return self._rho - - @property - def nu(self) -> Number: - """The :math:`\\nu` parameter of the SABR function.""" - return self._nu - - @property - def n(self) -> int: - return 4 - - @dataclass(frozen=True) class _FXSabrSurfaceMeta: """ An immutable container of meta data associated with a - :class:`~rateslib.fx_volatility.FXSabrSurface` used to make calculations. + :class:`~rateslib.volatility.FXSabrSurface` used to make calculations. """ _eval_date: datetime @@ -466,7 +425,7 @@ def weights_cum(self) -> Series[float] | None: @property def expiries(self) -> list[datetime]: """A list of the expiries of each cross-sectional - :class:`~rateslib.fx_volatility.FXSabrSmile`.""" + :class:`~rateslib.volatility.FXSabrSmile`.""" return self._expiries @cached_property @@ -500,226 +459,6 @@ def calendar(self) -> CalTypes: return self._calendar -def _validate_weights( - weights: Series[float] | NoInput, - eval_date: datetime, - expiries: list[datetime], -) -> Series[float] | None: - if isinstance(weights, NoInput): - return None - - w: Series[float] = Series( - 1.0, index=get_calendar("all").cal_date_range(eval_date, TERMINAL_DATE) - ) - w.update(weights) - # restrict to sorted and filtered for outliers - w = w.sort_index() - w = w[eval_date:] # type: ignore[misc] - - node_points: list[datetime] = [eval_date] + expiries + [TERMINAL_DATE] - for i in range(len(expiries) + 1): - s, e = node_points[i] + timedelta(days=1), node_points[i + 1] - days = (e - s).days + 1 - w[s:e] = ( # type: ignore[misc] - w[s:e] * days / w[s:e].sum() # type: ignore[misc] - ) # scale the weights to allocate the correct time between nodes. - w[eval_date] = 0.0 # type: ignore[call-overload] - return w - - -def _t_var_interp( - expiries: list[datetime], - expiries_posix: list[float], - expiry: datetime, - expiry_posix: float, - expiry_index: int, - expiry_next_index: int, - eval_posix: float, - weights_cum: Series[float] | None, - vol1: DualTypes, - vol2: DualTypes, - bounds_flag: int, -) -> DualTypes: - """ - Return the volatility of an intermediate timestamp via total linear variance interpolation. - Possibly scaled by time weights if weights is available. - - Parameters - ---------- - expiry_index: int - The index defining the interval within which expiry falls. - expiries_posix: list[datetime] - The list of datetimes associated with the expiries of the *Surface*. - expiries_posix: list[float] - The list of posix timestamps associated with the expiries of the *Surface*. - expiry: datetime - The target expiry to be interpolated. - expiry_posix: float - The pre-calculated posix timestamp for expiry. - expiry_index: int - The integer index of the expiries period in which the expiry falls. - expiry_next_index: int - Will be expiry_index + 1, unless the surface only has one expiry, in which case it will - equal the expiry_index. - eval_posix: float - The pre-calculated posix timestamp for eval date of the *Surface* - weights_cum: Series[float] or NoInput - The cumulative sum of the weights indexes by date - vol1: float, Dual, DUal2 - The volatility of the left side - vol2: float, Dual, Dual2 - The volatility on the right side - bounds_flag: int - -1: left side extrapolation, 0: normal interpolation, 1: right side extrapolation - - Notes - ----- - This function performs different interpolation if weights are given or not. ``bounds_flag`` - is used to parse the inputs when *Smiles* to the left and/or right are not available. - """ - return _t_var_interp_d_sabr_d_k_or_f( - expiries, - expiries_posix, - expiry, - expiry_posix, - expiry_index, - expiry_next_index, - eval_posix, - weights_cum, - vol1, - dvol1_dk=0.0, - vol2=vol2, - dvol2_dk=0.0, - bounds_flag=bounds_flag, - derivative=False, - )[0] - - -def _t_var_interp_d_sabr_d_k_or_f( - expiries: list[datetime], - expiries_posix: list[float], - expiry: datetime, - expiry_posix: float, - expiry_index: int, - expiry_next_index: int, - eval_posix: float, - weights_cum: Series[float] | None, - vol1: DualTypes, - dvol1_dk: DualTypes, - vol2: DualTypes, - dvol2_dk: DualTypes, - bounds_flag: int, - derivative: bool, -) -> tuple[DualTypes, DualTypes | None]: - if weights_cum is None: # weights must also be NoInput - if bounds_flag == 0: - t1 = expiries_posix[expiry_index] - eval_posix - t2 = expiries_posix[expiry_next_index] - eval_posix - elif bounds_flag == -1: - # left side extrapolation - t1 = 0.0 - t2 = expiries_posix[expiry_index] - eval_posix - else: # bounds_flag == 1: - # right side extrapolation - t1 = expiries_posix[expiry_next_index] - eval_posix - t2 = TERMINAL_DATE.replace(tzinfo=UTC).timestamp() - eval_posix - - t_hat = expiry_posix - eval_posix - t = expiry_posix - eval_posix - else: - if bounds_flag == 0: - t1 = weights_cum[expiries[expiry_index]] - t2 = weights_cum[expiries[expiry_next_index]] - elif bounds_flag == -1: - # left side extrapolation - t1 = 0.0 - t2 = weights_cum[expiries[expiry_index]] - else: # bounds_flag == 1: - # right side extrapolation - t1 = weights_cum[expiries[expiry_next_index]] - t2 = weights_cum[TERMINAL_DATE] - - t_hat = weights_cum[expiry] # number of vol weighted calendar days - t = (expiry_posix - eval_posix) / 86400.0 # number of calendar days - - t_quotient = (t_hat - t1) / (t2 - t1) - vol = ((t1 * vol1**2 + t_quotient * (t2 * vol2**2 - t1 * vol1**2)) / t) ** 0.5 - if derivative: - dvol_dk = ( - (t2 / t) * t_quotient * vol2 * dvol2_dk + (t1 / t) * (1 - t_quotient) * vol1 * dvol1_dk - ) / vol - else: - dvol_dk = None - return vol, dvol_dk - - -def _black76( - F: DualTypes, - K: DualTypes, - t_e: float, - v1: NoInput, - v2: DualTypes, - vol: DualTypes, - phi: float, -) -> DualTypes: - """ - Option price in points terms for immediate premium settlement. - - Parameters - ----------- - F: float, Dual, Dual2 - The forward price for settlement at the delivery date. - K: float, Dual, Dual2 - The strike price of the option. - t_e: float - The annualised time to expiry. - v1: float - Not used. The discounting rate on ccy1 side. - v2: float, Dual, Dual2 - The discounting rate to delivery on ccy2, at the appropriate collateral rate. - vol: float, Dual, Dual2 - The volatility measured over the period until expiry. - phi: float - Whether to calculate for call (1.0) or put (-1.0). - - Returns - -------- - float, Dual, Dual2 - """ - vs = vol * t_e**0.5 - d1 = _d_plus(K, F, vs) - d2 = d1 - vs - Nd1, Nd2 = dual_norm_cdf(phi * d1), dual_norm_cdf(phi * d2) - _: DualTypes = phi * (F * Nd1 - K * Nd2) - # Spot formulation instead of F (Garman Kohlhagen formulation) - # https://quant.stackexchange.com/a/63661/29443 - # r1, r2 = dual_log(df1) / -t, dual_log(df2) / -t - # S_imm = F * df2 / df1 - # d1 = (dual_log(S_imm / K) + (r2 - r1 + 0.5 * vol ** 2) * t) / vs - # d2 = d1 - vs - # Nd1, Nd2 = dual_norm_cdf(d1), dual_norm_cdf(d2) - # _ = df1 * S_imm * Nd1 - K * df2 * Nd2 - return _ * v2 - - -def _d_plus_min(K: DualTypes, f: DualTypes, vol_sqrt_t: DualTypes, eta: float) -> DualTypes: - # AD preserving calculation of d_plus in Black-76 formula (eta should +/- 0.5) - return dual_log(f / K) / vol_sqrt_t + eta * vol_sqrt_t - - -def _d_plus_min_u(u: DualTypes, vol_sqrt_t: DualTypes, eta: float) -> DualTypes: - # AD preserving calculation of d_plus in Black-76 formula (eta should +/- 0.5) - return -dual_log(u) / vol_sqrt_t + eta * vol_sqrt_t - - -def _d_min(K: DualTypes, f: DualTypes, vol_sqrt_t: DualTypes) -> DualTypes: - return _d_plus_min(K, f, vol_sqrt_t, -0.5) - - -def _d_plus(K: DualTypes, f: DualTypes, vol_sqrt_t: DualTypes) -> DualTypes: - return _d_plus_min(K, f, vol_sqrt_t, +0.5) - - def _delta_type_constants( delta_type: FXDeltaMethod, w: DualTypes | NoInput, u: DualTypes | NoInput ) -> tuple[float, DualTypes, DualTypes]: @@ -798,109 +537,3 @@ def _moneyness_from_delta_closed_form( _: DualTypes = dual_inv_norm_cdf(phi * delta / z_w_0) _ = dual_exp(vol_sqrt_t * (0.5 * vol_sqrt_t - phi * _)) return _ - - -def _d_sabr_d_k_or_f( - k: Number, - f: Number, - t: Number, - a: Number, - b: float | Variable, - p: Number, - v: Number, - derivative: int, -) -> tuple[Number, Number | None]: - """ - Calculate the SABR function and its derivative with respect to k or f. - - For formula see for example I. Clark "Foreign Exchange Option - Pricing" section 3.10. - - Rateslib uses the representation sigma(k) = X0 * X1 * X2, with these variables as defined in - "Coding Interest Rates" chapter 13 to handle AD using dual numbers effectively. - - For no derivative and just the SABR function value use 0. - For derivatives with respect to `k` use 1. - For derivatives with respect to `f` use 2. - - See "Coding Interest Rates: FX Swaps and Bonds edition 2" - """ - b_: Number = _to_number(b) - X0, dX0 = _sabr_X0(k, f, t, a, b_, p, v, derivative) - X1, dX1 = _sabr_X1(k, f, t, a, b_, p, v, derivative) - X2, dX2 = _sabr_X2(k, f, t, a, b_, p, v, derivative) - - if derivative == 0: - return X0 * X1 * X2, None - else: - return X0 * X1 * X2, dX0 * X1 * X2 + X0 * dX1 * X2 + X0 * X1 * dX2 # type: ignore[operator] - - -def _sabr_X0( - k: Number, - f: Number, - t: Number, - a: Number, - b: Number, - p: Number, - v: Number, - derivative: int = 0, -) -> tuple[Number, Number | None]: - """ - X0 = a / ((fk)^((1-b)/2) * (1 + (1-b)^2/24 ln^2(f/k) + (1-b)^4/1920 ln^4(f/k) ) - - If ``derivative`` is 1 also returns dX0/dk, derived using sympy auto code generator. - If ``derivative`` is 2 also returns dX0/df, derived using sympy auto code generator. - """ - return _rs_sabr_x0(k, f, t, a, b, p, v, derivative) - - -def _sabr_X1( - k: Number, - f: Number, - t: Number, - a: Number, - b: Number, - p: Number, - v: Number, - derivative: int = 0, -) -> tuple[Number, Number | None]: - """ - X1 = 1 + t ( (1-b)^2 / 24 * a^2 / (fk)^(1-b) + 1/4 p b v a / (fk)^((1-b)/2) + (2-3p^2)/24 v^2 ) - - If ``derivative`` also returns dX0/dk, calculated using sympy. - """ - return _rs_sabr_x1(k, f, t, a, b, p, v, derivative) - - -def _sabr_X2( - k: Number, - f: Number, - t: Number, - a: Number, - b: Number, - p: Number, - v: Number, - derivative: int = 0, -) -> tuple[Number, Number | None]: - """ - X2 = z / chi(z) - - z = v / a * (fk) ^((1-b)/2) * ln(f/k) - chi(z) = ln( (sqrt(1-2pz+z^2) + z -p) / (1-p) ) - - If ``derivative`` = 1 also returns dX2/dk, calculated using sympy. - If ``derivative`` = 2 also returns dX2/df, calculated using sympy. - """ - return _rs_sabr_x2(k, f, t, a, b, p, v, derivative) - - -def _surface_index_left(expiries_posix: list[float], expiry_posix: float) -> tuple[int, int]: - """use `index_left_f64` to derive left and right index, - but exclude surfaces with only one expiry.""" - if len(expiries_posix) == 1: - return 0, 0 - else: - e_idx = index_left_f64(expiries_posix, expiry_posix) - e_next_idx = e_idx + 1 - return e_idx, e_next_idx diff --git a/python/rateslib/volatility/ir/__init__.py b/python/rateslib/volatility/ir/__init__.py new file mode 100644 index 000000000..68aa2c7f5 --- /dev/null +++ b/python/rateslib/volatility/ir/__init__.py @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: LicenseRef-Rateslib-Dual +# +# Copyright (c) 2026 Siffrorna Technology Limited +# +# Dual-licensed: Free Educational Licence or Paid Commercial Licence (commercial/professional use) +# Source-available, not open source. +# +# See LICENSE and https://rateslib.com/py/en/latest/i_licence.html for details, +# and/or contact info (at) rateslib (dot) com +#################################################################################################### + + +from rateslib.volatility.ir.base import _BaseIRSmile +from rateslib.volatility.ir.sabr import IRSabrCube, IRSabrSmile +from rateslib.volatility.ir.utils import _IRSabrCubeMeta, _IRSmileMeta + +__all__ = [ + "IRSabrSmile", + "IRSabrCube", + "_BaseIRSmile", + "_IRSmileMeta", + "_IRSabrCubeMeta", +] + +IRVols = IRSabrSmile | IRSabrCube +IRVolObj = (IRSabrSmile, IRSabrCube) diff --git a/python/rateslib/volatility/ir/base.py b/python/rateslib/volatility/ir/base.py new file mode 100644 index 000000000..79aadf796 --- /dev/null +++ b/python/rateslib/volatility/ir/base.py @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: LicenseRef-Rateslib-Dual +# +# Copyright (c) 2026 Siffrorna Technology Limited +# +# Dual-licensed: Free Educational Licence or Paid Commercial Licence (commercial/professional use) +# Source-available, not open source. +# +# See LICENSE and https://rateslib.com/py/en/latest/i_licence.html for details, +# and/or contact info (at) rateslib (dot) com +#################################################################################################### + + +from __future__ import annotations # type hinting + +from typing import TYPE_CHECKING, NoReturn, TypeAlias + +from rateslib.default import PlotOutput, plot +from rateslib.dual import Dual, Dual2, Variable +from rateslib.enums.generics import NoInput, _drb +from rateslib.mutability import _WithCache, _WithState +from rateslib.volatility.ir.utils import _IRSmileMeta + +if TYPE_CHECKING: + pass + +DualTypes: TypeAlias = "float | Dual | Dual2 | Variable" # if not defined causes _WithCache failure + + +class _BaseIRSmile(_WithState, _WithCache[float, DualTypes]): + """Abstract base class for implementing *IR Smiles*.""" + + _ad: int + _default_plot_x_axis: str + meta: _IRSmileMeta + + @property + def ad(self) -> int: + """Int in {0,1,2} describing the AD order associated with the *Smile*.""" + return self._ad + + def __iter__(self) -> NoReturn: + raise TypeError("`Smile` types are not iterable.") + + def plot( + self, + comparators: list[_BaseIRSmile] | NoInput = NoInput(0), + labels: list[str] | NoInput = NoInput(0), + x_axis: str | NoInput = NoInput(0), + f: DualTypes | NoInput = NoInput(0), + ) -> PlotOutput: + """ + Plot volatilities associated with the *Smile*. + + Parameters + ---------- + comparators: list[Smile] + A list of Smiles which to include on the same plot as comparators. + labels : list[str] + A list of strings associated with the plot and comparators. Must be same + length as number of plots. + x_axis : str in {"strike", "moneyness"} + *'strike'* is the natural option for this *SabrSmile*. + If *'moneyness'* the strikes are converted using ``f``. + f: DualTypes + The mid-market IRS rate. + + Returns + ------- + (fig, ax, line) : Matplotlib.Figure, Matplotplib.Axes, Matplotlib.Lines2D + """ + # reversed for intuitive strike direction + comparators = _drb([], comparators) + labels = _drb([], labels) + + x_axis_: str = _drb(self.meta.plot_x_axis, x_axis) + + x_, y_ = self._plot(x_axis_, f) # type: ignore[attr-defined] + + x = [x_] + y = [y_] + if not isinstance(comparators, NoInput): + for smile in comparators: + if not isinstance(smile, _BaseIRSmile): + raise ValueError("A `comparator` must be a valid FX Smile type.") + x_, y_ = smile._plot(x_axis_, f) # type: ignore[attr-defined] + x.append(x_) + y.append(y_) + + return plot(x, y, labels) diff --git a/python/rateslib/volatility/ir/sabr.py b/python/rateslib/volatility/ir/sabr.py new file mode 100644 index 000000000..a5a8c1271 --- /dev/null +++ b/python/rateslib/volatility/ir/sabr.py @@ -0,0 +1,914 @@ +# SPDX-License-Identifier: LicenseRef-Rateslib-Dual +# +# Copyright (c) 2026 Siffrorna Technology Limited +# +# Dual-licensed: Free Educational Licence or Paid Commercial Licence (commercial/professional use) +# Source-available, not open source. +# +# See LICENSE and https://rateslib.com/py/en/latest/i_licence.html for details, +# and/or contact info (at) rateslib (dot) com +#################################################################################################### + + +from __future__ import annotations # type hinting + +from datetime import datetime, timezone +from typing import TYPE_CHECKING +from uuid import uuid4 + +import numpy as np +from pandas import DataFrame, Index + +from rateslib.curves.interpolation import index_left +from rateslib.data.fixings import IRSSeries, _get_irs_series +from rateslib.dual import ( + Dual, + Dual2, + Variable, + set_order_convert, +) +from rateslib.dual.utils import _dual_float, _to_number, dual_exp, dual_inv_norm_cdf +from rateslib.enums.generics import NoInput, _drb +from rateslib.mutability import ( + _clear_cache_post, + _new_state_post, + _WithCache, + _WithState, +) +from rateslib.volatility.ir.base import _BaseIRSmile +from rateslib.volatility.ir.utils import ( + _bilinear_interp, + _IRSabrCubeMeta, + _IRSmileMeta, + _IRVolPricingParams, +) +from rateslib.volatility.utils import _SabrModel, _SabrSmileNodes + +UTC = timezone.utc + +if TYPE_CHECKING: + from rateslib.local_types import ( # pragma: no cover + Arr2dObj, + Arr3dObj, + CurvesT_, + DualTypes, + DualTypes_, + Number, + Sequence, + Series, + datetime_, + ) + + +class IRSabrSmile(_BaseIRSmile): + r""" + Create an *IR Volatility Smile* at a given expiry indexed for a specific IRS tenor + using SABR parameters. + + .. role:: green + + .. role:: red + + Parameters + ---------- + nodes: dict[str, float], :red:`required` + The parameters for the SABR model. Keys must be *'alpha', 'beta', 'rho', 'nu'*. See below. + eval_date: datetime, :red:`required` + Acts like the initial node of a *Curve*. Should be assigned today's immediate date. + expiry: datetime, :red:`required` + The expiry date of the options associated with this *Smile*. + irs_series: IRSSeries, :red:`required` + The :class:`~rateslib.data.fixings.IRSSeries` that contains the parameters for the + underlying :class:`~rateslib.instruments.IRS` that the swaptions are settled against. + tenor: datetime, str, :red:`required` + The tenor parameter for the underlying :class:`~rateslib.instruments.IRS` that the + swaptions are settled against. + shift: float, Variable, :green:`optional (set as zero)` + The number of basis points to apply to the strike and forward under a 'Black Shifted + Volatility' model. + id: str, optional, :green:`optional (set as random)` + The unique identifier to distinguish between *Smiles* in a multicurrency framework + and/or *Surface*. + ad: int, :green:`optional (set by default)` + Sets the automatic differentiation order. Defines whether to convert node + values to float, :class:`~rateslib.dual.Dual` or + :class:`~rateslib.dual.Dual2`. It is advised against + using this setting directly. It is mainly used internally. + + Notes + ----- + The keys for ``nodes`` are described as the following: + + - ``alpha``: The initial volatility parameter (e.g. 0.10 for 10%) of the SABR model, + in (0, inf). + - ``beta``: The scaling parameter between normal (0) and lognormal (1) + of the SABR model in [0, 1]. + - ``rho``: The correlation between spot and volatility of the SABR model, + e.g. -0.10, in [-1.0, 1.0) + - ``nu``: The volatility of volatility parameter of the SABR model, e.g. 0.80. + + The parameters :math:`\alpha, \rho, \nu` will be calibrated/mutated by + a :class:`~rateslib.solver.Solver` object. These should be entered as *float* and the argument + ``ad`` can be used to automatically tag these as variables. + + The parameter :math:`\beta` will **not** be calibrated/mutated by a + :class:`~rateslib.solver.Solver`. This value can be entered either as a *float*, or a + :class:`~rateslib.dual.Variable` to capture exogenous sensitivities. + + Examples + -------- + See :ref:`Constructing a Smile `. + + """ + + _ini_solve = 1 + _meta: _IRSmileMeta + _id: str + _nodes: _SabrSmileNodes + + @_new_state_post + def __init__( + self, + nodes: dict[str, DualTypes], + eval_date: datetime, + expiry: datetime | str, + irs_series: IRSSeries | str, + tenor: datetime | str, + *, + shift: DualTypes_ = NoInput(0), + id: str | NoInput = NoInput(0), # noqa: A002 + ad: int | None = 0, + ): + self._id: str = ( + uuid4().hex[:5] + "_" if isinstance(id, NoInput) else id + ) # 1 in a million clash + self._meta = _IRSmileMeta( + _tenor_input=tenor, + _irs_series=_get_irs_series(irs_series), + _eval_date=eval_date, + _expiry_input=expiry, + _plot_x_axis="strike", + _shift=_drb(0.0, shift), + ) + + try: + self._nodes: _SabrSmileNodes = _SabrSmileNodes( + _alpha=_to_number(nodes["alpha"]), + _beta=nodes["beta"], # type: ignore[arg-type] + _rho=_to_number(nodes["rho"]), + _nu=_to_number(nodes["nu"]), + ) + except KeyError as e: + for _ in ["alpha", "beta", "rho", "nu"]: + if _ not in nodes: + raise ValueError( + f"'{_}' is a required SABR parameter that must be included in ``nodes``" + ) + raise e # pragma: no cover + + self._set_ad_order(ad) + + @property + def _n(self) -> int: + """The number of pricing parameters in ``nodes``.""" + return self.nodes.n + + @property + def id(self) -> str: + """A str identifier to name the *Smile* used in + :class:`~rateslib.solver.Solver` mappings.""" + return self._id + + @property + def meta(self) -> _IRSmileMeta: # type: ignore[override] + """An instance of :class:`~rateslib.volatility.ir.utils._IRSmileMeta`.""" + return self._meta + + @property + def nodes(self) -> _SabrSmileNodes: + """An instance of :class:`~rateslib.volatility.utils._SabrSmileNodes`.""" + return self._nodes + + def get_from_strike( + self, + k: DualTypes, + expiry: datetime_ = NoInput(0), + tenor: datetime_ = NoInput(0), + f: DualTypes_ = NoInput(0), + curves: CurvesT_ = NoInput(0), + ) -> _IRVolPricingParams: + """ + Given an option strike return the volatility. + + Parameters + ----------- + k: float, Dual, Dual2 + The strike of the option. + f: float, Dual, Dual2 + The forward rate at delivery of the option. + expiry: datetime, optional + The expiry of the option. Required for temporal interpolation. + tenor: datetime, optional + The termination date of the underlying *IRS*, required for parameter interpolation. + curves: _Curves, + Pricing objects. See **Pricing** on :class:`~rateslib.instruments.PayerSwaption` + for details of allowed inputs. + + Returns + ------- + _IRVolPricingParams + + """ + if isinstance(expiry, datetime) and self._meta.expiry != expiry: + raise ValueError( + "`expiry` of VolSmile and OptionPeriod do not match: calculation aborted " + "due to potential pricing errors.", + ) + + if isinstance(f, NoInput): + f_: DualTypes = self.meta.irs_fixing.irs.rate(curves=curves) + else: + f_ = f + del f + + vol_ = _SabrModel._d_sabr_d_k_or_f( + _to_number(k + self.meta.rate_shift), + _to_number(f_ + self.meta.rate_shift), + self._meta.t_expiry, + self.nodes.alpha, + self.nodes.beta, + self.nodes.rho, + self.nodes.nu, + derivative=0, + )[0] + return _IRVolPricingParams(vol=vol_ * 100.0, k=k, f=f_, shift=self.meta.rate_shift) + + def _d_sabr_d_k_or_f( + self, + k: DualTypes, + f: DualTypes, + expiry: datetime, + as_float: bool, + derivative: int, + ) -> tuple[DualTypes, DualTypes | None]: + """Get the derivative of sabr vol with respect to strike + + as_float: bool + Allow expedited calculation by avoiding dual numbers. Useful during the root solving + phase of Newton iterations. + derivative: int + For with respect to `k` use 1, or `f` use 2. + """ + t_e = (expiry - self._meta.eval_date).days / 365.0 + K = k + self.meta.rate_shift + F = f + self.meta.rate_shift + del k, f + + if as_float: + k_: Number = _dual_float(K) + f_: Number = _dual_float(F) + a_: Number = _dual_float(self.nodes.alpha) + b_: float | Variable = _dual_float(self.nodes.beta) + p_: Number = _dual_float(self.nodes.rho) + v_: Number = _dual_float(self.nodes.nu) + else: + k_ = _to_number(K) + f_ = _to_number(F) + a_ = self.nodes.alpha # + b_ = self.nodes.beta + p_ = self.nodes.rho + v_ = self.nodes.nu + + return _SabrModel._d_sabr_d_k_or_f(k_, f_, t_e, a_, b_, p_, v_, derivative) + + def _get_node_vector(self) -> np.ndarray[tuple[int, ...], np.dtype[np.object_]]: + """Get a 1d array of variables associated with nodes of this object updated by Solver""" + return np.array([self.nodes.alpha, self.nodes.rho, self.nodes.nu]) + + def _get_node_vars(self) -> tuple[str, ...]: + """Get the variable names of elements updated by a Solver""" + return tuple(f"{self.id}{i}" for i in range(3)) + + @_new_state_post + @_clear_cache_post + def _set_node_vector( + self, vector: np.ndarray[tuple[int, ...], np.dtype[np.object_]], ad: int + ) -> None: + """ + Update the node values in a Solver. ``ad`` in {1, 2}. + Only the real values in vector are used, dual components are dropped and restructured. + """ + DualType: type[Dual] | type[Dual2] = Dual if ad == 1 else Dual2 + DualArgs: tuple[list[float]] | tuple[list[float], list[float]] = ( + ([],) if ad == 1 else ([], []) + ) + base_obj = DualType(0.0, [f"{self.id}{i}" for i in range(3)], *DualArgs) + ident = np.eye(3) + + self._nodes = _SabrSmileNodes( + _beta=self.nodes.beta, + _alpha=DualType.vars_from( + base_obj, # type: ignore[arg-type] + vector[0].real, + base_obj.vars, + ident[0, :].tolist(), + *DualArgs[1:], + ), + _rho=DualType.vars_from( + base_obj, # type: ignore[arg-type] + vector[1].real, + base_obj.vars, + ident[1, :].tolist(), + *DualArgs[1:], + ), + _nu=DualType.vars_from( + base_obj, # type: ignore[arg-type] + vector[2].real, + base_obj.vars, + ident[2, :].tolist(), + *DualArgs[1:], + ), + ) + + @_clear_cache_post + def _set_ad_order(self, order: int | None) -> None: + """This does not alter the beta node, since that is not varied by a Solver. + beta values that are AD sensitive should be given as a Variable and not Dual/Dual2. + + Using `None` allows this Smile to be constructed without overwriting any variable names. + """ + if order == getattr(self, "_ad", None): + return None + elif order not in [0, 1, 2]: + raise ValueError("`order` can only be in {0, 1, 2} for auto diff calcs.") + + self._ad = order + + self._nodes = _SabrSmileNodes( + _beta=self.nodes.beta, + _alpha=set_order_convert(self.nodes.alpha, order, [f"{self.id}0"]), + _rho=set_order_convert(self.nodes.rho, order, [f"{self.id}1"]), + _nu=set_order_convert(self.nodes.nu, order, [f"{self.id}2"]), + ) + + @_new_state_post + @_clear_cache_post + def update_node(self, key: str, value: DualTypes) -> None: + """ + Update a single node value on the *SABRSmile*. + + Parameters + ---------- + key: str in {"alpha", "beta", "rho", "nu"} + The node value to update. + value: float, Dual, Dual2, Variable + Value to update on the *Smile*. + + Returns + ------- + None + + Notes + ----- + + .. warning:: + + *Rateslib* is an object-oriented library that uses complex associations. Although + Python may not object to directly mutating attributes of a *Curve* instance, this + should be avoided in *rateslib*. Only use official ``update`` methods to mutate the + values of an existing *Curve* instance. + This class is labelled as a **mutable on update** object. + + """ + params = ["alpha", "beta", "rho", "nu"] + if key not in params: + raise KeyError(f"'{key}' is not in `nodes`.") + kwargs = {f"_{_}": getattr(self.nodes, _) for _ in params if _ != key} + kwargs.update({f"_{key}": value}) + self._nodes = _SabrSmileNodes(**kwargs) + self._set_ad_order(self.ad) + + # Plotting + + def _plot( + self, + x_axis: str, + f: DualTypes_, + ) -> tuple[list[float], list[DualTypes]]: + if isinstance(f, NoInput): + raise ValueError("`f` (ATM-forward FX rate) is required by `FXSabrSmile.plot`.") + elif isinstance(f, float | Dual | Dual2 | Variable): + f_: float = _dual_float(f) + del f + + v_ = _dual_float(self.get_from_strike(k=f_, f=f_).vol) / 100.0 + sq_t = self._meta.t_expiry_sqrt + x_low = _dual_float( + dual_exp(0.5 * v_**2 * sq_t**2 - dual_inv_norm_cdf(0.95) * v_ * sq_t) * f_ + ) + x_top = _dual_float( + dual_exp(0.5 * v_**2 * sq_t**2 - dual_inv_norm_cdf(0.05) * v_ * sq_t) * f_ + ) + + x = np.linspace(x_low, x_top, 301, dtype=np.float64) + u: Sequence[float] = x / f_ # type: ignore[assignment] + y: list[DualTypes] = [self.get_from_strike(k=_, f=f_).vol for _ in x] + if x_axis == "moneyness": + return list(u), y + else: # x_axis = "strike" + return list(x), y + + +class IRSabrCube(_WithState, _WithCache[tuple[datetime, datetime], IRSabrSmile]): + r""" + Create an *FX Volatility Surface* parametrised by cross-sectional *Smiles* at different + expiries. + + See also the :ref:`FX Vol Surfaces section in the user guide `. + + Parameters + ---------- + expiries: list[datetime] + Datetimes representing the expiries of each cross-sectional *Smile*, in ascending order. + node_values: 2d-shape of float, Dual, Dual2 + An array of values representing each *alpha, beta, rho, nu* node value on each + cross-sectional *Smile*. Should be an array of size: (length of ``expiries``, 4). + eval_date: datetime + Acts as the initial node of a *Curve*. Should be assigned today's immediate date. + weights: Series, optional + Weights used for temporal volatility interpolation. See notes. + delivery_lag: int, optional + The number of business days after expiry that the physical settlement of the FX + exchange occurs. Uses ``defaults.fx_delivery_lag``. Used in determination of ATM forward + rates for different expiries. + calendar : calendar or str, optional + The holiday calendar object to use for FX delivery day determination. If str, looks up + named calendar from static data. + pair : str, optional + The FX currency pair used to determine ATM forward rates. + id: str, optional + The unique identifier to label the *Surface* and its variables. + ad: int, optional + Sets the automatic differentiation order. Defines whether to convert node + values to float, :class:`~rateslib.dual.Dual` or + :class:`~rateslib.dual.Dual2`. It is advised against + using this setting directly. It is mainly used internally. + + Notes + ----- + See :class:`~rateslib.volatility.FXSabrSmile` for a description of SABR parameters for + *Smile* construction. + + **Temporal Interpolation** + + Interpolation along the expiry axis occurs by performing total linear variance interpolation + for a given *strike* measured on neighboring *Smiles*. + + If ``weights`` are given this uses the scaling approach of forward volatility (as demonstrated + in Clark's *FX Option Pricing*) for calendar days (different options 'cuts' and timezone are + not implemented). A datetime indexed `Series` must be provided, where any calendar date that + is not included will be assigned the default weight of 1.0. + + See :ref:`constructing FX volatility surfaces ` for more details. + + **Extrapolation** + + When an ``expiry`` is sought that is prior to the first parametrised *Smile expiry* or after the + final parametrised *Smile expiry* extrapolation is required. This is not recommended, + however. It would be wiser to create parameterised *Smiles* at *expiries* which suit those + one wishes to obtian values for. + + When seeking an ``expiry`` beyond the final expiry, a new + :class:`~rateslib.volatility.SabrSmile` is created at that specific *expiry* using the + same SABR parameters as matching the final parametrised *Smile*. This will capture the + evolution of ATM-forward rates through time. + + When seeking an ``expiry`` prior to the first expiry, the volatility found on the first *Smile* + will be used an interpolated, using total linear variance accooridng to the given ``weights``. + If ``weights`` are not used then this will return the same value as obtained from that + first parametrised *Smile*. This does not account any evolution of ATM-forward rates. + + """ + + _ini_solve = 0 + _meta: _IRSabrCubeMeta + _id: str + + def __init__( + self, + expiries: list[datetime | str], + tenors: list[str], + eval_date: datetime, + beta: DualTypes, + alphas: DualTypes | Arr2dObj, + rhos: DualTypes | Arr2dObj, + nus: DualTypes | Arr2dObj, + irs_series: str | IRSSeries, + weights: Series[float] | NoInput = NoInput(0), + id: str | NoInput = NoInput(0), # noqa: A002 + ad: int = 0, + ): + self._id: str = ( + uuid4().hex[:5] + "_" if isinstance(id, NoInput) else id + ) # 1 in a million clash + + self._meta = _IRSabrCubeMeta( + _eval_date=eval_date, + _tenors=tenors, + _weights=weights, + _expiries=expiries, + _irs_series=_get_irs_series(irs_series), + ) + + self._beta = beta + _shape = (self.meta._n_expiries, self.meta._n_tenors) + self._node_values_: Arr3dObj = np.empty(shape=_shape + (3,), dtype=object) + for i, kw in enumerate([alphas, rhos, nus]): + if isinstance(kw, float | Dual | Dual2 | Variable): + self._node_values_[:, :, i] = np.full(fill_value=kw, shape=_shape) + else: + self._node_values_[:, :, i] = np.asarray(kw) + + self._set_ad_order(ad) # includes csolve on each smile + self._set_new_state() + + @property + def beta(self) -> DualTypes: + """The *beta* value of each :class:`~rateslib.volatility.IRSabrSmile` associated with + this *Cube*.""" + return self._beta + + @property + def alpha(self) -> DataFrame: + """The *alpha* value of each :class:`~rateslib.volatility.IRSabrSmile` associated with + this *Cube*.""" + return DataFrame( + index=Index(data=self.meta.expiries, name="expiry"), + columns=Index(data=self.meta.tenors, name="tenor"), + data=self._node_values_[:, :, 0], + ) + + @property + def rho(self) -> DataFrame: + """The *rho* value of each :class:`~rateslib.volatility.IRSabrSmile` associated with + this *Cube*.""" + return DataFrame( + index=Index(data=self.meta.expiries, name="expiry"), + columns=Index(data=self.meta.tenors, name="tenor"), + data=self._node_values_[:, :, 1], + ) + + @property + def nu(self) -> DataFrame: + """The *nu* value of each :class:`~rateslib.volatility.IRSabrSmile` associated with + this *Cube*.""" + return DataFrame( + index=Index(data=self.meta.expiries, name="expiry"), + columns=Index(data=self.meta.tenors, name="tenor"), + data=self._node_values_[:, :, 2], + ) + + @property + def _n(self) -> int: + """Number of pricing parameters of the *Cube*.""" + en = self._node_values_.shape[0] + tn = self._node_values_.shape[1] + return en * tn * 3 # alpha, beta, rho + + @property + def id(self) -> str: + """A str identifier to name the *Surface* used in + :class:`~rateslib.solver.Solver` mappings.""" + return self._id + + @property + def meta(self) -> _IRSabrCubeMeta: + """An instance of :class:`~rateslib.volatility.fx._FXSabrSurfaceMeta`.""" + return self._meta + + @property + def ad(self) -> int: + """Int in {0,1,2} describing the AD order associated with the *Surface*.""" + return self._ad + + @_clear_cache_post + def _set_ad_order(self, order: int) -> None: + if order == getattr(self, "ad", None): + return None + elif order not in [0, 1, 2]: + raise ValueError("`order` can only be in {0, 1, 2} for auto diff calcs.") + + self._ad = order + vec = self._get_node_vector() + vars_ = self._get_node_vars() + new_vec = [set_order_convert(v, order, [t]) for v, t in zip(vec, vars_, strict=False)] + en = self._node_values_.shape[0] + tn = self._node_values_.shape[1] + n = en * tn + self._node_values_[:, :, 0] = np.reshape(list(new_vec[:n]), (en, tn)) + self._node_values_[:, :, 1] = np.reshape(list(new_vec[n : 2 * n]), (en, tn)) + self._node_values_[:, :, 2] = np.reshape(list(new_vec[2 * n :]), (en, tn)) + return None + + @_new_state_post + @_clear_cache_post + def _set_node_vector( + self, vector: np.ndarray[tuple[int, ...], np.dtype[np.object_]], ad: int + ) -> None: + en = self._node_values_.shape[0] + tn = self._node_values_.shape[1] + n = en * tn + if ad == 0: + self._node_values_[:, :, 0] = np.reshape([_dual_float(_) for _ in vector[:n]], (en, tn)) + self._node_values_[:, :, 1] = np.reshape( + [_dual_float(_) for _ in vector[n : 2 * n]], (en, tn) + ) + self._node_values_[:, :, 2] = np.reshape( + [_dual_float(_) for _ in vector[2 * n :]], (en, tn) + ) + else: + DualType: type[Dual] | type[Dual2] = Dual if ad == 1 else Dual2 + DualArgs: tuple[list[float]] | tuple[list[float], list[float]] = ( + ([],) if ad == 1 else ([], []) + ) + vars_ = self._get_node_vars() + base_obj = DualType(0.0, vars_, *DualArgs) + ident = np.eye(len(vars_)) + for i in range(3): + self._node_values_[:, :, i] = np.reshape( + [ + DualType.vars_from( + base_obj, # type: ignore[arg-type] + _dual_float(vector[n * i + j]), + base_obj.vars, + ident[n * i + j, :].tolist(), + *DualArgs[1:], + ) + for j in range(n) + ], + (en, tn), + ) + + def _get_node_vector(self) -> np.ndarray[tuple[int, ...], np.dtype[np.object_]]: + """Get a 1d array of variables associated with nodes of this object updated by Solver""" + return np.block( + [ + self._node_values_[:, :, 0].ravel(), # alphas + self._node_values_[:, :, 1].ravel(), # rhos + self._node_values_[:, :, 2].ravel(), # nus + ] + ) + + def _get_node_vars(self) -> tuple[str, ...]: + """Get the variable names of elements updated by a Solver""" + vars_: tuple[str, ...] = () + for tag in ["_a_", "_p_", "_v_"]: + vars_ += tuple( + f"{self.id}{tag}{i}" + for i in range(self._node_values_.shape[0] * self._node_values_.shape[1]) + ) + return vars_ + + def _bilinear_interpolation( + self, + expiry: datetime, + tenor: datetime, + ) -> tuple[DualTypes, DualTypes, DualTypes]: + """ + Linearly interpolate the expiries / tenors array and return interpolated values + for the alpha, rho and nu parameters. + + Returns + ------- + (alpha, rho, nu) + """ + # For out of bounds expiry values convert to boundary expiries with tenor time adjustment + if expiry < self.meta.expiry_dates[0]: + return self._bilinear_interpolation( + expiry=self.meta.expiry_dates[0], + tenor=tenor + (self.meta.expiry_dates[0] - expiry), + ) + elif expiry > self.meta.expiry_dates[-1]: + return self._bilinear_interpolation( + expiry=self.meta.expiry_dates[-1], + tenor=tenor - (expiry - self.meta.expiry_dates[-1]), + ) + + e_posix = expiry.replace(tzinfo=UTC).timestamp() + t_posix = tenor.replace(tzinfo=UTC).timestamp() + + match (self.meta._n_expiries, self.meta._n_tenors): + case (1, 1): + # nothing to interpolate: return the only parameters of the surface + return ( + self._node_values_[0, 0, 0], + self._node_values_[0, 0, 1], + self._node_values_[0, 0, 2], + ) + + case (1, _): + # interpolate only over tenor + e_l = 0 + e_l_p = 0 + t_posix_1 = t_posix - (e_posix - self.meta.expiries_posix[0]) + t_l_1 = index_left( + list_input=self.meta.tenor_dates_posix[0, :], # type: ignore[arg-type] + list_length=self.meta._n_tenors, + value=t_posix_1, + ) + t_l_1_p = t_l_1 + 1 + v_ = (0.0, 0.0) # only one expiry so no interpolation over that dimension + t_l_2, t_l_2_p = t_l_1, t_l_1_p + h_: tuple[float, float] = ( + (t_posix_1 - self.meta.tenor_dates_posix[e_l, t_l_1]) + / ( + self.meta.tenor_dates_posix[e_l, t_l_1_p] + - self.meta.tenor_dates_posix[e_l, t_l_1] + ), + ) * 2 + + case (_, 1): + # interpolate only over expiry + e_l = index_left( + list_input=self.meta.expiries_posix, + list_length=self.meta._n_expiries, + value=e_posix, + ) + e_l_p = e_l + 1 + t_l_1, t_l_2 = 0, 0 + t_l_1_p, t_l_2_p = 0, 0 + h_ = (0, 0) + v_ = ( + (e_posix - self.meta.expiries_posix[e_l]) + / (self.meta.expiries_posix[e_l_p] - self.meta.expiries_posix[e_l]), + ) * 2 + + case _: + # perform true bilinear interpolation + e_l = index_left( + list_input=self.meta.expiries_posix, + list_length=self.meta._n_expiries, + value=e_posix, + ) + e_l_p = e_l + 1 + v_ = ( + (e_posix - self.meta.expiries_posix[e_l]) + / (self.meta.expiries_posix[e_l_p] - self.meta.expiries_posix[e_l]), + ) * 2 + + # these are the relative tenors as measured per each benchmark expiry + t_posix_1 = t_posix - (e_posix - self.meta.expiries_posix[e_l]) + t_posix_2 = t_posix - (e_posix - self.meta.expiries_posix[e_l_p]) + + t_l_1 = index_left( + list_input=self.meta.tenor_dates_posix[e_l, :], # type: ignore[arg-type] + list_length=self.meta._n_tenors, + value=t_posix_1, + ) + t_l_1_p = t_l_1 + 1 + t_l_2 = index_left( + list_input=self.meta.tenor_dates_posix[e_l_p, :], # type: ignore[arg-type] + list_length=self.meta._n_tenors, + value=t_posix_2, + ) + t_l_2_p = t_l_2 + 1 + + h_ = ( + (t_posix_1 - self.meta.tenor_dates_posix[e_l, t_l_1]) + / ( + self.meta.tenor_dates_posix[e_l, t_l_1 + 1] + - self.meta.tenor_dates_posix[e_l, t_l_1] + ), + (t_posix_2 - self.meta.tenor_dates_posix[e_l_p, t_l_2]) + / ( + self.meta.tenor_dates_posix[e_l_p, t_l_2 + 1] + - self.meta.tenor_dates_posix[e_l_p, t_l_2] + ), + ) + + h_ = (min(max(h_[0], 0), 1), min(max(h_[1], 0), 1)) + a = self._node_values_[:, :, 0] + p = self._node_values_[:, :, 1] + v = self._node_values_[:, :, 2] + + return tuple( # type: ignore[return-value] + [ + _bilinear_interp( + tl=param[e_l, t_l_1], + tr=param[e_l, t_l_1_p], + bl=param[e_l_p, t_l_2], + br=param[e_l_p, t_l_2_p], + h=h_, + v=v_, + ) + for param in [a, p, v] + ] + ) + + def get_from_strike( + self, + k: DualTypes, + expiry: datetime, + tenor: datetime, + f: DualTypes_ = NoInput(0), + curves: CurvesT_ = NoInput(0), + ) -> _IRVolPricingParams: + """ + Given an option strike, expiry and tenor, return the volatility. + + Parameters + ----------- + k: float, Dual, Dual2 + The strike of the option. + expiry: datetime, optional + The expiry of the option. Required for temporal interpolation. + tenor: datetime, optional + The termination date of the underlying *IRS*, required for parameter interpolation. + f: float, Dual, Dual2 + The forward rate at delivery of the option. + curves: _Curves, + Pricing objects. See **Pricing** on :class:`~rateslib.instruments.PayerSwaption` + for details of allowed inputs. + + Returns + ------- + tuple of DualTypes : (placeholder, vol, k) + + Notes + ----- + This function returns a tuple consistent with an + :class:`~rateslib.volatility.FXDeltaVolSmile`, however since the *FXSabrSmile* has no + concept of a `delta index` the first element returned is always zero and can be + effectively ignored. + """ + if (expiry, tenor) in self._cache: + smile = self._cache[expiry, tenor] + else: + alpha, rho, nu = self._bilinear_interpolation(expiry=expiry, tenor=tenor) + smile = self._cached_value( + key=(expiry, tenor), + val=IRSabrSmile( + nodes={ + "alpha": alpha, + "beta": self.beta, + "rho": rho, + "nu": nu, + }, + eval_date=self.meta.eval_date, + expiry=expiry, + tenor=tenor, + irs_series=self.meta.irs_series, + id="UNUSED_VARIABLE_NAME", + ad=None, # ensure variables tags are not overridden by new `id` + ), + ) + return smile.get_from_strike(k=k, f=f, curves=curves) + + @_new_state_post + @_clear_cache_post + def update_node(self, key: str, value: DualTypes | Arr2dObj) -> None: + """ + Update some generic parameters on the *SabrCube*. + + Parameters + ---------- + key: str in {"alpha", "beta", "rho", "nu"} + The node value to update. + value: Array, float, Dual, Dual2, Variable + Value to update on the *Cube*. + + Returns + ------- + None + + Notes + ----- + + .. warning:: + + *Rateslib* is an object-oriented library that uses complex associations. Although + Python may not object to directly mutating attributes of a *Curve* instance, this + should be avoided in *rateslib*. Only use official ``update`` methods to mutate the + values of an existing *Curve* instance. + This class is labelled as a **mutable on update** object. + + """ + params = ["alpha", "beta", "rho", "nu"] + if key not in params: + raise KeyError(f"'{key}' is not in `nodes`.") + + for i, key_ in enumerate(["alpha", "rho", "nu"]): + _shape = (self.meta._n_expiries, self.meta._n_tenors) + if key == key_: + if isinstance(value, float | Dual | Dual2 | Variable): + self._node_values_[:, :, i] = np.full(fill_value=value, shape=_shape) + else: + self._node_values_[:, :, i] = np.asarray(value) + return None + + if not isinstance(value, float | Dual | Dual2 | Variable): + raise ValueError("'beta' must must be a scalar quantity in [0, 1].") + else: + self._beta = value + + self._set_ad_order(self.ad) diff --git a/python/rateslib/volatility/ir/utils.py b/python/rateslib/volatility/ir/utils.py new file mode 100644 index 000000000..2dde7e8a9 --- /dev/null +++ b/python/rateslib/volatility/ir/utils.py @@ -0,0 +1,348 @@ +# SPDX-License-Identifier: LicenseRef-Rateslib-Dual +# +# Copyright (c) 2026 Siffrorna Technology Limited +# +# Dual-licensed: Free Educational Licence or Paid Commercial Licence (commercial/professional use) +# Source-available, not open source. +# +# See LICENSE and https://rateslib.com/py/en/latest/i_licence.html for details, +# and/or contact info (at) rateslib (dot) com +#################################################################################################### + + +from __future__ import annotations # type hinting + +from dataclasses import dataclass +from datetime import datetime, timezone +from functools import cached_property +from typing import TYPE_CHECKING, NamedTuple + +import numpy as np +from pandas import Series + +from rateslib.data.fixings import IRSFixing, _get_irs_series +from rateslib.enums.generics import NoInput +from rateslib.scheduling import Adjuster, add_tenor + +if TYPE_CHECKING: + from rateslib.local_types import ( # pragma: no cover + Arr2dObj, + DualTypes, + IRSSeries, + datetime_, + ) + +UTC = timezone.utc + + +class _IRVolPricingParams(NamedTuple): + vol: DualTypes # Black Shifted Vol + k: DualTypes # Strike + f: DualTypes # Forward + shift: DualTypes # Shift to apply to `k` and `f` to use with `vol` + + +class _IRSmileMeta: + """ + A container of meta data associated with a :class:`~rateslib.volatility._BaseIRSmile` + used to make calculations. + """ + + def __init__( + self, + _eval_date: datetime, + _expiry_input: datetime | str, + _tenor_input: datetime | str, + _irs_series: IRSSeries, + _shift: DualTypes, + _plot_x_axis: str, + ): + self._eval_date = _eval_date + self._expiry_input = _expiry_input + self._tenor_input = _tenor_input + self._irs_series = _irs_series + self._plot_x_axis = _plot_x_axis + self._irs_fixing = IRSFixing( + irs_series=self.irs_series, + publication=self.expiry, + tenor=self.tenor_input, + value=NoInput(0), + identifier=NoInput(0), + ) + self._shift = _shift + + @property + def eval_date(self) -> datetime: + """Evaluation date of the *Smile*.""" + return self._eval_date + + @property + def shift(self) -> DualTypes: + """ + The number of basis points used by this *Smile* when using 'Black Shifted Volatility'. + """ + return self._shift + + @cached_property + def rate_shift(self) -> DualTypes: + """ + The ``shift`` amount expressed in rate percentage terms. + """ + return self.shift / 100.0 + + @property + def plot_x_axis(self) -> str: + """The default ``x_axis`` parameter passed to + :meth:`~rateslib.volatility._BaseIRSmile.plot`""" + return self._plot_x_axis + + @property + def irs_series(self) -> IRSSeries: + """The :class:`~rateslib.data.fixings.IRSSeries` of for the conventions of the *Smile*.""" + return self._irs_series + + @property + def expiry_input(self) -> datetime | str: + """Expiry input of the options priced by this *Smile*.""" + return self._expiry_input + + @cached_property + def expiry(self) -> datetime: + """Derived expiry date of the options priced by this *Smile*.""" + if isinstance(self.expiry_input, str): + return add_tenor( + start=self.eval_date, + tenor=self.expiry_input, + modifier=self.irs_series.modifier, + calendar=self.irs_series.calendar, + ) + else: + return self.expiry_input + + @property + def tenor_input(self) -> datetime | str: + """Tenor input of the underlying IRS priced by this *Smile*.""" + return self._tenor_input + + @property + def irs_fixing(self) -> IRSFixing: + """The :class:`~rateslib.data.fixings.IRSFixing` underlying for the swaptions priced + by this *Smile*.""" + return self._irs_fixing + + @cached_property + def t_expiry(self) -> float: + """Calendar days from eval to expiry divided by 365.""" + return (self.expiry - self.eval_date).days / 365.0 + + @cached_property + def t_expiry_sqrt(self) -> float: + """Square root of ``t_expiry``.""" + ret: float = self.t_expiry**0.5 + return ret + + +@dataclass(frozen=True) +class _IRSabrCubeMeta: + """ + An immutable container of meta data associated with a + :class:`~rateslib.volatility.FXSabrSurface` used to make calculations. + """ + + _eval_date: datetime + _weights: Series[float] | NoInput + _expiries: list[str | datetime] + _tenors: list[str] + _irs_series: IRSSeries + + def __post_init__(self) -> None: + for idx in range(1, len(self.expiries)): + if self.expiry_dates[idx - 1] >= self.expiry_dates[idx]: + raise ValueError("Cube `expiries` are not sorted or contain duplicates.\n") + + @property + def _n_expiries(self) -> int: + """The number of expiries.""" + return len(self._expiries) + + @property + def _n_tenors(self) -> int: + """The number of tenors.""" + return len(self._tenors) + + @property + def irs_series(self) -> IRSSeries: + """ + The :class:`~rateslib.data.fixings.IRSSeries` of the underlying + :class:`~rateslib.instruments.IRS` + """ + return self._irs_series + + @property + def weights(self) -> Series[float] | NoInput: + """Weights used for temporal volatility interpolation.""" + return self._weights + + @cached_property + def weights_cum(self) -> Series[float] | NoInput: + """Weight adjusted time to expiry (in calendar days) per date for temporal volatility + interpolation.""" + if isinstance(self.weights, NoInput): + return self.weights + else: + return self.weights.cumsum() + + @property + def tenors(self) -> list[str]: + """A list of the tenors as measured according the underlying from each expiry.""" + return self._tenors + + @cached_property + def tenor_dates(self) -> Arr2dObj: + """An array of *IRS* termination dates measured from each expiry's effective date.""" + arr = np.empty(shape=(self._n_expiries, self._n_tenors), dtype=object) + for i, expiry in enumerate(self.expiry_dates): + effective = self.irs_series.calendar.adjust(expiry, self.irs_series.settle) + for j, tenor in enumerate(self.tenors): + arr[i, j] = add_tenor( + start=effective, + tenor=tenor, + modifier=self.irs_series.modifier, + calendar=self.irs_series.calendar, + ) + return arr + + @cached_property + def tenor_dates_posix(self) -> Arr2dObj: + """An array of *IRS* termination dates as unix timestamp.""" + return np.reshape( + [_.replace(tzinfo=UTC).timestamp() for _ in self.tenor_dates.ravel()], + (self._n_expiries, self._n_tenors), + ) + + # @cached_property + # def tenor_posix(self) -> list[float]: + # """A list of the tenors as posix timestamp.""" + # return [_.replace(tzinfo=UTC).timestamp() for _ in self.tenor_dates] + + @property + def expiries(self) -> list[datetime | str]: + """A list of the expiries.""" + return self._expiries + + @cached_property + def expiry_dates(self) -> list[datetime]: + """A list of the expiries as datetime.""" + _: list[datetime] = [] + effective = self.irs_series.calendar.adjust(self._eval_date, self.irs_series.settle) + for date in self.expiries: + if isinstance(date, str): + _.append( + add_tenor( + start=effective, + tenor=date, + modifier=self.irs_series.modifier, + calendar=self.irs_series.calendar, + ) + ) + else: + _.append(date) + return _ + + @cached_property + def expiries_posix(self) -> list[float]: + """A list of the unix timestamps of each date in ``expiries``.""" + return [_.replace(tzinfo=UTC).timestamp() for _ in self.expiry_dates] + + @cached_property + def eval_posix(self) -> float: + """The unix timestamp of the ``eval_date``.""" + return self.eval_date.replace(tzinfo=UTC).timestamp() + + @property + def eval_date(self) -> datetime: + """Evaluation date of the *Surface*.""" + return self._eval_date + + +def _get_ir_expiry_and_payment( + eval_date: datetime_, + expiry: str | datetime, + irs_series: str | IRSSeries, + payment_lag: int | datetime_, +) -> tuple[datetime, datetime]: + """ + Determines the expiry and payment date of an IR option using the following rules. + + Parameters + ---------- + eval_date: datetime + The evaluation date, which is today (if required) + expiry: str, datetime + The expiry date + irs_series: IRSSeries, str + The :class:`~rateslib.enums.parameters.IRSSeries` of the underlying IRS. + payment_lag: Adjuster, int, datetime + Number of business days to lag payment by after expiry. + + Returns + ------- + tuple of datetime + """ + irs_series_ = _get_irs_series(irs_series) + del irs_series + + if isinstance(payment_lag, int): + payment_lag_: datetime | Adjuster = Adjuster.BusDaysLagSettle(payment_lag) + elif isinstance(payment_lag, NoInput): + payment_lag_ = irs_series_.settle + else: + payment_lag_ = payment_lag + del payment_lag + + if isinstance(expiry, str): + # then use the objects to derive the expiry + + if isinstance(eval_date, NoInput): + raise ValueError("`expiry` as string tenor requires `eval_date`.") + # then the expiry will be implied + expiry_ = add_tenor( + start=eval_date, + tenor=expiry, + modifier=irs_series_.modifier, + calendar=irs_series_.calendar, + roll=eval_date.day, + settlement=False, + mod_days=False, + ) + else: + expiry_ = expiry + + if isinstance(payment_lag_, datetime): + payment_ = payment_lag_ + else: + payment_ = payment_lag_.adjust(expiry_, irs_series_.calendar) + + return expiry_, payment_ + + +def _bilinear_interp( + tl: DualTypes, + tr: DualTypes, + bl: DualTypes, + br: DualTypes, + h: tuple[float, float], + v: tuple[float, float], +) -> DualTypes: + """ + tl, tr, bl, br: the values on the vertices of a unit square. + h: the progression along the horizontal top edge and the horizontal bottom edge in [0,1]. + v: the progression along the vertical left edge and the vertical right edge in [0,1]. + p: the interior point as the intersection when lines are drawn between the progression on edges. + """ + return ( + tl * (1 - h[0]) * (1 - v[0]) + + tr * (h[0]) * (1 - v[1]) + + bl * (1 - h[1]) * v[0] + + br * h[1] * v[1] + ) diff --git a/python/rateslib/volatility/utils.py b/python/rateslib/volatility/utils.py new file mode 100644 index 000000000..01a9812c1 --- /dev/null +++ b/python/rateslib/volatility/utils.py @@ -0,0 +1,461 @@ +# SPDX-License-Identifier: LicenseRef-Rateslib-Dual +# +# Copyright (c) 2026 Siffrorna Technology Limited +# +# Dual-licensed: Free Educational Licence or Paid Commercial Licence (commercial/professional use) +# Source-available, not open source. +# +# See LICENSE and https://rateslib.com/py/en/latest/i_licence.html for details, +# and/or contact info (at) rateslib (dot) com +#################################################################################################### + + +from __future__ import annotations # type hinting + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, TypeAlias + +from pandas import Series + +from rateslib.dual import ( + Dual, + Dual2, + Variable, + dual_log, + dual_norm_cdf, + dual_norm_pdf, +) +from rateslib.dual.utils import _to_number +from rateslib.enums.generics import ( + NoInput, +) +from rateslib.rs import _sabr_x0 as _rs_sabr_x0 +from rateslib.rs import _sabr_x1 as _rs_sabr_x1 +from rateslib.rs import _sabr_x2 as _rs_sabr_x2 +from rateslib.rs import index_left_f64 +from rateslib.scheduling import get_calendar + +if TYPE_CHECKING: + from rateslib.local_types import ( # pragma: no cover + Number, + ) + +DualTypes: TypeAlias = "float | Dual | Dual2 | Variable" # if not defined causes _WithCache failure + +TERMINAL_DATE = datetime(2100, 1, 1) +UTC = timezone.utc + + +@dataclass(frozen=True) +class _SabrSmileNodes: + """ + A container for data relating to the SABR parameters of a + :class:`~rateslib.volatility.FXSabrSmile` and :class:`~rateslib.volatility.IRSabrSmile`. + """ + + _alpha: Number + _beta: float | Variable + _rho: Number + _nu: Number + + @property + def alpha(self) -> Number: + """The :math:`\\alpha` parameter of the SABR function.""" + return self._alpha + + @property + def beta(self) -> float | Variable: + """The :math:`\\beta` parameter of the SABR function.""" + return self._beta + + @property + def rho(self) -> Number: + """The :math:`\\rho` parameter of the SABR function.""" + return self._rho + + @property + def nu(self) -> Number: + """The :math:`\\nu` parameter of the SABR function.""" + return self._nu + + @property + def n(self) -> int: + return 4 + + +def _validate_weights( + weights: Series[float] | NoInput, + eval_date: datetime, + expiries: list[datetime], +) -> Series[float] | None: + if isinstance(weights, NoInput): + return None + + w: Series[float] = Series( + 1.0, index=get_calendar("all").cal_date_range(eval_date, TERMINAL_DATE) + ) + w.update(weights) + # restrict to sorted and filtered for outliers + w = w.sort_index() + w = w[eval_date:] # type: ignore[misc] + + node_points: list[datetime] = [eval_date] + expiries + [TERMINAL_DATE] + for i in range(len(expiries) + 1): + s, e = node_points[i] + timedelta(days=1), node_points[i + 1] + days = (e - s).days + 1 + w[s:e] = ( # type: ignore[misc] + w[s:e] * days / w[s:e].sum() # type: ignore[misc] + ) # scale the weights to allocate the correct time between nodes. + w[eval_date] = 0.0 # type: ignore[call-overload] + return w + + +def _t_var_interp( + expiries: list[datetime], + expiries_posix: list[float], + expiry: datetime, + expiry_posix: float, + expiry_index: int, + expiry_next_index: int, + eval_posix: float, + weights_cum: Series[float] | None, + vol1: DualTypes, + vol2: DualTypes, + bounds_flag: int, +) -> DualTypes: + """ + Return the volatility of an intermediate timestamp via total linear variance interpolation. + Possibly scaled by time weights if weights is available. + + Parameters + ---------- + expiry_index: int + The index defining the interval within which expiry falls. + expiries_posix: list[datetime] + The list of datetimes associated with the expiries of the *Surface*. + expiries_posix: list[float] + The list of posix timestamps associated with the expiries of the *Surface*. + expiry: datetime + The target expiry to be interpolated. + expiry_posix: float + The pre-calculated posix timestamp for expiry. + expiry_index: int + The integer index of the expiries period in which the expiry falls. + expiry_next_index: int + Will be expiry_index + 1, unless the surface only has one expiry, in which case it will + equal the expiry_index. + eval_posix: float + The pre-calculated posix timestamp for eval date of the *Surface* + weights_cum: Series[float] or NoInput + The cumulative sum of the weights indexes by date + vol1: float, Dual, DUal2 + The volatility of the left side + vol2: float, Dual, Dual2 + The volatility on the right side + bounds_flag: int + -1: left side extrapolation, 0: normal interpolation, 1: right side extrapolation + + Notes + ----- + This function performs different interpolation if weights are given or not. ``bounds_flag`` + is used to parse the inputs when *Smiles* to the left and/or right are not available. + """ + return _t_var_interp_d_sabr_d_k_or_f( + expiries, + expiries_posix, + expiry, + expiry_posix, + expiry_index, + expiry_next_index, + eval_posix, + weights_cum, + vol1, + dvol1_dk=0.0, + vol2=vol2, + dvol2_dk=0.0, + bounds_flag=bounds_flag, + derivative=False, + )[0] + + +def _t_var_interp_d_sabr_d_k_or_f( + expiries: list[datetime], + expiries_posix: list[float], + expiry: datetime, + expiry_posix: float, + expiry_index: int, + expiry_next_index: int, + eval_posix: float, + weights_cum: Series[float] | None, + vol1: DualTypes, + dvol1_dk: DualTypes, + vol2: DualTypes, + dvol2_dk: DualTypes, + bounds_flag: int, + derivative: bool, +) -> tuple[DualTypes, DualTypes | None]: + if weights_cum is None: # weights must also be NoInput + if bounds_flag == 0: + t1 = expiries_posix[expiry_index] - eval_posix + t2 = expiries_posix[expiry_next_index] - eval_posix + elif bounds_flag == -1: + # left side extrapolation + t1 = 0.0 + t2 = expiries_posix[expiry_index] - eval_posix + else: # bounds_flag == 1: + # right side extrapolation + t1 = expiries_posix[expiry_next_index] - eval_posix + t2 = TERMINAL_DATE.replace(tzinfo=UTC).timestamp() - eval_posix + + t_hat = expiry_posix - eval_posix + t = expiry_posix - eval_posix + else: + if bounds_flag == 0: + t1 = weights_cum[expiries[expiry_index]] + t2 = weights_cum[expiries[expiry_next_index]] + elif bounds_flag == -1: + # left side extrapolation + t1 = 0.0 + t2 = weights_cum[expiries[expiry_index]] + else: # bounds_flag == 1: + # right side extrapolation + t1 = weights_cum[expiries[expiry_next_index]] + t2 = weights_cum[TERMINAL_DATE] + + t_hat = weights_cum[expiry] # number of vol weighted calendar days + t = (expiry_posix - eval_posix) / 86400.0 # number of calendar days + + t_quotient = (t_hat - t1) / (t2 - t1) + vol = ((t1 * vol1**2 + t_quotient * (t2 * vol2**2 - t1 * vol1**2)) / t) ** 0.5 + if derivative: + dvol_dk = ( + (t2 / t) * t_quotient * vol2 * dvol2_dk + (t1 / t) * (1 - t_quotient) * vol1 * dvol1_dk + ) / vol + else: + dvol_dk = None + return vol, dvol_dk + + +class _OptionModelBlack76: + """Container for option pricing formulae relating to the lognormal Black-76 model.""" + + @staticmethod + def _d_plus_min(K: DualTypes, f: DualTypes, vol_sqrt_t: DualTypes, eta: float) -> DualTypes: + # AD preserving calculation of d_plus in Black-76 formula (eta should +/- 0.5) + return dual_log(f / K) / vol_sqrt_t + eta * vol_sqrt_t + + @staticmethod + def _d_plus_min_u(u: DualTypes, vol_sqrt_t: DualTypes, eta: float) -> DualTypes: + # AD preserving calculation of d_plus in Black-76 formula (eta should +/- 0.5) + return -dual_log(u) / vol_sqrt_t + eta * vol_sqrt_t + + @staticmethod + def _d_min(K: DualTypes, f: DualTypes, vol_sqrt_t: DualTypes) -> DualTypes: + return _OptionModelBlack76._d_plus_min(K, f, vol_sqrt_t, -0.5) + + @staticmethod + def _d_plus(K: DualTypes, f: DualTypes, vol_sqrt_t: DualTypes) -> DualTypes: + return _OptionModelBlack76._d_plus_min(K, f, vol_sqrt_t, +0.5) + + @staticmethod + def _value( + F: DualTypes, + K: DualTypes, + t_e: float, + v2: DualTypes, + vol: DualTypes, + phi: float, + ) -> DualTypes: + """ + Option price in points terms for immediate premium settlement. + + Parameters + ----------- + F: float, Dual, Dual2 + The forward price for settlement at the delivery date. + K: float, Dual, Dual2 + The strike price of the option. + t_e: float + The annualised time to expiry. + v2: float, Dual, Dual2 + The discounting rate to delivery (ccy2 on FX options), at the appropriate collateral + rate. + vol: float, Dual, Dual2 + The volatility measured over the period until expiry. + phi: float + Whether to calculate for call (1.0) or put (-1.0). + + Returns + -------- + float, Dual, Dual2 + """ + vs = vol * t_e**0.5 + d1 = _OptionModelBlack76._d_plus(K, F, vs) + d2 = d1 - vs + Nd1, Nd2 = dual_norm_cdf(phi * d1), dual_norm_cdf(phi * d2) + _: DualTypes = phi * (F * Nd1 - K * Nd2) + # Spot formulation instead of F (Garman Kohlhagen formulation) + # https://quant.stackexchange.com/a/63661/29443 + # r1, r2 = dual_log(df1) / -t, dual_log(df2) / -t + # S_imm = F * df2 / df1 + # d1 = (dual_log(S_imm / K) + (r2 - r1 + 0.5 * vol ** 2) * t) / vs + # d2 = d1 - vs + # Nd1, Nd2 = dual_norm_cdf(d1), dual_norm_cdf(d2) + # _ = df1 * S_imm * Nd1 - K * df2 * Nd2 + return _ * v2 + + +class _OptionModelBachelier: + """Container for option pricing formulae relating to the lognormal Black-76 model.""" + + @staticmethod + def _value( + F: DualTypes, + K: DualTypes, + t_e: float, + v2: DualTypes, + vol: DualTypes, + phi: float, + ) -> DualTypes: + """ + Option price in points terms for immediate premium settlement. + + Parameters + ----------- + F: float, Dual, Dual2 + The forward price for settlement at the delivery date. + K: float, Dual, Dual2 + The strike price of the option. + t_e: float + The annualised time to expiry. + v2: float, Dual, Dual2 + The discounting rate to delivery (ccy2 on FX options), at the appropriate collateral + rate. + vol: float, Dual, Dual2 + The volatility measured over the period until expiry. + phi: float + Whether to calculate for call (1.0) or put (-1.0). + + Returns + -------- + float, Dual, Dual2 + """ + vs = vol * t_e**0.5 + d = (F - K) / vs + + P = dual_norm_cdf(phi * d) + p = dual_norm_pdf(d) + + _: DualTypes = phi * (F - K) * P + vs * p + return _ * v2 + + +class _SabrModel: + """Container for formulae relating to the SABR volatility model.""" + + @staticmethod + def _d_sabr_d_k_or_f( + k: Number, + f: Number, + t: Number, + a: Number, + b: float | Variable, + p: Number, + v: Number, + derivative: int, + ) -> tuple[Number, Number | None]: + """ + Calculate the SABR function and its derivative with respect to k or f. + + For formula see for example I. Clark "Foreign Exchange Option + Pricing" section 3.10. + + Rateslib uses the representation sigma(k) = X0 * X1 * X2, with these variables as defined in + "Coding Interest Rates" chapter 13 to handle AD using dual numbers effectively. + + For no derivative and just the SABR function value use 0. + For derivatives with respect to `k` use 1. + For derivatives with respect to `f` use 2. + + See "Coding Interest Rates: FX Swaps and Bonds edition 2" + """ + b_: Number = _to_number(b) + X0, dX0 = _SabrModel._sabr_X0(k, f, t, a, b_, p, v, derivative) + X1, dX1 = _SabrModel._sabr_X1(k, f, t, a, b_, p, v, derivative) + X2, dX2 = _SabrModel._sabr_X2(k, f, t, a, b_, p, v, derivative) + + if derivative == 0: + return X0 * X1 * X2, None + else: + return X0 * X1 * X2, dX0 * X1 * X2 + X0 * dX1 * X2 + X0 * X1 * dX2 # type: ignore[operator] + + @staticmethod + def _sabr_X0( + k: Number, + f: Number, + t: Number, + a: Number, + b: Number, + p: Number, + v: Number, + derivative: int = 0, + ) -> tuple[Number, Number | None]: + """ + X0 = a / ((fk)^((1-b)/2) * (1 + (1-b)^2/24 ln^2(f/k) + (1-b)^4/1920 ln^4(f/k) ) + + If ``derivative`` is 1 also returns dX0/dk, derived using sympy auto code generator. + If ``derivative`` is 2 also returns dX0/df, derived using sympy auto code generator. + """ + return _rs_sabr_x0(k, f, t, a, b, p, v, derivative) + + @staticmethod + def _sabr_X1( + k: Number, + f: Number, + t: Number, + a: Number, + b: Number, + p: Number, + v: Number, + derivative: int = 0, + ) -> tuple[Number, Number | None]: + """ + X1 = 1 + t ( (1-b)^2 / 24 * a^2 / (fk)^(1-b) + 1/4 p b v a / (fk)^((1-b)/2) + (2-3p^2)/24 v^2 ) + + If ``derivative`` also returns dX0/dk, calculated using sympy. + """ # noqa: E501 + return _rs_sabr_x1(k, f, t, a, b, p, v, derivative) + + @staticmethod + def _sabr_X2( + k: Number, + f: Number, + t: Number, + a: Number, + b: Number, + p: Number, + v: Number, + derivative: int = 0, + ) -> tuple[Number, Number | None]: + """ + X2 = z / chi(z) + + z = v / a * (fk) ^((1-b)/2) * ln(f/k) + chi(z) = ln( (sqrt(1-2pz+z^2) + z -p) / (1-p) ) + + If ``derivative`` = 1 also returns dX2/dk, calculated using sympy. + If ``derivative`` = 2 also returns dX2/df, calculated using sympy. + """ + return _rs_sabr_x2(k, f, t, a, b, p, v, derivative) + + +def _surface_index_left(expiries_posix: list[float], expiry_posix: float) -> tuple[int, int]: + """use `index_left_f64` to derive left and right index, + but exclude surfaces with only one expiry.""" + if len(expiries_posix) == 1: + return 0, 0 + else: + e_idx = index_left_f64(expiries_posix, expiry_posix) + e_next_idx = e_idx + 1 + return e_idx, e_next_idx diff --git a/python/tests/instruments/test_instruments_legacy.py b/python/tests/instruments/test_instruments_legacy.py index 28f95b012..cd160763c 100644 --- a/python/tests/instruments/test_instruments_legacy.py +++ b/python/tests/instruments/test_instruments_legacy.py @@ -24,7 +24,6 @@ from rateslib.dual import Dual, Dual2, Variable, dual_exp, dual_log, gradient from rateslib.enums.parameters import FloatFixingMethod, LegMtm from rateslib.fx import FXForwards, FXRates -from rateslib.fx_volatility import FXDeltaVolSmile, FXDeltaVolSurface, FXSabrSmile, FXSabrSurface from rateslib.instruments import ( CDS, FRA, @@ -50,7 +49,9 @@ FXSwap, FXVolValue, IndexFixedRateBond, + PayerSwaption, Portfolio, + ReceiverSwaption, Spread, STIRFuture, Value, @@ -72,6 +73,7 @@ from rateslib.periods import ZeroFloatPeriod from rateslib.scheduling import Adjuster, NamedCal, Schedule, add_tenor, get_imm from rateslib.solver import Solver +from rateslib.volatility import FXDeltaVolSmile, FXDeltaVolSurface, FXSabrSmile, FXSabrSurface @pytest.fixture @@ -1573,6 +1575,11 @@ def test_irs_parse_curves(self, curve): r2 = irs.npv(curves={"rate_curve": curve, "disc_curve": curve}) assert r1 == r2 + def test_modifier_as_adjuster(self): + irs = IRS(dt(2000, 1, 1), "1y", "S", modifier=Adjuster.CalDaysLagSettle(10), calendar="ldn") + assert irs.leg1.schedule.uschedule[0] == dt(2000, 1, 1) + assert irs.leg1.schedule.aschedule[0] == dt(2000, 1, 11) + def test_cny_zero_periods(self): irs = IRS( effective=dt(2026, 2, 4), @@ -8957,3 +8964,144 @@ def test_wmr_crosses_not_allowed_standard_instruments(): ] for inst in instruments: inst.npv(vol=fxvs, fx=fxf) + + +class TestSwaptions: + def test_npv_no_set_premium(self): + curve = Curve( + nodes={dt(2026, 2, 16): 1.0, dt(2028, 2, 16): 0.941024343401225}, calendar="tgt" + ) + irsw = PayerSwaption( + expiry=dt(2027, 2, 16), + tenor="6m", + strike=3.020383, + irs_series="usd_irs", + ) + result = irsw.npv(curves=curve, vol=25.16) + expected = 0.0 + assert abs(result - expected) < 1e-6 + + def test_npv_with_set_premium(self): + curve = Curve( + nodes={dt(2026, 2, 16): 1.0, dt(2028, 2, 16): 0.941024343401225}, calendar="tgt" + ) + irsw = PayerSwaption( + expiry=dt(2027, 2, 16), + tenor="6m", + strike=3.020383, + irs_series="usd_irs", + premium=10000.0, + ) + result = irsw.npv(curves=curve, vol=25.16) + expected = -8246.831212232395 + assert abs(result - expected) < 1e-6 + + def test_npv_local(self): + curve = Curve( + nodes={dt(2026, 2, 16): 1.0, dt(2028, 2, 16): 0.941024343401225}, calendar="tgt" + ) + irsw = PayerSwaption( + expiry=dt(2027, 2, 16), + tenor="6m", + strike=3.020383, + irs_series="usd_irs", + premium=10000.0, + ) + result = irsw.npv(curves=curve, vol=25.16, local=True) + expected = -8246.831212232395 + assert abs(result["usd"] - expected) < 1e-6 + + def test_default_payment_date(self): + irsw = PayerSwaption( + expiry=dt(2027, 2, 16), + tenor="6m", + strike=3.020383, + irs_series="usd_irs", + premium=10000.0, + ) + assert irsw.leg2.periods[0].settlement_params.payment == dt(2027, 2, 18) + + @pytest.mark.parametrize( + ("metric", "expected"), + [ + ("LogNormalVol", 25.16), + ("BlackVolShift_0", 25.16), + ("Cash", 149725.796514), + ("NormalVol", 75.792872), + ("Black_vol_shift_100", 18.880156), + ("Black_vol_shift_200", 15.111396), + ("Black_vol_shift_300", 12.597702), + ("PercentNotional", 0.149725), + ], + ) + def test_rate(self, metric, expected): + # if we know that the exercise will occur (from the fixing_value) value the cashflow + curve = Curve( + nodes={dt(2026, 2, 16): 1.0, dt(2028, 2, 16): 0.941024343401225}, calendar="nyc" + ) + irsw = PayerSwaption( + expiry=dt(2027, 2, 16), + tenor="6m", + strike=3.020383, + notional=100e6, + irs_series="usd_irs", + premium=10000.0, + ) + result = irsw.rate( + curves=[curve], + vol=25.16, + metric=metric, + ) + assert abs(result - expected) < 1e-5 + + @pytest.mark.parametrize( + ("metric", "expected"), + [("Cash", 149725.796514), ("PercentNotional", 0.149725)], + ) + @pytest.mark.parametrize("date", [dt(2027, 1, 3), dt(2027, 3, 19)]) + def test_rate_unconventional_payment_date(self, metric, expected, date): + # if we know that the exercise will occur (from the fixing_value) value the cashflow + curve = Curve( + nodes={dt(2026, 2, 16): 1.0, dt(2028, 2, 16): 0.941024343401225}, calendar="nyc" + ) + alt_curve = Curve(nodes={dt(2026, 2, 16): 1.0, dt(2028, 2, 16): 0.91}, calendar="nyc") + irsw = PayerSwaption( + expiry=dt(2027, 2, 16), + tenor="6m", + strike=3.020383, + notional=100e6, + irs_series="usd_irs", + premium=10000.0, + payment_lag=date, + ) + result = irsw.rate( + curves=[curve, alt_curve, curve], + vol=25.16, + metric=metric, + ) + expected = expected * alt_curve[date] / alt_curve[dt(2027, 2, 18)] + assert abs(result - expected) < 1e-5 + + def test_cashflows(self): + # if we know that the exercise will occur (from the fixing_value) value the cashflow + curve = Curve( + nodes={dt(2026, 2, 16): 1.0, dt(2028, 2, 16): 0.941024343401225}, calendar="nyc" + ) + irsw = PayerSwaption( + expiry=dt(2027, 2, 16), + tenor="6m", + strike=3.020383, + notional=100e6, + irs_series="usd_irs", + premium=10000.0, + ) + result = irsw.cashflows( + curves=[curve], + vol=25.16, + ) + assert len(result.index) == 2 + assert abs(result.loc["leg1", "DF"].iloc[0] - 0.969902553602701) < 1e-8 + assert abs(result.loc["leg1", "Cashflow"].iloc[0] - 149725.7965143448) < 1e-8 + assert abs(result.loc["leg1", "NPV"].iloc[0] - 145219.43237946142) < 1e-8 + assert result.loc["leg1", "Ccy"].iloc[0] == "USD" + assert result.loc["leg1", "Type"].iloc[0] == "IRSCallPeriod" diff --git a/python/tests/legs/test_analytic_delta.py b/python/tests/legs/test_analytic_delta.py index 95a1a8f64..041890a28 100644 --- a/python/tests/legs/test_analytic_delta.py +++ b/python/tests/legs/test_analytic_delta.py @@ -57,3 +57,22 @@ def test_forward_settlement(curve): result = leg.analytic_delta(disc_curve=curve, local=False) result2 = leg.analytic_delta(disc_curve=curve, local=False, settlement=dt(2022, 1, 3)) assert result2 < (result - 5000) + + +def test_forward(curve): + # tset that the analytic delta reacts to the forward argument + leg = FixedLeg( + schedule=Schedule( + effective=dt(2021, 12, 2), + termination=dt(2022, 4, 2), + frequency="M", + payment_lag=0, + ), + fixed_rate=1.0, + notional=1e9, + ) + result = leg.analytic_delta(disc_curve=curve, local=False) + result2 = leg.analytic_delta(disc_curve=curve, local=False, forward=dt(2022, 3, 15)) + + expected = result / curve[dt(2022, 3, 15)] + assert abs(result2 - expected) < 1e-6 diff --git a/python/tests/periods/test_periods_legacy.py b/python/tests/periods/test_periods_legacy.py index 8a8ebff5b..d92188500 100644 --- a/python/tests/periods/test_periods_legacy.py +++ b/python/tests/periods/test_periods_legacy.py @@ -30,8 +30,6 @@ from rateslib.enums import FloatFixingMethod from rateslib.enums.parameters import FXDeltaMethod, IndexMethod, SpreadCompoundMethod from rateslib.fx import FXForwards, FXRates -from rateslib.fx_volatility import FXDeltaVolSmile, FXSabrSmile, FXSabrSurface -from rateslib.fx_volatility.utils import _d_plus_min_u from rateslib.periods import ( Cashflow, CreditPremiumPeriod, @@ -40,15 +38,15 @@ FloatPeriod, FXCallPeriod, FXPutPeriod, - # IndexCashflow, - # IndexFixedPeriod, + IRSCallPeriod, + IRSPutPeriod, MtmCashflow, - # NonDeliverableCashflow, - # NonDeliverableFixedPeriod, ZeroFixedPeriod, ) from rateslib.periods.float_rate import rate_value from rateslib.scheduling import Cal, Frequency, RollDay, Schedule +from rateslib.volatility import FXDeltaVolSmile, FXSabrSmile, FXSabrSurface +from rateslib.volatility.utils import _OptionModelBlack76 @pytest.fixture @@ -5337,7 +5335,7 @@ def test_kega(self, fxfo) -> None: delta_type="spot_pa", ) - d_eta = _d_plus_min_u(1.10 / 1.065, 0.10 * 0.5, -0.5) + d_eta = _OptionModelBlack76._d_plus_min_u(1.10 / 1.065, 0.10 * 0.5, -0.5) result = fxc._analytic_kega(1.10 / 1.065, 0.99, -0.5, 0.10, 0.50, 1.065, 1.0, 1.10, d_eta) expected = 0.355964619118249 assert abs(result - expected) < 1e-12 @@ -5789,3 +5787,175 @@ def test_cashflow_no_pricing_objects(self): ) cf = fxo.cashflows() assert isinstance(cf, dict) + + +class TestIROption: + @pytest.mark.parametrize("today", [dt(2026, 1, 3), dt(2026, 4, 15)]) + @pytest.mark.parametrize( + ("strike", "fixing", "klass"), [(2.0, 2.5, IRSCallPeriod), (2.0, 1.5, IRSPutPeriod)] + ) + def test_cashflow_known_exercise(self, today, strike, fixing, klass): + # if we know that the exercise will occur (from the fixing_value) value the cashflow + curve = Curve({today: 1.0, dt(2028, 4, 15): 0.95}, calendar="nyc") + ir_period = klass( + expiry=dt(2027, 2, 3), + irs_series="usd_irs", + tenor="6m", + strike=strike, + notional=100e6, + option_fixings=fixing, + ) + immediate_npv = ir_period.ir_option_params.option_fixing.irs.npv(curves=curve) + forward_npv = immediate_npv / curve[dt(2027, 2, 5)] * 100.0 + + result = ir_period.unindexed_reference_cashflow(rate_curve=curve, index_curve=curve) + assert abs(abs(result) - abs(forward_npv)) < 1e-8 + + def test_cashflow_option_value(self): + # if we know that the exercise will occur (from the fixing_value) value the cashflow + curve = Curve( + nodes={dt(2026, 2, 16): 1.0, dt(2028, 2, 16): 0.941024343401225}, calendar="tgt" + ) + ir_period = IRSCallPeriod( + expiry=dt(2027, 2, 16), + irs_series="usd_irs", + tenor="6m", + strike=3.020383, + notional=100e6, + ) + cashflow = ir_period.unindexed_reference_cashflow( + rate_curve=curve, ir_vol=25.16, index_curve=curve + ) + expected = cashflow * curve[dt(2027, 2, 18)] + result = ir_period.npv(rate_curve=curve, ir_vol=25.16, index_curve=curve) + assert abs(result - expected) < 1e-8 + assert abs(result - 145000) < 500.0 + + def test_option_npv_different_csa(self): + # test that a forward NPV alignbed with cashflow does not change, but an NPV does. + curve = Curve( + nodes={dt(2026, 2, 16): 1.0, dt(2028, 2, 16): 0.941024343401225}, calendar="tgt" + ) + alt_disc_curve = Curve(nodes={dt(2026, 2, 16): 1.0, dt(2028, 2, 16): 0.91}, calendar="tgt") + ir_period = IRSCallPeriod( + expiry=dt(2027, 2, 16), + irs_series="usd_irs", + tenor="6m", + strike=3.000, + notional=100e6, + ) + fwd_result = ir_period.npv( + rate_curve=curve, + ir_vol=25.16, + index_curve=curve, + disc_curve=alt_disc_curve, + forward=dt(2027, 2, 18), + ) + imm_exp = fwd_result * alt_disc_curve[dt(2027, 2, 18)] + imm_res = ir_period.npv( + rate_curve=curve, ir_vol=25.16, index_curve=curve, disc_curve=alt_disc_curve + ) + assert abs(imm_exp - imm_res) < 1e-6 + + fwd_result2 = ir_period.npv( + rate_curve=curve, ir_vol=25.16, index_curve=curve, forward=dt(2027, 2, 18) + ) + imm_exp2 = fwd_result * curve[dt(2027, 2, 18)] + imm_res2 = ir_period.npv(rate_curve=curve, ir_vol=25.16, index_curve=curve) + assert abs(imm_exp2 - imm_res2) < 1e-6 + + assert abs(fwd_result - fwd_result2) < 1e-6 + assert abs(imm_res - imm_res2) > 2000.0 + + @pytest.mark.parametrize( + ("metric", "expected"), + [ + ("NormalVol", 75.792872), + ("LogNormalVol", 25.16), + ("Cash", 149725.796514), + ("PercentNotional", 0.149725), + ("black_vol_shift_0", 25.16), + ("Black_vol_shift_100", 18.880156), + ("Black_vol_shift_200", 15.111396), + ("Black_vol_shift_300", 12.597702), + ("Black_vol_shift_117", 18.112063), + ], + ) + def test_option_rate(self, metric, expected): + # if we know that the exercise will occur (from the fixing_value) value the cashflow + curve = Curve( + nodes={dt(2026, 2, 16): 1.0, dt(2028, 2, 16): 0.941024343401225}, calendar="nyc" + ) + ir_period = IRSCallPeriod( + expiry=dt(2027, 2, 16), + irs_series="usd_irs", + tenor="6m", + strike=3.020383, + notional=100e6, + ) + result = ir_period.rate( + rate_curve=curve, + disc_curve=curve, + index_curve=curve, + ir_vol=25.16, + metric=metric, + ) + assert abs(result - expected) < 1e-5 + + def test_cashflows(self): + curve = Curve( + nodes={dt(2026, 2, 16): 1.0, dt(2028, 2, 16): 0.941024343401225}, calendar="nyc" + ) + ir_period = IRSCallPeriod( + expiry=dt(2027, 2, 16), + irs_series="usd_irs", + tenor="6m", + strike=3.020383, + notional=100e6, + ) + result = ir_period.cashflows( + rate_curve=curve, + disc_curve=curve, + index_curve=curve, + ir_vol=25.16, + ) + expected = { + "Base Ccy": "USD", + "Cashflow": 149725.7965143448, + "Ccy": "USD", + "Collateral": None, + "DF": 0.969902553602701, + "FX Rate": 1.0, + "NPV": 145219.43237946142, + "NPV Ccy": 145219.43237946142, + "Notional": 100000000.0, + "Payment": dt(2027, 2, 18, 0, 0), + "Type": "IRSCallPeriod", + } + assert result == expected + + def test_analytic_greeks(self): + curve = Curve( + nodes={dt(2026, 2, 16): 1.0, dt(2028, 2, 16): 0.941024343401225}, calendar="nyc" + ) + ir_period = IRSCallPeriod( + expiry=dt(2027, 2, 16), + irs_series="usd_irs", + tenor="6m", + strike=3.020383, + notional=100e6, + ) + result = ir_period.analytic_greeks( + rate_curve=curve, + disc_curve=curve, + index_curve=curve, + ir_vol=25.16, + ) + expected = { + "__forward": 3.0203829940764084, + "__sqrt_t": 1.0, + "__strike": 3.020383, + "__vol": 0.2516, + "delta": 0.5500548760276942, + } + assert result == expected diff --git a/python/tests/scheduling/test_calendars.py b/python/tests/scheduling/test_calendars.py index 6e2729ee1..92ea311a5 100644 --- a/python/tests/scheduling/test_calendars.py +++ b/python/tests/scheduling/test_calendars.py @@ -869,3 +869,15 @@ def test_print_compare_calendar(): def test_union_cal_try_from_name(): uc = UnionCal.from_name("ldn,tgt|fed") assert isinstance(uc, UnionCal) + + +@pytest.mark.parametrize("number", [-3, -2, -1, 0, 1, 2, 3]) +@pytest.mark.parametrize( + "start", [dt(2026, 2, 13), dt(2026, 2, 14), dt(2026, 2, 15), dt(2026, 2, 16)] +) +def test_add_bus_days_BusDaysLagSettle_equivalence(number, start): + cal = Cal([], [5, 6]) + adj = Adjuster.BusDaysLagSettle(number) + result = cal.adjust(start, adj) + expected = cal.lag_bus_days(start, number, True) + assert result == expected diff --git a/python/tests/serialization/test_json.py b/python/tests/serialization/test_json.py index 7268b0c68..6e28ea02a 100644 --- a/python/tests/serialization/test_json.py +++ b/python/tests/serialization/test_json.py @@ -11,7 +11,7 @@ import pytest from rateslib import Curve, Dual, Dual2, FXForwards, FXRates, dt, from_json -from rateslib.enums import FloatFixingMethod, LegIndexBase +from rateslib.enums import FloatFixingMethod, IROptionMetric, LegIndexBase from rateslib.rs import Schedule as ScheduleRs from rateslib.scheduling import ( Adjuster, @@ -82,6 +82,11 @@ FloatFixingMethod.RFRLockout(4), FloatFixingMethod.RFRLockoutAverage(4), FloatFixingMethod.IBOR(2), + IROptionMetric.Cash(), + IROptionMetric.PercentNotional(), + IROptionMetric.NormalVol(), + IROptionMetric.LogNormalVol(), + IROptionMetric.BlackVolShift(25), LegIndexBase.Initial, LegIndexBase.PeriodOnPeriod, ], diff --git a/python/tests/serialization/test_pickle.py b/python/tests/serialization/test_pickle.py index 7bc7f2e13..a4669e5c8 100644 --- a/python/tests/serialization/test_pickle.py +++ b/python/tests/serialization/test_pickle.py @@ -31,7 +31,7 @@ MultiCsaCurve, ProxyCurve, ) -from rateslib.enums import FloatFixingMethod, LegIndexBase +from rateslib.enums import FloatFixingMethod, IROptionMetric, LegIndexBase from rateslib.rs import Schedule as ScheduleRs from rateslib.scheduling import ( Adjuster, @@ -169,6 +169,12 @@ def test_pickle_round_trip_obj_via_equality(obj): (Convention.ActActICMA, Convention.ActActICMA, Convention.ActActISDA), (FloatFixingMethod.IBOR(2), FloatFixingMethod.IBOR(2), FloatFixingMethod.RFRLookback(2)), (FloatFixingMethod.IBOR(2), FloatFixingMethod.IBOR(2), FloatFixingMethod.IBOR(5)), + (IROptionMetric.Cash(), IROptionMetric.Cash(), IROptionMetric.BlackVolShift(200)), + ( + IROptionMetric.BlackVolShift(200), + IROptionMetric.BlackVolShift(200), + IROptionMetric.BlackVolShift(100), + ), (LegIndexBase.Initial, LegIndexBase.Initial, LegIndexBase.PeriodOnPeriod), ], ) @@ -181,6 +187,7 @@ def test_enum_equality(a1, a2, b1): ("enum", "klass"), [ (FloatFixingMethod.IBOR(2), FloatFixingMethod.IBOR), + (IROptionMetric.BlackVolShift(2), IROptionMetric.BlackVolShift), ], ) def test_complex_enum_isinstance(enum, klass): diff --git a/python/tests/test_default.py b/python/tests/test_default.py index fcc26aa27..405c1ea72 100644 --- a/python/tests/test_default.py +++ b/python/tests/test_default.py @@ -26,7 +26,7 @@ def test_version() -> None: - assert __version__ == "2.6.0" + assert __version__ == "2.7.0" def test_context_raises() -> None: diff --git a/python/tests/test_fixings.py b/python/tests/test_fixings.py index 125869058..a06615c8c 100644 --- a/python/tests/test_fixings.py +++ b/python/tests/test_fixings.py @@ -20,12 +20,13 @@ FloatRateSeries, FXFixing, FXIndex, + IRSFixing, RFRFixing, _FXFixingMajor, _UnitFixing, ) from rateslib.enums.generics import NoInput -from rateslib.enums.parameters import FloatFixingMethod +from rateslib.enums.parameters import FloatFixingMethod, SwaptionSettlementMethod from rateslib.instruments import IRS from rateslib.scheduling import Adjuster, get_calendar @@ -466,3 +467,28 @@ def test_no_state_update(self): assert fx_fixing.value == 0.20 assert fx_fixing._state == fixings["TEST_USDRUB"][0] fixings.pop("test_USDRUB") + + +class TestIRSFixing: + @pytest.mark.parametrize( + ("method", "expected"), + [ + (SwaptionSettlementMethod.Physical, 192.8729663786536), + (SwaptionSettlementMethod.CashParTenor, 189.90825721068495), + (SwaptionSettlementMethod.CashCollateralized, 192.8729663786536), + ], + ) + def test_annuity(self, method, expected) -> None: + + fixing = IRSFixing( + irs_series="usd_irs", + publication=dt(2026, 2, 18), + tenor="2Y", + ) + curve = Curve(nodes={dt(2026, 2, 18): 1.0, dt(2029, 2, 18): 0.9}) + result = fixing.annuity( + settlement_method=method, + rate_curve=curve, + index_curve=curve, + ) + assert abs(result - expected) < 1e-6 diff --git a/python/tests/test_fx_volatility.py b/python/tests/test_fx_volatility.py index e17f3d753..475c568fe 100644 --- a/python/tests/test_fx_volatility.py +++ b/python/tests/test_fx_volatility.py @@ -27,18 +27,18 @@ FXRates, forward_fx, ) -from rateslib.fx_volatility import ( +from rateslib.periods import FXCallPeriod +from rateslib.scheduling import get_calendar +from rateslib.volatility import ( FXDeltaVolSmile, FXDeltaVolSurface, FXSabrSmile, FXSabrSurface, ) -from rateslib.fx_volatility.utils import ( - _d_sabr_d_k_or_f, - _FXSabrSmileNodes, +from rateslib.volatility.utils import ( + _SabrModel, + _SabrSmileNodes, ) -from rateslib.periods import FXCallPeriod -from rateslib.scheduling import get_calendar @pytest.fixture @@ -682,7 +682,7 @@ def inc_(key1, inc1): in_ = {"k": k, "f": f, "alpha": a, "rho": p, "nu": v} in_[key1] += inc1 - fxss._nodes = _FXSabrSmileNodes( + fxss._nodes = _SabrSmileNodes( _alpha=in_["alpha"], _beta=1.0, _rho=in_["rho"], _nu=in_["nu"] ) _ = ( @@ -697,7 +697,7 @@ def inc_(key1, inc1): ) # reset - fxss._nodes = _FXSabrSmileNodes(_alpha=a, _beta=1.0, _rho=p, _nu=v) + fxss._nodes = _SabrSmileNodes(_alpha=a, _beta=1.0, _rho=p, _nu=v) return _ for key in ["k", "f", "alpha", "rho", "nu"]: @@ -739,7 +739,7 @@ def inc_(key1, key2, inc1, inc2): in_[key1] += inc1 in_[key2] += inc2 - fxss._nodes = _FXSabrSmileNodes( + fxss._nodes = _SabrSmileNodes( _alpha=in_["alpha"], _beta=1.0, _rho=in_["rho"], _nu=in_["nu"] ) _ = ( @@ -754,7 +754,7 @@ def inc_(key1, key2, inc1, inc2): ) # reset - fxss._nodes = _FXSabrSmileNodes(_alpha=a, _beta=1.0, _rho=p, _nu=v) + fxss._nodes = _SabrSmileNodes(_alpha=a, _beta=1.0, _rho=p, _nu=v) return _ v_map = {"k": "k", "f": "f", "alpha": "v0", "rho": "v1", "nu": "v2"} @@ -799,7 +799,7 @@ def inc_(key1, inc1): in_ = {"k": k, "f": f, "alpha": a, "rho": p, "nu": v} in_[key1] += inc1 - fxss._nodes = _FXSabrSmileNodes( + fxss._nodes = _SabrSmileNodes( _alpha=in_["alpha"], _beta=1.0, _rho=in_["rho"], _nu=in_["nu"] ) _ = ( @@ -814,7 +814,7 @@ def inc_(key1, inc1): ) # reset - fxss._nodes = _FXSabrSmileNodes(_alpha=a, _beta=1.0, _rho=p, _nu=v) + fxss._nodes = _SabrSmileNodes(_alpha=a, _beta=1.0, _rho=p, _nu=v) return _ v_map = {"k": "k", "f": "f", "alpha": "v0", "rho": "v1", "nu": "v2"} @@ -1004,7 +1004,7 @@ def test_sabr_derivative(self, a, p, k_): t = 1.0 k = Dual(k_, ["k"], [1.0]) - sabr_vol, result = _d_sabr_d_k_or_f(k, f, t, a, b, p, v, 1) + sabr_vol, result = _SabrModel._d_sabr_d_k_or_f(k, f, t, a, b, p, v, 1) expected = gradient(sabr_vol, ["k"])[0] assert abs(result - expected) < 1e-13 @@ -1022,7 +1022,7 @@ def test_sabr_derivative_f(self, a, p, f_): t = 1.0 f = Dual(f_, ["f"], [1.0]) - sabr_vol, result = _d_sabr_d_k_or_f(k, f, t, a, b, p, v, 2) + sabr_vol, result = _SabrModel._d_sabr_d_k_or_f(k, f, t, a, b, p, v, 2) expected = gradient(sabr_vol, ["f"])[0] assert abs(result - expected) < 1e-13 @@ -1056,7 +1056,7 @@ def inc_(key1, inc1): in_ = {"k": k, "f": f, "alpha": a, "rho": p, "nu": v} in_[key1] += inc1 - fxss._nodes = _FXSabrSmileNodes( + fxss._nodes = _SabrSmileNodes( _alpha=in_["alpha"], _beta=1.0, _rho=in_["rho"], _nu=in_["nu"] ) _ = fxss._d_sabr_d_k_or_f( @@ -1068,7 +1068,7 @@ def inc_(key1, inc1): )[1] # reset - fxss._nodes = _FXSabrSmileNodes(_alpha=a, _beta=1.0, _rho=p, _nu=v) + fxss._nodes = _SabrSmileNodes(_alpha=a, _beta=1.0, _rho=p, _nu=v) return _ for key in ["k", "f", "alpha", "rho", "nu"]: @@ -1112,7 +1112,7 @@ def inc_(key1, key2, inc1, inc2): in_[key1] += inc1 in_[key2] += inc2 - fxss._nodes = _FXSabrSmileNodes( + fxss._nodes = _SabrSmileNodes( _alpha=in_["alpha"], _beta=1.0, _rho=in_["rho"], _nu=in_["nu"] ) _ = fxss._d_sabr_d_k_or_f( @@ -1124,7 +1124,7 @@ def inc_(key1, key2, inc1, inc2): )[1] # reset - fxss._nodes = _FXSabrSmileNodes(_alpha=a, _beta=1.0, _rho=p, _nu=v) + fxss._nodes = _SabrSmileNodes(_alpha=a, _beta=1.0, _rho=p, _nu=v) return _ v_map = {"k": "k", "f": "f", "alpha": "v0", "rho": "v1", "nu": "v2"} @@ -1183,7 +1183,7 @@ def inc_(key1, inc1): Dual2(k_, ["k"], [], []), Dual2(f_, ["f"], [], []), dt(2002, 1, 1), False, 1 )[1] - fxss._nodes = _FXSabrSmileNodes(_alpha=a, _beta=1.0, _rho=p, _nu=v) + fxss._nodes = _SabrSmileNodes(_alpha=a, _beta=1.0, _rho=p, _nu=v) return _ v_map = {"k": "k", "f": "f", "alpha": "v0", "rho": "v1", "nu": "v2"} @@ -1235,16 +1235,16 @@ def test_sabr_derivative_ad(self): t = 1.0 k = Dual2(1.45, ["k"], [1.0], [0.0]) - _, result = _d_sabr_d_k_or_f(k, f, t, a, b, p, v, 1) - _, r1 = _d_sabr_d_k_or_f(k, f, t, a, b, p + 1e-4, v, 1) - _, r_1 = _d_sabr_d_k_or_f(k, f, t, a, b, p - 1e-4, v, 1) + _, result = _SabrModel._d_sabr_d_k_or_f(k, f, t, a, b, p, v, 1) + _, r1 = _SabrModel._d_sabr_d_k_or_f(k, f, t, a, b, p + 1e-4, v, 1) + _, r_1 = _SabrModel._d_sabr_d_k_or_f(k, f, t, a, b, p - 1e-4, v, 1) expected = (r1 - r_1) / (2e-4) result = gradient(result, ["p"])[0] assert abs(result - expected) < 1e-9 - _, result = _d_sabr_d_k_or_f(k, f, t, a, b, p, v, 1) - _, r1 = _d_sabr_d_k_or_f(k, f, t, a, b, p + 1e-4, v, 1) - _, r_1 = _d_sabr_d_k_or_f(k, f, t, a, b, p - 1e-4, v, 1) + _, result = _SabrModel._d_sabr_d_k_or_f(k, f, t, a, b, p, v, 1) + _, r1 = _SabrModel._d_sabr_d_k_or_f(k, f, t, a, b, p + 1e-4, v, 1) + _, r_1 = _SabrModel._d_sabr_d_k_or_f(k, f, t, a, b, p - 1e-4, v, 1) expected = (r1 - 2 * result + r_1) / (1e-8) result = gradient(result, ["p"], order=2)[0][0] assert abs(result - expected) < 1e-8 @@ -1261,7 +1261,7 @@ def test_sabr_derivative_root(self): t = 1.0 k = Dual(1.3395, ["k"], [1.0]) - sabr_vol, result = _d_sabr_d_k_or_f(k, f, t, a, b, p, v, 1) + sabr_vol, result = _SabrModel._d_sabr_d_k_or_f(k, f, t, a, b, p, v, 1) expected = gradient(sabr_vol, ["k"])[0] assert abs(result - expected) < 1e-13 @@ -1278,16 +1278,16 @@ def test_sabr_derivative_root_ad(self): t = 1.0 k = Dual2(1.3395, ["k"], [1.0], [0.0]) - _, result = _d_sabr_d_k_or_f(k, f, t, a, b, p, v, 1) - _, r1 = _d_sabr_d_k_or_f(k, f, t, a, b, p + 1e-4, v, 1) - _, r_1 = _d_sabr_d_k_or_f(k, f, t, a, b, p - 1e-4, v, 1) + _, result = _SabrModel._d_sabr_d_k_or_f(k, f, t, a, b, p, v, 1) + _, r1 = _SabrModel._d_sabr_d_k_or_f(k, f, t, a, b, p + 1e-4, v, 1) + _, r_1 = _SabrModel._d_sabr_d_k_or_f(k, f, t, a, b, p - 1e-4, v, 1) expected = (r1 - r_1) / (2e-4) result = gradient(result, ["p"])[0] assert abs(result - expected) < 1e-9 - _, result = _d_sabr_d_k_or_f(k, f, t, a, b, p, v, 1) - _, r1 = _d_sabr_d_k_or_f(k, f, t, a, b, p + 1e-4, v, 1) - _, r_1 = _d_sabr_d_k_or_f(k, f, t, a, b, p - 1e-4, v, 1) + _, result = _SabrModel._d_sabr_d_k_or_f(k, f, t, a, b, p, v, 1) + _, r1 = _SabrModel._d_sabr_d_k_or_f(k, f, t, a, b, p + 1e-4, v, 1) + _, r_1 = _SabrModel._d_sabr_d_k_or_f(k, f, t, a, b, p - 1e-4, v, 1) expected = (r1 - 2 * result + r_1) / (1e-8) result = gradient(result, ["p"], order=2)[0][0] assert abs(result - expected) < 1e-8 diff --git a/python/tests/test_ir_volatility.py b/python/tests/test_ir_volatility.py new file mode 100644 index 000000000..c13a4e37f --- /dev/null +++ b/python/tests/test_ir_volatility.py @@ -0,0 +1,1491 @@ +# SPDX-License-Identifier: LicenseRef-Rateslib-Dual +# +# Copyright (c) 2026 Siffrorna Technology Limited +# +# Dual-licensed: Free Educational Licence or Paid Commercial Licence (commercial/professional use) +# Source-available, not open source. +# +# See LICENSE and https://rateslib.com/py/en/latest/i_licence.html for details, +# and/or contact info (at) rateslib (dot) com +#################################################################################################### + +from datetime import datetime as dt +from itertools import combinations + +import numpy as np +import pytest +from matplotlib import pyplot as plt +from pandas import DataFrame, Index, Series +from pandas.testing import assert_frame_equal, assert_series_equal +from rateslib import default_context +from rateslib.curves import CompositeCurve, Curve, LineCurve +from rateslib.data.fixings import IRSSeries +from rateslib.default import NoInput +from rateslib.dual import Dual, Dual2, Variable, gradient +from rateslib.volatility import ( + IRSabrCube, + IRSabrSmile, +) +from rateslib.volatility.ir.utils import _bilinear_interp +from rateslib.volatility.utils import _SabrSmileNodes + + +@pytest.mark.parametrize( + ("h", "v", "expected"), + [ + ((1, 1), (1, 1), 10), + ((0.5, 0.5), (0.5, 0.5), 5.0), + ((0.0, 0.0), (0.0, 0.0), 0.0), + ((0.0, 0.5), (0.0, 0.0), 0.0), + ((0.0, 0.0), (0.8, 0.4), 4.80), + ((0.1, 0.2), (0.4, 0.5), 4.0 * 0.1 * 0.5 + 6.0 * 0.8 * 0.4 + 10.0 * 0.2 * 0.5), + ], +) +def test_bilinear_interp(h, v, expected): + result = _bilinear_interp(0.0, 4.0, 6.0, 10.0, h, v) + assert abs(result - expected) < 1e-10 + + +def test_numpy_ravel_for_dates_posix(): + a = np.array([[1, 1, 2], [3, 4, 5]]) + b = np.reshape(list(a.ravel()), (2, 3)) + assert np.all(a == b) + + +@pytest.fixture +def curve(): + return Curve( + nodes={ + dt(2022, 3, 1): 1.00, + dt(2032, 3, 31): 0.50, + }, + interpolation="log_linear", + id="v", + convention="Act360", + ad=1, + ) + + +class TestIRSabrSmile: + @pytest.mark.parametrize( + ("strike", "vol"), + [ + (1.2034, 19.49), + (1.2050, 19.47), + (1.3395, 18.31), # f == k + (1.3620, 18.25), + (1.5410, 18.89), + (1.5449, 18.93), + ], + ) + def test_sabr_vol(self, strike, vol): + # repeat the same test developed for FXSabrSmile + irss = IRSabrSmile( + nodes={ + "alpha": 0.17431060, + "beta": 1.0, + "rho": -0.11268306, + "nu": 0.81694072, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="vol", + ) + result = irss.get_from_strike(k=strike, f=1.3395).vol + assert abs(result - vol) < 1e-2 + + def test_sabr_vol_plot(self): + # repeat the same test developed for FXSabrSmile + irss = IRSabrSmile( + nodes={ + "alpha": 0.17431060, + "beta": 1.0, + "rho": -0.11268306, + "nu": 0.81694072, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="vol", + ) + result = irss.plot(f=1.0) + _x = result[2][0]._x + _y = result[2][0]._y + assert (_x[0], _y[0]) == (0.7524348790033292, 23.108399874378378) + assert (_x[-1], _y[-1]) == (1.3743407823531082, 21.950871667495214) + + def test_sabr_vol_plot_fail(self): + # repeat the same test developed for FXSabrSmile + irss = IRSabrSmile( + nodes={ + "alpha": 0.17431060, + "beta": 1.0, + "rho": -0.11268306, + "nu": 0.81694072, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="vol", + ) + with pytest.raises(ValueError, match=r"`f` \(ATM-forward FX rate\) is required by"): + irss.plot() + + @pytest.mark.parametrize(("k", "f"), [(1.34, 1.34), (1.33, 1.35), (1.35, 1.33)]) + def test_sabr_vol_finite_diff_first_order(self, k, f): + # Test all of the first order gradients using finite diff, for the case when f != k and + # when f == k, which is a branched calculation to handle a undefined point. + irss = IRSabrSmile( + nodes={ + "alpha": 0.17431060, + "beta": 1.0, + "rho": -0.11268306, + "nu": 0.81694072, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="vol", + ad=2, + ) + # F_0,T is stated in section 3.5.4 as 1.3395 + base = irss.get_from_strike(k=Dual2(k, ["k"], [], []), f=Dual2(f, ["f"], [], [])).vol + + a = irss.nodes.alpha + p = irss.nodes.rho + v = irss.nodes.nu + + def inc_(key1, inc1): + in_ = {"k": k, "f": f, "alpha": a, "rho": p, "nu": v} + in_[key1] += inc1 + + irss._nodes = _SabrSmileNodes( + _alpha=in_["alpha"], _beta=1.0, _rho=in_["rho"], _nu=in_["nu"] + ) + _ = ( + irss._d_sabr_d_k_or_f( + Dual2(in_["k"], ["k"], [], []), + Dual2(in_["f"], ["f"], [], []), + dt(2002, 1, 1), + False, + 1, + )[0] + * 100.0 + ) + + # reset + irss._nodes = _SabrSmileNodes(_alpha=a, _beta=1.0, _rho=p, _nu=v) + return _ + + for key in ["k", "f", "alpha", "rho", "nu"]: + map_ = {"k": "k", "f": "f", "alpha": "vol0", "rho": "vol1", "nu": "vol2"} + up_ = inc_(key, 1e-5) + dw_ = inc_(key, -1e-5) + assert abs((up_ - dw_) / 2e-5 - gradient(base, [map_[key]])[0]) < 1e-5 + + @pytest.mark.parametrize( + ("k", "f"), [(1.34, 1.34), (1.33, 1.35), (1.35, 1.33), (1.3399, 1.34), (1.34, 1.3401)] + ) + @pytest.mark.parametrize("pair", list(combinations(["k", "f", "alpha", "rho", "nu"], 2))) + def test_sabr_vol_cross_finite_diff_second_order(self, k, f, pair): + # Test all of the second order cross gradients using finite diff, + # for the case when f != k and + # when f == k, which is a branched calculation to handle a undefined point. + irss = IRSabrSmile( + nodes={ + "alpha": 0.17431060, + "beta": 1.0, + "rho": -0.11268306, + "nu": 0.81694072, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="v", + ad=2, + ) + + a = irss.nodes.alpha + p = irss.nodes.rho + v = irss.nodes.nu + + # F_0,T is stated in section 3.5.4 as 1.3395 + base = irss.get_from_strike(k=Dual2(k, ["k"], [], []), f=Dual2(f, ["f"], [], [])).vol + + def inc_(key1, key2, inc1, inc2): + in_ = {"k": k, "f": f, "alpha": a, "rho": p, "nu": v} + in_[key1] += inc1 + in_[key2] += inc2 + + irss._nodes = _SabrSmileNodes( + _alpha=in_["alpha"], _beta=1.0, _rho=in_["rho"], _nu=in_["nu"] + ) + _ = ( + irss._d_sabr_d_k_or_f( + Dual2(in_["k"], ["k"], [], []), + Dual2(in_["f"], ["f"], [], []), + dt(2002, 1, 1), + False, + 1, + )[0] + * 100.0 + ) + + # reset + irss._nodes = _SabrSmileNodes(_alpha=a, _beta=1.0, _rho=p, _nu=v) + return _ + + v_map = {"k": "k", "f": "f", "alpha": "v0", "rho": "v1", "nu": "v2"} + + upup = inc_(pair[0], pair[1], 1e-3, 1e-3) + updown = inc_(pair[0], pair[1], 1e-3, -1e-3) + downup = inc_(pair[0], pair[1], -1e-3, 1e-3) + downdown = inc_(pair[0], pair[1], -1e-3, -1e-3) + expected = (upup + downdown - updown - downup) / 4e-6 + result = gradient(base, [v_map[pair[0]], v_map[pair[1]]], order=2)[0][1] + assert abs(result - expected) < 1e-2 + + @pytest.mark.parametrize( + ("k", "f"), [(1.34, 1.34), (1.33, 1.35), (1.35, 1.33), (1.3399, 1.34), (1.34, 1.3401)] + ) + @pytest.mark.parametrize("var", ["k", "f", "alpha", "rho", "nu"]) + def test_sabr_vol_same_finite_diff_second_order(self, k, f, var): + # Test all of the second order cross gradients using finite diff, + # for the case when f != k and + # when f == k, which is a branched calculation to handle a undefined point. + irss = IRSabrSmile( + nodes={ + "alpha": 0.17431060, + "beta": 1.0, + "rho": -0.11268306, + "nu": 0.81694072, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="v", + ad=2, + ) + + a = irss.nodes.alpha + p = irss.nodes.rho + v = irss.nodes.nu + + # F_0,T is stated in section 3.5.4 as 1.3395 + base = irss.get_from_strike(k=Dual2(k, ["k"], [], []), f=Dual2(f, ["f"], [], [])).vol + + def inc_(key1, inc1): + in_ = {"k": k, "f": f, "alpha": a, "rho": p, "nu": v} + in_[key1] += inc1 + + irss._nodes = _SabrSmileNodes( + _alpha=in_["alpha"], _beta=1.0, _rho=in_["rho"], _nu=in_["nu"] + ) + _ = ( + irss._d_sabr_d_k_or_f( + Dual2(in_["k"], ["k"], [], []), + Dual2(in_["f"], ["f"], [], []), + dt(2002, 1, 1), + False, + 1, + )[0] + * 100.0 + ) + + # reset + irss._nodes = _SabrSmileNodes(_alpha=a, _beta=1.0, _rho=p, _nu=v) + return _ + + v_map = {"k": "k", "f": "f", "alpha": "v0", "rho": "v1", "nu": "v2"} + + up = inc_(var, 1e-4) + down = inc_(var, -1e-4) + expected = (up + down - 2 * base) / 1e-8 + result = gradient(base, [v_map[var]], order=2)[0][0] + assert abs(result - expected) < 5e-3 + + def test_sabr_vol_root_multi_duals_neighbourhood(self): + # test the SABR function when regular arithmetic operations produce an undefined 0/0 + # value so AD has to be hard coded into the solution. This occurs when f == k. + # test by comparing derivatives with those captured at a nearby valid point + irss = IRSabrSmile( + nodes={ + "alpha": 0.17431060, + "beta": 1.0, + "rho": -0.11268306, + "nu": 0.81694072, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="v", + ad=2, + ) + # F_0,T is stated in section 3.5.4 as 1.3395 + base = irss.get_from_strike(k=Dual2(1.34, ["k"], [], []), f=Dual2(1.34, ["f"], [], [])).vol + comparison1 = irss.get_from_strike( + k=Dual2(1.341, ["k"], [], []), f=Dual2(1.34, ["f"], [], []) + ).vol + + assert np.all(abs(base.dual - comparison1.dual) < 1e-1) + diff = base.dual2 - comparison1.dual2 + dual2 = abs(diff) < 5e-1 + assert np.all(dual2) + + @pytest.mark.parametrize("param", ["alpha", "beta", "rho", "nu"]) + def test_missing_param_raises(self, param): + nodes = { + "alpha": 0.17431060, + "beta": 1.0, + "rho": -0.11268306, + "nu": 0.81694072, + } + nodes.pop(param) + with pytest.raises(ValueError): + IRSabrSmile( + nodes=nodes, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="v", + ad=2, + ) + + def test_non_iterable(self): + irss = IRSabrSmile( + nodes={ + "alpha": 0.17431060, + "beta": 1.0, + "rho": -0.11268306, + "nu": 0.81694072, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="v", + ad=2, + ) + with pytest.raises(TypeError): + list(irss) + + def test_update_node_raises(self): + irss = IRSabrSmile( + nodes={ + "alpha": 0.17431060, + "beta": 1.0, + "rho": -0.11268306, + "nu": 0.81694072, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="v", + ad=2, + ) + with pytest.raises(KeyError, match="'bananas' is not in `nodes`."): + irss.update_node("bananas", 12.0) + + def test_set_ad_order_raises(self): + irss = IRSabrSmile( + nodes={ + "alpha": 0.17431060, + "beta": 1.0, + "rho": -0.11268306, + "nu": 0.81694072, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="v", + ad=2, + ) + with pytest.raises(ValueError, match="`order` can only be in {0, 1, 2} "): + irss._set_ad_order(12) + + def test_get_node_vars_and_vector(self): + irss = IRSabrSmile( + nodes={ + "alpha": 0.20, + "beta": 1.0, + "rho": -0.10, + "nu": 0.80, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="myid", + ) + result = irss._get_node_vars() + expected = ("myid0", "myid1", "myid2") + assert result == expected + + result = irss._get_node_vector() + expected = np.array([0.20, -0.1, 0.80]) + assert np.all(result == expected) + + def test_get_from_strike_expiry_raises(self): + irss = IRSabrSmile( + nodes={ + "alpha": 0.20, + "beta": 1.0, + "rho": -0.10, + "nu": 0.80, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="myid", + ) + with pytest.raises(ValueError, match="`expiry` of VolSmile and OptionPeriod do not match"): + irss.get_from_strike(k=1.0, f=1.0, expiry=dt(1999, 1, 1)) + + @pytest.mark.parametrize("k", [1.2034, 1.2050, 1.3620, 1.5410, 1.5449]) + def test_get_from_strike_ad_2(self, k) -> None: + # Use finite diff to validate the 2nd order AD of the SABR function in alpha and rho. + irss = IRSabrSmile( + nodes={ + "alpha": 0.20, + "beta": 1.0, + "rho": -0.10, + "nu": 0.80, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="vol", + ad=2, + ) + + kwargs = dict( + k=k, + f=1.350, + ) + pv00 = irss.get_from_strike(**kwargs) + + irss.update_node("alpha", 0.20 + 0.00001) + irss.update_node("rho", -0.10 + 0.00001) + pv11 = irss.get_from_strike(**kwargs) + + irss.update_node("alpha", 0.20 + 0.00001) + irss.update_node("rho", -0.10 - 0.00001) + pv1_1 = irss.get_from_strike(**kwargs) + + irss.update_node("alpha", 0.20 - 0.00001) + irss.update_node("rho", -0.10 - 0.00001) + pv_1_1 = irss.get_from_strike(**kwargs) + + irss.update_node("alpha", 0.20 - 0.00001) + irss.update_node("rho", -0.10 + 0.00001) + pv_11 = irss.get_from_strike(**kwargs) + + finite_diff = (pv11.vol + pv_1_1.vol - pv1_1.vol - pv_11.vol) * 1e10 / 4.0 + ad_grad = gradient(pv00.vol, ["vol0", "vol1"], 2)[0, 1] + + assert abs(finite_diff - ad_grad) < 1e-4 + + @pytest.mark.parametrize(("k", "f"), [(1.34, 1.34), (1.33, 1.35), (1.35, 1.33)]) + def test_sabr_derivative_finite_diff_first_order(self, k, f): + # Test all of the first order gradients using finite diff, for the case when f != k and + # when f == k, which is a branched calculation to handle a undefined point. + irss = IRSabrSmile( + nodes={ + "alpha": 0.20, + "beta": 1.0, + "rho": -0.10, + "nu": 0.80, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="vol", + ad=2, + ) + t = dt(2002, 1, 1) + base = irss._d_sabr_d_k_or_f( + Dual2(k, ["k"], [1.0], []), Dual2(f, ["f"], [1.0], []), t, False, 1 + )[1] + + a = irss.nodes.alpha + p = irss.nodes.rho + v = irss.nodes.nu + + def inc_(key1, inc1): + in_ = {"k": k, "f": f, "alpha": a, "rho": p, "nu": v} + in_[key1] += inc1 + + irss._nodes = _SabrSmileNodes( + _alpha=in_["alpha"], _beta=1.0, _rho=in_["rho"], _nu=in_["nu"] + ) + _ = irss._d_sabr_d_k_or_f( + Dual2(in_["k"], ["k"], [], []), + Dual2(in_["f"], ["f"], [], []), + dt(2002, 1, 1), + False, + 1, + )[1] + + # reset + irss._nodes = _SabrSmileNodes(_alpha=a, _beta=1.0, _rho=p, _nu=v) + return _ + + for key in ["k", "f", "alpha", "rho", "nu"]: + map_ = {"k": "k", "f": "f", "alpha": "vol0", "rho": "vol1", "nu": "vol2"} + up_ = inc_(key, 1e-5) + dw_ = inc_(key, -1e-5) + expected = (up_ - dw_) / 2e-5 + result = gradient(base, [map_[key]])[0] + assert abs(expected - result) < 7e-3 + + @pytest.mark.parametrize( + ("k", "f"), [(1.34, 1.34), (1.33, 1.35), (1.35, 1.33), (1.3395, 1.34), (1.34, 1.3405)] + ) + @pytest.mark.parametrize("pair", list(combinations(["k", "f", "alpha", "rho", "nu"], 2))) + def test_sabr_derivative_cross_finite_diff_second_order(self, k, f, pair): + # Test all of the second order cross gradients using finite diff, + # for the case when f != k and + # when f == k, which is a branched calculation to handle a undefined point. + irss = IRSabrSmile( + nodes={ + "alpha": 0.20, + "beta": 1.0, + "rho": -0.10, + "nu": 0.80, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="v", + ad=2, + ) + + a = irss.nodes.alpha + p = irss.nodes.rho + v = irss.nodes.nu + + # F_0,T is stated in section 3.5.4 as 1.3395 + base = irss._d_sabr_d_k_or_f( + Dual2(k, ["k"], [], []), Dual2(f, ["f"], [], []), dt(2002, 1, 1), False, 1 + )[1] + + def inc_(key1, key2, inc1, inc2): + in_ = {"k": k, "f": f, "alpha": a, "rho": p, "nu": v} + in_[key1] += inc1 + in_[key2] += inc2 + + irss._nodes = _SabrSmileNodes( + _alpha=in_["alpha"], _beta=1.0, _rho=in_["rho"], _nu=in_["nu"] + ) + _ = irss._d_sabr_d_k_or_f( + Dual2(in_["k"], ["k"], [], []), + Dual2(in_["f"], ["f"], [], []), + dt(2002, 1, 1), + False, + 1, + )[1] + + # reset + irss._nodes = _SabrSmileNodes(_alpha=a, _beta=1.0, _rho=p, _nu=v) + return _ + + v_map = {"k": "k", "f": "f", "alpha": "v0", "rho": "v1", "nu": "v2"} + + upup = inc_(pair[0], pair[1], 1e-3, 1e-3) + updown = inc_(pair[0], pair[1], 1e-3, -1e-3) + downup = inc_(pair[0], pair[1], -1e-3, 1e-3) + downdown = inc_(pair[0], pair[1], -1e-3, -1e-3) + expected = (upup + downdown - updown - downup) / 4e-6 + result = gradient(base, [v_map[pair[0]], v_map[pair[1]]], order=2)[0][1] + assert abs(result - expected) < 5e-3 + + @pytest.mark.parametrize( + ("k", "f"), + [(1.34, 1.34), (1.33, 1.35), (1.35, 1.33), (1.3395, 1.34), (1.34, 1.3405)], + ) + @pytest.mark.parametrize("var", ["k", "f", "alpha", "rho", "nu"]) + def test_sabr_derivative_same_finite_diff_second_order(self, k, f, var): + # Test all of the second order cross gradients using finite diff, + # for the case when f != k and + # when f == k, which is a branched calculation to handle a undefined point. + irss = IRSabrSmile( + nodes={ + "alpha": 0.20, + "beta": 1.0, + "rho": -0.10, + "nu": 0.80, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="v", + ad=2, + ) + + a = irss.nodes.alpha + p = irss.nodes.rho + v = irss.nodes.nu + + # F_0,T is stated in section 3.5.4 as 1.3395 + base = irss._d_sabr_d_k_or_f( + Dual2(k, ["k"], [], []), Dual2(f, ["f"], [], []), dt(2002, 1, 1), False, 1 + )[1] + + def inc_(key1, inc1): + k_ = k + f_ = f + if key1 == "k": + k_ = k + inc1 + elif key1 == "f": + f_ = f + inc1 + else: + irss.update_node(key1, getattr(irss.nodes, key1) + inc1) + # irss.nodes[key1] = irss.nodes[key1] + inc1 + + _ = irss._d_sabr_d_k_or_f( + Dual2(k_, ["k"], [], []), Dual2(f_, ["f"], [], []), dt(2002, 1, 1), False, 1 + )[1] + + irss._nodes = _SabrSmileNodes(_alpha=a, _beta=1.0, _rho=p, _nu=v) + return _ + + v_map = {"k": "k", "f": "f", "alpha": "v0", "rho": "v1", "nu": "v2"} + + up = inc_(var, 1e-3) + down = inc_(var, -1e-3) + expected = (up + down - 2 * base) / 1e-6 + result = gradient(base, [v_map[var]], order=2)[0][0] + assert abs(result - expected) < 3e-3 + + def test_sabr_derivative_root_multi_duals_neighbourhood(self): + # test the SABR function when regular arithmetic operations produce an undefined 0/0 + # value so AD has to be hard coded into the solution. This occurs when f == k. + # test by comparing derivatives with those captured at a nearby valid point + irss = IRSabrSmile( + nodes={ + "alpha": 0.20, + "beta": 1.0, + "rho": -0.10, + "nu": 0.80, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="v", + ad=2, + ) + # F_0,T is stated in section 3.5.4 as 1.3395 + base = irss._d_sabr_d_k_or_f( + Dual2(1.34, ["k"], [], []), Dual2(1.34, ["f"], [], []), dt(2002, 1, 1), False, 1 + )[1] + comparison1 = irss._d_sabr_d_k_or_f( + Dual2(1.341, ["k"], [], []), Dual2(1.34, ["f"], [], []), dt(2002, 1, 1), False, 1 + )[1] + + assert np.all(abs(base.dual - comparison1.dual) < 5e-3) + diff = base.dual2 - comparison1.dual2 + dual2 = abs(diff) < 3e-2 + assert np.all(dual2) + + # + # def test_plot_domain(self): + # ss = FXSabrSmile( + # eval_date=dt(2024, 5, 28), + # expiry=dt(2054, 5, 28), + # nodes={"alpha": 0.02, "beta": 1.0, "rho": 0.01, "nu": 0.05}, + # ) + # ax, fig, lines = ss.plot(f=1.60) + # assert abs(lines[0]._x[0] - 1.3427) < 1e-4 + # assert abs(lines[0]._x[-1] - 1.9299) < 1e-4 + # assert abs(lines[0]._y[0] - 2.0698) < 1e-4 + # assert abs(lines[0]._y[-1] - 2.0865) < 1e-4 + # + + # + # def test_solver_variable_numbers(self): + # from rateslib import IRS, FXBrokerFly, FXCall, FXRiskReversal, FXStraddle, FXSwap, Solver + # + # usdusd = Curve({dt(2024, 5, 7): 1.0, dt(2024, 5, 30): 1.0}, calendar="nyc", id="usdusd") + # eureur = Curve({dt(2024, 5, 7): 1.0, dt(2024, 5, 30): 1.0}, calendar="tgt", id="eureur") + # eurusd = Curve({dt(2024, 5, 7): 1.0, dt(2024, 5, 30): 1.0}, id="eurusd") + # + # # Create an FX Forward market with spot FX rate data + # fxr = FXRates({"eurusd": 1.0760}, settlement=dt(2024, 5, 9)) + # fxf = FXForwards( + # fx_rates=fxr, + # fx_curves={"eureur": eureur, "usdusd": usdusd, "eurusd": eurusd}, + # ) + # + # pre_solver = Solver( + # curves=[eureur, eurusd, usdusd], + # instruments=[ + # IRS(dt(2024, 5, 9), "3W", spec="eur_irs", curves="eureur"), + # IRS(dt(2024, 5, 9), "3W", spec="usd_irs", curves="usdusd"), + # FXSwap( + # dt(2024, 5, 9), "3W", pair="eurusd", curves=[None, "eurusd", None, "usdusd"] + # ), + # ], + # s=[3.90, 5.32, 8.85], + # fx=fxf, + # id="rates_sv", + # ) + # + # dv_smile = FXSabrSmile( + # nodes={"alpha": 0.05, "beta": 1.0, "rho": 0.01, "nu": 0.03}, + # eval_date=dt(2024, 5, 7), + # expiry=dt(2024, 5, 28), + # id="eurusd_3w_smile", + # pair="eurusd", + # ) + # option_args = dict( + # pair="eurusd", + # expiry=dt(2024, 5, 28), + # calendar="tgt|fed", + # delta_type="spot", + # curves=["eurusd", "usdusd"], + # vol="eurusd_3w_smile", + # ) + # + # dv_solver = Solver( + # pre_solvers=[pre_solver], + # curves=[dv_smile], + # instruments=[ + # FXStraddle(strike="atm_delta", **option_args), + # FXRiskReversal(strike=("-25d", "25d"), **option_args), + # FXRiskReversal(strike=("-10d", "10d"), **option_args), + # FXBrokerFly(strike=(("-25d", "25d"), "atm_delta"), **option_args), + # FXBrokerFly(strike=(("-10d", "10d"), "atm_delta"), **option_args), + # ], + # s=[5.493, -0.157, -0.289, 0.071, 0.238], + # fx=fxf, + # id="dv_solver", + # ) + # + # fc = FXCall( + # expiry=dt(2024, 5, 28), + # pair="eurusd", + # strike=1.07, + # notional=100e6, + # curves=["eurusd", "usdusd"], + # vol="eurusd_3w_smile", + # premium=98.216647 * 1e8 / 1e4, + # premium_ccy="usd", + # delta_type="spot", + # ) + # fc.delta(solver=dv_solver) + # + @pytest.mark.parametrize("a", [0.02, 0.06]) + @pytest.mark.parametrize("b", [0.0, 0.4, 0.65, 1.0]) + @pytest.mark.parametrize("p", [-0.1, 0.1]) + @pytest.mark.parametrize("v", [0.05, 0.15]) + @pytest.mark.parametrize("k", [1.05, 1.25, 1.6]) + def test_sabr_function_values(self, a, b, p, v, k): + irss = IRSabrSmile( + nodes={ + "alpha": a, + "beta": b, + "rho": p, + "nu": v, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="v", + ad=2, + ) + + # this code is taken from PySabr, another library implementing SABR. + # it is used as a benchmark + def _x(rho, z): + """Return function x used in Hagan's 2002 SABR lognormal vol expansion.""" + a = (1 - 2 * rho * z + z**2) ** 0.5 + z - rho + b = 1 - rho + return np.log(a / b) + + def lognormal_vol(k, f, t, alpha, beta, rho, volvol): + """ + Hagan's 2002 SABR lognormal vol expansion. + + The strike k can be a scalar or an array, the function will return an array + of lognormal vols. + """ + # Negative strikes or forwards + if k <= 0 or f <= 0: + return 0.0 + eps = 1e-07 + logfk = np.log(f / k) + fkbeta = (f * k) ** (1 - beta) + a = (1 - beta) ** 2 * alpha**2 / (24 * fkbeta) + b = 0.25 * rho * beta * volvol * alpha / fkbeta**0.5 + c = (2 - 3 * rho**2) * volvol**2 / 24 + d = fkbeta**0.5 + v = (1 - beta) ** 2 * logfk**2 / 24 + w = (1 - beta) ** 4 * logfk**4 / 1920 + z = volvol * fkbeta**0.5 * logfk / alpha + # if |z| > eps + if abs(z) > eps: + vz = alpha * z * (1 + (a + b + c) * t) / (d * (1 + v + w) * _x(rho, z)) + return vz + # if |z| <= eps + else: + v0 = alpha * (1 + (a + b + c) * t) / (d * (1 + v + w)) + return v0 + + expected = lognormal_vol(k, 1.25, 1.0, a, b, p, v) + result = irss.get_from_strike(k=k, f=1.25).vol / 100.0 + + assert abs(result - expected) < 1e-4 + + def test_init_raises_key(self): + with pytest.raises( + ValueError, match=r"'nu' is a required SABR parameter that must be inclu" + ): + IRSabrSmile( + nodes={ + "alpha": 0.05, + "beta": -0.03, + "rho": 0.1, + "bad": 0.1, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="v", + ad=2, + ) + + def test_attributes(self): + irss = IRSabrSmile( + nodes={ + "alpha": 0.05, + "beta": -0.03, + "rho": 0.1, + "nu": 0.1, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="v", + ad=2, + ) + assert irss._n == 4 + + def test_get_from_strike_with_curves(self): + curve = Curve({dt(2001, 1, 1): 1.0, dt(2003, 1, 1): 0.94}) + irss = IRSabrSmile( + nodes={ + "alpha": 0.05, + "beta": -0.03, + "rho": 0.1, + "nu": 0.1, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="v", + ) + result = irss.get_from_strike(k=3.0, curves=[curve]) + assert abs(result.f - 3.142139380) < 1e-6 + assert abs(result.vol - 1.575277) < 1e-4 + + def test_set_node_vector(self): + irss = IRSabrSmile( + nodes={ + "alpha": 0.05, + "beta": -0.03, + "rho": 0.1, + "nu": 0.1, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + ad=2, + id="v", + ) + irss._set_node_vector(np.array([1.0, 2.0, 3.0]), ad=1) + assert irss.nodes.alpha == Dual(1.0, ["v0"], []) + assert irss.nodes.rho == Dual(2.0, ["v1"], []) + assert irss.nodes.nu == Dual(3.0, ["v2"], []) + + +class TestIRSabrCube: + def test_init(self): + IRSabrCube( + eval_date=dt(2026, 2, 16), + expiries=["1m", "3m"], + tenors=["1Y", "2y", "3y"], + irs_series="usd_irs", + id="usd_ir_vol", + beta=0.5, + alphas=np.array([[0.1, 0.2, 0.3], [0.11, 0.12, 0.13]]), + rhos=np.array([[0.1, 0.2, 0.3], [0.11, 0.12, 0.13]]), + nus=np.array([[0.1, 0.2, 0.3], [0.11, 0.12, 0.13]]), + ) + pass + + @pytest.mark.parametrize(("ad", "klass"), [(1, Dual), (2, Dual2)]) + def test_constructed_sabr_smile_vars(self, ad, klass): + irsc = IRSabrCube( + eval_date=dt(2026, 2, 20), + expiries=["1m", "3m"], + tenors=["2y", "5y"], + irs_series="usd_irs", + beta=0.5, + alphas=0.05, + rhos=-0.01, + nus=0.01, + ad=ad, + id="my-c", + ) + _ = irsc.get_from_strike(k=1.0, f=1.02, expiry=dt(2026, 3, 30), tenor=dt(2028, 8, 12)) + smile = irsc._cache[(dt(2026, 3, 30), dt(2028, 8, 12))] + assert smile.nodes.alpha.vars == ["my-c_a_0", "my-c_a_1", "my-c_a_2", "my-c_a_3"] + assert smile.nodes.rho.vars == ["my-c_p_0", "my-c_p_1", "my-c_p_2", "my-c_p_3"] + assert smile.nodes.nu.vars == ["my-c_v_0", "my-c_v_1", "my-c_v_2", "my-c_v_3"] + assert isinstance(smile.nodes.alpha, klass) + + @pytest.mark.parametrize( + ("expiry", "tenor", "expected"), + [ + # tests on a node directly + (dt(2001, 1, 1), dt(2002, 1, 1), (0.1, 1.0, 10.0)), + (dt(2002, 1, 1), dt(2003, 1, 1), (0.3, 3.0, 30.0)), + (dt(2001, 1, 1), dt(2003, 1, 1), (0.2, 2.0, 20.0)), + (dt(2002, 1, 1), dt(2004, 1, 1), (0.4, 4.0, 40.0)), + # test within bounds + ( + dt(2001, 4, 1), + dt(2002, 7, 1), + (0.17424657534246576, 1.7424657534246577, 17.424657534246577), + ), + ( + dt(2001, 4, 1), + dt(2003, 1, 1), + (0.22465753424657536, 2.2465753424657535, 22.46575342465753), + ), + ( + dt(2001, 10, 1), + dt(2003, 1, 1), + (0.27479452054794523, 2.747945205479452, 27.47945205479452), + ), + ( + dt(2001, 10, 1), + dt(2003, 7, 1), + (0.32438356164383564, 3.243835616438356, 32.43835616438356), + ), + # test out of bounds + (dt(2000, 7, 1), dt(2001, 1, 1), (0.1, 1.0, 10.0)), # 6m6m + ( + dt(2000, 7, 1), + dt(2002, 1, 1), + (0.1504109589041096, 1.504109589041096, 15.04109589041096), + ), # 6m18m + (dt(2000, 7, 1), dt(2003, 7, 1), (0.2, 2.0, 20.0)), # 6m3y + ( + dt(2001, 7, 1), + dt(2002, 1, 1), + (0.1991780821917808, 1.9917808219178081, 19.91780821917808), + ), # 18m6m + ( + dt(2001, 7, 1), + dt(2004, 7, 1), + (0.2991780821917808, 2.991780821917808, 29.91780821917808), + ), # 18m3y + (dt(2003, 1, 1), dt(2003, 7, 1), (0.30, 3.0, 30.0)), # 3y6m + ( + dt(2003, 1, 1), + dt(2004, 7, 1), + (0.34986301369863015, 3.4986301369863018, 34.986301369863014), + ), # 3y18m + (dt(2003, 1, 1), dt(2006, 1, 1), (0.4, 4.0, 40.0)), # 3y3y + ], + ) + def test_interpolation_boundaries(self, expiry, tenor, expected): + # test that the SabrCube will interpolate the parameters if the expiry and tenors are + # - exactly falling on node dates + # - some elements within the node-mesh + # - some elements outside the node-mesh which are mapped to nearest components. + irsc = IRSabrCube( + eval_date=dt(2000, 1, 1), + expiries=["1y", "2y"], + tenors=["1y", "2y"], + irs_series=IRSSeries( + currency="usd", + settle=0, + frequency="A", + convention="Act360", + calendar="all", + leg2_fixing_method="ibor(2)", + ), + beta=0.5, + alphas=np.array([[0.1, 0.2], [0.3, 0.4]]), + rhos=np.array([[1.0, 2.0], [3.0, 4.0]]), + nus=np.array([[10.0, 20.0], [30.0, 40.0]]), + id="my-c", + ) + result = irsc._bilinear_interpolation(expiry=expiry, tenor=tenor) + assert result == expected + + @pytest.mark.parametrize( + ("expiry", "tenor", "expected"), + [ + (dt(2000, 7, 1), dt(2001, 1, 1), (0.1, 1.0, 10.0)), + (dt(2000, 7, 1), dt(2001, 7, 1), (0.1, 1.0, 10.0)), + ( + dt(2000, 7, 1), + dt(2002, 1, 1), + (0.1504109589041096, 1.504109589041096, 15.04109589041096), + ), + (dt(2000, 7, 1), dt(2003, 7, 1), (0.2, 2.0, 20.0)), + (dt(2001, 1, 1), dt(2001, 7, 1), (0.1, 1.0, 10.0)), + (dt(2001, 1, 1), dt(2002, 1, 1), (0.1, 1.0, 10.0)), + ( + dt(2001, 1, 1), + dt(2002, 7, 1), + (0.1495890410958904, 1.495890410958904, 14.95890410958904), + ), + (dt(2001, 1, 1), dt(2003, 7, 1), (0.2, 2.0, 20.0)), + (dt(2002, 1, 1), dt(2002, 7, 1), (0.1, 1.0, 10.0)), + (dt(2002, 1, 1), dt(2003, 1, 1), (0.1, 1.0, 10.0)), + ( + dt(2002, 1, 1), + dt(2003, 7, 1), + (0.1495890410958904, 1.495890410958904, 14.95890410958904), + ), + (dt(2002, 1, 1), dt(2004, 7, 1), (0.2, 2.0, 20.0)), + ], + ) + def test_interpolation_single_expiry(self, expiry, tenor, expected): + # test that the SabrCube will interpolate the parameters if the expiry and tenors are + # - exactly falling on node dates + # - some elements within the node-mesh + # - some elements outside the node-mesh which are mapped to nearest components. + irsc = IRSabrCube( + eval_date=dt(2000, 1, 1), + expiries=["1y"], + tenors=["1y", "2y"], + irs_series=IRSSeries( + currency="usd", + settle=0, + frequency="A", + convention="Act360", + calendar="all", + leg2_fixing_method="ibor(2)", + ), + beta=0.5, + alphas=np.array([[0.1, 0.2]]), + rhos=np.array([[1.0, 2.0]]), + nus=np.array([[10.0, 20.0]]), + id="my-c", + ) + result = irsc._bilinear_interpolation(expiry=expiry, tenor=tenor) + assert result == expected + + @pytest.mark.parametrize( + ("expiry", "tenor", "expected"), + [ + (dt(2000, 7, 1), dt(2001, 1, 1), (0.1, 1.0, 10.0)), + (dt(2000, 7, 1), dt(2001, 7, 1), (0.1, 1.0, 10.0)), + (dt(2000, 7, 1), dt(2002, 1, 1), (0.1, 1.0, 10.0)), + (dt(2001, 1, 1), dt(2001, 7, 1), (0.1, 1.0, 10.0)), + (dt(2001, 1, 1), dt(2002, 1, 1), (0.1, 1.0, 10.0)), + (dt(2001, 1, 1), dt(2002, 7, 1), (0.1, 1.0, 10.0)), + ( + dt(2001, 7, 1), + dt(2002, 1, 1), + (0.1495890410958904, 1.495890410958904, 14.95890410958904), + ), + ( + dt(2001, 7, 1), + dt(2002, 7, 1), + (0.1495890410958904, 1.495890410958904, 14.95890410958904), + ), + ( + dt(2001, 7, 1), + dt(2003, 1, 1), + (0.1495890410958904, 1.495890410958904, 14.95890410958904), + ), + (dt(2002, 7, 1), dt(2003, 1, 1), (0.2, 2.0, 20.0)), + (dt(2002, 7, 1), dt(2003, 7, 1), (0.2, 2.0, 20.0)), + (dt(2002, 7, 1), dt(2004, 7, 1), (0.2, 2.0, 20.0)), + ], + ) + def test_interpolation_single_tenor(self, expiry, tenor, expected): + # test that the SabrCube will interpolate the parameters if the expiry and tenors are + # - exactly falling on node dates + # - some elements within the node-mesh + # - some elements outside the node-mesh which are mapped to nearest components. + irsc = IRSabrCube( + eval_date=dt(2000, 1, 1), + expiries=["1y", "2y"], + tenors=["1y"], + irs_series=IRSSeries( + currency="usd", + settle=0, + frequency="A", + convention="Act360", + calendar="all", + leg2_fixing_method="ibor(2)", + ), + beta=0.5, + alphas=np.array([[0.1], [0.2]]), + rhos=np.array([[1.0], [2.0]]), + nus=np.array([[10.0], [20.0]]), + id="my-c", + ) + result = irsc._bilinear_interpolation(expiry=expiry, tenor=tenor) + assert result == expected + + def test_alphas(self): + irsc = IRSabrCube( + eval_date=dt(2026, 2, 16), + expiries=["1m", "3m"], + tenors=["1Y", "2Y"], + irs_series="usd_irs", + id="usd_ir_vol", + beta=0.5, + alphas=np.array([[0.1, 0.2], [0.11, 0.12]]), + rhos=np.array([[0.1, 0.3], [0.11, 0.12]]), + nus=np.array([[0.1, 0.4], [0.11, 0.12]]), + ) + expected = DataFrame( + index=Index(["1m", "3m"], name="expiry"), + columns=Index(["1Y", "2Y"], name="tenor"), + data=[[0.1, 0.2], [0.11, 0.12]], + dtype=object, + ) + assert_frame_equal(expected, irsc.alpha) + expected = DataFrame( + index=Index(["1m", "3m"], name="expiry"), + columns=Index(["1Y", "2Y"], name="tenor"), + data=[[0.1, 0.3], [0.11, 0.12]], + dtype=object, + ) + assert_frame_equal(expected, irsc.rho) + expected = DataFrame( + index=Index(["1m", "3m"], name="expiry"), + columns=Index(["1Y", "2Y"], name="tenor"), + data=[[0.1, 0.4], [0.11, 0.12]], + dtype=object, + ) + assert_frame_equal(expected, irsc.nu) + assert irsc._n == 12 + + def test_cache(self): + irsc = IRSabrCube( + eval_date=dt(2026, 2, 16), + expiries=["1m", "3m"], + tenors=["1Y", "2Y"], + irs_series="usd_irs", + id="usd_ir_vol", + beta=0.5, + alphas=np.array([[0.1, 0.2], [0.11, 0.12]]), + rhos=np.array([[0.1, 0.3], [0.11, 0.12]]), + nus=np.array([[0.1, 0.4], [0.11, 0.12]]), + ) + irsc.get_from_strike(k=1.02, f=1.04, expiry=dt(2026, 3, 30), tenor=dt(2027, 8, 12)) + assert (dt(2026, 3, 30), dt(2027, 8, 12)) in irsc._cache + + +class TestStateAndCache: + @pytest.mark.parametrize( + "obj", + [ + IRSabrSmile( + nodes={ + "alpha": 0.1, + "beta": 0.5, + "rho": -0.05, + "nu": 0.1, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="v", + ad=2, + ), + IRSabrCube( + eval_date=dt(2026, 2, 16), + expiries=["1m", "3m"], + tenors=["1Y", "2y", "3y"], + irs_series="usd_irs", + id="usd_ir_vol", + beta=0.5, + alphas=np.array([[0.1, 0.2, 0.3], [0.11, 0.12, 0.13]]), + rhos=np.array([[0.1, 0.2, 0.3], [0.11, 0.12, 0.13]]), + nus=np.array([[0.1, 0.2, 0.3], [0.11, 0.12, 0.13]]), + ), + ], + ) + @pytest.mark.parametrize(("method", "args"), [("_set_ad_order", (1,))]) + def test_method_does_not_change_state(self, obj, method, args): + before = obj._state + getattr(obj, method)(*args) + after = obj._state + assert before == after + + @pytest.mark.parametrize( + "obj", + [ + IRSabrSmile( + nodes={ + "alpha": 0.1, + "beta": 0.5, + "rho": -0.05, + "nu": 0.1, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="v", + ad=2, + ), + ], + ) + @pytest.mark.parametrize( + ("method", "args"), + [ + ("_set_node_vector", ([0.99, 0.98, 0.99], 1)), + ("update_node", ("alpha", 0.98)), + ], + ) + def test_method_changes_state(self, obj, method, args): + before = obj._state + getattr(obj, method)(*args) + after = obj._state + assert before != after + + @pytest.mark.parametrize( + "curve", + [ + IRSabrSmile( + nodes={ + "alpha": 0.1, + "beta": 0.5, + "rho": -0.05, + "nu": 0.1, + }, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="v", + ad=2, + ), + IRSabrCube( + eval_date=dt(2026, 2, 16), + expiries=["1m"], + tenors=["1Y"], + irs_series="usd_irs", + id="usd_ir_vol", + beta=0.5, + alphas=np.array([[0.1]]), + rhos=np.array([[0.2]]), + nus=np.array([[0.3]]), + ), + ], + ) + @pytest.mark.parametrize( + ("method", "args"), + [ + ("_set_node_vector", ([0.99, 0.98, 0.99], 1)), + ("update_node", ("alpha", 0.98)), + ], + ) + def test_method_changes_state_sabr(self, curve, method, args): + before = curve._state + getattr(curve, method)(*args) + after = curve._state + assert before != after + + # + # def test_populate_cache(self): + # # objects have yet to implement cache handling + # pass + # + # def test_method_clears_cache(self): + # # objects have yet to implement cache handling + # pass + # + @pytest.mark.parametrize( + ("method", "args"), + [ + ("_set_node_vector", ([0.99, 0.98, 1.0], 1)), + ("_set_ad_order", (2,)), + ], + ) + def test_surface_clear_cache(self, method, args): + surf = IRSabrCube( + eval_date=dt(2026, 2, 16), + expiries=["1m"], + tenors=["1Y"], + irs_series="usd_irs", + id="usd_ir_vol", + beta=0.5, + alphas=np.array([[0.1]]), + rhos=np.array([[0.2]]), + nus=np.array([[0.3]]), + ) + surf.get_from_strike(f=1.0, k=1.01, expiry=dt(2026, 3, 1), tenor=dt(2027, 3, 1)) + assert (dt(2026, 3, 1), dt(2027, 3, 1)) in surf._cache + + getattr(surf, method)(*args) + assert len(surf._cache) == 0 + + +# +# @pytest.mark.parametrize( +# ("method", "args"), +# [ +# ("get_from_strike", (1.0, 1.0, dt(2000, 5, 3), NoInput(0))), +# ("_get_index", (0.9, dt(2000, 5, 3))), +# ("get_smile", (dt(2000, 5, 3),)), +# ], +# ) +# def test_surface_populate_cache(self, method, args): +# surf = FXDeltaVolSurface( +# expiries=[dt(2000, 1, 1), dt(2001, 1, 1)], +# delta_indexes=[0.5], +# node_values=[[10.0], [9.0]], +# eval_date=dt(1999, 1, 1), +# delta_type="forward", +# ) +# before = surf._cache_len +# getattr(surf, method)(*args) +# assert surf._cache_len == before + 1 +# +# @pytest.mark.parametrize( +# ("method", "args"), +# [ +# ("_set_node_vector", ([0.99, 0.98, 0.99, 0.99, 0.98, 0.99], 1)), +# ], +# ) +# @pytest.mark.parametrize( +# "surface", +# [ +# FXDeltaVolSurface( +# expiries=[dt(2000, 1, 1), dt(2001, 1, 1)], +# delta_indexes=[0.25, 0.5, 0.75], +# node_values=[[10.0, 9.0, 8.0], [9.0, 8.0, 7.0]], +# eval_date=dt(1999, 1, 1), +# delta_type="forward", +# ), +# FXSabrSurface( +# expiries=[dt(2000, 1, 1), dt(2001, 1, 1)], +# node_values=[[10.0, 1.0, 8.0, 9.0], [9.0, 1.0, 8.0, 7.0]], +# eval_date=dt(1999, 1, 1), +# ), +# ], +# ) +# def test_surface_change_state(self, method, args, surface): +# pre_state = surface._state +# getattr(surface, method)(*args) +# assert surface._state != pre_state +# +# @pytest.mark.parametrize( +# ("method", "args"), +# [ +# ("_set_ad_order", (2,)), +# ], +# ) +# @pytest.mark.parametrize( +# "surface", +# [ +# FXDeltaVolSurface( +# expiries=[dt(2000, 1, 1), dt(2001, 1, 1)], +# delta_indexes=[0.25, 0.5, 0.75], +# node_values=[[10.0, 9.0, 8.0], [9.0, 8.0, 7.0]], +# eval_date=dt(1999, 1, 1), +# delta_type="forward", +# ), +# FXSabrSurface( +# expiries=[dt(2000, 1, 1), dt(2001, 1, 1)], +# node_values=[[10.0, 1.0, 8.0, 9.0], [9.0, 1.0, 8.0, 7.0]], +# eval_date=dt(1999, 1, 1), +# ), +# ], +# ) +# def test_surface_maintain_state(self, method, args, surface): +# pre_state = surface._state +# getattr(surface, method)(*args) +# assert surface._state == pre_state +# +# def test_surface_validate_states(self): +# # test the get_smile method validates the states after a mutation +# surf = FXDeltaVolSurface( +# expiries=[dt(2000, 1, 1), dt(2001, 1, 1)], +# delta_indexes=[0.5], +# node_values=[[10.0], [9.0]], +# eval_date=dt(1999, 1, 1), +# delta_type="forward", +# ) +# pre_state = surf._state +# surf.smiles[0].update_node(0.5, 11.0) +# surf.get_smile(dt(2000, 1, 9)) +# post_state = surf._state +# assert pre_state != post_state # validate states has been run and updated the state. +# +# @pytest.mark.parametrize( +# "smile", +# [ +# FXDeltaVolSmile( +# nodes={0.25: 10.0, 0.5: 10.0, 0.75: 11.0}, +# delta_type="forward", +# eval_date=dt(2023, 3, 16), +# expiry=dt(2023, 6, 16), +# id="vol", +# ), +# FXSabrSmile( +# nodes={ +# "alpha": 0.17431060, +# "beta": 1.0, +# "rho": -0.11268306, +# "nu": 0.81694072, +# }, +# eval_date=dt(2023, 3, 16), +# expiry=dt(2023, 6, 16), +# id="vol", +# ), +# ], +# ) +# def test_initialisation_state_smile(self, smile): +# assert smile._state != 0 +# +# def test_initialisation_state_surface(self): +# surf = FXDeltaVolSurface( +# expiries=[dt(2000, 1, 1), dt(2001, 1, 1)], +# delta_indexes=[0.5], +# node_values=[[10.0], [9.0]], +# eval_date=dt(1999, 1, 1), +# delta_type="forward", +# ) +# assert surf._state != 0 +# +# +# def test_validate_delta_type() -> None: +# with pytest.raises(ValueError, match="`delta_type` as string: 'BAD_TYPE' i"): +# _get_fx_delta_type("BAD_TYPE") diff --git a/python/tests/test_solver.py b/python/tests/test_solver.py index 89353205b..ff827554f 100644 --- a/python/tests/test_solver.py +++ b/python/tests/test_solver.py @@ -24,7 +24,6 @@ from rateslib.default import NoInput from rateslib.dual import Dual, Dual2, Variable, gradient, ift_1dim, newton_1dim, newton_ndim from rateslib.fx import FXForwards, FXRates -from rateslib.fx_volatility import FXDeltaVolSmile, FXDeltaVolSurface, FXSabrSmile, FXSabrSurface from rateslib.instruments import ( IRS, XCS, @@ -39,6 +38,7 @@ Value, ) from rateslib.solver import Gradients, Solver +from rateslib.volatility import FXDeltaVolSmile, FXDeltaVolSurface, FXSabrSmile, FXSabrSurface class TestIFTSolver: diff --git a/python/tests/test_to_fix.py b/python/tests/test_to_fix.py index a04aa5862..86da4b4ac 100644 --- a/python/tests/test_to_fix.py +++ b/python/tests/test_to_fix.py @@ -13,7 +13,7 @@ import pytest from rateslib.dual import Dual -from rateslib.fx_volatility import FXDeltaVolSmile +from rateslib.volatility import FXDeltaVolSmile def test_fxsmile_update_node(): diff --git a/rust/enums/mod.rs b/rust/enums/mod.rs index 571e4e207..f05498d22 100644 --- a/rust/enums/mod.rs +++ b/rust/enums/mod.rs @@ -13,7 +13,8 @@ // pub mod docs; mod parameters; -pub use crate::enums::parameters::{FloatFixingMethod, LegIndexBase}; +pub use crate::enums::parameters::{FloatFixingMethod, IROptionMetric, LegIndexBase}; pub(crate) mod py; pub(crate) use crate::enums::py::PyFloatFixingMethod; +pub(crate) use crate::enums::py::PyIROptionMetric; diff --git a/rust/enums/parameters.rs b/rust/enums/parameters.rs index e67a7ffd7..a925a8c3c 100644 --- a/rust/enums/parameters.rs +++ b/rust/enums/parameters.rs @@ -53,6 +53,21 @@ impl FloatFixingMethod { } } +/// Specifier for the rate metric on IR Option types. +#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] +pub enum IROptionMetric { + /// Volatility expressed in normalized basis points, i.e. used in the Bachelier pricing model. + NormalVol {}, + /// Alias for BlackVolShift(0) + LogNormalVol {}, + /// Cash option premium expressed as a percentage of the notional. + PercentNotional {}, + /// Option premium expressed as a cash quantity. + Cash {}, + /// Log-normal Black volatility applying a basis-points shift to the forward and strike. + BlackVolShift(i32), +} + /// Enumerable type for index base determination on each Period in a Leg. #[pyclass(module = "rateslib.rs", eq, eq_int, hash, frozen, from_py_object)] #[derive(Debug, Hash, Copy, Clone, Serialize, Deserialize, PartialEq)] diff --git a/rust/enums/py/ir_option_metric.rs b/rust/enums/py/ir_option_metric.rs new file mode 100644 index 000000000..4f650966c --- /dev/null +++ b/rust/enums/py/ir_option_metric.rs @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: LicenseRef-Rateslib-Dual +// +// Copyright (c) 2026 Siffrorna Technology Limited +// This code cannot be used or copied externally +// +// Dual-licensed: Free Educational Licence or Paid Commercial Licence (commercial/professional use) +// Source-available, not open source. +// +// See LICENSE and https://rateslib.com/py/en/latest/i_licence.html for details, +// and/or contact info (at) rateslib (dot) com +//////////////////////////////////////////////////////////////////////////////////////////////////// + +//! Wrapper module to export to Python using pyo3 bindings. + +use crate::enums::IROptionMetric; +use crate::json::{DeserializedObj, JSON}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyTuple; +use serde::{Deserialize, Serialize}; + +/// Enumerable type for IR Option rate metrics. +/// +/// .. rubric:: Variants +/// +/// .. ipython:: python +/// :suppress: +/// +/// from rateslib.rs import IROptionMetric +/// variants = [item for item in IROptionMetric.__dict__ if \ +/// "__" != item[:2] and \ +/// item not in ['to_json', 'method_param'] \ +/// ] +/// +/// .. ipython:: python +/// +/// variants +/// +#[pyclass(module = "rateslib.rs", name = "IROptionMetric", eq, from_py_object)] +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub(crate) enum PyIROptionMetric { + #[pyo3(constructor = (_u8=0))] + NormalVol { _u8: u8 }, + #[pyo3(constructor = (_u8=1))] + LogNormalVol { _u8: u8 }, + #[pyo3(constructor = (_u8=2))] + PercentNotional { _u8: u8 }, + #[pyo3(constructor = (_u8=3))] + Cash { _u8: u8 }, + #[pyo3(constructor = (param, _u8=4))] + BlackVolShift { param: i32, _u8: u8 }, +} + +/// Used for providing pickle support for PyIROptionMetric +enum PyIROptionMetricNewArgs { + NoArgs(u8), + I32(i32, u8), +} + +impl<'py> IntoPyObject<'py> for PyIROptionMetricNewArgs { + type Target = PyTuple; + type Output = Bound<'py, Self::Target>; + type Error = std::convert::Infallible; + + fn into_pyobject(self, py: Python<'py>) -> Result { + match self { + PyIROptionMetricNewArgs::NoArgs(x) => Ok((x,).into_pyobject(py).unwrap()), + PyIROptionMetricNewArgs::I32(x, y) => Ok((x, y).into_pyobject(py).unwrap()), + } + } +} + +impl<'py> FromPyObject<'py, 'py> for PyIROptionMetricNewArgs { + type Error = PyErr; + + fn extract(obj: Borrowed<'_, 'py, PyAny>) -> Result { + let ext: PyResult<(u8,)> = obj.extract(); + if ext.is_ok() { + let (x,) = ext.unwrap(); + return Ok(PyIROptionMetricNewArgs::NoArgs(x)); + } + let ext: PyResult<(i32, u8)> = obj.extract(); + if ext.is_ok() { + let (x, y) = ext.unwrap(); + return Ok(PyIROptionMetricNewArgs::I32(x, y)); + } + Err(PyValueError::new_err("Undefined behaviour")) + } +} + +impl From for PyIROptionMetric { + fn from(value: IROptionMetric) -> Self { + match value { + IROptionMetric::NormalVol {} => PyIROptionMetric::NormalVol { _u8: 0 }, + IROptionMetric::LogNormalVol {} => PyIROptionMetric::LogNormalVol { _u8: 1 }, + IROptionMetric::PercentNotional {} => PyIROptionMetric::PercentNotional { _u8: 2 }, + IROptionMetric::Cash {} => PyIROptionMetric::Cash { _u8: 3 }, + IROptionMetric::BlackVolShift(n) => { + PyIROptionMetric::BlackVolShift { param: n, _u8: 4 } + } + } + } +} + +impl From for IROptionMetric { + fn from(value: PyIROptionMetric) -> Self { + match value { + PyIROptionMetric::NormalVol { _u8: _ } => IROptionMetric::NormalVol {}, + PyIROptionMetric::LogNormalVol { _u8: _ } => IROptionMetric::LogNormalVol {}, + PyIROptionMetric::PercentNotional { _u8: _ } => IROptionMetric::PercentNotional {}, + PyIROptionMetric::Cash { _u8: _ } => IROptionMetric::Cash {}, + PyIROptionMetric::BlackVolShift { param: n, _u8: _ } => { + IROptionMetric::BlackVolShift(n) + } + } + } +} + +#[pymethods] +impl PyIROptionMetric { + /// Return the shift associated with the Black Vol metric. + /// + /// Returns + /// ------- + /// int + #[pyo3(name = "shift")] + fn shift_py(&self) -> i32 { + match self { + PyIROptionMetric::BlackVolShift { param: n, _u8: _ } => *n, + _ => 0_i32, + } + } + + fn __str__(&self) -> String { + match self { + PyIROptionMetric::NormalVol { _u8: _ } => "normal_vol".to_string(), + PyIROptionMetric::LogNormalVol { _u8: _ } => "log_normal_vol".to_string(), + PyIROptionMetric::PercentNotional { _u8: _ } => "percent_notional".to_string(), + PyIROptionMetric::Cash { _u8: _ } => "cash".to_string(), + PyIROptionMetric::BlackVolShift { param: n, _u8: _ } => { + format!("black_vol_shift_{}", n) + } + } + } + + fn __getnewargs__(&self) -> PyIROptionMetricNewArgs { + match self { + PyIROptionMetric::NormalVol { _u8: u } => PyIROptionMetricNewArgs::NoArgs(*u), + PyIROptionMetric::LogNormalVol { _u8: u } => PyIROptionMetricNewArgs::NoArgs(*u), + PyIROptionMetric::PercentNotional { _u8: u } => PyIROptionMetricNewArgs::NoArgs(*u), + PyIROptionMetric::Cash { _u8: u } => PyIROptionMetricNewArgs::NoArgs(*u), + PyIROptionMetric::BlackVolShift { param: n, _u8: u } => { + PyIROptionMetricNewArgs::I32(*n, *u) + } + } + } + + #[new] + fn new_py(args: PyIROptionMetricNewArgs) -> PyIROptionMetric { + match args { + PyIROptionMetricNewArgs::NoArgs(0) => PyIROptionMetric::NormalVol { _u8: 0 }, + PyIROptionMetricNewArgs::NoArgs(1) => PyIROptionMetric::LogNormalVol { _u8: 1 }, + PyIROptionMetricNewArgs::NoArgs(2) => PyIROptionMetric::PercentNotional { _u8: 2 }, + PyIROptionMetricNewArgs::NoArgs(3) => PyIROptionMetric::Cash { _u8: 3 }, + PyIROptionMetricNewArgs::I32(n, 4) => { + PyIROptionMetric::BlackVolShift { param: n, _u8: 4 } + } + _ => panic!("Undefined behaviour."), + } + } + + fn __repr__(&self) -> String { + let metric: IROptionMetric = (*self).into(); + format!("", metric, self) + } + + /// Return a JSON representation of the object. + /// + /// Returns + /// ------- + /// str + #[pyo3(name = "to_json")] + fn to_json_py(&self) -> PyResult { + match DeserializedObj::PyIROptionMetric(self.clone()).to_json() { + Ok(v) => Ok(v), + Err(_) => Err(PyValueError::new_err( + "Failed to serialize `IROptionMetric` to JSON.", + )), + } + } +} diff --git a/rust/enums/py/mod.rs b/rust/enums/py/mod.rs index 825b89be3..703a545e3 100644 --- a/rust/enums/py/mod.rs +++ b/rust/enums/py/mod.rs @@ -11,6 +11,8 @@ //////////////////////////////////////////////////////////////////////////////////////////////////// pub(crate) mod float_fixing_method; +pub(crate) mod ir_option_metric; pub(crate) mod leg_index_base; pub(crate) use crate::enums::py::float_fixing_method::PyFloatFixingMethod; +pub(crate) use crate::enums::py::ir_option_metric::PyIROptionMetric; diff --git a/rust/json/json_py.rs b/rust/json/json_py.rs index f51662cdd..814ec7bce 100644 --- a/rust/json/json_py.rs +++ b/rust/json/json_py.rs @@ -19,7 +19,7 @@ use crate::curves::curve_py::Curve; use crate::dual::{Dual, Dual2}; -use crate::enums::{LegIndexBase, PyFloatFixingMethod}; +use crate::enums::{LegIndexBase, PyFloatFixingMethod, PyIROptionMetric}; use crate::fx::rates::FXRates; use crate::json::JSON; use crate::scheduling::{ @@ -56,6 +56,7 @@ pub(crate) enum DeserializedObj { Convention(Convention), PyFloatFixingMethod(PyFloatFixingMethod), LegIndexBase(LegIndexBase), + PyIROptionMetric(PyIROptionMetric), } impl JSON for DeserializedObj {} diff --git a/rust/lib.rs b/rust/lib.rs index ec1810a0c..ba5b0573f 100644 --- a/rust/lib.rs +++ b/rust/lib.rs @@ -60,7 +60,7 @@ use scheduling::{ }; pub mod enums; -use enums::{LegIndexBase, PyFloatFixingMethod}; +use enums::{LegIndexBase, PyFloatFixingMethod, PyIROptionMetric}; #[pymodule] fn rs(m: &Bound<'_, PyModule>) -> PyResult<()> { @@ -122,6 +122,7 @@ fn rs(m: &Bound<'_, PyModule>) -> PyResult<()> { // Rates and Indexes m.add_class::()?; m.add_class::()?; + m.add_class::()?; Ok(()) }