Add Yahoo Finance-backed terminal search

This commit is contained in:
2026-04-04 12:32:35 -04:00
parent a6731e1034
commit 1598383ee1
253 changed files with 21988 additions and 67 deletions

View File

@@ -8,6 +8,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"version_check",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -47,6 +58,12 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "as-any"
version = "0.3.2"
@@ -77,6 +94,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-compression"
version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1"
dependencies = [
"compression-codecs",
"compression-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "async-executor"
version = "1.14.0"
@@ -305,6 +334,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "bitvec"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -336,6 +377,30 @@ dependencies = [
"piper",
]
[[package]]
name = "borsh"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a"
dependencies = [
"borsh-derive",
"bytes",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59"
dependencies = [
"once_cell",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "brotli"
version = "8.0.2"
@@ -363,6 +428,28 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytecheck"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
dependencies = [
"bytecheck_derive",
"ptr_meta",
"simdutf8",
]
[[package]]
name = "bytecheck_derive"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "bytemuck"
version = "1.25.0"
@@ -509,11 +596,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
]
[[package]]
name = "chrono-tz"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf 0.12.1",
"serde",
]
[[package]]
name = "cmake"
version = "0.1.58"
@@ -533,6 +633,24 @@ dependencies = [
"memchr",
]
[[package]]
name = "compression-codecs"
version = "0.4.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
dependencies = [
"brotli",
"compression-core",
"flate2",
"memchr",
]
[[package]]
name = "compression-core"
version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -554,10 +672,29 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -862,6 +999,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "dom_query"
version = "0.27.0"
@@ -913,6 +1059,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "embed-resource"
version = "3.0.8"
@@ -1129,6 +1281,12 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futf"
version = "0.1.5"
@@ -1587,6 +1745,9 @@ name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
@@ -1723,6 +1884,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots 1.0.6",
]
[[package]]
@@ -1965,6 +2127,41 @@ dependencies = [
"once_cell",
]
[[package]]
name = "isin"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19bcf2544150282ebe712b4615813c1986bb014564928b1c4a4a567402d6bf02"
[[package]]
name = "iso_country"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20633e788d3948ea7336861fdb09ec247f5dae4267e8f0743fa97de26c28624d"
dependencies = [
"lazy_static",
]
[[package]]
name = "iso_currency"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fed4b3f0921193400b1df556228bfd917c57c7fa38bda904d552653c5c3b641b"
dependencies = [
"iso_country",
"proc-macro2",
"quote",
]
[[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.18"
@@ -2105,6 +2302,12 @@ dependencies = [
"selectors 0.24.0",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -2172,6 +2375,12 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -2312,6 +2521,7 @@ dependencies = [
"tauri-plugin-opener",
"tauri-plugin-store",
"tokio",
"yfinance-rs",
]
[[package]]
@@ -2605,6 +2815,119 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "paft"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c8f13ab3152b3fc1d596fdc6881ab5f0499647e8f1620b393dd04df3d8abcd"
dependencies = [
"iso_currency",
"paft-aggregates",
"paft-core",
"paft-domain",
"paft-fundamentals",
"paft-market",
"paft-money",
"paft-utils",
"thiserror 2.0.18",
]
[[package]]
name = "paft-aggregates"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b122c5099ea444a5d3bdc70963311b22d14ee372ef407c60a84b1b1e446074e7"
dependencies = [
"chrono",
"paft-domain",
"paft-fundamentals",
"paft-market",
"paft-money",
"serde",
]
[[package]]
name = "paft-core"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40251d2efd21aa5615927400858c5fa000db5ae6de4cd58320e57b0d2d52336"
dependencies = [
"chrono",
"serde",
"thiserror 2.0.18",
]
[[package]]
name = "paft-domain"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b7f9a2d69d11e1324d6b3a7dc1ad62f7f909442531406b8b3052d8eea69546e"
dependencies = [
"chrono",
"isin",
"paft-core",
"paft-utils",
"regex",
"serde",
"thiserror 2.0.18",
]
[[package]]
name = "paft-fundamentals"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d591b958232362a8121e27dc5ef570007f7e64788238b2b1b280701536cf336"
dependencies = [
"chrono",
"iso_currency",
"paft-core",
"paft-domain",
"paft-money",
"paft-utils",
"serde",
"thiserror 2.0.18",
]
[[package]]
name = "paft-market"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4708f3a8b55a06c277b4830fae834c03d3cd75f3407be27d0dc7f6b17863700"
dependencies = [
"bitflags 2.11.0",
"chrono",
"chrono-tz",
"iso_currency",
"paft-core",
"paft-domain",
"paft-money",
"paft-utils",
"serde",
"thiserror 2.0.18",
]
[[package]]
name = "paft-money"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078c12dfca26fe2db783d2940b1a01bcf44832ad46006fdfb54b9eee6727e13d"
dependencies = [
"iso_currency",
"paft-utils",
"rust_decimal",
"serde",
"thiserror 2.0.18",
]
[[package]]
name = "paft-utils"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f154451a10bb55eb00f81dfdc6410237ef348ead5b096ae3f186b232a240b3d6"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2701,6 +3024,15 @@ dependencies = [
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared 0.12.1",
]
[[package]]
name = "phf"
version = "0.13.1"
@@ -2849,6 +3181,15 @@ dependencies = [
"siphasher 1.0.2",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher 1.0.2",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
@@ -3049,6 +3390,65 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "prost"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-derive"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
dependencies = [
"anyhow",
"itertools",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "ptr_meta"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
dependencies = [
"ptr_meta_derive",
]
[[package]]
name = "ptr_meta_derive"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -3135,6 +3535,12 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.7.3"
@@ -3320,6 +3726,55 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rend"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
dependencies = [
"bytecheck",
]
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"cookie",
"cookie_store",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.6",
]
[[package]]
name = "reqwest"
version = "0.13.2"
@@ -3385,13 +3840,13 @@ dependencies = [
"nanoid",
"ordered-float",
"pin-project-lite",
"reqwest",
"reqwest 0.13.2",
"schemars 1.2.1",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite",
"tokio-tungstenite 0.23.1",
"tracing",
"tracing-futures",
"url",
@@ -3411,6 +3866,52 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rkyv"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
dependencies = [
"bitvec",
"bytecheck",
"bytes",
"hashbrown 0.12.3",
"ptr_meta",
"rend",
"rkyv_derive",
"seahash",
"tinyvec",
"uuid",
]
[[package]]
name = "rkyv_derive"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "rust_decimal"
version = "1.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a"
dependencies = [
"arrayvec",
"borsh",
"bytes",
"num-traits",
"rand 0.8.5",
"rkyv",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -3447,6 +3948,7 @@ checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@@ -3520,6 +4022,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -3608,6 +4116,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -3773,6 +4287,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.18.0"
@@ -3889,6 +4415,12 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "0.3.11"
@@ -4174,6 +4706,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -4210,7 +4748,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_repr",
@@ -4627,10 +5165,26 @@ dependencies = [
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
"tungstenite 0.23.0",
"webpki-roots 0.26.11",
]
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite 0.28.0",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4770,13 +5324,18 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"async-compression",
"bitflags 2.11.0",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"iri-string",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
@@ -4885,6 +5444,25 @@ dependencies = [
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.2",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 2.0.18",
"utf-8",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -5118,6 +5696,7 @@ dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"serde",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
@@ -5970,6 +6549,15 @@ dependencies = [
"x11-dl",
]
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]]
name = "x11"
version = "2.21.0"
@@ -5991,6 +6579,27 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "yfinance-rs"
version = "0.7.2"
dependencies = [
"base64 0.22.1",
"chrono",
"chrono-tz",
"futures",
"futures-util",
"paft",
"prost",
"reqwest 0.12.28",
"rust_decimal",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite 0.28.0",
"url",
]
[[package]]
name = "yoke"
version = "0.8.1"

View File

@@ -26,6 +26,7 @@ rig-core = "0.34.0"
tauri-plugin-store = "2"
tokio = { version = "1", features = ["time"] }
futures = "0.3"
yfinance-rs = { path = "vendor/yfinance-rs-0.7.2" }
[dev-dependencies]
tauri = { version = "2", features = ["test"] }

View File

@@ -16,7 +16,7 @@ pub async fn execute_terminal_command(
state: tauri::State<'_, AppState>,
request: ExecuteTerminalCommandRequest,
) -> Result<TerminalCommandResponse, String> {
Ok(state.command_service.execute(request))
Ok(state.command_service.execute(request).await)
}
/// Starts a streaming plain-text chat turn and emits progress over Tauri events.

View File

@@ -1,7 +1,7 @@
//! Shared application state managed by Tauri.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Wry};
@@ -23,7 +23,9 @@ impl AppState {
pub fn new(app_handle: &AppHandle<Wry>) -> Result<Self, AppError> {
Ok(Self {
agent: Mutex::new(AgentService::new(app_handle)?),
command_service: TerminalCommandService::default(),
command_service: TerminalCommandService::new(Arc::new(
crate::terminal::yahoo_finance::YahooFinanceLookup::default(),
)),
next_request_id: AtomicU64::new(1),
})
}

View File

@@ -1,29 +1,48 @@
use std::sync::Arc;
use crate::terminal::mock_data::load_mock_financial_data;
use crate::terminal::yahoo_finance::{
SecurityLookup, SecurityLookupError, SecurityMatch, YahooFinanceLookup,
};
use crate::terminal::{
ChatCommandRequest, Company, ExecuteTerminalCommandRequest, MockFinancialData, PanelPayload,
ChatCommandRequest, ExecuteTerminalCommandRequest, MockFinancialData, PanelPayload,
TerminalCommandResponse,
};
/// Executes supported slash commands against the shared mock financial dataset.
/// Executes supported slash commands against live search plus shared local fixture data.
pub struct TerminalCommandService {
mock_data: MockFinancialData,
security_lookup: Arc<dyn SecurityLookup>,
}
impl Default for TerminalCommandService {
fn default() -> Self {
Self {
mock_data: load_mock_financial_data(),
}
Self::with_dependencies(load_mock_financial_data(), Arc::new(YahooFinanceLookup::default()))
}
}
impl TerminalCommandService {
/// Creates a terminal command service with a custom security lookup backend.
pub fn new(security_lookup: Arc<dyn SecurityLookup>) -> Self {
Self::with_dependencies(load_mock_financial_data(), security_lookup)
}
fn with_dependencies(
mock_data: MockFinancialData,
security_lookup: Arc<dyn SecurityLookup>,
) -> Self {
Self {
mock_data,
security_lookup,
}
}
/// Resolves a slash command into either a text reply or a structured panel payload.
pub fn execute(&self, request: ExecuteTerminalCommandRequest) -> TerminalCommandResponse {
pub async fn execute(&self, request: ExecuteTerminalCommandRequest) -> TerminalCommandResponse {
let command = parse_command(&request.input);
match command.command.as_str() {
"/search" => self.search(command.args.join(" ").trim()),
"/search" => self.search(command.args.join(" ").trim()).await,
"/portfolio" => TerminalCommandResponse::Panel {
panel: PanelPayload::Portfolio {
data: self.mock_data.portfolio.clone(),
@@ -38,34 +57,58 @@ impl TerminalCommandService {
}
}
fn search(&self, query: &str) -> TerminalCommandResponse {
async fn search(&self, query: &str) -> TerminalCommandResponse {
if query.is_empty() {
return TerminalCommandResponse::Text {
content: "Usage: /search [ticker or company name]".to_string(),
};
}
if let Some(company) = self.find_company_by_symbol(query) {
return TerminalCommandResponse::Panel {
panel: PanelPayload::Company { data: company },
};
}
let matches = match self.security_lookup.search(query).await {
Ok(matches) => matches
.into_iter()
.filter(|security_match| security_match.kind.is_supported())
.collect::<Vec<_>>(),
Err(SecurityLookupError::SearchUnavailable) => {
return TerminalCommandResponse::Text {
content: format!("Live search failed for \"{query}\"."),
};
}
Err(SecurityLookupError::DetailUnavailable { .. }) => {
return TerminalCommandResponse::Text {
content: format!("Live search failed for \"{query}\"."),
};
}
};
let matches = self.search_companies(query);
if matches.is_empty() {
return TerminalCommandResponse::Text {
content: format!("No results found for \"{query}\"."),
content: format!("No live results found for \"{query}\"."),
};
}
let lines = matches
.iter()
.map(|company| format!(" {} {}", company.symbol, company.name))
.collect::<Vec<_>>()
.join("\n");
if let Some(selected_match) = select_exact_symbol_match(query, &matches) {
let selected_symbol = selected_match.symbol.clone();
return match self.security_lookup.load_company(&selected_match).await {
Ok(company) => TerminalCommandResponse::Panel {
panel: PanelPayload::Company { data: company },
},
Err(SecurityLookupError::DetailUnavailable { symbol }) => {
TerminalCommandResponse::Text {
content: format!("Live security data unavailable for \"{symbol}\"."),
}
}
Err(SecurityLookupError::SearchUnavailable) => TerminalCommandResponse::Text {
content: format!("Live security data unavailable for \"{selected_symbol}\"."),
},
};
}
TerminalCommandResponse::Text {
content: format!("Multiple matches found for \"{query}\":\n{lines}"),
content: format!(
"Multiple matches found for \"{query}\":\n{}",
format_search_matches(&matches)
),
}
}
@@ -112,28 +155,48 @@ impl TerminalCommandService {
},
}
}
}
fn find_company_by_symbol(&self, query: &str) -> Option<Company> {
self.mock_data
.companies
.iter()
.find(|company| company.symbol.eq_ignore_ascii_case(query))
.cloned()
fn select_exact_symbol_match(query: &str, matches: &[SecurityMatch]) -> Option<SecurityMatch> {
matches
.iter()
.enumerate()
.filter(|(_, security_match)| security_match.symbol.eq_ignore_ascii_case(query))
.min_by_key(|(index, security_match)| {
(
exchange_priority(security_match.exchange.as_deref()),
*index,
)
})
.map(|(_, security_match)| security_match.clone())
}
fn exchange_priority(exchange: Option<&str>) -> usize {
match exchange {
Some("NASDAQ") => 0,
Some("NYSE") => 1,
Some("AMEX") => 2,
Some("BATS") => 3,
_ => 4,
}
}
fn search_companies(&self, query: &str) -> Vec<Company> {
let normalized_query = query.to_lowercase();
self.mock_data
.companies
.iter()
.filter(|company| {
company.symbol.to_lowercase().contains(&normalized_query)
|| company.name.to_lowercase().contains(&normalized_query)
})
.cloned()
.collect()
}
fn format_search_matches(matches: &[SecurityMatch]) -> String {
matches
.iter()
.map(|security_match| {
let name = security_match.name.as_deref().unwrap_or("Unknown");
let exchange = security_match.exchange.as_deref().unwrap_or("N/A");
format!(
" {} {} {} {}",
security_match.symbol,
name,
exchange,
security_match.kind.label()
)
})
.collect::<Vec<_>>()
.join("\n")
}
/// Parses raw slash-command input into a normalized command plus positional arguments.
@@ -152,7 +215,7 @@ fn parse_command(input: &str) -> ChatCommandRequest {
/// Human-readable help text returned for `/help` and unknown commands.
fn help_text() -> &'static str {
"Available Commands:\n\n /search [ticker] - Search for a stock\n /portfolio - Show your portfolio\n /news [ticker?] - Show market news\n /analyze [ticker] - Get stock analysis\n /help - Show this help text\n /clear - Clear terminal locally"
"Available Commands:\n\n /search [ticker] - Search live security data\n /portfolio - Show your portfolio\n /news [ticker?] - Show market news\n /analyze [ticker] - Get stock analysis\n /help - Show this help text\n /clear - Clear terminal locally"
}
/// Wraps the shared help text into the terminal command response envelope.
@@ -164,17 +227,94 @@ fn help_response() -> TerminalCommandResponse {
#[cfg(test)]
mod tests {
use std::sync::Arc;
use futures::future::BoxFuture;
use super::TerminalCommandService;
use crate::terminal::{ExecuteTerminalCommandRequest, PanelPayload, TerminalCommandResponse};
use crate::terminal::mock_data::load_mock_financial_data;
use crate::terminal::yahoo_finance::{
SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch,
};
use crate::terminal::{Company, ExecuteTerminalCommandRequest, PanelPayload, TerminalCommandResponse};
struct FakeSecurityLookup {
search_result: Result<Vec<SecurityMatch>, SecurityLookupError>,
fail_detail: bool,
}
impl FakeSecurityLookup {
fn successful(matches: Vec<SecurityMatch>) -> Self {
Self {
search_result: Ok(matches),
fail_detail: false,
}
}
}
impl SecurityLookup for FakeSecurityLookup {
fn search<'a>(
&'a self,
_query: &'a str,
) -> BoxFuture<'a, Result<Vec<SecurityMatch>, SecurityLookupError>> {
Box::pin(async move { self.search_result.clone() })
}
fn load_company<'a>(
&'a self,
security_match: &'a SecurityMatch,
) -> BoxFuture<'a, Result<Company, SecurityLookupError>> {
Box::pin(async move {
if self.fail_detail {
return Err(SecurityLookupError::DetailUnavailable {
symbol: security_match.symbol.clone(),
});
}
Ok(Company {
symbol: security_match.symbol.clone(),
name: security_match.exchange.clone().unwrap_or_else(|| "N/A".to_string()),
price: 100.0,
change: 1.0,
change_percent: 1.0,
market_cap: 1_000_000.0,
volume: 10_000,
pe: Some(20.0),
eps: Some(2.0),
high52_week: Some(110.0),
low52_week: Some(80.0),
})
})
}
}
fn build_service(search_result: Result<Vec<SecurityMatch>, SecurityLookupError>) -> TerminalCommandService {
TerminalCommandService::with_dependencies(
load_mock_financial_data(),
Arc::new(FakeSecurityLookup {
search_result,
fail_detail: false,
}),
)
}
fn execute(service: &TerminalCommandService, input: &str) -> TerminalCommandResponse {
futures::executor::block_on(service.execute(ExecuteTerminalCommandRequest {
workspace_id: "workspace-1".to_string(),
input: input.to_string(),
}))
}
#[test]
fn returns_company_panel_for_exact_search_match() {
let service = TerminalCommandService::default();
let service = build_service(Ok(vec![SecurityMatch {
symbol: "AAPL".to_string(),
name: Some("Apple Inc.".to_string()),
exchange: Some("NASDAQ".to_string()),
kind: SecurityKind::Equity,
}]));
let response = service.execute(ExecuteTerminalCommandRequest {
workspace_id: "workspace-1".to_string(),
input: "/search AAPL".to_string(),
});
let response = execute(&service, "/search AAPL");
match response {
TerminalCommandResponse::Panel {
@@ -185,13 +325,145 @@ mod tests {
}
#[test]
fn returns_text_for_unknown_command() {
let service = TerminalCommandService::default();
fn returns_text_list_for_name_search() {
let service = build_service(Ok(vec![
SecurityMatch {
symbol: "AAPL".to_string(),
name: Some("Apple Inc.".to_string()),
exchange: Some("NASDAQ".to_string()),
kind: SecurityKind::Equity,
},
SecurityMatch {
symbol: "APLE".to_string(),
name: Some("Apple Hospitality REIT".to_string()),
exchange: Some("NYSE".to_string()),
kind: SecurityKind::Equity,
},
]));
let response = service.execute(ExecuteTerminalCommandRequest {
workspace_id: "workspace-1".to_string(),
input: "/wat".to_string(),
});
let response = execute(&service, "/search apple");
match response {
TerminalCommandResponse::Text { content } => {
assert!(content.contains("Multiple matches found for \"apple\""));
assert!(content.contains("AAPL"));
assert!(content.contains("NASDAQ"));
assert!(content.contains("Equity"));
}
other => panic!("expected text response, got {other:?}"),
}
}
#[test]
fn filters_unsupported_assets() {
let service = build_service(Ok(vec![SecurityMatch {
symbol: "BTC-USD".to_string(),
name: Some("Bitcoin USD".to_string()),
exchange: None,
kind: SecurityKind::Other("Crypto".to_string()),
}]));
let response = execute(&service, "/search bitcoin");
match response {
TerminalCommandResponse::Text { content } => {
assert_eq!(content, "No live results found for \"bitcoin\".");
}
other => panic!("expected text response, got {other:?}"),
}
}
#[test]
fn prefers_us_exchange_priority_for_exact_matches() {
let service = build_service(Ok(vec![
SecurityMatch {
symbol: "ABC".to_string(),
name: Some("ABC Ltd".to_string()),
exchange: Some("NYSE".to_string()),
kind: SecurityKind::Equity,
},
SecurityMatch {
symbol: "ABC".to_string(),
name: Some("ABC Ltd".to_string()),
exchange: Some("NASDAQ".to_string()),
kind: SecurityKind::Equity,
},
]));
let response = execute(&service, "/search ABC");
match response {
TerminalCommandResponse::Panel {
panel: PanelPayload::Company { data },
} => assert_eq!(data.name, "NASDAQ"),
other => panic!("expected company panel, got {other:?}"),
}
}
#[test]
fn returns_live_search_error_when_provider_search_fails() {
let service = build_service(Err(SecurityLookupError::SearchUnavailable));
let response = execute(&service, "/search AAPL");
match response {
TerminalCommandResponse::Text { content } => {
assert_eq!(content, "Live search failed for \"AAPL\".");
}
other => panic!("expected text response, got {other:?}"),
}
}
#[test]
fn returns_live_detail_error_when_exact_resolution_fails() {
let service = TerminalCommandService::with_dependencies(
load_mock_financial_data(),
Arc::new(FakeSecurityLookup {
search_result: Ok(vec![SecurityMatch {
symbol: "AAPL".to_string(),
name: Some("Apple Inc.".to_string()),
exchange: Some("NASDAQ".to_string()),
kind: SecurityKind::Equity,
}]),
fail_detail: true,
}),
);
let response = execute(&service, "/search AAPL");
match response {
TerminalCommandResponse::Text { content } => {
assert_eq!(content, "Live security data unavailable for \"AAPL\".");
}
other => panic!("expected text response, got {other:?}"),
}
}
#[test]
fn returns_usage_for_search_without_query() {
let service = TerminalCommandService::with_dependencies(
load_mock_financial_data(),
Arc::new(FakeSecurityLookup::successful(vec![])),
);
let response = execute(&service, "/search");
match response {
TerminalCommandResponse::Text { content } => {
assert_eq!(content, "Usage: /search [ticker or company name]");
}
other => panic!("expected text response, got {other:?}"),
}
}
#[test]
fn returns_text_for_unknown_command() {
let service = TerminalCommandService::with_dependencies(
load_mock_financial_data(),
Arc::new(FakeSecurityLookup::successful(vec![])),
);
let response = execute(&service, "/wat");
match response {
TerminalCommandResponse::Text { content } => {
@@ -203,12 +475,12 @@ mod tests {
#[test]
fn returns_usage_for_analyze_without_ticker() {
let service = TerminalCommandService::default();
let service = TerminalCommandService::with_dependencies(
load_mock_financial_data(),
Arc::new(FakeSecurityLookup::successful(vec![])),
);
let response = service.execute(ExecuteTerminalCommandRequest {
workspace_id: "workspace-1".to_string(),
input: "/analyze".to_string(),
});
let response = execute(&service, "/analyze");
match response {
TerminalCommandResponse::Text { content } => {

View File

@@ -1,6 +1,7 @@
mod command_service;
mod mock_data;
mod types;
pub(crate) mod yahoo_finance;
pub use command_service::TerminalCommandService;
pub use types::{

View File

@@ -126,6 +126,7 @@ pub struct StockAnalysis {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MockFinancialData {
#[allow(dead_code)]
pub companies: Vec<Company>,
pub portfolio: Portfolio,
pub news_items: Vec<NewsItem>,

View File

@@ -0,0 +1,160 @@
use futures::future::BoxFuture;
use yfinance_rs::core::conversions::money_to_f64;
use yfinance_rs::{CacheMode, SearchBuilder, Ticker, YfClient};
use crate::terminal::Company;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SecurityKind {
Equity,
Fund,
Other(String),
}
impl SecurityKind {
#[must_use]
pub(crate) const fn is_supported(&self) -> bool {
matches!(self, Self::Equity | Self::Fund)
}
#[must_use]
pub(crate) fn label(&self) -> &str {
match self {
Self::Equity => "Equity",
Self::Fund => "Fund",
Self::Other(label) => label.as_str(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SecurityMatch {
pub symbol: String,
pub name: Option<String>,
pub exchange: Option<String>,
pub kind: SecurityKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SecurityLookupError {
SearchUnavailable,
DetailUnavailable { symbol: String },
}
pub(crate) trait SecurityLookup: Send + Sync {
fn search<'a>(
&'a self,
query: &'a str,
) -> BoxFuture<'a, Result<Vec<SecurityMatch>, SecurityLookupError>>;
fn load_company<'a>(
&'a self,
security_match: &'a SecurityMatch,
) -> BoxFuture<'a, Result<Company, SecurityLookupError>>;
}
pub(crate) struct YahooFinanceLookup {
client: YfClient,
}
impl Default for YahooFinanceLookup {
fn default() -> Self {
Self {
client: YfClient::default(),
}
}
}
impl SecurityLookup for YahooFinanceLookup {
fn search<'a>(
&'a self,
query: &'a str,
) -> BoxFuture<'a, Result<Vec<SecurityMatch>, SecurityLookupError>> {
Box::pin(async move {
let response = SearchBuilder::new(&self.client, query)
.quotes_count(10)
.news_count(0)
.lists_count(0)
.lang("en-US")
.region("US")
.cache_mode(CacheMode::Bypass)
.fetch()
.await
.map_err(|_| SecurityLookupError::SearchUnavailable)?;
Ok(response
.results
.into_iter()
.map(|result| {
let kind = result.kind.to_string();
SecurityMatch {
symbol: result.symbol.to_string(),
name: result.name,
exchange: result.exchange.map(|exchange| exchange.to_string()),
kind: match kind.as_str() {
"EQUITY" => SecurityKind::Equity,
"FUND" => SecurityKind::Fund,
_ => SecurityKind::Other(kind),
},
}
})
.collect())
})
}
fn load_company<'a>(
&'a self,
security_match: &'a SecurityMatch,
) -> BoxFuture<'a, Result<Company, SecurityLookupError>> {
Box::pin(async move {
let ticker = Ticker::new(&self.client, security_match.symbol.clone());
let detail_error = || SecurityLookupError::DetailUnavailable {
symbol: security_match.symbol.clone(),
};
let (fast_info, info) = futures::try_join!(ticker.fast_info(), ticker.info())
.map_err(|_| detail_error())?;
let name = info
.name
.clone()
.or_else(|| security_match.name.clone())
.or_else(|| fast_info.name.clone())
.filter(|value| !value.trim().is_empty())
.ok_or_else(detail_error)?;
let price = fast_info
.last
.as_ref()
.map(money_to_f64)
.ok_or_else(detail_error)?;
let previous_close = fast_info
.previous_close
.as_ref()
.map(money_to_f64)
.unwrap_or(0.0);
let change = price - previous_close;
let change_percent = if previous_close > 0.0 {
(change / previous_close) * 100.0
} else {
0.0
};
Ok(Company {
symbol: security_match.symbol.clone(),
name,
price,
change,
change_percent,
market_cap: info.market_cap.as_ref().map(money_to_f64).unwrap_or(0.0),
volume: info.volume.or(fast_info.volume).unwrap_or(0),
pe: info.pe_ttm,
eps: info.eps_ttm.as_ref().map(money_to_f64),
high52_week: info.fifty_two_week_high.as_ref().map(money_to_f64),
low52_week: info.fifty_two_week_low.as_ref().map(money_to_f64),
})
})
}
}

View File

@@ -0,0 +1 @@
{"v":1}

View File

@@ -0,0 +1,6 @@
{
"git": {
"sha1": "629adbef87cffa40928b356934dba62b22246502"
},
"path_in_vcs": ""
}

View File

@@ -0,0 +1 @@
github: [gramistella]

View File

@@ -0,0 +1,82 @@
name: CI
on:
push:
branches: [ "main" ]
paths-ignore:
- '**.md'
- 'LICENSE'
pull_request:
branches: [ "main" ]
paths-ignore:
- '**.md'
- 'LICENSE'
env:
CARGO_TERM_COLOR: always
jobs:
checks:
name: Check Formatting & Lints
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Install protoc
uses: arduino/setup-protoc@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Cache Cargo
uses: Swatinem/rust-cache@v2
- name: Install just
uses: taiki-e/install-action@just
- name: Check formatting
run: cargo fmt --all -- --check
- name: Run clippy linter
run: just lint
test:
name: Run Tests
runs-on: ubuntu-latest
needs: checks
steps:
- name: Clean up runner image
run: |
df -h
rm -rf /opt/hostedtoolcache
sudo rm -rf /usr/local/lib/android
sudo rm -rf /usr/share/dotnet
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
df -h
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Install protoc
uses: arduino/setup-protoc@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Cache Cargo
uses: Swatinem/rust-cache@v2
- name: Install just
uses: taiki-e/install-action@just
- name: Run offline tests
run: just test-offline

View File

@@ -0,0 +1,85 @@
# .github/workflows/publish.yml
name: Publish to crates.io
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+*' # Trigger on tags like v0.1.0, v1.2.3, v1.0.0-beta.1
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Clean up runner image
run: |
df -h
rm -rf /opt/hostedtoolcache
sudo rm -rf /usr/local/lib/android
sudo rm -rf /usr/share/dotnet
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
df -h
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Install protoc
uses: arduino/setup-protoc@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Cache Cargo
uses: Swatinem/rust-cache@v2
- name: Install just
uses: taiki-e/install-action@just
- name: Run offline tests
run: just test-offline
- name: Run clippy linter
run: just lint
publish:
name: Publish
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Install protoc
uses: arduino/setup-protoc@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish to crates.io
run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }}
create-release:
needs: test
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: read
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Create GitHub Releases based on changelog
uses: taiki-e/create-gh-release-action@v1.9.1
with:
changelog: CHANGELOG.md
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -0,0 +1,5 @@
/target
.idea
.stitchworkspace/
Cargo.lock
.DS_Store

View File

@@ -0,0 +1,336 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.7.2] - 2025-10-31
### Dependencies
- Bump `paft` to `v0.7.1`.
### Note
Yahoo Finance appears to have removed or relocated the ESG data endpoint. As a result, `ticker.sustainability()` currently panics during normal usage and live testing. This issue is under investigation.
## [0.7.1] - 2025-10-30
### Fixed
- Format fundamentals timeseries statement row period from epoch to YYYY-MM-DD.
- Correct `calendarEvents` mapping and extraction for `exDividendDate` and `dividendDate`.
- Correct gross profit and operating income in income statement.
## [0.7.0] - 2025-10-28
### Added
- Per-update volume deltas in real-time streaming: `QuoteUpdate.volume` now reflects the delta
since the previous update for a symbol. First tick per symbol and after a detected reset/rollover
yields `None`. Applies to both WebSocket and HTTP polling streams.
- Expose intraday cumulative volume on snapshots: populate `Quote.day_volume` from v7 quotes and
surface it on convenience types (`Ticker::quote()` and `Ticker::info()` as `Info.volume`).
- SearchBuilder accessors: `lang_ref()` and `region_ref()` to inspect configured parameters.
- Populate convenience `Info` with analytics and ESG when available: `price_target`,
`recommendation_summary`, `esg_scores`.
### Breaking Change
- Upgrade to `paft` v0.7.0 adds a new field to `paft::market::quote::QuoteUpdate`:
`volume: Option<u64>`. If you construct or exhaustively destructure `QuoteUpdate`, update your
code to include the new field or use `..`. Stream APIs and typical consumers that only read
updates are unaffected.
### Changed
- Stream volume semantics: WebSocket and polling streams compute per-update volume deltas. The
low-level decoder helper remains stateless and always returns `volume = None`.
- Polling stream `diff_only` now emits when either price or volume changes.
### Documentation
- README: added a "Volume semantics" section for streaming; clarified delta behavior and how to
obtain cumulative volume.
- Examples: updated streaming and convenience examples to display volume; SearchBuilder example now
demonstrates `lang_ref()`/`region_ref()`.
### Dependencies
- Bump `paft` to `v0.7.0`.
## [0.6.1] - 2025-10-27
### Fixed
- Fixed critical timestamp interpretation bug in WebSocket stream processing: use `DateTime::from_timestamp_millis()` instead of `i64_to_datetime()` to correctly interpret millisecond timestamps, preventing incorrect date values in quote updates
#### Notes
- **WebSocket Stream Timestamps:** Users may occasionally observe `QuoteUpdate` messages arriving via the WebSocket stream with timestamps that are older than previously received messages ("time traveling ticks"), sometimes by significant amounts (minutes or hours). This behavior appears to originate from the **Yahoo Finance data feed itself** and is not a bug introduced by `yfinance-rs`. To provide the most direct representation of the source data, `yfinance-rs` **does not automatically filter** these out-of-order messages. Applications requiring strictly chronological quote updates should implement their own filtering logic based on the timestamp (`ts`) field of the received `QuoteUpdate`.
## [0.6.0] - 2025-10-21
### Breaking Change
- `DownloadBuilder::run()` now returns `paft::market::responses::download::DownloadResponse` with an `entries: Vec<DownloadEntry>` instead of the previous `DownloadResult` maps. Access candles via `entry.history.candles` and the symbol via `entry.instrument.symbol_str()`.
### Changed
- Re-export `DownloadEntry` and `DownloadResponse` at the crate root for convenient imports.
- Examples and tests updated to iterate over `entries` rather than `series`.
### Performance
- Introduced an instrument cache in `YfClient` and populate it opportunistically from v7 quote responses to reduce symbol resolution overhead during multi-symbol downloads.
### Documentation
- Updated README examples to reflect the new `DownloadResponse.entries` usage.
### Dependencies
- Bump `paft` to `v0.6.0`.
## [0.5.2] - 2025-10-20
### Added
- Optional `tracing` feature: emits spans and key events across network I/O and major logical boundaries. Instrumented `send_with_retry`, profile fallback, quote summary fetch (including invalid crumb retry), history `fetch_full`, and `Ticker` public APIs (`info`, `quote`, `history`, etc.). Disabled by default; zero overhead when not enabled.
- Optional `tracing-subscriber` feature (dev/testing): convenience initializer `init_tracing_for_tests()` to set up a basic subscriber in examples/tests. The library itself does not configure a subscriber.
### Dependencies
- Bump `paft` to `v0.5.2`.
### Docs
- Readme now includes a "Tracing" section.
## [0.5.1] - 2025-10-17
### Changed
- Updated to paft v0.5.1
## [0.5.0] - 2025-10-16
### Breaking
- Adopted `paft` 0.5.0 identity and money types across search, streaming, and ticker info. `Quote.symbol`, `SearchResult.symbol`, `OptionContract.contract_symbol`, and `QuoteUpdate.symbol` now use `paft::domain::Symbol`; values are uppercased and validated during construction, and invalid search results are dropped.
- `Ticker::Info` now re-exports `paft::aggregates::Info`. The previous struct with raw strings and floats has been removed, and fields such as `sector`, `industry`, analyst targets, recommendation metrics, and ESG scores are no longer populated on this convenience type. Monetary and exchange data now use `Money`, `Currency`, `Exchange`, and `MarketState`.
- Real-time streaming emits `paft::market::quote::QuoteUpdate`. `last_price` is renamed to `price` and now carries `Money` (with embedded currency metadata), the standalone `currency` string is gone, and `ts` is now a `DateTime<Utc>`. Update stream consumers accordingly.
- Search now returns `paft::market::responses::search::SearchResponse` with a `results` list. Each item exposes `Symbol`, `AssetKind`, and `Exchange` enums. Replace usages of `resp.quotes` and `quote.longname/shortname` with `resp.results` and `result.name`.
### Changed
- Bumped `paft` to 0.5.0 via the workspace checkout and aligned with the new symbol validation.
- Updated dependencies and fixtures: `reqwest 0.12.24`, `tokio 1.48`.
### Documentation
- Added troubleshooting guidance for consent-related errors in `README.md` (thanks to [@hrishim](https://github.com/hrishim) for the contribution!)
- Expanded `CONTRIBUTING.md` with `just` helpers and clarified repository setup.
### Internal
- Added `.github/FUNDING.yml` to advertise GitHub Sponsors support.
- Removed stray `.DS_Store` files and regenerated fixtures for the new models.
### Migration notes
- Symbols are now uppercase-validated `paft::domain::Symbol`. Use `.as_str()` for string comparisons or construct values with `Symbol::new("AAPL")` (handle the `Result` when user input is dynamic).
- Stream updates now expose `update.price` (`Money`) and `update.ts: DateTime<Utc>`. Replace direct `last_price`/`ts` usage with the new typed fields and derive primitive values as needed.
- Search responses provide `resp.results` instead of `resp.quotes`. Access display data via `result.name`, `result.kind`, and `result.exchange`.
- The convenience info snapshot no longer embeds fundamentals, analyst, or ESG data. Fetch those via `profile::load_profile`, `analysis::AnalysisBuilder`, and `esg::EsgBuilder` if you still need them.
---
## [0.4.0] - 2025-10-12
### Added
- Enabled `paft` facade `aggregates` feature.
- `Ticker::fast_info()` now returns `paft_aggregates::FastInfo` (typed enums and `Money`), offering a richer, consistent snapshot model.
- Options models expanded (re-exported from `paft-market`):
- `OptionContract` gains `expiration_date` (NaiveDate), `expiration_at` (Option<DateTime\<Utc>>), `last_trade_at` (Option<DateTime\<Utc>>), and `greeks` (Option\<OptionGreeks>).
- DataFrame support for options types is available when enabling this crates `dataframe` feature (forwards to `paft/dataframe`).
### Changed
- History response alignment with `paft` 0.4.0:
- `Candle` now carries `close_unadj: Option<Money>` (original unadjusted close, when available).
- `HistoryResponse` no longer includes a top-level `unadjusted_close` vector.
- Examples and tests updated to use Money-typed values and typed enums (Exchange, MarketState, Currency).
### Breaking
- Fast Info return type changed:
- Old: struct with `last_price: f64`, `previous_close: Option<f64>`, string-y `currency`/`exchange`/`market_state`.
- New: `paft_aggregates::FastInfo` with `last: Option<Money>`, `previous_close: Option<Money>`, `currency: Option<paft_money::Currency>`, `exchange: Option<paft_domain::Exchange>`, `market_state: Option<paft_domain::MarketState>`, plus `name: Option<String>`.
- Options contract fields changed:
- Old: `OptionContract { ..., expiration: DateTime<Utc>, ... }`
- New: `OptionContract { ..., expiration_date: NaiveDate, expiration_at: Option<DateTime<Utc>>, last_trade_at: Option<DateTime<Utc>>, greeks: Option<OptionGreeks>, ... }`
- History unadjusted close location changed:
- Old: `HistoryResponse { ..., unadjusted_close: Option<Vec<Money>> }`
- New: `Candle { ..., close_unadj: Option<Money> }` (per-candle).
### Migration notes
- Fast Info
- Price as f64: replace `fi.last_price` with `fi.last.as_ref().map(money_to_f64).or_else(|| fi.previous_close.as_ref().map(money_to_f64))`.
- Currency string: replace `fi.currency` (String) with `fi.currency.map(|c| c.to_string())`.
- Exchange/MarketState strings: `.map(|e| e.to_string())`.
- Options
- Replace usages of `contract.expiration` with `contract.expiration_at.unwrap_or_else(|| ...)`, or use `contract.expiration_date` for calendar-only logic.
- New optional fields `last_trade_at` and `greeks` are available (greeks currently not populated from Yahoo v7).
- History
- Replace `resp.unadjusted_close[i]` with `resp.candles[i].close_unadj.as_ref()`.
### Internal
- Tests updated for `httpmock` 0.8 API changes.
- Lints and examples adjusted for Money/typed enums.
## [0.3.2] - 2025-10-03
### Changed
- Bump `paft` to 0.3.2 (docs-only upstream release; no functional impact).
## [0.3.1] - 2025-10-02
### Changed
- Internal migration to `paft` 0.3.0 without changing the public API surface.
- Switched internal imports to `paft::domain` (domain types) and `paft::money` (money/currency).
- Updated internal `Money` construction to the new `Result`-returning API and replaced scalar ops with `try_mul` where appropriate.
- Examples and docs now import DataFrame traits from `paft::prelude::{ToDataFrame, ToDataFrameVec}`.
- Conversion helpers in `core::conversions` now document potential panics if a non-ISO currency lacks registered metadata (behavior aligned with `paft-money`).
- Profile ISIN fields now validate ISIN format using `paft::domain::Isin` - invalid ISINs are filtered out and stored as `None`.
- Updated tokio-tungstenite to version 0.28
## [0.3.0] - 2025-09-20
### Changed
- Migrated to `paft` 0.2.0 with explicit module paths; removed all `paft::prelude` imports across the codebase, tests, and examples.
- Updated enum/string conversions to use `FromStr/TryFrom` parsing from `paft` 0.2.0 (e.g., `MarketState`, `Exchange`, `Period`, insider/transaction/recommendation types).
- Adjusted `Money` operations to use `try_*` methods and made conversions more robust against non-finite values.
- Consolidated public re-exports under `core::models` (e.g., `Interval`, `Range`, `Quote`, `Action`, `Candle`, `HistoryMeta`, `HistoryResponse`) to provide stable, explicit paths.
- Simplified the Polars example behind the `dataframe` feature to avoid prelude usage and to compile cleanly with the new APIs.
### Fixed
- Updated examples and tests to import `Interval`/`Range` from `yfinance_rs::core` explicitly and to avoid wildcard matches in pattern tests.
### Notes
- This release removes reliance on `paft` preludes and may require users to update imports to explicit module paths if depending on re-exported paft items directly.
## [0.2.1] - 2025-09-18
### Added
- Profile-based reporting currency inference with per-symbol caching. The client now inspects the profile country on first use to determine an appropriate currency and reuses that decision across fundamentals and analysis calls.
- ESG involvement exposure: `Ticker::sustainability()` now returns involvement flags (e.g., tobacco, thermal_coal) alongside component scores via `EsgSummary`.
### Changed
- **Breaking change:** `Ticker` convenience methods for fundamentals and analysis (and their corresponding builders) now accept an extra `Option<Currency>` argument. Pass `None` to use the inferred reporting currency, or `Some(currency)` to override the heuristic explicitly.
- **Breaking change:** `Ticker::sustainability()` and `esg::EsgBuilder::fetch()` now return `EsgSummary` instead of `EsgScores`. Access component values via `summary.scores` and involvement via `summary.involvement`.
## [0.2.0] - 2025-09-16
### Added
- New optional `dataframe` feature: all `paft` data models now support `.to_dataframe()` when the feature is enabled, returning Polars `DataFrame`s. Added example `14_polars_dataframes.rs` and README section.
- Custom HTTP client support via `YfClient::builder().custom_client(...)` for full control over `reqwest` configuration.
- Proxy configuration helpers on the client builder: `.proxy()`, `.https_proxy()`, `.try_proxy()`, `.try_https_proxy()`. Added example `13_custom_client_and_proxy.rs`.
- Explicit `User-Agent` is set on all HTTP/WebSocket requests by default, with `.user_agent(...)` to customize it.
- Improved numeric precision in historical adjustments and conversions using `rust_decimal`.
### Changed
- **Breaking change:** All public data models (such as `Quote`, `HistoryBar`, `EarningsTrendRow`, etc.) now use types from the [`paft`](https://crates.io/crates/paft) crate instead of custom-defined structs. This unifies data structures with other financial Rust libraries and improves interoperability, but may require code changes for downstream users.
- Monetary value handling now uses `paft::Money` with currency awareness across APIs and helpers.
- Consolidated and simplified fundamentals timeseries fetching via a generic helper for consistency.
- Error handling refined: `YfError` variants and messages standardized for 404/429/5xx and unexpected statuses.
- Dependencies updated and internal structure adjusted to support the new features.
### Fixed
- Minor clippy findings and documentation typos.
### Known Issues
- Currency inference relies on company profile metadata. If Yahoo omits or mislabels the headquarters country, the inferred currency can still be incorrect—use the new override parameter to force a specific currency in that case.
## [0.1.3] - 2025-08-31
### Added
- Re-exported `CacheMode` and `RetryConfig` from the `core` module.
### Changed
- `Ticker::new` now takes `&YfClient` instead of taking ownership.
- `SearchBuilder` now takes `&YfClient` instead of taking ownership.
## [0.1.2] - 2025-08-30
### Added
- New examples: `10_convenience_methods.rs`, `11_builder_configuration.rs`, `12_advanced_client.rs`.
- Development tooling: `just` recipes `lint`, `lint-fix`, and `lint-strict`.
- Re-exported `YfClientBuilder` at the crate root (`use yfinance_rs::YfClientBuilder`).
### Changed
- Centralized raw wire types (e.g., `RawNum`) into `src/core/wire.rs`.
- Gated debug file dumps behind the `debug-dumps` feature flag.
### Fixed
- Analyst recommendations now read from `financialData` instead of the incorrect `recommendationMean` field.
- Fixed unnecessary mutable borrow in `StreamBuilder` `run_websocket_stream`
## [0.1.1] - 2025-08-28
### Added
- `ticker.earnings_trend()` for analyst earnings and revenue estimates.
- `ticker.shares()` and `ticker.quarterly_shares()` for historical shares outstanding.
- `ticker.capital_gains()` and inclusion of capital gains in `ticker.actions()`.
- Documentation: added doc comments for `EarningsTrendRow`, `ShareCount`, and `Action::CapitalGain`.
## [0.1.0] - 2025-08-27
### Added
- Initial release of `yfinance-rs`.
- Core functionality: `info`, `history`, `quote`, `fast_info`.
- Advanced data: `options`, `option_chain`, `news`, `income_stmt`, `balance_sheet`, `cashflow`.
- Analysis tools: `recommendations`, `sustainability`, `major_holders`, `institutional_holders`.
- Utilities: `DownloadBuilder`, `StreamBuilder`, `SearchBuilder`.
[0.7.2]: https://github.com/gramistella/yfinance-rs/compare/v0.7.1...v0.7.2
[0.7.1]: https://github.com/gramistella/yfinance-rs/compare/v0.7.0...v0.7.1
[0.7.0]: https://github.com/gramistella/yfinance-rs/compare/v0.6.1...v0.7.0
[0.6.1]: https://github.com/gramistella/yfinance-rs/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/gramistella/yfinance-rs/compare/v0.5.2...v0.6.0
[0.5.2]: https://github.com/gramistella/yfinance-rs/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/gramistella/yfinance-rs/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/gramistella/yfinance-rs/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/gramistella/yfinance-rs/compare/v0.3.1...v0.4.0
[0.3.2]: https://github.com/gramistella/yfinance-rs/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/gramistella/yfinance-rs/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/gramistella/yfinance-rs/compare/v0.2.1...v0.3.0
[0.2.1]: https://github.com/gramistella/yfinance-rs/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/gramistella/yfinance-rs/compare/v0.1.3...v0.2.0
[0.1.3]: https://github.com/gramistella/yfinance-rs/compare/v0.1.2...v0.1.3
[0.1.2]: https://github.com/gramistella/yfinance-rs/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/gramistella/yfinance-rs/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/gramistella/yfinance-rs/releases/tag/v0.1.0

View File

@@ -0,0 +1,129 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the maintainers responsible for enforcement at
limos.seam-64@icloud.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

View File

@@ -0,0 +1,64 @@
# Contributing to yfinance-rs
Thanks for considering a contribution to yfinance-rs! This guide helps you get set up and submit effective pull requests.
## Code of Conduct
Please read and follow our [Code of Conduct](CODE_OF_CONDUCT.md).
## Getting Started
### Prerequisites
- Rust (latest stable)
- Cargo
- Git
- Just command runner
### Setup
```bash
git clone https://github.com/gramistella/yfinance-rs.git
cd yfinance-rs
```
## Development Workflow
### Test (full test, live recording + offline)
```bash
just test
```
### Offline test
```bash
just test-offline
```
### Lint & Format
```bash
just fmt
just lint
```
## Commit Messages
Use [Conventional Commits](https://www.conventionalcommits.org/) for clear history.
## Pull Requests
1. Create a feature branch.
2. Add tests and documentation as needed.
3. Ensure CI basics pass locally (fmt, clippy, test).
4. Open a PR with a concise description and issue links.
## Release
- Maintainers handle releases following [Semantic Versioning](https://semver.org/).
- Update `CHANGELOG.md` with notable changes.
## Support
Open an issue with details, environment info, and steps to reproduce.

View File

@@ -0,0 +1,280 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2024"
name = "yfinance-rs"
version = "0.7.2"
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Ergonomic Rust client for Yahoo Finance, supporting historical prices, real-time streaming, options, fundamentals, and more."
homepage = "https://github.com/gramistella/yfinance-rs"
documentation = "https://docs.rs/yfinance-rs"
readme = "README.md"
keywords = [
"yahoo",
"finance",
"stocks",
"trading",
"yfinance",
]
categories = [
"api-bindings",
"finance",
]
license = "MIT"
repository = "https://github.com/gramistella/yfinance-rs"
[package.metadata.docs.rs]
all-features = true
[package.metadata.cargo-doc]
all-features = true
[features]
dataframe = [
"polars",
"paft/dataframe",
]
debug-dumps = []
default = []
test-mode = []
tracing = ["dep:tracing"]
tracing-subscriber = [
"tracing",
"dep:tracing-subscriber",
]
[lib]
name = "yfinance_rs"
path = "src/lib.rs"
[[example]]
name = "01_basic_usage"
path = "examples/01_basic_usage.rs"
[[example]]
name = "02_fundamentals_and_search"
path = "examples/02_fundamentals_and_search.rs"
[[example]]
name = "03_esg_and_analysis"
path = "examples/03_esg_and_analysis.rs"
[[example]]
name = "04_historical_actions"
path = "examples/04_historical_actions.rs"
[[example]]
name = "05_concurrent_requests"
path = "examples/05_concurrent_requests.rs"
[[example]]
name = "06_realtime_polling"
path = "examples/06_realtime_polling.rs"
[[example]]
name = "07_quarterly_fundamentals"
path = "examples/07_quarterly_fundamentals.rs"
[[example]]
name = "08_advanced_analysis"
path = "examples/08_advanced_analysis.rs"
[[example]]
name = "09_holders_and_insiders"
path = "examples/09_holders_and_insiders.rs"
[[example]]
name = "10_convenience_methods"
path = "examples/10_convenience_methods.rs"
[[example]]
name = "11_builder_configuration"
path = "examples/11_builder_configuration.rs"
[[example]]
name = "12_advanced_client"
path = "examples/12_advanced_client.rs"
[[example]]
name = "13_custom_client_and_proxy"
path = "examples/13_custom_client_and_proxy.rs"
[[example]]
name = "14_polars_dataframes"
path = "examples/14_polars_dataframes.rs"
required-features = ["dataframe"]
[[test]]
name = "analysis"
path = "tests/analysis.rs"
[[test]]
name = "auth"
path = "tests/auth.rs"
[[test]]
name = "common"
path = "tests/common.rs"
[[test]]
name = "currency"
path = "tests/currency.rs"
[[test]]
name = "download"
path = "tests/download.rs"
[[test]]
name = "esg"
path = "tests/esg.rs"
[[test]]
name = "fundamentals"
path = "tests/fundamentals.rs"
[[test]]
name = "history"
path = "tests/history.rs"
[[test]]
name = "holders"
path = "tests/holders.rs"
[[test]]
name = "news"
path = "tests/news.rs"
[[test]]
name = "profile"
path = "tests/profile.rs"
[[test]]
name = "quotes"
path = "tests/quotes.rs"
[[test]]
name = "quotes_status_mapping"
path = "tests/quotes_status_mapping.rs"
[[test]]
name = "search"
path = "tests/search.rs"
[[test]]
name = "stream"
path = "tests/stream.rs"
[[test]]
name = "ticker"
path = "tests/ticker.rs"
[dependencies.base64]
version = "0.22"
[dependencies.chrono]
version = "0.4.42"
features = ["serde"]
[dependencies.chrono-tz]
version = "0.10"
features = ["serde"]
[dependencies.futures]
version = "0.3"
[dependencies.futures-util]
version = "0.3"
[dependencies.paft]
version = "0.7.1"
features = [
"market",
"fundamentals",
"domain",
"aggregates",
]
[dependencies.polars]
version = "0.51"
optional = true
default-features = false
[dependencies.prost]
version = "0.14"
[dependencies.reqwest]
version = "0.12.24"
features = [
"json",
"rustls-tls",
"gzip",
"brotli",
"deflate",
"cookies",
]
default-features = false
[dependencies.rust_decimal]
version = "1.36"
[dependencies.serde]
version = "1.0.228"
features = ["derive"]
[dependencies.serde_json]
version = "1.0.145"
[dependencies.thiserror]
version = "2.0"
[dependencies.tokio]
version = "1.48"
features = [
"macros",
"rt-multi-thread",
]
[dependencies.tokio-tungstenite]
version = "0.28"
features = ["rustls-tls-native-roots"]
[dependencies.tracing]
version = "0.1"
optional = true
[dependencies.tracing-subscriber]
version = "0.3"
features = [
"fmt",
"std",
"env-filter",
]
optional = true
default-features = false
[dependencies.url]
version = "2.5.7"
[dev-dependencies.httpmock]
version = "0.8.2"
[dev-dependencies.polars]
version = "0.51"
features = [
"lazy",
"rolling_window",
]
default-features = false

View File

@@ -0,0 +1,64 @@
[package]
name = "yfinance-rs"
version = "0.7.2"
edition = "2024"
description = "Ergonomic Rust client for Yahoo Finance, supporting historical prices, real-time streaming, options, fundamentals, and more."
license = "MIT"
repository = "https://github.com/gramistella/yfinance-rs"
homepage = "https://github.com/gramistella/yfinance-rs"
documentation = "https://docs.rs/yfinance-rs"
readme = "README.md"
keywords = ["yahoo", "finance", "stocks", "trading", "yfinance"]
categories = ["api-bindings", "finance"]
build = "build.rs"
[dependencies]
chrono = { version = "0.4.42", features = ["serde"] }
chrono-tz = { version = "0.10", features = ["serde"] }
reqwest = { version = "0.12.24", default-features = false, features = ["json", "rustls-tls", "gzip", "brotli", "deflate", "cookies"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
thiserror = "2.0"
url = "2.5.7"
tokio = { version = "1.48", features = ["macros", "rt-multi-thread"] }
futures = "0.3"
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-native-roots"] }
futures-util = "0.3"
prost = "0.14"
base64 = "0.22"
polars = { version = "0.51", default-features = false, optional = true }
paft = { version = "0.7.1", features = ["market", "fundamentals", "domain", "aggregates"]}
rust_decimal = "1.36"
tracing = { version = "0.1", optional = true }
[dependencies.tracing-subscriber]
version = "0.3"
optional = true
default-features = false
features = ["fmt", "std", "env-filter"]
[dev-dependencies]
httpmock = "0.8.2"
polars = { version = "0.51", default-features = false, features = ["lazy", "rolling_window"] }
[build-dependencies]
prost-build = "0.14"
[features]
default = []
test-mode = []
debug-dumps = []
dataframe = ["polars", "paft/dataframe"]
tracing = ["dep:tracing"]
# Dev-only convenience to initialize a subscriber in examples/tests
tracing-subscriber = ["tracing", "dep:tracing-subscriber"]
[package.metadata.docs.rs]
all-features = true
[package.metadata.cargo-doc]
all-features = true
[[example]]
name = "14_polars_dataframes"
required-features = ["dataframe"]

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Giovanni Ramistella
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,571 @@
# yfinance-rs
[![Crates.io](https://img.shields.io/crates/v/yfinance-rs.svg)](https://crates.io/crates/yfinance-rs)
[![Docs.rs](https://docs.rs/yfinance-rs/badge.svg)](https://docs.rs/yfinance-rs)
[![CI](https://github.com/gramistella/yfinance-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/gramistella/yfinance-rs/actions/workflows/ci.yml)
[![Downloads](https://img.shields.io/crates/d/yfinance-rs)](https://crates.io/crates/yfinance-rs)
[![License](https://img.shields.io/crates/l/yfinance-rs)](LICENSE)
## Overview
An ergonomic, async-first Rust client for the unofficial Yahoo Finance API. It provides a simple and efficient way to fetch financial data, with a convenient, yfinance-like API, leveraging Rust's type system and async runtime for performance and safety.
## Features
### Core Data
* **Historical Data**: Fetch daily, weekly, or monthly OHLCV data with automatic split/dividend adjustments.
* **Real-time Quotes**: Get live quote updates with detailed market information.
* **Fast Quotes**: Optimized quote fetching with essential data only (`fast_info`).
* **Multi-Symbol Downloads**: Concurrently download historical data for many symbols at once.
* **Batch Quotes**: Fetch quotes for multiple symbols efficiently.
### Corporate Actions & Dividends
* **Dividend History**: Fetch complete dividend payment history with amounts and dates.
* **Stock Splits**: Get stock split history with split ratios.
* **Capital Gains**: Retrieve capital gains distributions (especially for mutual funds).
* **All Corporate Actions**: Comprehensive access to dividends, splits, and capital gains in one call.
### Financial Statements & Fundamentals
* **Income Statements**: Access annual and quarterly income statements.
* **Balance Sheets**: Get annual and quarterly balance sheet data.
* **Cash Flow Statements**: Fetch annual and quarterly cash flow data.
* **Earnings Data**: Historical earnings, revenue estimates, and EPS data.
* **Shares Outstanding**: Historical data on shares outstanding (annual and quarterly).
* **Corporate Calendar**: Earnings dates, ex-dividend dates, and dividend payment dates.
### Options & Derivatives
* **Options Chains**: Fetch expiration dates and full option chains (calls and puts).
* **Option Contracts**: Detailed option contract information.
### Analysis & Research
* **Analyst Ratings**: Get price targets, recommendations, and upgrade/downgrade history.
* **Earnings Trends**: Detailed earnings and revenue estimates from analysts.
* **Recommendations Summary**: Summary of current analyst recommendations.
* **Upgrades/Downgrades**: History of analyst rating changes.
### Ownership & Holders
* **Major Holders**: Get major, institutional, and mutual fund holder data.
* **Institutional Holders**: Top institutional shareholders and their holdings.
* **Mutual Fund Holders**: Mutual fund ownership breakdown.
* **Insider Transactions**: Recent insider buying and selling activity.
* **Insider Roster**: Company insiders and their current holdings.
* **Net Share Activity**: Summary of insider purchase/sale activity.
### ESG & Sustainability
* **ESG Scores**: Fetch detailed Environmental, Social, and Governance ratings.
* **ESG Involvement**: Specific ESG involvement and controversy data.
### News & Information
* **Company News**: Retrieve the latest articles and press releases for a ticker.
* **Company Profiles**: Detailed information about companies, ETFs, and funds.
* **Search**: Find tickers by name or keyword.
### Real-time Streaming (WebSocket/Polling)
* **WebSocket Streaming**: Get live quote updates using WebSockets (preferred method).
* **HTTP Polling**: Fallback polling method for real-time data.
* **Configurable Streaming**: Customize update frequency and change-only filtering.
* **Per-update volume deltas**: `QuoteUpdate.volume` reflects the delta since the previous update for that symbol. The first observed tick (and after a reset/rollover) has `volume = None`.
### Advanced Features
* **Data Repair**: Automatic detection and repair of price outliers.
* **Data Rounding**: Control price precision and rounding.
* **Missing Data Handling**: Configurable handling of NA/missing values.
* **Back Adjustment**: Alternative price adjustment methods.
* **Historical Metadata**: Timezone and other metadata for historical data.
* **ISIN Lookup**: Get International Securities Identification Numbers.
* **Polars DataFrames**: Convert results to Polars DataFrames via `.to_dataframe()` (enable the `dataframe` feature).
### Developer Experience
* **Async API**: Built on `tokio` and `reqwest` for non-blocking I/O.
* **High-Level `Ticker` Interface**: A convenient, yfinance-like struct for accessing all data for a single symbol.
* **Builder Pattern**: Fluent builders for constructing complex queries.
* **Configurable Retries**: Automatic retries with exponential backoff for transient network errors.
* **Caching**: Configurable caching behavior for API responses.
* **Custom Timeouts**: Configurable request timeouts and connection settings.
## Quick Start
To get started, add `yfinance-rs` to your `Cargo.toml`:
```toml
[dependencies]
yfinance-rs = "0.7.2"
tokio = { version = "1", features = ["full"] }
```
To enable DataFrame conversions backed by Polars, turn on the optional `dataframe` feature and (if you use Polars types in your code) add `polars`:
```toml
[dependencies]
yfinance-rs = { version = "0.7.2", features = ["dataframe"] }
polars = "0.51"
```
Then, create a `YfClient` and use a `Ticker` to fetch data.
```rust
use yfinance_rs::{Interval, Range, Ticker, YfClient};
use yfinance_rs::core::conversions::money_to_f64;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::default();
let ticker = Ticker::new(&client, "AAPL");
// Get the latest quote
let quote = ticker.quote().await?;
println!(
"Latest price for AAPL: ${:.2}",
quote.price.as_ref().map(money_to_f64).unwrap_or(0.0)
);
// Get historical data for the last 6 months
let history = ticker.history(Some(Range::M6), Some(Interval::D1), false).await?;
if let Some(last_bar) = history.last() {
println!(
"Last closing price: ${:.2} on {}",
money_to_f64(&last_bar.close),
last_bar.ts
);
}
// Get analyst recommendations
let recs = ticker.recommendations().await?;
if let Some(latest_rec) = recs.first() {
println!("Latest recommendation period: {}", latest_rec.period);
}
// Dividends in the last year
let dividends = ticker.dividends(Some(Range::Y1)).await?;
println!("Found {} dividend payments in the last year", dividends.len());
// Earnings trend
let trends = ticker.earnings_trend(None).await?;
if let Some(latest) = trends.first() {
println!(
"Latest earnings estimate: ${:.2}",
latest
.earnings_estimate
.avg
.as_ref()
.map(money_to_f64)
.unwrap_or(0.0)
);
}
Ok(())
}
```
### Troubleshooting
**Possible network or consent issues**
Some users [have reported](https://github.com/gramistella/yfinance-rs/issues/1) encountering errors on first use, such as:
- `Rate limited at ...`
- `HTTP error: error sending request for url (https://fc.yahoo.com/consent)`
These are typically **environmental** (network or regional) issues with Yahoos public API.
In some regions, Yahoo may require a one-time consent or session initialization.
**Workaround:**
Open [`https://fc.yahoo.com/consent`](https://fc.yahoo.com/consent) in a web browser **from the same network** before running your code again.
This usually resolves the issue for that IP/network.
### Tracing (optional)
This crate can emit structured tracing spans and key events when the optional `tracing` feature is enabled. When disabled (default), all instrumentation is compiled out with zero overhead. The library does not configure a subscriber; set one up in your application.
Spans are added at: `Ticker` public APIs (`info`, `quote`, `history`, etc.), HTTP `send_with_retry`, profile fallback, quote summary fetch (including invalid-crumb retry), and full history fetch. Key events include retry/backoff and fallback notifications.
## Advanced Examples
### Polars DataFrames (to_dataframe)
Enable the `dataframe` feature to convert paft models into a Polars `DataFrame` with `.to_dataframe()`.
```rust
use yfinance_rs::{Interval, Range, Ticker, YfClient};
use paft::prelude::{ToDataFrame, ToDataFrameVec};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::default();
let ticker = Ticker::new(&client, "AAPL");
// Quote → DataFrame
let quote_df = ticker.quote().await?.to_dataframe()?;
println!("Quote as DataFrame:\n{}", quote_df);
// History (Vec<Candle>) → DataFrame
let hist_df = ticker
.history(Some(Range::M1), Some(Interval::D1), false)
.await?
.to_dataframe()?;
println!("History rows: {}", hist_df.height());
Ok(())
}
```
Works for quotes, historical candles, fundamentals, analyst data, holders, options, and more. All `paft` structs returned by this crate implement `.to_dataframe()` when the `dataframe` feature is enabled. See the full example: `examples/14_polars_dataframes.rs`.
### Multi-Symbol Data Download
```rust
use yfinance_rs::{DownloadBuilder, Interval, Range, YfClient};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::default();
let symbols = vec!["AAPL", "GOOGL", "MSFT", "TSLA"];
let results = DownloadBuilder::new(&client)
.symbols(symbols)
.range(Range::M6)
.interval(Interval::D1)
.auto_adjust(true)
.actions(true)
.repair(true)
.rounding(true)
.run()
.await?;
for entry in &results.entries {
println!("{}: {} data points", entry.instrument.symbol(), entry.history.candles.len());
}
Ok(())
}
```
### Real-time Streaming
```rust
use yfinance_rs::{StreamBuilder, StreamMethod, YfClient};
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::default();
let (handle, mut receiver) = StreamBuilder::new(&client)
.symbols(vec!["AAPL", "GOOGL"])
.method(StreamMethod::WebsocketWithFallback)
.interval(Duration::from_secs(1))
.diff_only(true)
.start()?;
while let Some(update) = receiver.recv().await {
let vol = update.volume.map(|v| format!(" (vol Δ: {v})")).unwrap_or_default();
println!("{}: ${:.2}{}",
update.symbol,
update.price.as_ref().map(yfinance_rs::core::conversions::money_to_f64).unwrap_or(0.0),
vol);
}
#### Volume semantics
Yahoos websocket stream provides cumulative intraday volume (`day_volume`). This crate converts it to per-update deltas on the consumer-facing `QuoteUpdate`:
- First tick per symbol and after a detected reset (current < last): `volume = None`.
- Otherwise: `volume = Some(current_day_volume - last_day_volume)`.
- The polling stream applies the same logic using the v7 `regularMarketVolume` field.
- The low-level decoder helper `stream::decode_and_map_message` is stateless and always returns `volume = None`.
If you need cumulative volume, sum the emitted per-update `volume` values, or use `Quote.day_volume` from the quote endpoints.
Ok(())
}
```
### Financial Statements
```rust
use yfinance_rs::{Ticker, YfClient};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::default();
let ticker = Ticker::new(&client, "AAPL");
let income_stmt = ticker.quarterly_income_stmt(None).await?;
let balance_sheet = ticker.quarterly_balance_sheet(None).await?;
let cashflow = ticker.quarterly_cashflow(None).await?;
println!("Found {} quarterly income statements.", income_stmt.len());
println!("Found {} quarterly balance sheet statements.", balance_sheet.len());
println!("Found {} quarterly cashflow statements.", cashflow.len());
let shares = ticker.quarterly_shares().await?;
if let Some(latest) = shares.first() {
println!("Latest shares outstanding: {}", latest.shares);
}
Ok(())
}
```
> 💡 Need to force a specific reporting currency? Pass `Some(paft::money::Currency::USD)` (or another currency) instead of `None` when calling the fundamentals/analysis helpers.
### Options Trading
```rust
use yfinance_rs::{Ticker, YfClient};
use yfinance_rs::core::conversions::money_to_f64;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::default();
let ticker = Ticker::new(&client, "AAPL");
let expirations = ticker.options().await?;
if let Some(nearest) = expirations.first() {
let chain = ticker.option_chain(Some(*nearest)).await?;
println!("Calls: {}", chain.calls.len());
println!("Puts: {}", chain.puts.len());
let fi = ticker.fast_info().await?;
let current_price = fi
.last
.as_ref()
.map(money_to_f64)
.or_else(|| fi.previous_close.as_ref().map(money_to_f64))
.unwrap_or(0.0);
for call in &chain.calls {
if (money_to_f64(&call.strike) - current_price).abs() < 5.0 {
println!(
"ATM Call: Strike ${:.2}, Bid ${:.2}, Ask ${:.2}",
money_to_f64(&call.strike),
call.bid.as_ref().map(money_to_f64).unwrap_or(0.0),
call.ask.as_ref().map(money_to_f64).unwrap_or(0.0)
);
}
}
}
Ok(())
}
```
### Advanced Analysis
```rust
use yfinance_rs::{Ticker, YfClient};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::default();
let ticker = Ticker::new(&client, "AAPL");
let price_target = ticker.analyst_price_target(None).await?;
let recs_summary = ticker.recommendations_summary().await?;
let upgrades = ticker.upgrades_downgrades().await?;
let earnings_trends = ticker.earnings_trend(None).await?;
println!(
"Price Target: ${:.2}",
price_target.mean.as_ref().map(yfinance_rs::core::conversions::money_to_f64).unwrap_or(0.0)
);
println!(
"Recommendation: {}",
recs_summary
.mean_rating_text
.as_deref()
.unwrap_or("N/A")
);
println!("Trend rows: {}", earnings_trends.len());
println!("Upgrades: {}", upgrades.len());
Ok(())
}
```
### Holder Information
```rust
use yfinance_rs::{Ticker, YfClient};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::default();
let ticker = Ticker::new(&client, "AAPL");
let major_holders = ticker.major_holders().await?;
let institutional = ticker.institutional_holders().await?;
let mutual_funds = ticker.mutual_fund_holders().await?;
let insider_transactions = ticker.insider_transactions().await?;
for holder in &major_holders {
println!("{}: {}", holder.category, holder.value);
}
println!("Institutional rows: {}", institutional.len());
println!("Mutual fund rows: {}", mutual_funds.len());
println!("Insider transactions: {}", insider_transactions.len());
Ok(())
}
```
### ESG Scores & Involvement
```rust
use yfinance_rs::{Ticker, YfClient};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::default();
let ticker = Ticker::new(&client, "AAPL");
let summary = ticker.sustainability().await?;
let parts = summary
.scores
.as_ref()
.map(|s| [s.environmental, s.social, s.governance])
.unwrap_or([None, None, None]);
let vals = parts.into_iter().flatten().collect::<Vec<_>>();
let total = if vals.is_empty() { 0.0 } else { vals.iter().copied().sum::<f64>() / (vals.len() as f64) };
println!("Total ESG Score: {:.2}", total);
if let Some(scores) = summary.scores.as_ref() {
println!("Environmental Score: {:.2}", scores.environmental.unwrap_or(0.0));
println!("Social Score: {:.2}", scores.social.unwrap_or(0.0));
println!("Governance Score: {:.2}", scores.governance.unwrap_or(0.0));
}
Ok(())
}
```
### Advanced Client Configuration
```rust
use yfinance_rs::{YfClientBuilder, Ticker, core::client::{Backoff, CacheMode, RetryConfig}};
use std::time::Duration;
use yfinance_rs::core::conversions::money_to_f64;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClientBuilder::default()
.timeout(Duration::from_secs(10))
.retry_config(RetryConfig {
max_retries: 3,
backoff: Backoff::Exponential {
base: Duration::from_millis(100),
factor: 2.0,
max: Duration::from_secs(5),
jitter: true,
},
..Default::default()
})
.build()?;
let ticker = Ticker::new(&client, "AAPL")
.cache_mode(CacheMode::Bypass)
.retry_policy(Some(RetryConfig {
max_retries: 5,
..Default::default()
}));
let quote = ticker.quote().await?;
println!(
"Latest price for AAPL with custom client: ${:.2}",
quote.price.as_ref().map(money_to_f64).unwrap_or(0.0)
);
Ok(())
}
```
#### Custom Reqwest Client
For full control over HTTP configuration, you can provide your own reqwest client:
```rust
use yfinance_rs::{YfClient, Ticker};
use yfinance_rs::core::conversions::money_to_f64;
use reqwest::Client;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let custom_client = Client::builder()
.user_agent("yfinance-rs-playground") // Make sure to set a proper user agent
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.pool_idle_timeout(Duration::from_secs(90))
.pool_max_idle_per_host(10)
.tcp_keepalive(Some(Duration::from_secs(60)))
.build()?;
let client = YfClient::builder()
.custom_client(custom_client)
.cache_ttl(Duration::from_secs(300))
.build()?;
let ticker = Ticker::new(&client, "AAPL");
let quote = ticker.quote().await?;
println!(
"Latest price for AAPL: ${:.2}",
quote.price.as_ref().map(money_to_f64).unwrap_or(0.0)
);
Ok(())
}
```
#### Proxy Configuration
You can configure HTTP/HTTPS proxies through the builder:
```rust
use yfinance_rs::{YfClient, YfClientBuilder, Ticker};
use yfinance_rs::core::conversions::money_to_f64;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::builder()
.try_proxy("http://proxy.example.com:8080")?
.timeout(Duration::from_secs(30))
.build()?;
let client_https = YfClient::builder()
.try_https_proxy("https://proxy.example.com:8443")?
.timeout(Duration::from_secs(30))
.build()?;
let client_simple = YfClient::builder()
.proxy("http://proxy.example.com:8080")
.timeout(Duration::from_secs(30))
.build()?;
let ticker = Ticker::new(&client, "AAPL");
let quote = ticker.quote().await?;
println!(
"Latest price for AAPL via proxy: ${:.2}",
quote.price.as_ref().map(money_to_f64).unwrap_or(0.0)
);
Ok(())
}
```
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Contributing
Please see our [Contributing Guide](CONTRIBUTING.md) and our [Code of Conduct](CODE_OF_CONDUCT.md). We welcome pull requests and issues.
## Changelog
See **[CHANGELOG.md](https://github.com/gramistella/yfinance-rs/blob/main/CHANGELOG.md)** for release notes and breaking changes.

View File

@@ -0,0 +1,3 @@
fn main() {
println!("cargo:rerun-if-changed=build.rs");
}

View File

@@ -0,0 +1,204 @@
use chrono::{Duration, Utc};
use std::time::Duration as StdDuration;
use yfinance_rs::core::Interval;
use yfinance_rs::core::conversions::money_to_f64;
use yfinance_rs::{
DownloadBuilder, NewsTab, StreamBuilder, StreamMethod, Ticker, YfClient, YfClientBuilder,
YfError,
};
#[tokio::main]
async fn main() -> Result<(), YfError> {
let client = YfClientBuilder::default()
.timeout(StdDuration::from_secs(5))
.build()?;
section_info(&client).await?;
section_fast_info(&client).await?;
section_batch_quotes(&client).await?;
section_download(&client).await?;
section_options(&client).await?;
section_stream(&client).await?;
section_news(&client).await?;
Ok(())
}
async fn section_info(client: &YfClient) -> Result<(), YfError> {
let msft = Ticker::new(client, "MSFT");
let info = msft.info().await?;
println!("--- Ticker Info for {} ---", info.symbol);
println!("Name: {}", info.name.unwrap_or_default());
println!(
"Last Price: ${:.2}",
info.last.as_ref().map(money_to_f64).unwrap_or_default()
);
if let Some(v) = info.volume {
println!("Volume (day): {v}");
}
if let Some(pt) = info.price_target.as_ref()
&& let Some(mean) = pt.mean.as_ref()
{
println!("Price target mean: ${:.2}", money_to_f64(mean));
}
if let Some(rs) = info.recommendation_summary.as_ref() {
let mean = rs.mean.unwrap_or_default();
let text = rs.mean_rating_text.as_deref().unwrap_or("N/A");
println!("Recommendation mean: {mean:.2} ({text})");
}
println!();
Ok(())
}
async fn section_fast_info(client: &YfClient) -> Result<(), YfError> {
println!("--- Fast Info for NVDA ---");
let nvda = Ticker::new(client, "NVDA");
let fast_info = nvda.fast_info().await?;
let price_money = fast_info
.last
.clone()
.or_else(|| fast_info.previous_close.clone())
.expect("last or previous_close present");
println!(
"{} is trading at ${:.2} in {}",
fast_info.symbol,
yfinance_rs::core::conversions::money_to_f64(&price_money),
fast_info
.exchange
.map(|e| e.to_string())
.unwrap_or_default()
);
if let Some(v) = fast_info.volume {
println!("Day volume: {v}");
}
println!();
Ok(())
}
async fn section_batch_quotes(client: &YfClient) -> Result<(), YfError> {
println!("--- Batch Quotes for Multiple Symbols ---");
let quotes = yfinance_rs::quotes(client, vec!["AMD", "INTC", "QCOM"]).await?;
for quote in quotes {
let vol = quote
.day_volume
.map(|v| format!(" (vol: {v})"))
.unwrap_or_default();
println!(
" {}: ${:.2}{}",
quote.symbol,
quote.price.as_ref().map(money_to_f64).unwrap_or_default(),
vol
);
}
println!();
Ok(())
}
async fn section_download(client: &YfClient) -> Result<(), YfError> {
let symbols = vec!["AAPL", "GOOG", "TSLA"];
let today = Utc::now();
let three_months_ago = today - Duration::days(90);
println!("--- Historical Data for Multiple Symbols ---");
let results = DownloadBuilder::new(client)
.symbols(symbols)
.between(three_months_ago, today)
.interval(Interval::D1)
.run()
.await?;
for entry in &results.entries {
let symbol = entry.instrument.symbol();
let candles = &entry.history.candles;
println!("{} has {} data points.", symbol, candles.len());
if let Some(last_candle) = candles.last() {
println!(
" Last close price: ${:.2}",
money_to_f64(&last_candle.close)
);
}
}
println!();
Ok(())
}
async fn section_options(client: &YfClient) -> Result<(), YfError> {
let aapl = Ticker::new(client, "AAPL");
let expirations = aapl.options().await?;
if let Some(first_expiry) = expirations.first() {
println!("--- Options Chain for AAPL ({first_expiry}) ---");
let chain = aapl.option_chain(Some(*first_expiry)).await?;
println!(
" Found {} calls and {} puts.",
chain.calls.len(),
chain.puts.len()
);
if let Some(first_call) = chain.calls.first() {
println!(
" First call option: {} @ ${:.2}",
first_call.contract_symbol,
money_to_f64(&first_call.strike)
);
}
}
println!();
Ok(())
}
async fn section_stream(client: &YfClient) -> Result<(), YfError> {
println!("--- Streaming Real-time Quotes for MSFT and GOOG ---");
println!("(Streaming for 10 seconds or until stopped...)");
let (handle, mut receiver) = StreamBuilder::new(client)
.symbols(vec!["GME"])
.method(StreamMethod::WebsocketWithFallback)
.start()?;
let stream_task = tokio::spawn(async move {
let mut count = 0;
while let Some(update) = receiver.recv().await {
let vol = update
.volume
.map(|v| format!(" (vol Δ: {v})"))
.unwrap_or_default();
println!(
"[{}] {} @ {:.2}{}",
update.ts,
update.symbol,
update.price.as_ref().map(money_to_f64).unwrap_or_default(),
vol
);
count += 1;
if count >= 1000 {
break;
}
}
println!("Finished streaming after {count} updates.");
});
tokio::select! {
() = tokio::time::sleep(StdDuration::from_secs(1000)) => {
println!("Stopping stream due to timeout.");
handle.stop().await;
}
_ = stream_task => {
println!("Stream task completed on its own.");
}
};
Ok(())
}
async fn section_news(client: &YfClient) -> Result<(), YfError> {
let tesla_news = Ticker::new(client, "TSLA");
let articles = tesla_news
.news_builder()
.tab(NewsTab::PressReleases)
.count(5)
.fetch()
.await?;
println!("\n--- Latest 5 Press Releases for TSLA ---");
for article in articles {
println!(
"- {} by {}",
article.title,
article.publisher.unwrap_or_else(|| "Unknown".to_string())
);
}
Ok(())
}

View File

@@ -0,0 +1,103 @@
use yfinance_rs::core::conversions::money_to_f64;
use yfinance_rs::{FundamentalsBuilder, HoldersBuilder, SearchBuilder, YfClient};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::default();
let symbol = "MSFT";
// --- Part 1: Fetching Fundamentals ---
println!("--- Fetching Fundamentals for {symbol} ---");
let fundamentals = FundamentalsBuilder::new(&client, symbol);
let annual_income_stmt = fundamentals.income_statement(false, None).await?;
println!(
"Latest Annual Income Statement ({} periods):",
annual_income_stmt.len()
);
if let Some(stmt) = annual_income_stmt.first() {
println!(
" Period End: {} | Total Revenue: {:.2}",
stmt.period,
stmt.total_revenue
.as_ref()
.map(money_to_f64)
.unwrap_or_default()
);
}
let quarterly_balance_sheet = fundamentals.balance_sheet(true, None).await?;
println!(
"Latest Quarterly Balance Sheet ({} periods):",
quarterly_balance_sheet.len()
);
if let Some(stmt) = quarterly_balance_sheet.first() {
println!(
" Period End: {} | Total Assets: {:.2}",
stmt.period,
stmt.total_assets
.as_ref()
.map(money_to_f64)
.unwrap_or_default()
);
}
let earnings = fundamentals.earnings(None).await?;
println!("Latest Earnings Summary:");
if let Some(e) = earnings.quarterly.first() {
println!(
" Quarter {}: Revenue: {:.2} | Earnings: {:.2}",
e.period,
e.revenue.as_ref().map(money_to_f64).unwrap_or_default(),
e.earnings.as_ref().map(money_to_f64).unwrap_or_default()
);
}
println!("--------------------------------------\n");
// --- Part 2: Fetching Holder Information ---
println!("--- Fetching Holder Info for {symbol} ---");
let holders_builder = HoldersBuilder::new(&client, symbol);
let major_holders = holders_builder.major_holders().await?;
println!("Major Holders Breakdown:");
for holder in major_holders {
println!(" {}: {}", holder.category, holder.value);
}
let inst_holders = holders_builder.institutional_holders().await?;
println!("\nTop 5 Institutional Holders:");
for holder in inst_holders.iter().take(5) {
println!(
" - {}: {:?} shares ({:?}%)",
holder.holder, holder.shares, holder.pct_held
);
}
let net_activity = holders_builder.net_share_purchase_activity().await?;
if let Some(activity) = net_activity {
println!("\nNet Insider Purchase Activity ({}):", activity.period);
println!(" Net shares bought/sold: {:?}", activity.net_shares);
}
println!("--------------------------------------\n");
// --- Part 3: Searching for Tickers ---
let query = "S&P 500";
println!("--- Searching for '{query}' ---");
let search_results = SearchBuilder::new(&client, query)
.lang("en")
.region("US")
.fetch()
.await?;
println!("Found {} results:", search_results.results.len());
for quote in search_results.results {
let name = quote.name.unwrap_or_default();
let exchange = quote.exchange.map(|e| e.to_string()).unwrap_or_default();
let kind = quote.kind.to_string();
println!(" - {}: {} ({}) on {}", quote.symbol, name, kind, exchange);
}
println!("--------------------------------------");
Ok(())
}

View File

@@ -0,0 +1,124 @@
use chrono::Duration;
use yfinance_rs::{SearchBuilder, Ticker, YfClientBuilder};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClientBuilder::default()
.timeout(Duration::seconds(5).to_std()?)
.build()?;
section_esg(&client).await?;
section_analysis(&client).await?;
section_search(&client).await?;
Ok(())
}
async fn section_esg(client: &yfinance_rs::YfClient) -> Result<(), Box<dyn std::error::Error>> {
let msft_ticker = Ticker::new(client, "MSFT");
let esg_scores = msft_ticker.sustainability().await;
println!("--- ESG Scores for MSFT ---");
match esg_scores {
Ok(summary) => {
let scores = summary.scores.unwrap_or_default();
let total_esg = [scores.environmental, scores.social, scores.governance]
.into_iter()
.flatten()
.collect::<Vec<_>>();
let total_esg_score = if total_esg.is_empty() {
0.0
} else {
let denom = u32::try_from(total_esg.len()).map(f64::from).unwrap_or(1.0);
total_esg.iter().sum::<f64>() / denom
};
println!("Total ESG Score: {total_esg_score:.2}");
println!(
"Environmental Score: {:.2}",
scores.environmental.unwrap_or_default()
);
println!("Social Score: {:.2}", scores.social.unwrap_or_default());
println!(
"Governance Score: {:.2}",
scores.governance.unwrap_or_default()
);
if !summary.involvement.is_empty() {
println!("Involvement categories ({}):", summary.involvement.len());
for inv in summary.involvement.iter().take(5) {
println!(" - {}", inv.category);
}
}
}
Err(e) => eprintln!("Failed to fetch ESG scores: {e}"),
}
println!("--------------------------------------\n");
Ok(())
}
async fn section_analysis(
client: &yfinance_rs::YfClient,
) -> Result<(), Box<dyn std::error::Error>> {
let tsla_ticker = Ticker::new(client, "TSLA");
let recommendations = tsla_ticker.recommendations().await;
println!("--- Analyst Recommendations for TSLA ---");
match recommendations {
Ok(recs) => {
if let Some(latest) = recs.first() {
println!(
"Latest Recommendation Period ({}): Strong Buy: {:?}, Buy: {:?}, Hold: {:?}, Sell: {:?}, Strong Sell: {:?}",
latest.period,
latest.strong_buy,
latest.buy,
latest.hold,
latest.sell,
latest.strong_sell
);
}
}
Err(e) => eprintln!("Failed to fetch recommendations: {e}"),
}
let upgrades = tsla_ticker.upgrades_downgrades().await;
if let Ok(upgrades_list) = upgrades {
println!("\nRecent Upgrades/Downgrades:");
for upgrade in upgrades_list.iter().take(3) {
println!(
" - Firm: {} | Action: {} | From: {} | To: {}",
upgrade.firm.as_deref().unwrap_or("N/A"),
upgrade
.action
.as_ref()
.map_or_else(|| "N/A".to_string(), std::string::ToString::to_string),
upgrade
.from_grade
.as_ref()
.map_or_else(|| "N/A".to_string(), std::string::ToString::to_string),
upgrade
.to_grade
.as_ref()
.map_or_else(|| "N/A".to_string(), std::string::ToString::to_string)
);
}
}
println!("--------------------------------------\n");
Ok(())
}
async fn section_search(client: &yfinance_rs::YfClient) -> Result<(), Box<dyn std::error::Error>> {
let query = "Apple Inc.";
let search_results = SearchBuilder::new(client, query).fetch().await;
println!("--- Searching for '{query}' ---");
match search_results {
Ok(results) => {
println!("Found {} results:", results.results.len());
for quote in results.results.iter().take(5) {
println!(
" - {} ({}) : {}",
quote.symbol,
quote.kind,
quote.name.as_deref().unwrap_or("N/A")
);
}
}
Err(e) => eprintln!("Search failed: {e}"),
}
println!("--------------------------------------");
Ok(())
}

View File

@@ -0,0 +1,68 @@
use chrono::{Duration, Utc};
use yfinance_rs::core::conversions::money_to_f64;
use yfinance_rs::core::{Interval, Range};
use yfinance_rs::{DownloadBuilder, Ticker, YfClient};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::default();
// --- Part 1: Fetching Historical Dividends and Splits ---
let aapl_ticker = Ticker::new(&client, "AAPL");
println!("--- Fetching Historical Actions for AAPL (last 5 years) ---");
let dividends = aapl_ticker.dividends(Some(Range::Y5)).await?;
println!("Found {} dividends in the last 5 years.", dividends.len());
if let Some((ts, amount)) = dividends.last() {
println!(" Latest dividend: ${amount:.2} on {ts}");
}
let splits = aapl_ticker.splits(Some(Range::Y5)).await?;
println!("\nFound {} splits in the last 5 years.", splits.len());
for (ts, num, den) in splits {
println!(" - Split of {num}:{den} on {ts}");
}
println!("--------------------------------------\n");
// --- Part 2: Advanced Multi-Symbol Download with Customization ---
let symbols = vec!["AAPL", "GOOGL", "MSFT", "AMZN"];
println!("--- Downloading Custom Historical Data for Multiple Symbols ---");
println!("Fetching 1-week, auto-adjusted data for the last 30 days...");
let thirty_days_ago = Utc::now() - Duration::days(30);
let now = Utc::now();
let results = DownloadBuilder::new(&client)
.symbols(symbols)
.between(thirty_days_ago, now)
.interval(Interval::W1)
.auto_adjust(true) // default, but explicit here
.back_adjust(true) // show back-adjustment
.repair(true) // show outlier repair
.rounding(true) // show rounding
.run()
.await?;
for entry in &results.entries {
let symbol = entry.instrument.symbol();
let candles = &entry.history.candles;
println!("- {} ({} candles)", symbol, candles.len());
if let Some(first_candle) = candles.first() {
println!(" First Open: ${:.2}", money_to_f64(&first_candle.open));
}
if let Some(last_candle) = candles.last() {
println!(" Last Close: ${:.2}", money_to_f64(&last_candle.close));
}
}
println!("--------------------------------------");
let meta = aapl_ticker.get_history_metadata(Some(Range::Y1)).await?;
println!("\n--- History Metadata for AAPL ---");
if let Some(m) = meta {
println!(" Timezone: {}", m.timezone.unwrap_or_default());
println!(" GMT Offset: {}", m.utc_offset_seconds.unwrap_or_default());
}
println!("--------------------------------------");
Ok(())
}

View File

@@ -0,0 +1,103 @@
use futures::future::try_join_all;
use yfinance_rs::core::conversions::money_to_f64;
use yfinance_rs::{FundamentalsBuilder, SearchBuilder, Ticker, YfClient};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::default();
let symbols = ["AAPL", "GOOGL", "TSLA"];
println!("--- Fetching a comprehensive overview for multiple tickers ---");
let fetch_info_tasks: Vec<_> = symbols
.iter()
.map(|&s| {
let ticker = Ticker::new(&client, s);
async move {
let info = ticker.info().await?;
let vol = info
.volume
.map(|v| format!(" (vol: {v})"))
.unwrap_or_default();
println!(
"Symbol: {}, Name: {}, Price: {:.2}{}",
info.symbol,
info.name.unwrap_or_default(),
info.last.as_ref().map_or(0.0, money_to_f64),
vol
);
Ok::<_, yfinance_rs::YfError>(())
}
})
.collect();
let _ = try_join_all(fetch_info_tasks).await?;
println!();
println!("--- Fetching annual fundamentals for a single ticker (AAPL) ---");
let aapl_fundamentals = FundamentalsBuilder::new(&client, "AAPL");
let annual_income_stmt = aapl_fundamentals.income_statement(false, None).await?;
if let Some(stmt) = annual_income_stmt.first() {
println!(
"AAPL Latest Annual Revenue: {:.2} (from {})",
stmt.total_revenue
.as_ref()
.map(money_to_f64)
.unwrap_or_default(),
stmt.period
);
}
let annual_cashflow = aapl_fundamentals.cashflow(false, None).await?;
if let Some(cf) = annual_cashflow.first() {
println!(
"AAPL Latest Annual Free Cash Flow: {:.2}",
cf.free_cash_flow
.as_ref()
.map(money_to_f64)
.unwrap_or_default()
);
}
println!();
println!("--- Fetching ESG and holder data for MSFT ---");
let msft_ticker = Ticker::new(&client, "MSFT");
let esg_summary = msft_ticker.sustainability().await?;
let parts = esg_summary
.scores
.map_or([None, None, None], |s| {
[s.environmental, s.social, s.governance]
})
.into_iter()
.flatten()
.collect::<Vec<_>>();
let total_esg = if parts.is_empty() {
0.0
} else {
let denom: f64 = u32::try_from(parts.len()).map(f64::from).unwrap_or(1.0);
parts.iter().sum::<f64>() / denom
};
println!("MSFT Total ESG Score: {total_esg:.2}");
let institutional_holders = msft_ticker.institutional_holders().await?;
if let Some(holder) = institutional_holders.first() {
println!(
"MSFT Top institutional holder: {} with {:?} shares",
holder.holder, holder.shares
);
}
println!();
println!("--- Searching for SPY and getting its ticker ---");
let search_results = SearchBuilder::new(&client, "SPY").fetch().await?;
if let Some(sp500_quote) = search_results
.results
.iter()
.find(|q| q.symbol.as_str() == "SPY")
{
println!(
"Found: {} ({})",
sp500_quote.name.as_deref().unwrap_or("N/A"),
sp500_quote.symbol
);
}
println!();
Ok(())
}

View File

@@ -0,0 +1,54 @@
use chrono::Duration;
use yfinance_rs::{StreamBuilder, StreamMethod, YfClient};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::default();
let symbols = vec!["TSLA", "GOOG"];
println!("--- Polling for Real-time Quotes every 5 seconds ---");
println!("(Polling for 20 seconds or until stopped...)");
// Create a StreamBuilder explicitly configured for polling.
let (handle, mut receiver) = StreamBuilder::new(&client)
.symbols(symbols)
.method(StreamMethod::Polling)
.interval(Duration::seconds(5).to_std().unwrap())
.diff_only(false) // Get updates even if price hasn't changed
.start()?;
let stream_task = tokio::spawn(async move {
let mut count = 0;
while let Some(update) = receiver.recv().await {
println!(
"[{}] {} @ {:.2} {}",
update.ts,
update.symbol,
update
.price
.as_ref()
.map(yfinance_rs::core::conversions::money_to_f64)
.unwrap_or_default(),
update
.volume
.map(|v| format!("({v} delta)"))
.unwrap_or_default()
);
count += 1;
}
println!("Finished polling after {count} updates.");
});
// Stop the stream after 20 seconds, regardless of how many updates were received.
tokio::select! {
() = tokio::time::sleep(Duration::seconds(20).to_std()?) => {
println!("Stopping polling due to timeout.");
handle.stop().await;
}
_ = stream_task => {
println!("Polling task completed on its own.");
}
};
Ok(())
}

View File

@@ -0,0 +1,59 @@
use yfinance_rs::core::conversions::money_to_f64;
use yfinance_rs::{Ticker, YfClient};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::default();
let ticker = Ticker::new(&client, "MSFT");
println!("--- Fetching Quarterly Financial Statements for MSFT ---");
println!("Fetching latest quarterly income statement...");
let income_stmt = ticker.quarterly_income_stmt(None).await?;
if let Some(latest) = income_stmt.first() {
println!(
"Latest quarterly revenue: {:.2} (from {})",
latest.total_revenue.as_ref().map_or(0.0, money_to_f64),
latest.period
);
} else {
println!("No quarterly income statement found.");
}
println!("\nFetching latest quarterly balance sheet...");
let balance_sheet = ticker.quarterly_balance_sheet(None).await?;
if let Some(latest) = balance_sheet.first() {
println!(
"Latest quarterly total assets: {:.2} (from {})",
latest.total_assets.as_ref().map_or(0.0, money_to_f64),
latest.period
);
} else {
println!("No quarterly balance sheet found.");
}
println!("\nFetching latest quarterly cash flow statement...");
let cashflow_stmt = ticker.quarterly_cashflow(None).await?;
if let Some(latest) = cashflow_stmt.first() {
println!(
"Latest quarterly operating cash flow: {:.2} (from {})",
latest.operating_cashflow.as_ref().map_or(0.0, money_to_f64),
latest.period
);
} else {
println!("No quarterly cash flow statement found.");
}
println!("\nFetching latest quarterly shares outstanding...");
let shares = ticker.quarterly_shares().await?;
if let Some(latest) = shares.first() {
println!(
"Latest quarterly shares outstanding: {} (from {})",
latest.shares,
latest.date.date_naive()
);
} else {
println!("No quarterly shares outstanding found.");
}
Ok(())
}

View File

@@ -0,0 +1,127 @@
use yfinance_rs::core::conversions::money_to_f64;
use yfinance_rs::{Ticker, YfClient, YfError};
#[tokio::main]
async fn main() -> Result<(), YfError> {
let client = YfClient::default();
let symbol = "AAPL";
let ticker_aapl = Ticker::new(&client, symbol);
section_earnings_and_shares(symbol, &ticker_aapl).await?;
section_capital_gains().await?;
section_price_target(symbol, &ticker_aapl).await?;
section_recommendations(symbol, &ticker_aapl).await?;
section_isin_calendar(symbol, &ticker_aapl).await?;
Ok(())
}
async fn section_earnings_and_shares(symbol: &str, ticker: &Ticker) -> Result<(), YfError> {
println!("--- Fetching Advanced Analysis for {symbol} ---");
let earnings_trend = ticker.earnings_trend(None).await?;
println!("Earnings Trend ({} periods):", earnings_trend.len());
if let Some(trend) = earnings_trend.iter().find(|t| t.period.to_string() == "0y") {
println!(
" Current Year ({}): Earnings Est. Avg: {:.2}, Revenue Est. Avg: {}",
trend.period,
trend
.earnings_estimate
.avg
.as_ref()
.map(money_to_f64)
.unwrap_or_default(),
trend
.revenue_estimate
.avg
.as_ref()
.map(money_to_f64)
.unwrap_or_default()
);
}
println!();
println!("--- Fetching Historical Shares for {symbol} ---");
let shares = ticker.shares().await?;
println!("Annual Shares Outstanding ({} periods):", shares.len());
if let Some(share_count) = shares.first() {
println!(
" Latest Period ({}): {} shares",
share_count.date, share_count.shares
);
}
println!();
Ok(())
}
async fn section_capital_gains() -> Result<(), YfError> {
println!("--- Fetching Capital Gains for VFINX (Vanguard 500 Index Fund) ---");
let client = YfClient::default();
let ticker_vfinx = Ticker::new(&client, "VFINX");
let capital_gains = ticker_vfinx.capital_gains(None).await?;
println!(
"Capital Gains Distributions ({} periods):",
capital_gains.len()
);
if let Some((date, gain)) = capital_gains.last() {
println!(" Most Recent Gain: ${gain:.2} on {date}");
}
Ok(())
}
async fn section_price_target(symbol: &str, ticker: &Ticker) -> Result<(), YfError> {
println!("--- Analyst Price Target for {symbol} ---");
let price_target = ticker.analyst_price_target(None).await?;
println!(
" Target: avg=${:.2}, high=${:.2}, low=${:.2} (from {} analysts)",
price_target
.mean
.as_ref()
.map(money_to_f64)
.unwrap_or_default(),
price_target
.high
.as_ref()
.map(money_to_f64)
.unwrap_or_default(),
price_target
.low
.as_ref()
.map(money_to_f64)
.unwrap_or_default(),
price_target.number_of_analysts.unwrap_or_default()
);
println!();
Ok(())
}
async fn section_recommendations(symbol: &str, ticker: &Ticker) -> Result<(), YfError> {
println!("--- Recommendation Summary for {symbol} ---");
let rec_summary = ticker.recommendations_summary().await?;
println!(
" Mean score: {:.2} ({})",
rec_summary.mean.unwrap_or_default(),
rec_summary.mean_rating_text.as_deref().unwrap_or("N/A")
);
println!();
Ok(())
}
async fn section_isin_calendar(symbol: &str, ticker: &Ticker) -> Result<(), YfError> {
println!("--- ISIN for {symbol} ---");
let isin = ticker.isin().await?;
println!(
" ISIN: {}",
isin.unwrap_or_else(|| "Not found".to_string())
);
println!();
println!("--- Upcoming Calendar Events for {symbol} ---");
let calendar = ticker.calendar().await?;
if let Some(date) = calendar.earnings_dates.first() {
println!(" Next earnings date (approx): {}", date.date_naive());
}
if let Some(date) = calendar.ex_dividend_date {
println!(" Ex-dividend date: {}", date.date_naive());
}
println!();
Ok(())
}

View File

@@ -0,0 +1,48 @@
use yfinance_rs::{Ticker, YfClient};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::default();
let ticker = Ticker::new(&client, "TSLA");
println!("--- Fetching Holder Information for TSLA ---");
// Mutual Fund Holders
let mf_holders = ticker.mutual_fund_holders().await?;
println!("\nTop 5 Mutual Fund Holders:");
for holder in mf_holders.iter().take(5) {
println!(
" - {}: {:?} shares ({:.2}%)",
holder.holder,
holder.shares,
holder.pct_held.unwrap_or(0.0) * 100.0
);
}
// Insider Transactions
let insider_txns = ticker.insider_transactions().await?;
println!("\nLatest 5 Insider Transactions:");
for txn in insider_txns.iter().take(5) {
println!(
" - {}: {} {:?} shares on {}",
txn.insider,
txn.transaction_type,
txn.shares,
txn.transaction_date.date_naive()
);
}
// Insider Roster
let insider_roster = ticker.insider_roster_holders().await?;
println!("\nTop 5 Insider Roster:");
for insider in insider_roster.iter().take(5) {
println!(
" - {} ({}): {:?} shares",
insider.name, insider.position, insider.shares_owned_directly
);
}
println!("-----------------------------------------");
Ok(())
}

View File

@@ -0,0 +1,93 @@
use yfinance_rs::core::conversions::money_to_f64;
use yfinance_rs::core::{Interval, Range};
use yfinance_rs::{Ticker, YfClient};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::default();
let ticker = Ticker::new(&client, "AAPL");
println!("--- Ticker Quote (Convenience) ---");
let quote = ticker.quote().await?;
let vol = quote
.day_volume
.map(|v| format!(" (vol: {v})"))
.unwrap_or_default();
println!(
" {}: ${:.2} (prev_close: ${:.2}){}",
quote.symbol,
quote.price.as_ref().map(money_to_f64).unwrap_or_default(),
quote
.previous_close
.as_ref()
.map(money_to_f64)
.unwrap_or_default(),
vol
);
println!();
println!("--- Ticker News (Convenience, default count) ---");
let news = ticker.news().await?;
println!(" Found {} articles with default settings.", news.len());
if let Some(article) = news.first() {
println!(" First article: {}", article.title);
}
println!();
println!("--- Ticker History (Convenience, last 5 days) ---");
let history = ticker
.history(Some(Range::D5), Some(Interval::D1), false)
.await?;
if let Some(candle) = history.last() {
println!(
" Last close on {}: ${:.2}",
candle.ts.date_naive(),
money_to_f64(&candle.close)
);
}
println!();
println!("--- Ticker Actions (Convenience, YTD) ---");
let actions = ticker.actions(Some(Range::Ytd)).await?;
println!(" Found {} actions (dividends/splits) YTD.", actions.len());
if let Some(action) = actions.last() {
println!(" Most recent action: {action:?}");
}
println!();
println!("--- Annual Financials (Convenience) ---");
let annual_income = ticker.income_stmt(None).await?;
if let Some(stmt) = annual_income.first() {
println!(
" Latest annual revenue: {:.2}",
stmt.total_revenue
.as_ref()
.map(money_to_f64)
.unwrap_or_default()
);
}
let annual_balance = ticker.balance_sheet(None).await?;
if let Some(stmt) = annual_balance.first() {
println!(
" Latest annual assets: {:.2}",
stmt.total_assets
.as_ref()
.map(money_to_f64)
.unwrap_or_default()
);
}
let annual_cashflow = ticker.cashflow(None).await?;
if let Some(stmt) = annual_cashflow.first() {
println!(
" Latest annual free cash flow: {:.2}",
stmt.free_cash_flow
.as_ref()
.map(money_to_f64)
.unwrap_or_default()
);
}
Ok(())
}

View File

@@ -0,0 +1,95 @@
use std::time;
use chrono::{Duration, Utc};
use yfinance_rs::core::Interval;
use yfinance_rs::{
DownloadBuilder, QuotesBuilder, SearchBuilder, Ticker, YfClient,
core::client::{Backoff, CacheMode, RetryConfig},
};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::default();
println!("--- QuotesBuilder Usage ---");
let quotes = QuotesBuilder::new(client.clone())
.symbols(vec!["F", "GM", "TSLA"])
.fetch()
.await?;
println!(" Fetched {} quotes via QuotesBuilder.", quotes.len());
println!();
println!("--- Per-Request Configuration: No Cache ---");
let aapl = Ticker::new(&client, "AAPL").cache_mode(CacheMode::Bypass);
let quote_no_cache = aapl.quote().await?;
println!(
" Fetched {} quote, bypassing the client's cache.",
quote_no_cache.symbol
);
println!();
println!("--- SearchBuilder Customization ---");
let sb = SearchBuilder::new(&client, "Microsoft")
.quotes_count(2)
.region("US")
.lang("en-US");
println!(
" Using lang={} region={}",
sb.lang_ref().unwrap_or("N/A"),
sb.region_ref().unwrap_or("N/A")
);
let search_results = sb.fetch().await?;
println!(
" Found {} results for 'Microsoft' in US region.",
search_results.results.len()
);
for quote in search_results.results {
println!(
" - {} ({})",
quote.symbol,
quote.name.unwrap_or_default()
);
}
println!();
println!("--- DownloadBuilder with pre/post market and keepna ---");
// Get recent data including pre/post market, which might have gaps (keepna=true)
let today = Utc::now();
let yesterday = today - Duration::days(1);
let download = DownloadBuilder::new(&client)
.symbols(vec!["TSLA"])
.between(yesterday, today)
.interval(Interval::I15m)
.prepost(true)
.keepna(true)
.run()
.await?;
if let Some(entry) = download
.entries
.iter()
.find(|e| e.instrument.symbol_str() == "TSLA")
{
println!(
" Fetched {} 15m candles for TSLA in the last 24h (pre/post included).",
entry.history.candles.len()
);
}
println!();
println!("--- Overriding Retry Policy for a Single Ticker ---");
let custom_retry = RetryConfig {
enabled: true,
max_retries: 1,
backoff: Backoff::Fixed(time::Duration::from_millis(100)),
..Default::default()
};
let goog = Ticker::new(&client, "GOOG").retry_policy(Some(custom_retry));
// This call will now use the custom retry policy instead of the client's default
let goog_info = goog.fast_info().await?;
println!(
" Fetched fast info for {} with a custom retry policy.",
goog_info.symbol
);
Ok(())
}

View File

@@ -0,0 +1,77 @@
use std::time::Duration;
use yfinance_rs::core::conversions::money_to_f64;
use yfinance_rs::{
Ticker, YfClientBuilder, YfError,
core::client::{Backoff, RetryConfig},
};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. --- Advanced Client Configuration ---
println!("--- Building a client with custom configuration ---");
let custom_retry = RetryConfig {
enabled: true,
max_retries: 2,
backoff: Backoff::Fixed(Duration::from_millis(500)),
..Default::default()
};
let client = YfClientBuilder::default()
.retry_config(custom_retry)
.cache_ttl(Duration::from_secs(60)) // Cache responses for 60 seconds
.build()?;
println!("Client built with custom retry policy.");
println!();
// 2. --- Using the custom client ---
let aapl = Ticker::new(&client, "AAPL");
let quote1 = aapl.quote().await?;
println!(
"First fetch for {}: ${:.2} (from network)",
quote1.symbol,
quote1.price.as_ref().map(money_to_f64).unwrap_or_default()
);
let quote2 = aapl.quote().await?;
println!(
"Second fetch for {}: ${:.2} (should be from cache)",
quote2.symbol,
quote2.price.as_ref().map(money_to_f64).unwrap_or_default()
);
println!();
// 3. --- Cache Management ---
println!("--- Managing the client cache ---");
client.clear_cache().await;
println!("Client cache cleared.");
let quote3 = aapl.quote().await?;
println!(
"Third fetch for {}: ${:.2} (from network again)",
quote3.symbol,
quote3.price.as_ref().map(money_to_f64).unwrap_or_default()
);
println!();
// 4. --- Demonstrating a missing data point (dividend date) ---
println!("--- Fetching Calendar Events for AAPL (including dividend date) ---");
let calendar = aapl.calendar().await?;
if let Some(date) = calendar.ex_dividend_date {
println!(" Dividend date: {}", date.date_naive());
} else {
println!(" No upcoming dividend date found.");
}
println!();
// 5. --- Error Handling Example ---
println!("--- Handling a non-existent ticker ---");
let bad_ticker = Ticker::new(&client, "THIS-TICKER-DOES-NOT-EXIST-XYZ");
match bad_ticker.info().await {
Ok(_) => println!("Unexpected success fetching bad ticker."),
Err(YfError::MissingData(msg)) => {
println!("Correctly failed with a missing data error: {msg}");
}
Err(e) => {
println!("Failed with an unexpected error type: {e}");
}
}
Ok(())
}

View File

@@ -0,0 +1,141 @@
use reqwest::Client;
use std::time::Duration;
use yfinance_rs::{Ticker, YfClient};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("=== Custom Client and Proxy Configuration Examples ===\n");
// Example 1: Using a custom reqwest client for full control
println!("1. Custom Reqwest Client Example:");
let custom_client = Client::builder()
// Set user agent to avoid 429 errors
.user_agent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36")
// You must enable cookie storage to avoid 403 Invalid Cookie errors
.cookie_store(true)
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.pool_idle_timeout(Duration::from_secs(90))
.build()?;
let client_with_custom = YfClient::builder().custom_client(custom_client).build()?;
let ticker = Ticker::new(&client_with_custom, "AAPL");
match ticker.quote().await {
Ok(quote) => println!(" Fetched quote for {} using custom client", quote.symbol),
Err(e) => println!(" Rate limited or error fetching quote: {e}"),
}
println!();
// Example 2: Using HTTP proxy through builder
println!("2. HTTP Proxy Configuration Example:");
// Note: This example uses a dummy proxy URL - replace with actual proxy if needed
// let client_with_proxy = YfClient::builder()
// .proxy("http://proxy.example.com:8080")
// .timeout(Duration::from_secs(30))
// .build()?;
// For demonstration, we'll show the builder pattern without actually using a proxy
let client_with_timeout = YfClient::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.build()?;
let ticker = Ticker::new(&client_with_timeout, "MSFT");
match ticker.quote().await {
Ok(quote) => println!(" Fetched quote for {} with custom timeout", quote.symbol),
Err(e) => println!(" Rate limited or error fetching quote: {e}"),
}
println!();
// Example 3: Using HTTPS proxy with error handling
println!("3. HTTPS Proxy with Error Handling Example:");
// Note: This example shows the pattern but uses a dummy URL
// let client_with_https_proxy = YfClient::builder()
// .try_https_proxy("https://proxy.example.com:8443")?
// .timeout(Duration::from_secs(30))
// .build()?;
// For demonstration, we'll show the error handling pattern
let client_with_retry = YfClient::builder()
.timeout(Duration::from_secs(30))
.retry_enabled(true)
.build()?;
let ticker = Ticker::new(&client_with_retry, "GOOGL");
match ticker.quote().await {
Ok(quote) => println!(" Fetched quote for {} with retry enabled", quote.symbol),
Err(e) => println!(" Rate limited or error fetching quote: {e}"),
}
println!();
// Example 4: Advanced custom client configuration
println!("4. Advanced Custom Client Configuration:");
let advanced_client = Client::builder()
.timeout(Duration::from_secs(60))
.connect_timeout(Duration::from_secs(15))
.pool_idle_timeout(Duration::from_secs(120))
.pool_max_idle_per_host(10)
.tcp_keepalive(Some(Duration::from_secs(60)))
.build()?;
let client_with_advanced = YfClient::builder()
.custom_client(advanced_client)
.cache_ttl(Duration::from_secs(300)) // 5 minutes cache
.build()?;
let ticker = Ticker::new(&client_with_advanced, "TSLA");
match ticker.quote().await {
Ok(quote) => println!(
" Fetched quote for {} with advanced client config",
quote.symbol
),
Err(e) => println!(" Rate limited or error fetching quote: {e}"),
}
println!();
// Example 5: Error handling for invalid proxy URLs
println!("5. Error Handling for Invalid Proxy URLs:");
match YfClient::builder().try_proxy("invalid-url") {
Ok(_) => println!(" Unexpected: Invalid proxy URL was accepted"),
Err(e) => println!(" Expected error for invalid proxy URL: {e}"),
}
match YfClient::builder().try_https_proxy("not-a-url") {
Ok(_) => println!(" Unexpected: Invalid HTTPS proxy URL was accepted"),
Err(e) => println!(" Expected error for invalid HTTPS proxy URL: {e}"),
}
println!();
// Example 6: Builder pattern validation
println!("6. Builder Pattern Validation:");
let client = YfClient::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.retry_enabled(true)
.cache_ttl(Duration::from_secs(60))
.build()?;
println!(" Successfully built client with custom configuration");
println!(" - Retry config: {:?}", client.retry_config());
println!();
// Example 7: Working HTTPS proxy example (commented out for safety)
// Uncomment and replace with your actual proxy URL:
// let client_with_https = YfClient::builder()
// .https_proxy("https://your-proxy.com:8443")
// .timeout(Duration::from_secs(30))
// .build()?;
println!("=== All examples completed successfully! ===");
println!();
println!("Key points:");
println!("- Use .custom_client() for full reqwest control");
println!("- Use .proxy() for HTTP proxy setup");
println!("- Use .https_proxy() for HTTPS proxy setup");
println!("- Use .try_proxy() or .try_https_proxy() for error handling");
println!("- Custom client takes precedence over other HTTP settings");
println!("- Rate limiting (429) is common with live API calls");
Ok(())
}

View File

@@ -0,0 +1,193 @@
//! Example demonstrating Polars `DataFrame` integration with yfinance-rs.
//!
//! Run with: cargo run --example `14_polars_dataframes` --features dataframe
#[cfg(feature = "dataframe")]
use polars::prelude::*;
#[cfg(feature = "dataframe")]
use paft::prelude::{ToDataFrame, ToDataFrameVec};
#[cfg(feature = "dataframe")]
use yfinance_rs::{Ticker, YfClient};
#[cfg(feature = "dataframe")]
use yfinance_rs::core::{Interval, Range};
#[cfg(feature = "dataframe")]
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = YfClient::default();
println!("=== Polars DataFrame Integration with yfinance-rs ===\n");
let ticker = Ticker::new(&client, "AAPL");
section_history_df(&ticker).await?;
section_quote_df(&ticker).await?;
section_recommendations_df(&ticker).await?;
section_income_df(&ticker).await?;
section_esg(&ticker).await?;
section_holders_df(&ticker).await?;
section_analysis_df(&ticker).await?;
println!("\n=== DataFrame Integration Complete ===");
Ok(())
}
#[cfg(feature = "dataframe")]
async fn section_history_df(ticker: &Ticker) -> Result<(), Box<dyn std::error::Error>> {
println!("📈 1. Historical Price Data to DataFrame");
let history = ticker
.history(Some(Range::M6), Some(Interval::D1), false)
.await?;
if history.is_empty() {
println!(" No history returned.");
} else {
let df = history.to_dataframe()?;
println!(" DataFrame shape: {:?}", df.shape());
println!(" Sample data:\n{}", df.head(Some(5)));
}
println!();
Ok(())
}
#[cfg(feature = "dataframe")]
async fn section_quote_df(ticker: &Ticker) -> Result<(), Box<dyn std::error::Error>> {
println!("📊 2. Current Quote to DataFrame");
match ticker.quote().await {
Ok(quote) => {
let df = quote.to_dataframe()?;
println!(" DataFrame shape: {:?}", df.shape());
println!(" Quote data:\n{df}");
}
Err(e) => println!(" Error fetching quote: {e}"),
}
println!();
Ok(())
}
#[cfg(feature = "dataframe")]
async fn section_recommendations_df(ticker: &Ticker) -> Result<(), Box<dyn std::error::Error>> {
println!("🧾 3. Analyst Recommendations to DataFrame");
match ticker.recommendations().await {
Ok(recommendations) => {
if recommendations.is_empty() {
println!(" No recommendation data available");
} else {
let df = recommendations.to_dataframe()?;
println!(" DataFrame shape: {:?}", df.shape());
println!(" Recommendation data:\n{}", df.head(Some(5)));
}
}
Err(e) => println!(" Error fetching recommendations: {e}"),
}
println!();
Ok(())
}
#[cfg(feature = "dataframe")]
async fn section_income_df(ticker: &Ticker) -> Result<(), Box<dyn std::error::Error>> {
println!("💰 4. Financial Statements to DataFrame");
match ticker.income_stmt(None).await {
Ok(financials) => {
if financials.is_empty() {
println!(" No financial data available");
} else {
let df = financials.to_dataframe()?;
println!(" DataFrame shape: {:?}", df.shape());
println!(" Income statement data:\n{}", df.head(Some(3)));
}
}
Err(e) => println!(" Error fetching financials: {e}"),
}
println!();
Ok(())
}
#[cfg(feature = "dataframe")]
async fn section_esg(ticker: &Ticker) -> Result<(), Box<dyn std::error::Error>> {
println!("🌱 5. ESG Scores");
match ticker.sustainability().await {
Ok(summary) => {
if let Some(scores) = summary.scores {
println!(" Environmental: {:?}", scores.environmental);
println!(" Social: {:?}", scores.social);
println!(" Governance: {:?}", scores.governance);
} else {
println!(" No ESG component scores available");
}
}
Err(e) => println!(" ESG data not available for this ticker: {e}"),
}
println!();
Ok(())
}
#[cfg(feature = "dataframe")]
async fn section_holders_df(ticker: &Ticker) -> Result<(), Box<dyn std::error::Error>> {
println!("🏛️ 6. Institutional Holders to DataFrame");
match ticker.institutional_holders().await {
Ok(holders) => {
if holders.is_empty() {
println!(" No institutional holders data available");
} else {
let df = holders.to_dataframe()?;
println!(" DataFrame shape: {:?}", df.shape());
println!(" Top institutional holders:\n{}", df.head(Some(5)));
}
}
Err(e) => println!(" Institutional holders data not available: {e}"),
}
println!();
Ok(())
}
#[cfg(feature = "dataframe")]
async fn section_analysis_df(ticker: &Ticker) -> Result<(), Box<dyn std::error::Error>> {
println!("🔍 7. Simple Analysis with Polars");
let history = ticker
.history(Some(Range::M6), Some(Interval::D1), false)
.await?;
if history.is_empty() {
println!(" No history for analysis.");
return Ok(());
}
let df = history.to_dataframe()?;
// Lazily compute a few stats
let lf = df.lazy();
let stats = lf
.clone()
.select([
col("close.amount").mean().alias("avg_close"),
col("close.amount").min().alias("min_close"),
col("close.amount").max().alias("max_close"),
col("volume").sum().alias("total_volume"),
])
.collect()?;
println!(" 6M Close/Volume Stats:\n{stats}");
let with_ma = lf
.sort(["ts"], SortMultipleOptions::default())
.with_column(
col("close.amount")
.rolling_mean(RollingOptionsFixedWindow {
window_size: 5,
min_periods: 1,
..Default::default()
})
.alias("ma_5d"),
)
.select([col("ts"), col("close.amount"), col("ma_5d"), col("volume")])
.limit(10)
.collect()?;
println!(" First 10 rows with 5-day moving average:\n{with_ma}");
Ok(())
}
#[cfg(not(feature = "dataframe"))]
fn main() {
println!("This example requires the 'dataframe' feature to be enabled.");
println!("Run with: cargo run --example 14_polars_dataframes --features dataframe");
}

View File

@@ -0,0 +1,200 @@
# 🧪 Test Runner Justfile
# Run `just` or `just help` to see this help.
set shell := ["bash", "-cu"]
set dotenv-load := true
set export := true
# set quiet := true # optional: hide all command echoing
# ---- Tunables ---------------------------------------------------------------
FEATURES := 'test-mode,dataframe' # cargo features for tests
TEST_THREADS := '1' # default for live/record (override: just TEST_THREADS=4 live)
FIXDIR := '' # default when YF_FIXDIR isn't set in the env
# ---- Helpers ----------------------------------------------------------------
banner MESSAGE:
@printf "\n\033[1m▶ %s\033[0m\n\n" "{{MESSAGE}}"
vars:
@echo "FEATURES = {{FEATURES}}"
@echo "TEST_THREADS = {{TEST_THREADS}}"
@echo "YF_FIXDIR = ${YF_FIXDIR:-{{FIXDIR}}}"
@echo "YF_LIVE = ${YF_LIVE:-}"
@echo "YF_RECORD = ${YF_RECORD:-}"
# ---- Recipes ----------------------------------------------------------------
default: help
help:
@just --list --unsorted
# NOTE on arg parsing:
# - If the first token looks like a test binary name (no leading `--`, no `::`),
# it's passed as `--test <name>` BEFORE `--`.
# - Everything else goes AFTER `--` to the harness.
# Offline (replay cached fixtures)
test-offline +args='':
@just banner "Offline tests (cached fixtures)"
@set -euo pipefail; \
TARGET_OPT=(); TEST_ARGS=(); \
if [ -n "{{args}}" ]; then \
set -- {{args}}; \
first="${1:-}"; shift || true; \
if [ -n "$first" ] && [[ "$first" != --* ]] && [[ "$first" != *::* ]]; then \
TARGET_OPT=(--test "$first"); \
TEST_ARGS=("$@"); \
else \
TEST_ARGS=("$first" "$@"); \
fi; \
fi; \
cargo test --features {{FEATURES}} "${TARGET_OPT[@]+"${TARGET_OPT[@]}"}" -- "${TEST_ARGS[@]+"${TEST_ARGS[@]}"}"
# Full live sweep (no writes; runs all tests including ignored)
test-live +args='':
@just banner "Live sweep (no writes, includes ignored)"
@set -euo pipefail; \
TARGET_OPT=(); TEST_ARGS=(); \
if [ -n "{{args}}" ]; then \
set -- {{args}}; \
first="${1:-}"; shift || true; \
if [ -n "$first" ] && [[ "$first" != --* ]] && [[ "$first" != *::* ]]; then \
TARGET_OPT=(--test "$first"); \
TEST_ARGS=("$@"); \
else \
TEST_ARGS=("$first" "$@"); \
fi; \
fi; \
YF_LIVE=1 cargo test --features {{FEATURES}} "${TARGET_OPT[@]+"${TARGET_OPT[@]}"}" -- --include-ignored --test-threads={{TEST_THREADS}} "${TEST_ARGS[@]+"${TEST_ARGS[@]}"}"
# Record fixtures (live → cache)
test-record +args='':
@just banner "Recording fixtures (runs ignored tests)"
@set -euo pipefail; \
TARGET_OPT=(); TEST_ARGS=(); \
if [ -n "{{args}}" ]; then \
set -- {{args}}; \
first="${1:-}"; shift || true; \
if [ -n "$first" ] && [[ "$first" != --* ]] && [[ "$first" != *::* ]]; then \
TARGET_OPT=(--test "$first"); \
TEST_ARGS=("$@"); \
else \
TEST_ARGS=("$first" "$@"); \
fi; \
fi; \
YF_RECORD=1 cargo test --features {{FEATURES}} "${TARGET_OPT[@]+"${TARGET_OPT[@]}"}" -- --ignored --test-threads={{TEST_THREADS}} "${TEST_ARGS[@]+"${TEST_ARGS[@]}"}"
# Use a different fixture directory, then replay
test-with-fixdir dir='/tmp/yf-fixtures' +args='':
@just banner "Recording to {{dir}} then replaying offline"
@set -euo pipefail; \
TARGET_OPT=(); TEST_ARGS=(); \
if [ -n "{{args}}" ]; then \
set -- {{args}}; \
first="${1:-}"; shift || true; \
if [ -n "$first" ] && [[ "$first" != --* ]] && [[ "$first" != *::* ]]; then \
TARGET_OPT=(--test "$first"); \
TEST_ARGS=("$@"); \
else \
TEST_ARGS=("$first" "$@"); \
fi; \
fi; \
export YF_FIXDIR="{{dir}}"; \
YF_RECORD=1 cargo test --features {{FEATURES}} "${TARGET_OPT[@]+"${TARGET_OPT[@]}"}" -- --ignored --test-threads={{TEST_THREADS}} "${TEST_ARGS[@]+"${TEST_ARGS[@]}"}"; \
cargo test --features {{FEATURES}} "${TARGET_OPT[@]+"${TARGET_OPT[@]}"}" -- "${TEST_ARGS[@]+"${TEST_ARGS[@]}"}"
# Full test: clear phase markers; only run offline if live/record passes
test-full +args='':
@just banner "Full test (Phase 1: live/record → Phase 2: offline)"
@set -euo pipefail; \
ts() { date '+%Y-%m-%d %H:%M:%S'; }; \
TARGET_OPT=(); TEST_ARGS=(); \
if [ -n "{{args}}" ]; then \
set -- {{args}}; \
first="${1:-}"; shift || true; \
if [ -n "$first" ] && [[ "$first" != --* ]] && [[ "$first" != *::* ]]; then \
TARGET_OPT=(--test "$first"); \
TEST_ARGS=("$@"); \
else \
TEST_ARGS=("$first" "$@"); \
fi; \
fi; \
echo "[$(ts)] 🟦 Phase 1/2 START — Live/Record (runs ignored, writes fixtures)"; \
if YF_RECORD=1 cargo test --features {{FEATURES}} "${TARGET_OPT[@]+"${TARGET_OPT[@]}"}" -- --ignored --test-threads={{TEST_THREADS}} "${TEST_ARGS[@]+"${TEST_ARGS[@]}"}"; then \
echo "[$(ts)] ✅ Phase 1/2 PASS — Live/Record passed"; \
echo "[$(ts)] 🟩 Phase 2/2 START — Offline replay (cached fixtures)"; \
if cargo test --features {{FEATURES}} "${TARGET_OPT[@]+"${TARGET_OPT[@]}"}" -- "${TEST_ARGS[@]+"${TEST_ARGS[@]}"}"; then \
echo "[$(ts)] ✅ Phase 2/2 PASS — Offline replay passed"; \
echo "[$(ts)] 🎉 Full test complete: BOTH phases passed"; \
else \
status=$?; \
echo "[$(ts)] ❌ Phase 2/2 FAIL — Offline replay failed (exit $status)"; \
echo "Tip: re-run only the offline pass with:"; \
echo " just test-offline {{args}}"; \
exit $status; \
fi; \
else \
status=$?; \
echo "[$(ts)] ❌ Phase 1/2 FAIL — Live/Record failed (exit $status)"; \
echo "Skipping offline. Tip: re-run only the live/record pass with:"; \
echo " just test-record {{args}}"; \
exit $status; \
fi
test-full-debug +args='':
@just banner "Full test DEBUG (Phase 1: live/record → Phase 2: offline)"
@set -euo pipefail; \
ts() { date '+%Y-%m-%d %H:%M:%S'; }; \
TARGET_OPT=(); TEST_ARGS=(); \
if [ -n "{{args}}" ]; then \
set -- {{args}}; \
first="${1:-}"; shift || true; \
if [ -n "$first" ] && [[ "$first" != --* ]] && [[ "$first" != *::* ]]; then \
TARGET_OPT=(--test "$first"); \
TEST_ARGS=("$@"); \
else \
TEST_ARGS=("$first" "$@"); \
fi; \
fi; \
echo "[$(ts)] 🟦 Phase 1/2 START — Live/Record DEBUG (runs ignored, writes fixtures)"; \
if YF_DEBUG=1 YF_RECORD=1 cargo test --features {{FEATURES}} "${TARGET_OPT[@]+"${TARGET_OPT[@]}"}" -- --ignored --test-threads={{TEST_THREADS}} "${TEST_ARGS[@]+"${TEST_ARGS[@]}"}"; then \
echo "[$(ts)] ✅ Phase 1/2 PASS — Live/Record passed"; \
echo "[$(ts)] 🟩 Phase 2/2 START — Offline replay DEBUG (cached fixtures)"; \
if YF_DEBUG=1 cargo test --features {{FEATURES}} "${TARGET_OPT[@]+"${TARGET_OPT[@]}"}" -- "${TEST_ARGS[@]+"${TEST_ARGS[@]}"}"; then \
echo "[$(ts)] ✅ Phase 2/2 PASS — Offline replay passed"; \
echo "[$(ts)] 🎉 Full debug test complete: BOTH phases passed"; \
else \
status=$?; \
echo "[$(ts)] ❌ Phase 2/2 FAIL — Offline replay failed (exit $status)"; \
echo "Tip: re-run only the offline pass with:"; \
echo " just test-offline {{args}}"; \
exit $status; \
fi; \
else \
status=$?; \
echo "[$(ts)] ❌ Phase 1/2 FAIL — Live/Record failed (exit $status)"; \
echo "Skipping offline. Tip: re-run only the live/record pass with:"; \
echo " just test-record {{args}}"; \
exit $status; \
fi
test +args='':
@just banner "Alias: test → test-full"
just test-full {{args}}
lint:
cargo clippy --workspace --all-targets --all-features -- \
-W clippy::all -W clippy::cargo -W clippy::pedantic -W clippy::nursery -A clippy::multiple-crate-versions -D warnings
# just lint-fix [optional flags...]
# Example: just lint-fix --allow-dirty
# just lint-fix --allow-dirty --allow-staged
lint-fix *FLAGS:
cargo clippy --workspace --all-targets --all-features --fix {{FLAGS}} -- \
-W clippy::all -W clippy::cargo -W clippy::pedantic -W clippy::nursery -A clippy::multiple-crate-versions -D warnings
fmt:
cargo fmt --all

View File

@@ -0,0 +1,361 @@
use crate::{
analysis::model::EarningsTrendRow,
core::{
YfClient, YfError,
client::{CacheMode, RetryConfig},
conversions::{
f64_to_money_with_currency, i64_to_datetime, i64_to_money_with_currency,
string_to_period, string_to_recommendation_action, string_to_recommendation_grade,
},
wire::{from_raw, from_raw_u32_round},
},
};
use super::fetch::fetch_modules;
use super::model::{PriceTarget, RecommendationRow, RecommendationSummary, UpgradeDowngradeRow};
use chrono::DateTime;
use paft::fundamentals::analysis::{
EarningsEstimate, EpsRevisions, EpsTrend, RevenueEstimate, RevisionPoint, TrendPoint,
};
use paft::money::Currency;
// Period is available via prelude or directly; we use string_to_period for parsing, so import not needed
/* ---------- Public entry points (mapping wire → public models) ---------- */
pub(super) async fn recommendation_trend(
client: &YfClient,
symbol: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<Vec<RecommendationRow>, YfError> {
let root = fetch_modules(
client,
symbol,
"recommendationTrend",
cache_mode,
retry_override,
)
.await?;
let trend = root
.recommendation_trend
.and_then(|x| x.trend)
.unwrap_or_default();
let rows = trend
.into_iter()
.map(|n| RecommendationRow {
period: string_to_period(&n.period.unwrap_or_default()),
strong_buy: n.strong_buy.and_then(|v| u32::try_from(v).ok()),
buy: n.buy.and_then(|v| u32::try_from(v).ok()),
hold: n.hold.and_then(|v| u32::try_from(v).ok()),
sell: n.sell.and_then(|v| u32::try_from(v).ok()),
strong_sell: n.strong_sell.and_then(|v| u32::try_from(v).ok()),
})
.collect();
Ok(rows)
}
pub(super) async fn recommendation_summary(
client: &YfClient,
symbol: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<RecommendationSummary, YfError> {
let root = fetch_modules(
client,
symbol,
"recommendationTrend,financialData",
cache_mode,
retry_override,
)
.await?;
let trend = root
.recommendation_trend
.and_then(|x| x.trend)
.unwrap_or_default();
let latest = trend.first();
let (latest_period, sb, b, h, s, ss) =
latest.map_or((None, None, None, None, None, None), |t| {
(
Some(string_to_period(&t.period.clone().unwrap_or_default())),
t.strong_buy.and_then(|v| u32::try_from(v).ok()),
t.buy.and_then(|v| u32::try_from(v).ok()),
t.hold.and_then(|v| u32::try_from(v).ok()),
t.sell.and_then(|v| u32::try_from(v).ok()),
t.strong_sell.and_then(|v| u32::try_from(v).ok()),
)
});
let (mean, _mean_key) = root.financial_data.map_or((None, None), |fd| {
(from_raw(fd.recommendation_mean), fd.recommendation_key)
});
Ok(RecommendationSummary {
latest_period,
strong_buy: sb,
buy: b,
hold: h,
sell: s,
strong_sell: ss,
mean,
mean_rating_text: None,
})
}
pub(super) async fn upgrades_downgrades(
client: &YfClient,
symbol: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<Vec<UpgradeDowngradeRow>, YfError> {
let root = fetch_modules(
client,
symbol,
"upgradeDowngradeHistory",
cache_mode,
retry_override,
)
.await?;
let hist = root
.upgrade_downgrade_history
.and_then(|x| x.history)
.unwrap_or_default();
let mut rows: Vec<UpgradeDowngradeRow> = hist
.into_iter()
.map(|h| UpgradeDowngradeRow {
ts: h.epoch_grade_date.map_or_else(
|| DateTime::from_timestamp(0, 0).unwrap_or_default(),
i64_to_datetime,
),
firm: h.firm,
from_grade: h.from_grade.as_deref().map(string_to_recommendation_grade),
to_grade: h.to_grade.as_deref().map(string_to_recommendation_grade),
action: h
.action
.or(h.grade_change)
.as_deref()
.map(string_to_recommendation_action),
})
.collect();
rows.sort_by_key(|r| r.ts);
Ok(rows)
}
pub(super) async fn analyst_price_target(
client: &YfClient,
symbol: &str,
currency: Currency,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<PriceTarget, YfError> {
let root = fetch_modules(client, symbol, "financialData", cache_mode, retry_override).await?;
let fd = root
.financial_data
.ok_or_else(|| YfError::MissingData("financialData missing".into()))?;
Ok(PriceTarget {
mean: from_raw(fd.target_mean_price)
.map(|v| f64_to_money_with_currency(v, currency.clone())),
high: from_raw(fd.target_high_price)
.map(|v| f64_to_money_with_currency(v, currency.clone())),
low: from_raw(fd.target_low_price).map(|v| f64_to_money_with_currency(v, currency.clone())),
number_of_analysts: from_raw_u32_round(fd.number_of_analyst_opinions),
})
}
#[allow(clippy::too_many_lines)]
pub(super) async fn earnings_trend(
client: &YfClient,
symbol: &str,
currency: Currency,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<Vec<EarningsTrendRow>, YfError> {
let root = fetch_modules(client, symbol, "earningsTrend", cache_mode, retry_override).await?;
let trend = root
.earnings_trend
.and_then(|x| x.trend)
.unwrap_or_default();
let rows = trend
.into_iter()
.map(|n| {
let (
earnings_estimate_avg,
earnings_estimate_low,
earnings_estimate_high,
earnings_estimate_year_ago_eps,
earnings_estimate_num_analysts,
earnings_estimate_growth,
) = n
.earnings_estimate
.map(|e| {
(
from_raw(e.avg),
from_raw(e.low),
from_raw(e.high),
from_raw(e.year_ago_eps),
from_raw_u32_round(e.num_analysts),
from_raw(e.growth),
)
})
.unwrap_or_default();
let (
revenue_estimate_avg,
revenue_estimate_low,
revenue_estimate_high,
revenue_estimate_year_ago_revenue,
revenue_estimate_num_analysts,
revenue_estimate_growth,
) = n
.revenue_estimate
.map(|e| {
(
from_raw(e.avg),
from_raw(e.low),
from_raw(e.high),
from_raw(e.year_ago_revenue),
from_raw_u32_round(e.num_analysts),
from_raw(e.growth),
)
})
.unwrap_or_default();
let (
eps_trend_current,
eps_trend_7_days_ago,
eps_trend_30_days_ago,
eps_trend_60_days_ago,
eps_trend_90_days_ago,
) = n
.eps_trend
.map(|e| {
(
from_raw(e.current),
from_raw(e.seven_days_ago),
from_raw(e.thirty_days_ago),
from_raw(e.sixty_days_ago),
from_raw(e.ninety_days_ago),
)
})
.unwrap_or_default();
let (
eps_revisions_up_last_7_days,
eps_revisions_up_last_30_days,
eps_revisions_down_last_7_days,
eps_revisions_down_last_30_days,
) = n
.eps_revisions
.map(|e| {
(
from_raw_u32_round(e.up_last_7_days),
from_raw_u32_round(e.up_last_30_days),
from_raw_u32_round(e.down_last_7_days),
from_raw_u32_round(e.down_last_30_days),
)
})
.unwrap_or_default();
EarningsTrendRow {
period: string_to_period(&n.period.unwrap_or_default()),
growth: from_raw(n.growth),
earnings_estimate: EarningsEstimate {
avg: earnings_estimate_avg
.map(|v| f64_to_money_with_currency(v, currency.clone())),
low: earnings_estimate_low
.map(|v| f64_to_money_with_currency(v, currency.clone())),
high: earnings_estimate_high
.map(|v| f64_to_money_with_currency(v, currency.clone())),
year_ago_eps: earnings_estimate_year_ago_eps
.map(|v| f64_to_money_with_currency(v, currency.clone())),
num_analysts: earnings_estimate_num_analysts,
growth: earnings_estimate_growth,
},
revenue_estimate: RevenueEstimate {
avg: revenue_estimate_avg
.map(|v| i64_to_money_with_currency(v, currency.clone())),
low: revenue_estimate_low
.map(|v| i64_to_money_with_currency(v, currency.clone())),
high: revenue_estimate_high
.map(|v| i64_to_money_with_currency(v, currency.clone())),
year_ago_revenue: revenue_estimate_year_ago_revenue
.map(|v| i64_to_money_with_currency(v, currency.clone())),
num_analysts: revenue_estimate_num_analysts,
growth: revenue_estimate_growth,
},
eps_trend: EpsTrend {
current: eps_trend_current
.map(|v| f64_to_money_with_currency(v, currency.clone())),
historical: {
let mut hist = Vec::new();
if let Some(v) = eps_trend_7_days_ago
&& let Ok(tp) = TrendPoint::try_new_str(
"7d",
f64_to_money_with_currency(v, currency.clone()),
)
{
hist.push(tp);
}
if let Some(v) = eps_trend_30_days_ago
&& let Ok(tp) = TrendPoint::try_new_str(
"30d",
f64_to_money_with_currency(v, currency.clone()),
)
{
hist.push(tp);
}
if let Some(v) = eps_trend_60_days_ago
&& let Ok(tp) = TrendPoint::try_new_str(
"60d",
f64_to_money_with_currency(v, currency.clone()),
)
{
hist.push(tp);
}
if let Some(v) = eps_trend_90_days_ago
&& let Ok(tp) = TrendPoint::try_new_str(
"90d",
f64_to_money_with_currency(v, currency.clone()),
)
{
hist.push(tp);
}
hist
},
},
eps_revisions: EpsRevisions {
historical: {
let mut hist = Vec::new();
if let (Some(up), Some(down)) =
(eps_revisions_up_last_7_days, eps_revisions_down_last_7_days)
&& let Ok(rp) = RevisionPoint::try_new_str("7d", up, down)
{
hist.push(rp);
}
if let (Some(up), Some(down)) = (
eps_revisions_up_last_30_days,
eps_revisions_down_last_30_days,
) && let Ok(rp) = RevisionPoint::try_new_str("30d", up, down)
{
hist.push(rp);
}
hist
},
},
}
})
.collect();
Ok(rows)
}

View File

@@ -0,0 +1,24 @@
use super::wire::V10Result;
use crate::core::{
YfClient, YfError,
client::{CacheMode, RetryConfig},
quotesummary,
};
pub(super) async fn fetch_modules(
client: &YfClient,
symbol: &str,
modules: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<V10Result, YfError> {
quotesummary::fetch_module_result(
client,
symbol,
modules,
"analysis",
cache_mode,
retry_override,
)
.await
}

View File

@@ -0,0 +1,149 @@
mod api;
mod model;
mod fetch;
mod wire;
pub use model::{
EarningsTrendRow, PriceTarget, RecommendationRow, RecommendationSummary, UpgradeDowngradeRow,
};
use crate::core::{
YfClient, YfError,
client::{CacheMode, RetryConfig},
};
use paft::money::Currency;
/// A builder for fetching analyst-related data for a specific symbol.
pub struct AnalysisBuilder {
client: YfClient,
symbol: String,
cache_mode: CacheMode,
retry_override: Option<RetryConfig>,
}
impl AnalysisBuilder {
/// Creates a new `AnalysisBuilder` for a given symbol.
pub fn new(client: &YfClient, symbol: impl Into<String>) -> Self {
Self {
client: client.clone(),
symbol: symbol.into(),
cache_mode: CacheMode::Use,
retry_override: None,
}
}
/// Sets the cache mode for this specific API call.
#[must_use]
pub const fn cache_mode(mut self, mode: CacheMode) -> Self {
self.cache_mode = mode;
self
}
/// Overrides the default retry policy for this specific API call.
#[must_use]
pub fn retry_policy(mut self, cfg: Option<RetryConfig>) -> Self {
self.retry_override = cfg;
self
}
/// Fetches the analyst recommendation trend over time.
///
/// # Errors
///
/// Returns an error if the request fails or the data is malformed.
pub async fn recommendations(self) -> Result<Vec<RecommendationRow>, YfError> {
api::recommendation_trend(
&self.client,
&self.symbol,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
/// Fetches a summary of the latest analyst recommendations.
///
/// # Errors
///
/// Returns an error if the request fails or the data is malformed.
pub async fn recommendations_summary(self) -> Result<RecommendationSummary, YfError> {
api::recommendation_summary(
&self.client,
&self.symbol,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
/// Fetches the history of analyst upgrades and downgrades for the symbol.
///
/// # Errors
///
/// Returns an error if the request fails or the data is malformed.
pub async fn upgrades_downgrades(self) -> Result<Vec<UpgradeDowngradeRow>, YfError> {
api::upgrades_downgrades(
&self.client,
&self.symbol,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
/// Fetches the analyst price target summary.
///
/// Provide `Some(currency)` to override the inferred reporting currency; pass `None`
/// to use the cached profile-based heuristic.
///
/// # Errors
///
/// Returns an error if the request fails or the data is malformed.
pub async fn analyst_price_target(
self,
override_currency: Option<Currency>,
) -> Result<PriceTarget, YfError> {
let currency = self
.client
.reporting_currency(&self.symbol, override_currency)
.await;
api::analyst_price_target(
&self.client,
&self.symbol,
currency,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
/// Fetches earnings trend data.
///
/// This includes earnings estimates, revenue estimates, EPS trends, and EPS revisions.
/// Provide `Some(currency)` to override the inferred reporting currency; pass `None`
/// to use the cached profile-based heuristic.
///
/// # Errors
///
/// Returns an error if the request fails or the data is malformed.
pub async fn earnings_trend(
self,
override_currency: Option<Currency>,
) -> Result<Vec<EarningsTrendRow>, YfError> {
let currency = self
.client
.reporting_currency(&self.symbol, override_currency)
.await;
api::earnings_trend(
&self.client,
&self.symbol,
currency,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
}

View File

@@ -0,0 +1,4 @@
// Re-export types from paft without using prelude
pub use paft::fundamentals::analysis::{
EarningsTrendRow, PriceTarget, RecommendationRow, RecommendationSummary, UpgradeDowngradeRow,
};

View File

@@ -0,0 +1,153 @@
use serde::Deserialize;
use crate::core::wire::RawNum;
/* ---------------- Serde mapping (only what we need) ---------------- */
#[derive(Deserialize)]
pub struct V10Result {
#[serde(rename = "recommendationTrend")]
pub(crate) recommendation_trend: Option<RecommendationTrendNode>,
#[serde(rename = "upgradeDowngradeHistory")]
pub(crate) upgrade_downgrade_history: Option<UpgradeDowngradeHistoryNode>,
#[serde(rename = "financialData")]
pub(crate) financial_data: Option<FinancialDataNode>,
#[serde(rename = "earningsTrend")]
pub(crate) earnings_trend: Option<EarningsTrendNode>,
}
/* --- recommendation trend --- */
#[derive(Deserialize)]
pub struct RecommendationTrendNode {
pub(crate) trend: Option<Vec<RecommendationNode>>,
}
#[derive(Deserialize)]
pub struct RecommendationNode {
pub(crate) period: Option<String>,
#[serde(rename = "strongBuy")]
pub(crate) strong_buy: Option<i64>,
pub(crate) buy: Option<i64>,
pub(crate) hold: Option<i64>,
pub(crate) sell: Option<i64>,
#[serde(rename = "strongSell")]
pub(crate) strong_sell: Option<i64>,
}
/* --- upgrades / downgrades --- */
#[derive(Deserialize)]
pub struct UpgradeDowngradeHistoryNode {
pub(crate) history: Option<Vec<UpgradeNode>>,
}
#[derive(Deserialize)]
pub struct UpgradeNode {
#[serde(rename = "epochGradeDate")]
pub(crate) epoch_grade_date: Option<i64>,
pub(crate) firm: Option<String>,
#[serde(rename = "toGrade")]
pub(crate) to_grade: Option<String>,
#[serde(rename = "fromGrade")]
pub(crate) from_grade: Option<String>,
pub(crate) action: Option<String>,
#[serde(rename = "gradeChange")]
pub(crate) grade_change: Option<String>,
}
/* --- financial data (price targets) --- */
#[derive(Deserialize)]
pub struct FinancialDataNode {
#[serde(rename = "targetMeanPrice")]
pub(crate) target_mean_price: Option<RawNum<f64>>,
#[serde(rename = "targetHighPrice")]
pub(crate) target_high_price: Option<RawNum<f64>>,
#[serde(rename = "targetLowPrice")]
pub(crate) target_low_price: Option<RawNum<f64>>,
#[serde(rename = "numberOfAnalystOpinions")]
pub(crate) number_of_analyst_opinions: Option<RawNum<f64>>,
#[serde(rename = "recommendationMean")]
pub(crate) recommendation_mean: Option<RawNum<f64>>,
#[serde(rename = "recommendationKey")]
pub(crate) recommendation_key: Option<String>,
}
#[derive(Deserialize)]
pub struct EarningsTrendNode {
pub(crate) trend: Option<Vec<EarningsTrendItemNode>>,
}
#[derive(Deserialize)]
pub struct EarningsTrendItemNode {
pub(crate) period: Option<String>,
pub(crate) growth: Option<RawNum<f64>>,
#[serde(rename = "earningsEstimate")]
pub(crate) earnings_estimate: Option<EarningsEstimateNode>,
#[serde(rename = "revenueEstimate")]
pub(crate) revenue_estimate: Option<RevenueEstimateNode>,
#[serde(rename = "epsTrend")]
pub(crate) eps_trend: Option<EpsTrendNode>,
#[serde(rename = "epsRevisions")]
pub(crate) eps_revisions: Option<EpsRevisionsNode>,
}
#[derive(Deserialize)]
pub struct EarningsEstimateNode {
pub(crate) avg: Option<RawNum<f64>>,
pub(crate) low: Option<RawNum<f64>>,
pub(crate) high: Option<RawNum<f64>>,
#[serde(rename = "yearAgoEps")]
pub(crate) year_ago_eps: Option<RawNum<f64>>,
#[serde(rename = "numberOfAnalysts")]
pub(crate) num_analysts: Option<RawNum<f64>>,
pub(crate) growth: Option<RawNum<f64>>,
}
#[derive(Deserialize)]
pub struct RevenueEstimateNode {
pub(crate) avg: Option<RawNum<i64>>,
pub(crate) low: Option<RawNum<i64>>,
pub(crate) high: Option<RawNum<i64>>,
#[serde(rename = "yearAgoRevenue")]
pub(crate) year_ago_revenue: Option<RawNum<i64>>,
#[serde(rename = "numberOfAnalysts")]
pub(crate) num_analysts: Option<RawNum<f64>>,
pub(crate) growth: Option<RawNum<f64>>,
}
#[derive(Deserialize)]
pub struct EpsTrendNode {
pub(crate) current: Option<RawNum<f64>>,
#[serde(rename = "7daysAgo")]
pub(crate) seven_days_ago: Option<RawNum<f64>>,
#[serde(rename = "30daysAgo")]
pub(crate) thirty_days_ago: Option<RawNum<f64>>,
#[serde(rename = "60daysAgo")]
pub(crate) sixty_days_ago: Option<RawNum<f64>>,
#[serde(rename = "90daysAgo")]
pub(crate) ninety_days_ago: Option<RawNum<f64>>,
}
#[derive(Deserialize)]
#[allow(clippy::struct_field_names)]
pub struct EpsRevisionsNode {
#[serde(rename = "upLast7days")]
pub(crate) up_last_7_days: Option<RawNum<f64>>,
#[serde(rename = "upLast30days")]
pub(crate) up_last_30_days: Option<RawNum<f64>>,
#[serde(rename = "downLast7days")]
pub(crate) down_last_7_days: Option<RawNum<f64>>,
#[serde(rename = "downLast30days")]
pub(crate) down_last_30_days: Option<RawNum<f64>>,
}

View File

@@ -0,0 +1,73 @@
//! Cookie & crumb acquisition for Yahoo endpoints.
use crate::core::error::YfError;
use reqwest::header::SET_COOKIE;
impl super::YfClient {
pub(crate) async fn ensure_credentials(&self) -> Result<(), YfError> {
// Fast path: check if credentials exist with a read lock.
if self.state.read().await.crumb.is_some() {
return Ok(());
}
// Slow path: acquire the dedicated fetch lock to ensure only one task proceeds.
let _guard = self.credential_fetch_lock.lock().await;
// Double-check: another task might have fetched credentials while this one was waiting.
if self.state.read().await.crumb.is_some() {
return Ok(());
}
// With the lock held, we can safely perform the network operations.
self.get_cookie().await?;
self.get_crumb_internal().await?;
Ok(())
}
pub(crate) async fn clear_crumb(&self) {
let mut state = self.state.write().await;
state.crumb = None;
}
pub(crate) async fn crumb(&self) -> Option<String> {
let state = self.state.read().await;
state.crumb.clone()
}
async fn get_cookie(&self) -> Result<(), YfError> {
let req = self.http.get(self.cookie_url.clone());
let resp = self.send_with_retry(req, None).await?;
let cookie = resp
.headers()
.get(SET_COOKIE)
.ok_or(YfError::Auth("No cookie received from fc.yahoo.com".into()))?
.to_str()
.map_err(|_| YfError::Auth("Invalid cookie header format".into()))?
.to_string();
self.state.write().await.cookie = Some(cookie);
Ok(())
}
async fn get_crumb_internal(&self) -> Result<(), YfError> {
let state = self.state.read().await;
if state.cookie.is_none() {
return Err(YfError::Auth("Cookie is missing, cannot get crumb".into()));
}
drop(state); // release read lock before making http call
let url = self.crumb_url.clone();
let req = self.http.get(url);
let resp = self.send_with_retry(req, None).await?;
let crumb = resp.text().await?;
if crumb.is_empty() || crumb.contains('{') || crumb.contains('<') {
return Err(YfError::Auth(format!("Received invalid crumb: {crumb}")));
}
self.state.write().await.crumb = Some(crumb);
Ok(())
}
}

View File

@@ -0,0 +1,44 @@
//! Centralized constants for default endpoints and UA.
/// Default desktop UA to avoid trivial bot blocking.
pub const USER_AGENT: &str = concat!(
"Mozilla/5.0 (X11; Linux x86_64) ",
"AppleWebKit/537.36 (KHTML, like Gecko) ",
"Chrome/122.0.0.0 Safari/537.36"
);
/// Yahoo chart API base (symbol is appended).
pub const DEFAULT_BASE_CHART: &str = "https://query1.finance.yahoo.com/v8/finance/chart/";
/// Yahoo quote HTML base (symbol is appended).
pub const DEFAULT_BASE_QUOTE: &str = "https://finance.yahoo.com/quote/";
/// Yahoo quoteSummary API base (symbol is appended).
pub const DEFAULT_BASE_QUOTE_API: &str =
"https://query1.finance.yahoo.com/v10/finance/quoteSummary/";
/// A URL that returns a Set-Cookie header for Yahoo domains.
pub const DEFAULT_COOKIE_URL: &str = "https://fc.yahoo.com/consent";
/// URL to fetch a crumb (requires cookie from `DEFAULT_COOKIE_URL`).
pub const DEFAULT_CRUMB_URL: &str = "https://query1.finance.yahoo.com/v1/test/getcrumb";
/// Base URL for the Yahoo Finance v7 quote API.
pub const DEFAULT_BASE_QUOTE_V7: &str = "https://query1.finance.yahoo.com/v7/finance/quote";
/// Base URL for the Yahoo Finance v7 options API.
pub const DEFAULT_BASE_OPTIONS_V7: &str = "https://query1.finance.yahoo.com/v7/finance/options/";
/// Base URL for the Yahoo Finance search API.
pub const DEFAULT_BASE_STREAM: &str = "wss://streamer.finance.yahoo.com/?version=2";
/// Base URL for the Business Insider search API (for ISIN lookup).
pub const DEFAULT_BASE_INSIDER_SEARCH: &str =
"https://markets.businessinsider.com/ajax/SearchController_Suggest";
/// Base URL for Yahoo Finance site (used for news).
pub const DEFAULT_BASE_NEWS: &str = "https://finance.yahoo.com";
/// Base URL for the Yahoo Finance timeseries API.
pub const DEFAULT_BASE_TIMESERIES: &str =
"https://query2.finance.yahoo.com/ws/fundamentals-timeseries/v1/finance/timeseries/";

View File

@@ -0,0 +1,913 @@
//! Public client surface + builder.
//! Internals are split into `auth` (cookie/crumb) and `constants` (UA + defaults).
mod auth;
mod constants;
mod retry;
use crate::core::YfError;
use crate::core::client::constants::DEFAULT_BASE_INSIDER_SEARCH;
use crate::core::currency::currency_for_country;
use paft::money::{Currency, IsoCurrency};
pub use retry::{Backoff, CacheMode, RetryConfig};
use constants::{
DEFAULT_BASE_CHART, DEFAULT_BASE_QUOTE, DEFAULT_BASE_QUOTE_API, DEFAULT_COOKIE_URL,
DEFAULT_CRUMB_URL, USER_AGENT,
};
use reqwest::Client;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use url::Url;
/// Defines the preferred data source for profile lookups when testing.
///
/// This enum is always available for API compatibility, but only has effect when
/// the `test-mode` feature is enabled.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ApiPreference {
/// Try the API first, then fall back to scraping if the API fails. (Default)
ApiThenScrape,
/// Use only the `quoteSummary` API.
ApiOnly,
/// Use only the HTML scraping method.
ScrapeOnly,
}
#[derive(Debug)]
struct CacheEntry {
body: String,
expires_at: Instant,
}
#[derive(Debug)]
struct CacheStore {
map: RwLock<HashMap<String, CacheEntry>>,
default_ttl: Duration,
}
#[derive(Debug, Default)]
struct ClientState {
cookie: Option<String>,
crumb: Option<String>,
}
/// The main asynchronous client for interacting with the Yahoo Finance API.
///
/// The client manages an HTTP client, authentication (cookies and crumbs),
/// caching, and retry logic. It is cloneable and designed to be shared
/// across multiple tasks.
///
/// Create a client using [`YfClient::builder()`] or [`YfClient::default()`].
#[derive(Debug, Clone)]
pub struct YfClient {
http: Client,
base_chart: Url,
base_quote: Url,
base_quote_api: Url,
base_quote_v7: Url,
base_options_v7: Url,
base_stream: Url,
base_news: Url,
base_insider_search: Url,
base_timeseries: Url,
cookie_url: Url,
crumb_url: Url,
user_agent: String,
state: Arc<RwLock<ClientState>>,
credential_fetch_lock: Arc<tokio::sync::Mutex<()>>,
#[cfg(feature = "test-mode")]
api_preference: ApiPreference,
retry: RetryConfig,
reporting_currency_cache: Arc<RwLock<HashMap<String, Currency>>>,
// Cache of resolved instruments by original ticker string
instrument_cache: Arc<RwLock<HashMap<String, paft::domain::Instrument>>>,
cache: Option<Arc<CacheStore>>,
}
impl Default for YfClient {
fn default() -> Self {
Self::builder().build().expect("default client")
}
}
impl YfClient {
/// Creates a new builder for a `YfClient`.
#[must_use]
pub fn builder() -> YfClientBuilder {
YfClientBuilder::default()
}
/* -------- internal getters used by other modules -------- */
pub(crate) const fn http(&self) -> &Client {
&self.http
}
pub(crate) fn user_agent(&self) -> &str {
&self.user_agent
}
pub(crate) const fn base_chart(&self) -> &Url {
&self.base_chart
}
pub(crate) const fn base_quote(&self) -> &Url {
&self.base_quote
}
pub(crate) const fn base_quote_api(&self) -> &Url {
&self.base_quote_api
}
pub(crate) const fn base_quote_v7(&self) -> &Url {
&self.base_quote_v7
}
pub(crate) const fn base_options_v7(&self) -> &Url {
&self.base_options_v7
}
pub(crate) const fn base_stream(&self) -> &Url {
&self.base_stream
}
pub(crate) const fn base_news(&self) -> &Url {
&self.base_news
}
pub(crate) const fn base_insider_search(&self) -> &Url {
&self.base_insider_search
}
pub(crate) const fn base_timeseries(&self) -> &Url {
&self.base_timeseries
}
#[cfg(feature = "test-mode")]
pub(crate) const fn api_preference(&self) -> ApiPreference {
self.api_preference
}
/// Returns `true` if in-memory caching is enabled for this client.
#[must_use]
pub const fn cache_enabled(&self) -> bool {
self.cache.is_some()
}
pub(crate) async fn cache_get(&self, url: &Url) -> Option<String> {
let store = self.cache.as_ref()?;
let key = url.as_str().to_string();
if let Some(entry) = store.map.read().await.get(&key)
&& Instant::now() <= entry.expires_at
{
return Some(entry.body.clone());
}
None
}
pub(crate) async fn cache_put(&self, url: &Url, body: &str, ttl_override: Option<Duration>) {
let store = match &self.cache {
Some(s) => s.clone(),
None => return,
};
let key = url.as_str().to_string();
let ttl = ttl_override.unwrap_or(store.default_ttl);
let expires_at = Instant::now() + ttl;
let entry = CacheEntry {
body: body.to_string(),
expires_at,
};
let mut guard = store.map.write().await;
guard.insert(key, entry);
}
// -------- instrument cache (async) --------
pub(crate) async fn cached_instrument(&self, key: &str) -> Option<paft::domain::Instrument> {
let guard = self.instrument_cache.read().await;
guard.get(key).cloned()
}
pub(crate) async fn store_instrument(&self, key: String, inst: paft::domain::Instrument) {
let mut guard = self.instrument_cache.write().await;
guard.insert(key, inst);
}
/// Clears the entire in-memory cache.
///
/// This is an asynchronous operation that will acquire a write lock on the cache.
/// It does nothing if caching is disabled for the client.
pub async fn clear_cache(&self) {
if let Some(store) = &self.cache {
let mut guard = store.map.write().await;
guard.clear();
}
}
/// Removes a specific URL-based entry from the in-memory cache.
///
/// This is useful if you know that the data for a specific request has become stale.
/// It does nothing if caching is disabled for the client.
pub async fn invalidate_cache_entry(&self, url: &Url) {
if let Some(store) = &self.cache {
let key = url.as_str().to_string();
let mut guard = store.map.write().await;
guard.remove(&key);
}
}
async fn cached_reporting_currency(&self, symbol: &str) -> Option<Currency> {
let guard = self.reporting_currency_cache.read().await;
guard.get(symbol).cloned()
}
async fn store_reporting_currency(&self, symbol: &str, currency: Currency) {
let mut guard = self.reporting_currency_cache.write().await;
guard.insert(symbol.to_string(), currency);
}
/// Returns the cached or inferred reporting currency for a symbol.
pub(crate) async fn reporting_currency(
&self,
symbol: &str,
override_currency: Option<Currency>,
) -> Currency {
if let Some(currency) = override_currency {
self.store_reporting_currency(symbol, currency.clone())
.await;
return currency;
}
if let Some(currency) = self.cached_reporting_currency(symbol).await {
return currency;
}
let mut debug_reason: Option<String> = None;
let currency = match crate::profile::load_profile(self, symbol).await {
Ok(profile) => extract_currency_from_profile(&profile).map_or_else(
|| {
debug_reason = Some("profile missing country or unsupported currency".into());
Currency::Iso(IsoCurrency::USD)
},
|currency| currency,
),
Err(err) => {
debug_reason = Some(format!("failed to load profile: {err}"));
Currency::Iso(IsoCurrency::USD)
}
};
if let Some(reason) =
debug_reason.filter(|_| std::env::var("YF_DEBUG").ok().as_deref() == Some("1"))
{
eprintln!(
"YF_DEBUG(currency): {symbol} -> {reason}; using {}",
currency.code()
);
}
self.store_reporting_currency(symbol, currency.clone())
.await;
currency
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(
skip(self, req, override_retry),
err,
fields(
url = %{
let b = req
.try_clone()
.expect("cloneable")
.build()
.unwrap();
b.url().clone()
}
)
)
)]
pub(crate) async fn send_with_retry(
&self,
mut req: reqwest::RequestBuilder,
override_retry: Option<&RetryConfig>,
) -> Result<reqwest::Response, reqwest::Error> {
// Always set User-Agent header explicitly
req = req.header("User-Agent", &self.user_agent);
let cfg = override_retry.unwrap_or(&self.retry);
if !cfg.enabled {
return req.send().await;
}
let mut attempt = 0u32;
loop {
let response = req.try_clone().expect("cloneable request").send().await;
match response {
Ok(resp) => {
let code = resp.status().as_u16();
if cfg.retry_on_status.contains(&code) && attempt < cfg.max_retries {
#[cfg(feature = "tracing")]
{
let backoff = compute_backoff_duration(&cfg.backoff, attempt);
tracing::event!(
tracing::Level::INFO,
attempt,
backoff_ms = backoff.as_secs_f64() * 1000.0,
status = code,
"retrying after status"
);
tokio::time::sleep(backoff).await;
}
#[cfg(not(feature = "tracing"))]
{
sleep_backoff(&cfg.backoff, attempt).await;
}
attempt += 1;
continue;
}
return Ok(resp);
}
Err(e) => {
let should_retry = (cfg.retry_on_timeout && e.is_timeout())
|| (cfg.retry_on_connect && e.is_connect());
if should_retry && attempt < cfg.max_retries {
#[cfg(feature = "tracing")]
{
let backoff = compute_backoff_duration(&cfg.backoff, attempt);
tracing::event!(
tracing::Level::INFO,
attempt,
backoff_ms = backoff.as_secs_f64() * 1000.0,
error = %e,
timeout = e.is_timeout(),
connect = e.is_connect(),
"retrying after error"
);
tokio::time::sleep(backoff).await;
}
#[cfg(not(feature = "tracing"))]
{
sleep_backoff(&cfg.backoff, attempt).await;
}
attempt += 1;
continue;
}
return Err(e);
}
}
}
}
/// Returns a reference to the default `RetryConfig` for this client.
///
/// This config is used for all requests unless overridden on a per-call basis.
#[must_use]
pub const fn retry_config(&self) -> &RetryConfig {
&self.retry
}
}
/* ----------------------- Builder ----------------------- */
/// A builder for creating and configuring a [`YfClient`].
#[derive(Default)]
pub struct YfClientBuilder {
user_agent: Option<String>,
base_chart: Option<Url>,
base_quote: Option<Url>,
base_quote_api: Option<Url>,
base_quote_v7: Option<Url>,
base_options_v7: Option<Url>,
base_stream: Option<Url>,
base_news: Option<Url>,
base_insider_search: Option<Url>,
base_timeseries: Option<Url>,
cookie_url: Option<Url>,
crumb_url: Option<Url>,
#[allow(dead_code)]
api_preference: Option<ApiPreference>,
#[allow(dead_code)]
preauth_cookie: Option<String>,
#[allow(dead_code)]
preauth_crumb: Option<String>,
timeout: Option<Duration>,
connect_timeout: Option<Duration>,
retry: Option<RetryConfig>,
cache_ttl: Option<Duration>,
// New fields for custom client and proxy configuration
custom_client: Option<Client>,
proxy: Option<reqwest::Proxy>,
}
fn extract_currency_from_profile(profile: &crate::profile::Profile) -> Option<Currency> {
match profile {
crate::profile::Profile::Company(company) => company
.address
.as_ref()
.and_then(|addr| addr.country.as_deref())
.and_then(currency_for_country),
crate::profile::Profile::Fund(_) => None,
}
}
impl YfClientBuilder {
/// Sets the `User-Agent` header for all HTTP requests and WebSocket connections.
///
/// The user agent is applied consistently across all request types:
/// - HTTP requests (quotes, history, fundamentals, etc.)
/// - WebSocket streaming connections
/// - Authentication requests (cookies, crumbs)
///
/// Defaults to a common desktop browser User-Agent to avoid being blocked.
/// This setting is applied per-request rather than at the HTTP client level.
#[must_use]
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
self.user_agent = Some(ua.into());
self
}
/// Overrides the base URL for quote HTML pages (used for scraping).
/// Default: `https://finance.yahoo.com/quote/`.
#[must_use]
pub fn base_quote(mut self, url: Url) -> Self {
self.base_quote = Some(url);
self
}
/// Overrides the base URL for the chart API (used for historical data).
/// Default: `https://query1.finance.yahoo.com/v8/finance/chart/`.
#[must_use]
pub fn base_chart(mut self, url: Url) -> Self {
self.base_chart = Some(url);
self
}
/// Overrides the base URL for the `quoteSummary` API (used for profiles, financials, etc.).
/// Default: `https://query1.finance.yahoo.com/v10/finance/quoteSummary/`.
#[must_use]
pub fn base_quote_api(mut self, url: Url) -> Self {
self.base_quote_api = Some(url);
self
}
/// Sets a custom base URL for the v7 quote endpoint.
///
/// This is primarily used for testing or to target a different Yahoo Finance region.
/// If not set, a default URL (`https://query1.finance.yahoo.com/v7/finance/quote`) is used.
#[must_use]
pub fn base_quote_v7(mut self, url: Url) -> Self {
self.base_quote_v7 = Some(url);
self
}
/// Sets a custom base URL for the v7 options endpoint.
///
/// This is primarily used for testing or to target a different Yahoo Finance region.
/// If not set, a default URL (`https://query1.finance.yahoo.com/v7/finance/options/`) is used.
#[must_use]
pub fn base_options_v7(mut self, url: Url) -> Self {
self.base_options_v7 = Some(url);
self
}
/// Sets a custom base URL for the streaming API.
#[must_use]
pub fn base_stream(mut self, url: Url) -> Self {
self.base_stream = Some(url);
self
}
/// Sets a custom base URL for the news endpoint.
/// Default: `https://finance.yahoo.com`.
#[must_use]
pub fn base_news(mut self, url: Url) -> Self {
self.base_news = Some(url);
self
}
/// Sets a custom base URL for the Business Insider search (for ISIN lookup).
#[must_use]
pub fn base_insider_search(mut self, url: Url) -> Self {
self.base_insider_search = Some(url);
self
}
/// Sets a custom base URL for the timeseries endpoint.
#[must_use]
pub fn base_timeseries(mut self, url: Url) -> Self {
self.base_timeseries = Some(url);
self
}
/// Overrides the URL used to acquire an initial cookie.
#[must_use]
pub fn cookie_url(mut self, url: Url) -> Self {
self.cookie_url = Some(url);
self
}
/// Overrides the URL used to acquire a crumb for authenticated requests.
#[must_use]
pub fn crumb_url(mut self, url: Url) -> Self {
self.crumb_url = Some(url);
self
}
/// Sets the entire retry configuration.
///
/// Replaces the default retry settings.
#[must_use]
pub fn retry_config(mut self, cfg: RetryConfig) -> Self {
self.retry = Some(cfg);
self
}
/// A convenience method to enable or disable the retry mechanism.
#[must_use]
pub fn retry_enabled(mut self, yes: bool) -> Self {
let mut cfg = self.retry.unwrap_or_default();
cfg.enabled = yes;
self.retry = Some(cfg);
self
}
/// Disables in-memory caching for this client.
#[must_use]
pub const fn no_cache(mut self) -> Self {
self.cache_ttl = None;
self
}
/// (Internal testing only) Chooses which data source path to use for profile lookups.
///
/// This setting only has effect when the `test-mode` feature is enabled.
/// In normal usage, this setting is ignored.
#[doc(hidden)]
#[must_use]
#[allow(unused_variables, unused_mut)]
pub const fn _api_preference(mut self, pref: ApiPreference) -> Self {
#[cfg(feature = "test-mode")]
{
self.api_preference = Some(pref);
}
self
}
/// (Internal testing only) Provides pre-authenticated credentials to bypass the cookie/crumb fetch.
///
/// This setting only has effect when the `test-mode` feature is enabled.
/// In normal usage, this setting is ignored.
#[doc(hidden)]
#[must_use]
#[allow(unused_variables, unused_mut)]
pub fn _preauth(mut self, cookie: impl Into<String>, crumb: impl Into<String>) -> Self {
#[cfg(feature = "test-mode")]
{
self.preauth_cookie = Some(cookie.into());
self.preauth_crumb = Some(crumb.into());
}
self
}
/// Sets a global timeout for the entire HTTP request.
///
/// Default: none.
#[must_use]
pub const fn timeout(mut self, dur: Duration) -> Self {
self.timeout = Some(dur);
self
}
/// Sets a timeout for the connection phase of an HTTP request.
///
/// Default: none.
#[must_use]
pub const fn connect_timeout(mut self, dur: Duration) -> Self {
self.connect_timeout = Some(dur);
self
}
/// Enables in-memory caching with a default Time-To-Live (TTL) for all responses.
///
/// If not set, caching is disabled by default.
#[must_use]
pub const fn cache_ttl(mut self, dur: Duration) -> Self {
self.cache_ttl = Some(dur);
self
}
/// Sets a custom reqwest client for full control over HTTP configuration.
///
/// This allows you to configure advanced features like custom TLS settings,
/// connection pooling, or other reqwest-specific options. When this is set,
/// other HTTP-related builder methods (timeout, `connect_timeout`, proxy) are ignored.
///
/// # Example
///
/// ```rust
/// use reqwest::Client;
/// use yfinance_rs::YfClient;
///
/// let custom_client = Client::builder()
/// .timeout(std::time::Duration::from_secs(30))
/// .build()
/// .unwrap();
///
/// let client = YfClient::builder()
/// .custom_client(custom_client)
/// .build()
/// .unwrap();
/// ```
#[must_use]
pub fn custom_client(mut self, client: Client) -> Self {
self.custom_client = Some(client);
self
}
/// Sets an HTTP proxy for all requests.
///
/// This is a convenience method for setting up proxy configuration without
/// needing to create a full custom client. If you need more advanced proxy
/// configuration, use `custom_client()` instead.
///
/// # Example
///
/// ```rust
/// use yfinance_rs::YfClient;
///
/// let client = YfClient::builder()
/// .proxy("http://proxy.example.com:8080")
/// .build()
/// .unwrap();
/// ```
///
/// # Errors
///
/// This method will panic if the proxy URL is invalid. For production code,
/// consider using `try_proxy()` instead.
///
/// # Panics
///
/// Panics if the proxy URL format is invalid.
#[must_use]
pub fn proxy(mut self, proxy_url: &str) -> Self {
// Validate URL format before creating proxy
assert!(
url::Url::parse(proxy_url).is_ok(),
"invalid proxy URL format: {proxy_url}"
);
self.proxy = Some(reqwest::Proxy::http(proxy_url).expect("invalid proxy URL"));
self
}
/// Sets an HTTP proxy for all requests with error handling.
///
/// This is a convenience method for setting up proxy configuration without
/// needing to create a full custom client. If you need more advanced proxy
/// configuration, use `custom_client()` instead.
///
/// # Example
///
/// ```rust
/// use yfinance_rs::YfClient;
///
/// let client = YfClient::builder()
/// .try_proxy("http://proxy.example.com:8080")?
/// .build()?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
///
/// # Errors
///
/// Returns an error if the proxy URL is invalid.
pub fn try_proxy(mut self, proxy_url: &str) -> Result<Self, YfError> {
// Validate URL format first
url::Url::parse(proxy_url)
.map_err(|e| YfError::InvalidParams(format!("invalid proxy URL format: {e}")))?;
let proxy = reqwest::Proxy::http(proxy_url)
.map_err(|e| YfError::InvalidParams(format!("invalid proxy URL: {e}")))?;
self.proxy = Some(proxy);
Ok(self)
}
/// Sets an HTTPS proxy for all requests.
///
/// This is a convenience method for setting up HTTPS proxy configuration.
///
/// # Example
///
/// ```rust
/// use yfinance_rs::YfClient;
///
/// let client = YfClient::builder()
/// .https_proxy("https://proxy.example.com:8443")
/// .build()
/// .unwrap();
/// ```
///
/// # Errors
///
/// This method will panic if the proxy URL is invalid. For production code,
/// consider using `try_https_proxy()` instead.
///
/// # Panics
///
/// Panics if the proxy URL format is invalid.
#[must_use]
pub fn https_proxy(mut self, proxy_url: &str) -> Self {
// Validate URL format before creating proxy
assert!(
url::Url::parse(proxy_url).is_ok(),
"invalid HTTPS proxy URL format: {proxy_url}"
);
self.proxy = Some(reqwest::Proxy::https(proxy_url).expect("invalid HTTPS proxy URL"));
self
}
/// Sets an HTTPS proxy for all requests with error handling.
///
/// This is a convenience method for setting up HTTPS proxy configuration.
///
/// # Example
///
/// ```rust
/// use yfinance_rs::YfClient;
///
/// let client = YfClient::builder()
/// .try_https_proxy("https://proxy.example.com:8443")?
/// .build()?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
///
/// # Errors
///
/// Returns an error if the proxy URL is invalid.
pub fn try_https_proxy(mut self, proxy_url: &str) -> Result<Self, YfError> {
// Validate URL format first
url::Url::parse(proxy_url)
.map_err(|e| YfError::InvalidParams(format!("invalid HTTPS proxy URL format: {e}")))?;
let proxy = reqwest::Proxy::https(proxy_url)
.map_err(|e| YfError::InvalidParams(format!("invalid HTTPS proxy URL: {e}")))?;
self.proxy = Some(proxy);
Ok(self)
}
/// Builds the `YfClient`.
///
/// # Errors
///
/// Returns an error if the base URLs are invalid or the HTTP client fails to build.
pub fn build(self) -> Result<YfClient, YfError> {
let base_chart = self.base_chart.unwrap_or(Url::parse(DEFAULT_BASE_CHART)?);
let base_quote = self.base_quote.unwrap_or(Url::parse(DEFAULT_BASE_QUOTE)?);
let base_quote_api = self
.base_quote_api
.unwrap_or(Url::parse(DEFAULT_BASE_QUOTE_API)?);
let base_quote_v7 = self
.base_quote_v7
.unwrap_or(Url::parse(constants::DEFAULT_BASE_QUOTE_V7)?);
let base_options_v7 = self
.base_options_v7
.unwrap_or(Url::parse(constants::DEFAULT_BASE_OPTIONS_V7)?);
let base_stream = self
.base_stream
.unwrap_or(Url::parse(constants::DEFAULT_BASE_STREAM)?);
let base_news = self
.base_news
.unwrap_or(Url::parse(constants::DEFAULT_BASE_NEWS)?);
let base_insider_search = self
.base_insider_search
.unwrap_or(Url::parse(DEFAULT_BASE_INSIDER_SEARCH)?);
let base_timeseries = self
.base_timeseries
.unwrap_or(Url::parse(constants::DEFAULT_BASE_TIMESERIES)?);
let cookie_url = self.cookie_url.unwrap_or(Url::parse(DEFAULT_COOKIE_URL)?);
let crumb_url = self.crumb_url.unwrap_or(Url::parse(DEFAULT_CRUMB_URL)?);
let user_agent = self.user_agent.as_deref().unwrap_or(USER_AGENT).to_string();
// Use custom client if provided, otherwise build a new one
let http = if let Some(custom_client) = self.custom_client {
custom_client
} else {
let mut httpb = reqwest::Client::builder().cookie_store(true);
if let Some(t) = self.timeout {
httpb = httpb.timeout(t);
}
if let Some(ct) = self.connect_timeout {
httpb = httpb.connect_timeout(ct);
}
if let Some(proxy) = self.proxy {
httpb = httpb.proxy(proxy);
}
httpb.build()?
};
let initial_state = ClientState {
cookie: {
#[cfg(feature = "test-mode")]
{
self.preauth_cookie
}
#[cfg(not(feature = "test-mode"))]
{
None
}
},
crumb: {
#[cfg(feature = "test-mode")]
{
self.preauth_crumb
}
#[cfg(not(feature = "test-mode"))]
{
None
}
},
};
Ok(YfClient {
http,
base_chart,
base_quote,
base_quote_api,
base_quote_v7,
base_options_v7,
base_stream,
base_news,
base_insider_search,
base_timeseries,
cookie_url,
crumb_url,
user_agent,
state: Arc::new(RwLock::new(initial_state)),
credential_fetch_lock: Arc::new(tokio::sync::Mutex::new(())),
#[cfg(feature = "test-mode")]
api_preference: self.api_preference.unwrap_or(ApiPreference::ApiThenScrape),
retry: self.retry.unwrap_or_default(),
reporting_currency_cache: Arc::new(RwLock::new(HashMap::new())),
instrument_cache: Arc::new(RwLock::new(HashMap::new())),
cache: self.cache_ttl.map(|ttl| {
Arc::new(CacheStore {
map: RwLock::new(HashMap::new()),
default_ttl: ttl,
})
}),
})
}
}
#[cfg(not(feature = "tracing"))]
async fn sleep_backoff(b: &Backoff, attempt: u32) {
let dur = compute_backoff_duration(b, attempt);
tokio::time::sleep(dur).await;
}
#[inline]
fn compute_backoff_duration(b: &Backoff, attempt: u32) -> Duration {
use std::time::Duration;
match *b {
Backoff::Fixed(d) => d,
Backoff::Exponential {
base,
factor,
max,
jitter,
} => {
let pow = factor.powi(i32::try_from(attempt).unwrap());
let mut d = Duration::from_secs_f64(base.as_secs_f64() * pow);
if d > max {
d = max;
}
if jitter {
let nanos = d.as_nanos();
let j = u64::try_from(nanos / 2).unwrap_or(0)
* ((u64::from(attempt) % 5 + 1) * 13 % 100)
/ 100;
let add = attempt.is_multiple_of(2);
d = if add {
d.saturating_add(Duration::from_nanos(j))
} else {
d.saturating_sub(Duration::from_nanos(j))
};
}
d
}
}
}

View File

@@ -0,0 +1,65 @@
/// Specifies the backoff strategy for retrying failed requests.
#[derive(Clone, Debug)]
pub enum Backoff {
/// Uses a fixed delay between retries.
Fixed(std::time::Duration),
/// Uses an exponential delay between retries.
/// The delay is calculated as `base * (factor ^ attempt)`.
Exponential {
/// The initial backoff duration.
base: std::time::Duration,
/// The multiplicative factor for each subsequent retry.
factor: f64,
/// The maximum duration to wait between retries.
max: std::time::Duration,
/// Whether to apply random jitter (+/- 50%) to the delay.
jitter: bool,
},
}
/// Configuration for the automatic retry mechanism.
#[derive(Clone, Debug)]
pub struct RetryConfig {
/// Enables or disables the retry mechanism.
pub enabled: bool,
/// The maximum number of retries to attempt. The total number of attempts will be `max_retries + 1`.
pub max_retries: u32,
/// The backoff strategy to use between retries.
pub backoff: Backoff,
/// A list of HTTP status codes that should trigger a retry.
pub retry_on_status: Vec<u16>,
/// Whether to retry on request timeouts.
pub retry_on_timeout: bool,
/// Whether to retry on connection errors.
pub retry_on_connect: bool,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
enabled: true,
max_retries: 4,
backoff: Backoff::Exponential {
base: std::time::Duration::from_millis(200),
factor: 2.0,
max: std::time::Duration::from_secs(3),
jitter: true,
},
retry_on_status: vec![408, 429, 500, 502, 503, 504],
retry_on_timeout: true,
retry_on_connect: true,
}
}
}
/// Defines the behavior of the in-memory cache for an API call.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CacheMode {
/// Read from the cache if a non-expired entry is present; otherwise, fetch from the network
/// and write the response to the cache. (Default)
Use,
/// Always fetch from the network, bypassing any cached entry, and write the new response to the cache.
Refresh,
/// Always fetch from the network and do not read from or write to the cache.
Bypass,
}

View File

@@ -0,0 +1,222 @@
//! Conversion utilities
use chrono::{DateTime, Utc};
use paft::domain::{Exchange, MarketState, Period};
use paft::fundamentals::analysis::{RecommendationAction, RecommendationGrade};
use paft::fundamentals::holders::{InsiderPosition, TransactionType};
use paft::fundamentals::profile::FundKind;
use paft::money::{Currency, IsoCurrency, Money};
use rust_decimal::prelude::ToPrimitive;
use std::str::FromStr;
fn f64_to_decimal_safely(value: f64) -> rust_decimal::Decimal {
if !value.is_finite() {
return rust_decimal::Decimal::ZERO;
}
let formatted = format!("{value:.4}");
rust_decimal::Decimal::from_str(&formatted).unwrap_or(rust_decimal::Decimal::ZERO)
}
/// Convert f64 to Money with specified currency
///
/// # Panics
/// Panics if currency metadata is not registered for non-ISO currencies.
#[must_use]
pub fn f64_to_money_with_currency(value: f64, currency: Currency) -> Money {
// Use string formatting to avoid f64 precision issues; coerce non-finite to zero
let decimal = f64_to_decimal_safely(value);
Money::new(decimal, currency).expect("currency metadata available")
}
/// Convert i64 to Money with specified currency (no precision loss)
///
/// # Panics
/// Panics if currency metadata is not registered for non-ISO currencies.
#[must_use]
pub fn i64_to_money_with_currency(value: i64, currency: Currency) -> Money {
let decimal = rust_decimal::Decimal::from_i128_with_scale(i128::from(value), 0);
Money::new(decimal, currency).expect("currency metadata available")
}
/// Convert u64 to Money with specified currency (no precision loss)
///
/// # Panics
/// Panics if currency metadata is not registered for non-ISO currencies.
#[must_use]
pub fn u64_to_money_with_currency(value: u64, currency: Currency) -> Money {
let decimal = rust_decimal::Decimal::from_i128_with_scale(i128::from(value), 0);
Money::new(decimal, currency).expect("currency metadata available")
}
/// Convert f64 to Money with currency string (parses currency string to Currency enum)
#[must_use]
pub fn f64_to_money_with_currency_str(value: f64, currency_str: Option<&str>) -> Money {
let currency = currency_str
.and_then(|s| Currency::from_str(s).ok())
.unwrap_or(Currency::Iso(IsoCurrency::USD));
f64_to_money_with_currency(value, currency)
}
/// Convert Money to f64 (loses currency information)
#[must_use]
pub fn money_to_f64(money: &Money) -> f64 {
money.amount().to_f64().unwrap_or(0.0)
}
/// Extract currency string from Money object
#[must_use]
pub fn money_to_currency_str(money: &Money) -> Option<String> {
Some(money.currency().to_string())
}
/// Convert i64 timestamp to `DateTime`<Utc>
#[must_use]
pub fn i64_to_datetime(timestamp: i64) -> DateTime<Utc> {
DateTime::from_timestamp(timestamp, 0).unwrap_or_default()
}
/// Convert `DateTime`<Utc> to i64 timestamp
#[must_use]
pub const fn datetime_to_i64(dt: DateTime<Utc>) -> i64 {
dt.timestamp()
}
/// Convert String to Exchange enum
#[allow(clippy::single_option_map)]
#[must_use]
pub fn string_to_exchange(s: Option<String>) -> Option<Exchange> {
s.and_then(|s| {
// Map Yahoo Finance exchange names to paft Exchange values
match s.as_str() {
"NasdaqGS" | "NasdaqCM" | "NasdaqGM" => Some(Exchange::NASDAQ),
"NYSE" => Some(Exchange::NYSE),
"AMEX" => Some(Exchange::AMEX),
"BATS" => Some(Exchange::BATS),
"OTC" => Some(Exchange::OTC),
"LSE" => Some(Exchange::LSE),
"TSE" => Some(Exchange::TSE),
"HKEX" => Some(Exchange::HKEX),
"SSE" => Some(Exchange::SSE),
"SZSE" => Some(Exchange::SZSE),
"TSX" => Some(Exchange::TSX),
"ASX" => Some(Exchange::ASX),
"Euronext" => Some(Exchange::Euronext),
"XETRA" => Some(Exchange::XETRA),
"SIX" => Some(Exchange::SIX),
"BIT" => Some(Exchange::BIT),
"BME" => Some(Exchange::BME),
"AEX" => Some(Exchange::AEX),
"BRU" => Some(Exchange::BRU),
"LIS" => Some(Exchange::LIS),
"EPA" => Some(Exchange::EPA),
"OSL" => Some(Exchange::OSL),
"STO" => Some(Exchange::STO),
"CPH" => Some(Exchange::CPH),
"WSE" => Some(Exchange::WSE),
"PSE" => Some(Exchange::PSE),
"BSE" => Some(Exchange::BSE),
"MOEX" => Some(Exchange::MOEX),
"BIST" => Some(Exchange::BIST),
"JSE" => Some(Exchange::JSE),
"TASE" => Some(Exchange::TASE),
"BSE_HU" => Some(Exchange::BSE_HU),
"NSE" => Some(Exchange::NSE),
"KRX" => Some(Exchange::KRX),
"SGX" => Some(Exchange::SGX),
"SET" => Some(Exchange::SET),
"KLSE" => Some(Exchange::KLSE),
"PSE_CZ" => Some(Exchange::PSE_CZ),
"IDX" => Some(Exchange::IDX),
"HOSE" => Some(Exchange::HOSE),
_ => Exchange::try_from(s).ok(),
}
})
}
/// Convert Exchange to String
#[must_use]
pub fn exchange_to_string(exchange: Option<Exchange>) -> Option<String> {
exchange.map(|e| e.to_string())
}
/// Convert String to `MarketState` enum
#[must_use]
pub fn string_to_market_state(s: Option<String>) -> Option<MarketState> {
s.and_then(|s| s.parse().ok())
}
/// Convert `MarketState` to String
#[must_use]
pub fn market_state_to_string(state: Option<MarketState>) -> Option<String> {
state.map(|s| s.to_string())
}
/// Convert String to `FundKind` enum
#[allow(clippy::single_option_map)]
#[must_use]
pub fn string_to_fund_kind(s: Option<String>) -> Option<FundKind> {
s.and_then(|s| {
// Map Yahoo Finance legal types to paft FundKind values
match s.as_str() {
"Exchange Traded Fund" => Some(FundKind::Etf),
"Mutual Fund" => Some(FundKind::MutualFund),
"Index Fund" => Some(FundKind::IndexFund),
"Closed-End Fund" => Some(FundKind::ClosedEndFund),
"Money Market Fund" => Some(FundKind::MoneyMarketFund),
"Hedge Fund" => Some(FundKind::HedgeFund),
"Real Estate Investment Trust" => Some(FundKind::Reit),
"Unit Investment Trust" => Some(FundKind::UnitInvestmentTrust),
_ => FundKind::try_from(s).ok(),
}
})
}
/// Convert `FundKind` to String
#[must_use]
pub fn fund_kind_to_string(kind: Option<FundKind>) -> Option<String> {
kind.map(|k| k.to_string())
}
/// Convert String to `InsiderPosition` enum
#[must_use]
pub fn string_to_insider_position(s: &str) -> InsiderPosition {
let token = s.trim();
let token_nonempty = if token.is_empty() { "UNKNOWN" } else { token };
token_nonempty.parse().unwrap_or(InsiderPosition::Officer)
}
/// Convert String to `TransactionType` enum
#[must_use]
pub fn string_to_transaction_type(s: &str) -> TransactionType {
let token = s.trim();
let token_nonempty = if token.is_empty() { "UNKNOWN" } else { token };
token_nonempty.parse().unwrap_or(TransactionType::Buy)
}
/// Convert String to Period
#[must_use]
pub fn string_to_period(s: &str) -> Period {
if s.trim().is_empty() {
return "UNKNOWN".parse().map_or(Period::Year { year: 1970 }, |p| p);
}
s.parse()
.unwrap_or_else(|_| "UNKNOWN".parse().map_or(Period::Year { year: 1970 }, |p| p))
}
/// Convert String to `RecommendationGrade` enum
#[must_use]
pub fn string_to_recommendation_grade(s: &str) -> RecommendationGrade {
let token = s.trim();
let token_nonempty = if token.is_empty() { "UNKNOWN" } else { token };
token_nonempty.parse().unwrap_or(RecommendationGrade::Hold)
}
/// Convert String to `RecommendationAction` enum
#[must_use]
pub fn string_to_recommendation_action(s: &str) -> RecommendationAction {
let token = s.trim();
let token_nonempty = if token.is_empty() { "UNKNOWN" } else { token };
token_nonempty
.parse()
.unwrap_or(RecommendationAction::Maintain)
}

View File

@@ -0,0 +1,659 @@
//! Helpers for inferring currencies from country information.
use std::{collections::HashMap, sync::LazyLock};
use paft::money::{Currency, IsoCurrency};
/// Normalized country → currency code pairs.
///
/// Keys must be uppercase and ASCII; values are ISO 4217 currency codes.
const COUNTRY_TO_CURRENCY_RAW: &[(&str, &str)] = &[
("UNITED STATES", "USD"),
("UNITED STATES OF AMERICA", "USD"),
("US", "USD"),
("USA", "USD"),
("CANADA", "CAD"),
("MEXICO", "MXN"),
("BRAZIL", "BRL"),
("ARGENTINA", "ARS"),
("CHILE", "CLP"),
("COLOMBIA", "COP"),
("PERU", "PEN"),
("URUGUAY", "UYU"),
("PARAGUAY", "PYG"),
("BOLIVIA", "BOB"),
("ECUADOR", "USD"),
("VENEZUELA", "VES"),
("COSTA RICA", "CRC"),
("GUATEMALA", "GTQ"),
("HONDURAS", "HNL"),
("NICARAGUA", "NIO"),
("PANAMA", "USD"),
("EL SALVADOR", "USD"),
("BELIZE", "BZD"),
("DOMINICAN REPUBLIC", "DOP"),
("JAMAICA", "JMD"),
("TRINIDAD AND TOBAGO", "TTD"),
("BARBADOS", "BBD"),
("BAHAMAS", "BSD"),
("BERMUDA", "BMD"),
("CAYMAN ISLANDS", "KYD"),
("ARUBA", "AWG"),
("CURACAO", "ANG"),
("BRITISH VIRGIN ISLANDS", "USD"),
("PUERTO RICO", "USD"),
("UNITED KINGDOM", "GBP"),
("ENGLAND", "GBP"),
("SCOTLAND", "GBP"),
("WALES", "GBP"),
("NORTHERN IRELAND", "GBP"),
("IRELAND", "EUR"),
("FRANCE", "EUR"),
("GERMANY", "EUR"),
("ITALY", "EUR"),
("SPAIN", "EUR"),
("PORTUGAL", "EUR"),
("NETHERLANDS", "EUR"),
("BELGIUM", "EUR"),
("LUXEMBOURG", "EUR"),
("AUSTRIA", "EUR"),
("SWITZERLAND", "CHF"),
("SWEDEN", "SEK"),
("NORWAY", "NOK"),
("DENMARK", "DKK"),
("FINLAND", "EUR"),
("ICELAND", "ISK"),
("POLAND", "PLN"),
("CZECH REPUBLIC", "CZK"),
("CZECHIA", "CZK"),
("HUNGARY", "HUF"),
("SLOVAKIA", "EUR"),
("SLOVENIA", "EUR"),
("CROATIA", "EUR"),
("ROMANIA", "RON"),
("BULGARIA", "BGN"),
("GREECE", "EUR"),
("CYPRUS", "EUR"),
("MALTA", "EUR"),
("ESTONIA", "EUR"),
("LATVIA", "EUR"),
("LITHUANIA", "EUR"),
("UKRAINE", "UAH"),
("BELARUS", "BYN"),
("RUSSIA", "RUB"),
("TURKEY", "TRY"),
("SERBIA", "RSD"),
("BOSNIA AND HERZEGOVINA", "BAM"),
("NORTH MACEDONIA", "MKD"),
("ALBANIA", "ALL"),
("MONTENEGRO", "EUR"),
("KOSOVO", "EUR"),
("ARMENIA", "AMD"),
("GEORGIA", "GEL"),
("AZERBAIJAN", "AZN"),
("KAZAKHSTAN", "KZT"),
("UZBEKISTAN", "UZS"),
("TURKMENISTAN", "TMT"),
("KYRGYZSTAN", "KGS"),
("TAJIKISTAN", "TJS"),
("CHINA", "CNY"),
("PEOPLES REPUBLIC OF CHINA", "CNY"),
("HONG KONG", "HKD"),
("MACAU", "MOP"),
("TAIWAN", "TWD"),
("JAPAN", "JPY"),
("SOUTH KOREA", "KRW"),
("REPUBLIC OF KOREA", "KRW"),
("NORTH KOREA", "KPW"),
("INDIA", "INR"),
("PAKISTAN", "PKR"),
("BANGLADESH", "BDT"),
("SRI LANKA", "LKR"),
("NEPAL", "NPR"),
("BHUTAN", "BTN"),
("MALDIVES", "MVR"),
("MYANMAR", "MMK"),
("THAILAND", "THB"),
("VIETNAM", "VND"),
("LAOS", "LAK"),
("CAMBODIA", "KHR"),
("MALAYSIA", "MYR"),
("SINGAPORE", "SGD"),
("INDONESIA", "IDR"),
("PHILIPPINES", "PHP"),
("BRUNEI", "BND"),
("MONGOLIA", "MNT"),
("AUSTRALIA", "AUD"),
("NEW ZEALAND", "NZD"),
("FIJI", "FJD"),
("PAPUA NEW GUINEA", "PGK"),
("NEW CALEDONIA", "XPF"),
("FRENCH POLYNESIA", "XPF"),
("SAMOA", "WST"),
("TONGA", "TOP"),
("VANUATU", "VUV"),
("SOLOMON ISLANDS", "SBD"),
("EAST TIMOR", "USD"),
("TIMOR-LESTE", "USD"),
("UNITED ARAB EMIRATES", "AED"),
("SAUDI ARABIA", "SAR"),
("QATAR", "QAR"),
("KUWAIT", "KWD"),
("BAHRAIN", "BHD"),
("OMAN", "OMR"),
("JORDAN", "JOD"),
("LEBANON", "LBP"),
("ISRAEL", "ILS"),
("PALESTINE", "ILS"),
("IRAQ", "IQD"),
("IRAN", "IRR"),
("AFGHANISTAN", "AFN"),
("SYRIA", "SYP"),
("YEMEN", "YER"),
("EGYPT", "EGP"),
("MOROCCO", "MAD"),
("ALGERIA", "DZD"),
("TUNISIA", "TND"),
("LIBYA", "LYD"),
("SUDAN", "SDG"),
("SOUTH SUDAN", "SSP"),
("NIGERIA", "NGN"),
("GHANA", "GHS"),
("COTE DIVOIRE", "XOF"),
("COTE D IVOIRE", "XOF"),
("COTE D'IVOIRE", "XOF"),
("SENEGAL", "XOF"),
("MALI", "XOF"),
("BENIN", "XOF"),
("BURKINA FASO", "XOF"),
("NIGER", "XOF"),
("TOGO", "XOF"),
("GUINEA-BISSAU", "XOF"),
("GUINEA BISSAU", "XOF"),
("CAMEROON", "XAF"),
("CHAD", "XAF"),
("CENTRAL AFRICAN REPUBLIC", "XAF"),
("REPUBLIC OF THE CONGO", "XAF"),
("CONGO", "XAF"),
("GABON", "XAF"),
("EQUATORIAL GUINEA", "XAF"),
("GAMBIA", "GMD"),
("GUINEA", "GNF"),
("SIERRA LEONE", "SLE"),
("LIBERIA", "LRD"),
("ETHIOPIA", "ETB"),
("ERITREA", "ERN"),
("DJIBOUTI", "DJF"),
("KENYA", "KES"),
("UGANDA", "UGX"),
("TANZANIA", "TZS"),
("RWANDA", "RWF"),
("BURUNDI", "BIF"),
("SOMALIA", "SOS"),
("SEYCHELLES", "SCR"),
("MADAGASCAR", "MGA"),
("MAURITIUS", "MUR"),
("MOZAMBIQUE", "MZN"),
("ZIMBABWE", "ZWL"),
("ZAMBIA", "ZMW"),
("MALAWI", "MWK"),
("ANGOLA", "AOA"),
("NAMIBIA", "NAD"),
("BOTSWANA", "BWP"),
("SOUTH AFRICA", "ZAR"),
("LESOTHO", "LSL"),
("ESWATINI", "SZL"),
("SWAZILAND", "SZL"),
("COMOROS", "KMF"),
("MAURITANIA", "MRU"),
("SAO TOME AND PRINCIPE", "STN"),
("GRENADA", "XCD"),
("SAINT LUCIA", "XCD"),
("SAINT VINCENT AND THE GRENADINES", "XCD"),
("ANTIGUA AND BARBUDA", "XCD"),
("DOMINICA", "XCD"),
("SAINT KITTS AND NEVIS", "XCD"),
];
/// Precomputed lookup table using `COUNTRY_TO_CURRENCY_RAW`.
static COUNTRY_TO_CURRENCY: LazyLock<HashMap<&'static str, Currency>> = LazyLock::new(|| {
let mut map = HashMap::new();
for (country, code) in COUNTRY_TO_CURRENCY_RAW {
let parsed = (*code).parse().unwrap_or(Currency::Iso(IsoCurrency::USD));
map.insert(*country, parsed);
}
map
});
/// Normalize a country string to an uppercase ASCII key.
fn normalize_country(country: &str) -> Option<String> {
let trimmed = country.trim();
if trimmed.is_empty() {
return None;
}
let mut buf = String::with_capacity(trimmed.len());
for ch in trimmed.chars() {
match ch {
'A'..='Z' | '0'..='9' => buf.push(ch),
'a'..='z' => buf.push(ch.to_ascii_uppercase()),
' ' | '\t' | '\n' | '\r' | '\'' | '`' | '"' => buf.push(' '),
'-' | '_' | '/' | ',' | '.' | ';' | ':' | '&' | '(' | ')' | '[' | ']' | '{' | '}' => {
buf.push(' ');
}
'á' | 'à' | 'â' | 'ä' | 'ã' | 'å' | 'Á' | 'À' | 'Â' | 'Ä' | 'Ã' | 'Å' => {
buf.push('A');
}
'ç' | 'Ç' => buf.push('C'),
'é' | 'è' | 'ê' | 'ë' | 'É' | 'È' | 'Ê' | 'Ë' => buf.push('E'),
'í' | 'ì' | 'î' | 'ï' | 'Í' | 'Ì' | 'Î' | 'Ï' => buf.push('I'),
'ñ' | 'Ñ' => buf.push('N'),
'ó' | 'ò' | 'ô' | 'ö' | 'õ' | 'Ó' | 'Ò' | 'Ô' | 'Ö' | 'Õ' => buf.push('O'),
'ú' | 'ù' | 'û' | 'ü' | 'Ú' | 'Ù' | 'Û' | 'Ü' => buf.push('U'),
'ý' | 'ÿ' | 'Ý' => buf.push('Y'),
_ => {
// Ignore other symbols to keep normalization simple.
}
}
}
let normalized = buf
.split_whitespace()
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" ");
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
/// Attempt to infer a currency from a country string.
///
/// Returns `None` if the country string is empty or cannot be matched.
pub fn currency_for_country(country: &str) -> Option<Currency> {
let normalized = normalize_country(country)?;
if let Some(currency) = COUNTRY_TO_CURRENCY.get(normalized.as_str()) {
return Some(currency.clone());
}
heuristic_currency_match(&normalized)
}
fn heuristic_currency_match(normalized: &str) -> Option<Currency> {
match_americas(normalized)
.or_else(|| match_europe(normalized))
.or_else(|| match_asia_pacific(normalized))
.or_else(|| match_mena(normalized))
.or_else(|| match_caucasus_central_asia(normalized))
.or_else(|| match_africa(normalized))
}
fn match_americas(s: &str) -> Option<Currency> {
let c = |n| s.contains(n);
if c("UNITED STATES") {
return Some(Currency::Iso(IsoCurrency::USD));
}
if c("CANADA") {
return Some(Currency::Iso(IsoCurrency::CAD));
}
if c("MEXICO") {
return Some(Currency::Iso(IsoCurrency::MXN));
}
if c("BRAZIL") {
return Some(Currency::Iso(IsoCurrency::BRL));
}
if c("ARGENTINA") {
return "ARS".parse().ok();
}
if c("CHILE") {
return "CLP".parse().ok();
}
if c("COLOMBIA") {
return "COP".parse().ok();
}
if c("PERU") {
return "PEN".parse().ok();
}
if c("URUGUAY") {
return "UYU".parse().ok();
}
if c("PARAGUAY") {
return "PYG".parse().ok();
}
if c("BOLIVIA") {
return "BOB".parse().ok();
}
if c("VENEZUELA") {
return "VES".parse().ok();
}
if c("PANAMA") || c("ECUADOR") || c("EL SALVADOR") {
return Some(Currency::Iso(IsoCurrency::USD));
}
if c("BAHAMAS") {
return "BSD".parse().ok();
}
if c("CAYMAN") {
return "KYD".parse().ok();
}
if c("BERMUDA") {
return "BMD".parse().ok();
}
if c("TRINIDAD") {
return "TTD".parse().ok();
}
if c("JAMAICA") {
return "JMD".parse().ok();
}
if c("BARBADOS") {
return "BBD".parse().ok();
}
if c("DOMINICAN") {
return "DOP".parse().ok();
}
Some(None?).or(None)
}
fn match_europe(s: &str) -> Option<Currency> {
let c = |n| s.contains(n);
if c("UNITED KINGDOM") || c("ENGLAND") || c("SCOTLAND") {
return Some(Currency::Iso(IsoCurrency::GBP));
}
if c("EUROPEAN UNION") || c("EURO AREA") {
return Some(Currency::Iso(IsoCurrency::EUR));
}
if c("SWITZERLAND") {
return Some(Currency::Iso(IsoCurrency::CHF));
}
if c("NORWAY") {
return Some(Currency::Iso(IsoCurrency::NOK));
}
if c("SWEDEN") {
return Some(Currency::Iso(IsoCurrency::SEK));
}
if c("DENMARK") {
return Some(Currency::Iso(IsoCurrency::DKK));
}
if c("ICELAND") {
return "ISK".parse().ok();
}
if c("POLAND") {
return Some(Currency::Iso(IsoCurrency::PLN));
}
if c("CZECH") {
return Some(Currency::Iso(IsoCurrency::CZK));
}
if c("HUNGARY") {
return Some(Currency::Iso(IsoCurrency::HUF));
}
if c("ROMANIA") {
return "RON".parse().ok();
}
if c("BULGARIA") {
return "BGN".parse().ok();
}
if c("UKRAINE") {
return "UAH".parse().ok();
}
if c("BELARUS") {
return "BYN".parse().ok();
}
if c("SERBIA") {
return "RSD".parse().ok();
}
if c("TURKEY") {
return Some(Currency::Iso(IsoCurrency::TRY));
}
Some(None?).or(None)
}
fn match_asia_pacific(s: &str) -> Option<Currency> {
let c = |n| s.contains(n);
if c("HONG KONG") {
return Some(Currency::Iso(IsoCurrency::HKD));
}
if c("MACAU") {
return "MOP".parse().ok();
}
if c("TAIWAN") {
return "TWD".parse().ok();
}
if c("KOREA") {
return Some(Currency::Iso(IsoCurrency::KRW));
}
if c("JAPAN") {
return Some(Currency::Iso(IsoCurrency::JPY));
}
if c("CHINA") {
return Some(Currency::Iso(IsoCurrency::CNY));
}
if c("INDIA") {
return Some(Currency::Iso(IsoCurrency::INR));
}
if c("SINGAPORE") {
return Some(Currency::Iso(IsoCurrency::SGD));
}
if c("MALAYSIA") {
return Some(Currency::Iso(IsoCurrency::MYR));
}
if c("INDONESIA") {
return Some(Currency::Iso(IsoCurrency::IDR));
}
if c("PHILIPPINES") {
return Some(Currency::Iso(IsoCurrency::PHP));
}
if c("VIETNAM") {
return Some(Currency::Iso(IsoCurrency::VND));
}
if c("THAILAND") {
return Some(Currency::Iso(IsoCurrency::THB));
}
if c("LAOS") {
return "LAK".parse().ok();
}
if c("CAMBODIA") {
return "KHR".parse().ok();
}
if c("BRUNEI") {
return "BND".parse().ok();
}
if c("MONGOLIA") {
return "MNT".parse().ok();
}
if c("AUSTRALIA") {
return Some(Currency::Iso(IsoCurrency::AUD));
}
if c("NEW ZEALAND") {
return Some(Currency::Iso(IsoCurrency::NZD));
}
if c("FIJI") {
return "FJD".parse().ok();
}
if c("SAMOA") {
return "WST".parse().ok();
}
if c("TONGA") {
return "TOP".parse().ok();
}
if c("VANUATU") {
return "VUV".parse().ok();
}
if c("SOLOMON") {
return "SBD".parse().ok();
}
if c("PAPUA") {
return "PGK".parse().ok();
}
Some(None?).or(None)
}
fn match_mena(s: &str) -> Option<Currency> {
let c = |n| s.contains(n);
if c("ISRAEL") {
return Some(Currency::Iso(IsoCurrency::ILS));
}
if c("SAUDI ARABIA") {
return "SAR".parse().ok();
}
if c("UNITED ARAB EMIRATES") {
return "AED".parse().ok();
}
if c("QATAR") {
return "QAR".parse().ok();
}
if c("KUWAIT") {
return "KWD".parse().ok();
}
if c("BAHRAIN") {
return "BHD".parse().ok();
}
if c("OMAN") {
return "OMR".parse().ok();
}
if c("EGYPT") {
return "EGP".parse().ok();
}
if c("JORDAN") {
return "JOD".parse().ok();
}
if c("LEBANON") {
return "LBP".parse().ok();
}
if c("IRAQ") {
return "IQD".parse().ok();
}
if c("IRAN") {
return "IRR".parse().ok();
}
if c("AFGHANISTAN") {
return "AFN".parse().ok();
}
if c("SYRIA") {
return "SYP".parse().ok();
}
if c("YEMEN") {
return "YER".parse().ok();
}
Some(None?).or(None)
}
fn match_caucasus_central_asia(s: &str) -> Option<Currency> {
let c = |n| s.contains(n);
if c("GEORGIA") {
return "GEL".parse().ok();
}
if c("ARMENIA") {
return "AMD".parse().ok();
}
if c("AZERBAIJAN") {
return "AZN".parse().ok();
}
if c("KAZAKHSTAN") {
return "KZT".parse().ok();
}
if c("UZBEKISTAN") {
return "UZS".parse().ok();
}
if c("TURKMENISTAN") {
return "TMT".parse().ok();
}
if c("KYRGYZSTAN") {
return "KGS".parse().ok();
}
if c("TAJIKISTAN") {
return "TJS".parse().ok();
}
Some(None?).or(None)
}
fn match_africa(s: &str) -> Option<Currency> {
let c = |n| s.contains(n);
if c("SOUTH AFRICA") {
return Some(Currency::Iso(IsoCurrency::ZAR));
}
if c("NIGERIA") {
return "NGN".parse().ok();
}
if c("GHANA") {
return "GHS".parse().ok();
}
if c("KENYA") {
return "KES".parse().ok();
}
if c("MOROCCO") {
return "MAD".parse().ok();
}
if c("ALGERIA") {
return "DZD".parse().ok();
}
if c("TUNISIA") {
return "TND".parse().ok();
}
if c("ZAMBIA") {
return "ZMW".parse().ok();
}
if c("ZIMBABWE") {
return "ZWL".parse().ok();
}
if c("ANGOLA") {
return "AOA".parse().ok();
}
if c("NAMIBIA") {
return "NAD".parse().ok();
}
if c("BOTSWANA") {
return "BWP".parse().ok();
}
if c("LESOTHO") {
return "LSL".parse().ok();
}
if c("ESWATINI") || c("SWAZILAND") {
return "SZL".parse().ok();
}
if c("MOZAMBIQUE") {
return "MZN".parse().ok();
}
if c("MADAGASCAR") {
return "MGA".parse().ok();
}
if c("MAURITIUS") {
return "MUR".parse().ok();
}
if c("MALAWI") {
return "MWK".parse().ok();
}
if c("SEYCHELLES") {
return "SCR".parse().ok();
}
if c("RWANDA") {
return "RWF".parse().ok();
}
if c("BURUNDI") {
return "BIF".parse().ok();
}
if c("UGANDA") {
return "UGX".parse().ok();
}
if c("TANZANIA") {
return "TZS".parse().ok();
}
if c("SOMALIA") {
return "SOS".parse().ok();
}
if c("DJIBOUTI") {
return "DJF".parse().ok();
}
if c("ERITREA") {
return "ERN".parse().ok();
}
if c("NIGER") || c("SENEGAL") || c("IVORY COAST") || c("COTE DIVOIRE") {
return "XOF".parse().ok();
}
if c("CAMEROON") {
return "XAF".parse().ok();
}
Some(None?).or(None)
}

View File

@@ -0,0 +1,36 @@
use polars::prelude::*;
/// Trait for converting financial data structures into Polars `DataFrames`.
///
/// This trait provides a consistent interface for converting various yfinance-rs data
/// structures into Polars `DataFrames` for advanced data analysis and manipulation.
pub trait ToDataFrame {
/// Converts the object into a Polars `DataFrame`.
///
/// # Errors
///
/// Returns an error if `DataFrame` construction fails (e.g., invalid schema or data).
fn to_dataframe(&self) -> PolarsResult<DataFrame>;
/// Creates an empty `DataFrame` with the correct schema for this type.
///
/// # Errors
///
/// Returns an error if the schema definition cannot be represented in a `DataFrame`.
fn empty_dataframe() -> PolarsResult<DataFrame>
where
Self: Sized;
/// Returns the complete flattened schema for this type.
///
/// This method provides static access to the type's schema without requiring
/// an instance, making it useful for building nested schemas and validating
/// data structures at compile time.
///
/// # Errors
///
/// Returns an error if the schema cannot be derived for the type.
fn schema() -> PolarsResult<Vec<(&'static str, DataType)>>
where
Self: Sized;
}

View File

@@ -0,0 +1,93 @@
use thiserror::Error;
/// The primary error type for the `yfinance-rs` crate.
#[derive(Debug, Error)]
pub enum YfError {
/// An error originating from the underlying HTTP client (`reqwest`).
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
/// An error related to WebSocket communication.
#[error("WebSocket error: {0}")]
Websocket(Box<tokio_tungstenite::tungstenite::Error>),
/// An error during Protobuf decoding, typically from a WebSocket stream.
#[error("Protobuf decoding error: {0}")]
Protobuf(#[from] prost::DecodeError),
/// An error during JSON serialization or deserialization.
#[error("JSON parsing error: {0}")]
Json(#[from] serde_json::Error),
/// An error during Base64 decoding.
#[error("Base64 decoding error: {0}")]
Base64(#[from] base64::DecodeError),
/// An error that occurs when parsing a URL.
#[error("Invalid URL: {0}")]
Url(#[from] url::ParseError),
/// A 404 Not Found returned by Yahoo endpoints.
#[error("Not found at {url}")]
NotFound {
/// The URL that returned a 404.
url: String,
},
/// A 429 Too Many Requests (rate limit) returned by Yahoo endpoints.
#[error("Rate limited at {url}")]
RateLimited {
/// The URL that returned a 429.
url: String,
},
/// A 5xx server error returned by Yahoo endpoints.
#[error("Server error {status} at {url}")]
ServerError {
/// The HTTP status code in the 5xx range.
status: u16,
/// The URL that returned a server error.
url: String,
},
/// An error indicating an unexpected, non-successful HTTP status code (non-404/429/5xx).
#[error("Unexpected response status: {status} at {url}")]
Status {
/// The unexpected HTTP status code returned.
status: u16,
/// The URL that returned the status.
url: String,
},
/// An error returned by the Yahoo Finance API within an otherwise successful response.
///
/// For example, a `200 OK` response might contain a JSON body with an `error` field.
#[error("Yahoo API error: {0}")]
Api(String),
/// An error related to authentication, such as failing to retrieve a cookie or crumb.
#[error("Authentication error: {0}")]
Auth(String),
/// An error that occurs during the web scraping process.
#[error("Web scraping error: {0}")]
Scrape(String),
/// Indicates that an expected piece of data was missing from the API response.
#[error("Missing data in response: {0}")]
MissingData(String),
/// An error indicating that the parameters provided by the caller were invalid.
#[error("Invalid parameters: {0}")]
InvalidParams(String),
/// An error indicating that the provided date range is invalid (e.g., start date after end date).
#[error("Invalid date range: start date must be before end date")]
InvalidDates,
}
impl From<tokio_tungstenite::tungstenite::Error> for YfError {
fn from(e: tokio_tungstenite::tungstenite::Error) -> Self {
Self::Websocket(Box::new(e))
}
}

View File

@@ -0,0 +1,36 @@
//! Test/recording helpers for persisting HTTP fixtures.
//! Compiled only when the `test-mode` feature is enabled.
use std::env;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
pub fn get_fixture_dir() -> PathBuf {
env::var("YF_FIXDIR").map_or_else(
|_| Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"),
PathBuf::from,
)
}
pub fn record_fixture(
endpoint: &str,
symbol: &str,
ext: &str,
body: &str,
) -> Result<(), std::io::Error> {
let dir = get_fixture_dir();
if !dir.exists() {
fs::create_dir_all(&dir)?;
}
let filename = format!("{endpoint}_{symbol}.{ext}");
let path = dir.join(filename);
let mut file = fs::File::create(&path)?;
file.write_all(body.as_bytes())?;
if env::var("YF_DEBUG").ok().as_deref() == Some("1") {
eprintln!("YF_RECORD: wrote fixture to {}", path.display());
}
Ok(())
}

View File

@@ -0,0 +1,36 @@
//! Core components of the `yfinance-rs` client.
//!
//! This module contains the foundational building blocks of the library, including:
//! - The main [`YfClient`] and its builder.
//! - The primary [`YfError`] type.
//! - Shared data models like [`Quote`] and [`Candle`].
//! - Internal networking and authentication logic.
/// The main client (`YfClient`), builder, and configuration.
pub mod client;
pub(crate) mod currency;
/// The primary error type (`YfError`) for the crate.
pub mod error;
/// Shared data models used across multiple API modules (e.g., `Quote`, `Candle`).
pub mod models;
pub(crate) mod quotes;
pub(crate) mod quotesummary;
/// Service traits for abstracting functionality like history fetching.
pub mod services;
pub(crate) mod wire;
#[cfg(feature = "test-mode")]
pub(crate) mod fixtures;
#[cfg(feature = "dataframe")]
/// `DataFrame` conversion traits.
pub mod dataframe;
pub mod conversions;
pub(crate) mod net;
// convenient re-exports so most code can just `use crate::core::YfClient`
pub use client::{CacheMode, RetryConfig, YfClient, YfClientBuilder};
pub use error::YfError;
pub use models::{Action, Candle, HistoryMeta, HistoryResponse, Interval, Quote, Range};
pub use services::{HistoryRequest, HistoryService};

View File

@@ -0,0 +1,39 @@
// Re-export types from paft explicitly
pub use paft::market::action::Action;
pub use paft::market::quote::Quote;
pub use paft::market::requests::history::{Interval, Range};
pub use paft::market::responses::history::{Candle, HistoryMeta, HistoryResponse};
// Helper functions for converting to string representations
pub(crate) const fn range_as_str(range: Range) -> &'static str {
match range {
Range::D1 => "1d",
Range::D5 => "5d",
Range::M1 => "1mo",
Range::M3 => "3mo",
Range::M6 => "6mo",
Range::Y1 => "1y",
Range::Y2 => "2y",
Range::Y5 => "5y",
Range::Y10 => "10y",
Range::Ytd => "ytd",
Range::Max => "max",
}
}
pub(crate) const fn interval_as_str(interval: Interval) -> &'static str {
match interval {
Interval::I1m => "1m",
Interval::I2m => "2m",
Interval::I5m => "5m",
Interval::I15m => "15m",
Interval::I30m => "30m",
Interval::I90m => "90m",
Interval::I1h => "1h",
Interval::D1 => "1d",
Interval::D5 => "5d",
Interval::W1 => "1wk",
Interval::M1 => "1mo",
Interval::M3 => "3mo",
}
}

View File

@@ -0,0 +1,25 @@
#[cfg(feature = "test-mode")]
use std::env;
/// Read the response body as text.
/// In `test-mode`, if `YF_RECORD=1`, the body is saved as a fixture via `net_fixtures`.
#[allow(unused_variables)]
pub async fn get_text(
resp: reqwest::Response,
endpoint: &str,
symbol: &str,
ext: &str,
) -> Result<String, reqwest::Error> {
let text = resp.text().await?;
#[cfg(feature = "test-mode")]
{
if env::var("YF_RECORD").ok().as_deref() == Some("1")
&& let Err(e) = crate::core::fixtures::record_fixture(endpoint, symbol, ext, &text)
{
eprintln!("YF_RECORD: failed to write fixture for {symbol}: {e}");
}
}
Ok(text)
}

View File

@@ -0,0 +1,219 @@
// src/core/quotes.rs
use serde::Deserialize;
use std::str::FromStr;
use url::Url;
use crate::{
YfClient, YfError,
core::{
client::{CacheMode, RetryConfig},
conversions::f64_to_money_with_currency_str,
net,
},
};
use paft::domain::{AssetKind, Instrument};
use paft::market::quote::Quote;
// Centralized wire model for the v7 quote API
#[derive(Deserialize)]
pub struct V7Envelope {
#[serde(rename = "quoteResponse")]
pub(crate) quote_response: Option<V7QuoteResponse>,
}
#[derive(Deserialize)]
pub struct V7QuoteResponse {
pub(crate) result: Option<Vec<V7QuoteNode>>,
#[allow(dead_code)]
pub(crate) error: Option<serde_json::Value>,
}
#[derive(Deserialize, Clone)]
pub struct V7QuoteNode {
#[serde(default)]
pub(crate) symbol: Option<String>,
#[serde(rename = "quoteType")]
pub(crate) quote_type: Option<String>,
#[serde(rename = "shortName")]
pub(crate) short_name: Option<String>,
#[serde(rename = "regularMarketPrice")]
pub(crate) regular_market_price: Option<f64>,
#[serde(rename = "regularMarketPreviousClose")]
pub(crate) regular_market_previous_close: Option<f64>,
#[serde(rename = "regularMarketVolume")]
pub(crate) regular_market_volume: Option<u64>,
pub(crate) currency: Option<String>,
#[serde(rename = "fullExchangeName")]
pub(crate) full_exchange_name: Option<String>,
pub(crate) exchange: Option<String>,
pub(crate) market: Option<String>,
#[serde(rename = "marketCapFigureExchange")]
pub(crate) market_cap_figure_exchange: Option<String>,
#[serde(rename = "marketState")]
pub(crate) market_state: Option<String>,
}
/// Centralized function to fetch one or more quotes from the v7 API.
/// It handles caching, retries, and authentication (crumb).
#[allow(clippy::too_many_lines)]
pub async fn fetch_v7_quotes(
client: &YfClient,
symbols: &[&str],
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<Vec<V7QuoteNode>, YfError> {
// Inner function to attempt the fetch, allowing for an auth retry.
async fn attempt_fetch(
client: &YfClient,
symbols: &[&str],
crumb: Option<&str>,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<(String, Url, Option<u16>), YfError> {
let mut url = client.base_quote_v7().clone();
{
let mut qp = url.query_pairs_mut();
qp.append_pair("symbols", &symbols.join(","));
if let Some(c) = crumb {
qp.append_pair("crumb", c);
}
}
if cache_mode == CacheMode::Use
&& let Some(body) = client.cache_get(&url).await
{
return Ok((body, url, None));
}
let resp = client
.send_with_retry(
client
.http()
.get(url.clone())
.header("accept", "application/json"),
retry_override,
)
.await?;
let status = resp.status();
let body = net::get_text(resp, "quote_v7", &symbols.join("-"), "json").await?;
if status.is_success() {
if cache_mode != CacheMode::Bypass {
client.cache_put(&url, &body, None).await;
}
Ok((body, url, None))
} else {
Ok((body, url, Some(status.as_u16())))
}
}
// First attempt, without a crumb.
let (body, url, maybe_status) =
attempt_fetch(client, symbols, None, cache_mode, retry_override).await?;
let body_to_parse = if let Some(status_code) = maybe_status {
// If unauthorized, get a crumb and retry.
if status_code == 401 || status_code == 403 {
client.ensure_credentials().await?;
let crumb = client.crumb().await.ok_or_else(|| {
YfError::Auth("Crumb is not set after ensuring credentials".into())
})?;
// Second attempt, with a crumb.
let (body, url, maybe_status) =
attempt_fetch(client, symbols, Some(&crumb), cache_mode, retry_override).await?;
if let Some(status_code) = maybe_status {
let url_s = url.to_string();
return Err(match status_code {
404 => YfError::NotFound { url: url_s },
429 => YfError::RateLimited { url: url_s },
500..=599 => YfError::ServerError {
status: status_code,
url: url_s,
},
_ => YfError::Status {
status: status_code,
url: url_s,
},
});
}
body
} else {
let url_s = url.to_string();
return Err(match status_code {
404 => YfError::NotFound { url: url_s },
429 => YfError::RateLimited { url: url_s },
500..=599 => YfError::ServerError {
status: status_code,
url: url_s,
},
_ => YfError::Status {
status: status_code,
url: url_s,
},
});
}
} else {
body
};
let env: V7Envelope = serde_json::from_str(&body_to_parse)?;
let nodes = env
.quote_response
.and_then(|qr| qr.result)
.unwrap_or_default();
// Populate instrument cache best-effort from v7 quote nodes
for n in &nodes {
if let Some(sym) = n.symbol.as_deref() {
let exch = crate::core::conversions::string_to_exchange(
n.full_exchange_name
.clone()
.or_else(|| n.exchange.clone())
.or_else(|| n.market.clone())
.or_else(|| n.market_cap_figure_exchange.clone()),
);
let kind = n
.quote_type
.as_deref()
.and_then(|s| s.parse::<AssetKind>().ok())
.unwrap_or(AssetKind::Equity);
let inst = exch.map_or_else(
|| Instrument::from_symbol(sym, kind),
|ex| Instrument::from_symbol_and_exchange(sym, ex, kind),
);
if let Ok(inst) = inst {
client.store_instrument(sym.to_string(), inst).await;
}
}
}
Ok(nodes)
}
impl From<V7QuoteNode> for Quote {
fn from(n: V7QuoteNode) -> Self {
Self {
symbol: paft::domain::Symbol::from_str(&n.symbol.unwrap_or_default())
.expect("v7 quote node had invalid/missing symbol"),
shortname: n.short_name,
price: n
.regular_market_price
.map(|price| f64_to_money_with_currency_str(price, n.currency.as_deref())),
previous_close: n
.regular_market_previous_close
.map(|price| f64_to_money_with_currency_str(price, n.currency.as_deref())),
day_volume: n.regular_market_volume,
exchange: crate::core::conversions::string_to_exchange(
n.full_exchange_name
.or(n.exchange)
.or(n.market)
.or(n.market_cap_figure_exchange),
),
market_state: n.market_state.and_then(|s| s.parse().ok()),
}
}
}

View File

@@ -0,0 +1,145 @@
use crate::core::{
YfClient, YfError,
client::{CacheMode, RetryConfig},
net,
};
use serde::Deserialize;
#[cfg(feature = "debug-dumps")]
use crate::profile::debug::debug_dump_api;
#[derive(Deserialize)]
pub struct V10Envelope {
#[serde(rename = "quoteSummary")]
pub(crate) quote_summary: Option<V10QuoteSummary>,
}
#[derive(Deserialize)]
pub struct V10QuoteSummary {
pub(crate) result: Option<Vec<serde_json::Value>>,
pub(crate) error: Option<V10Error>,
}
#[derive(Deserialize)]
pub struct V10Error {
pub(crate) description: String,
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(
skip(client, cache_mode, retry_override),
err,
fields(symbol = %symbol, modules = %modules, caller = %caller)
)
)]
pub async fn fetch(
client: &YfClient,
symbol: &str,
modules: &str,
caller: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<V10Envelope, YfError> {
async fn attempt_fetch(
client: &YfClient,
symbol: &str,
modules: &str,
caller: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<V10Envelope, YfError> {
client.ensure_credentials().await?;
let crumb = client
.crumb()
.await
.ok_or_else(|| YfError::Auth("Crumb is not set".into()))?;
let mut url = client.base_quote_api().join(symbol)?;
{
let mut qp = url.query_pairs_mut();
qp.append_pair("modules", modules);
qp.append_pair("crumb", &crumb);
}
if cache_mode == CacheMode::Use
&& let Some(text) = client.cache_get(&url).await
{
#[cfg(feature = "debug-dumps")]
let _ = debug_dump_api(symbol, &text);
return serde_json::from_str(&text).map_err(YfError::Json);
}
let req = client.http().get(url.clone());
let resp = client.send_with_retry(req, retry_override).await?;
// Create a sanitized key from module names for a unique fixture filename.
let module_key = modules
.replace(',', "-")
.replace(|c: char| !c.is_alphanumeric() && c != '-', "");
let fixture_endpoint = format!("{caller}_api_{module_key}");
let text = net::get_text(resp, &fixture_endpoint, symbol, "json").await?;
#[cfg(feature = "debug-dumps")]
let _ = debug_dump_api(symbol, &text);
if cache_mode != CacheMode::Bypass {
client.cache_put(&url, &text, None).await;
}
serde_json::from_str(&text).map_err(YfError::Json)
}
for attempt in 0..=1 {
let env =
attempt_fetch(client, symbol, modules, caller, cache_mode, retry_override).await?;
if let Some(error) = env.quote_summary.as_ref().and_then(|qs| qs.error.as_ref()) {
let desc = error.description.to_ascii_lowercase();
if desc.contains("invalid crumb") && attempt == 0 {
if std::env::var("YF_DEBUG").ok().as_deref() == Some("1") {
eprintln!("YF_DEBUG: Invalid crumb in {caller}; refreshing and retrying.");
}
#[cfg(feature = "tracing")]
tracing::event!(
tracing::Level::WARN,
"invalid crumb; refreshing and retrying"
);
client.clear_crumb().await;
continue;
}
#[cfg(feature = "tracing")]
tracing::event!(tracing::Level::ERROR, description = %error.description, "quoteSummary error");
return Err(YfError::Api(format!("yahoo error: {}", error.description)));
}
return Ok(env);
}
Err(YfError::Api(format!(
"{caller} API call failed after retry"
)))
}
pub async fn fetch_module_result<T>(
client: &YfClient,
symbol: &str,
modules: &str,
caller: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<T, YfError>
where
T: for<'de> serde::Deserialize<'de>,
{
let env = fetch(client, symbol, modules, caller, cache_mode, retry_override).await?;
let result_val = env
.quote_summary
.and_then(|qs| qs.result)
.and_then(|mut v| v.pop())
.ok_or_else(|| YfError::MissingData("empty quoteSummary result".into()))?;
serde_json::from_value(result_val).map_err(YfError::Json)
}

View File

@@ -0,0 +1,49 @@
use crate::core::{HistoryResponse, Interval, Range, YfError};
/// Encapsulates all parameters for a single historical data request.
///
/// This struct is used as a generic way to request historical data, decoupling modules
/// like `download` from the specific implementation details of `history::HistoryBuilder`.
#[derive(Debug, Clone, Copy)]
#[allow(clippy::struct_excessive_bools)]
pub struct HistoryRequest {
/// A relative time range for the request (e.g., `1y`, `6mo`).
///
/// If `Some`, this takes precedence over `period`.
pub range: Option<Range>,
/// An absolute time period for the request, specified as `(start, end)` Unix timestamps.
pub period: Option<(i64, i64)>,
/// The time interval for each data point (candle).
pub interval: Interval,
/// Whether to include pre-market and post-market data for intraday intervals.
pub include_prepost: bool,
/// Whether to include corporate actions (dividends and splits) in the response.
pub include_actions: bool,
/// Whether to automatically adjust prices for splits and dividends.
pub auto_adjust: bool,
/// Whether to keep data rows that have missing OHLC values.
pub keepna: bool,
}
/// A trait for services that can fetch historical financial data.
///
/// This allows for abstracting the history fetching logic, making it easier to test
/// and decoupling different parts of the crate. It is implemented by [`YfClient`].
pub trait HistoryService: Send + Sync {
/// Asynchronously fetches the complete historical data for a given symbol and request.
///
/// # Arguments
/// * `symbol` - The ticker symbol to fetch data for.
/// * `req` - A `HistoryRequest` struct containing all the parameters for the query.
///
/// # Returns
/// A `Future` that resolves to a `Result` containing either a `HistoryResponse` on success
/// or a `YfError` on failure.
fn fetch_full_history<'a>(
&'a self,
symbol: &'a str,
req: HistoryRequest,
) -> core::pin::Pin<
Box<dyn core::future::Future<Output = Result<HistoryResponse, YfError>> + Send + 'a>,
>;
}

View File

@@ -0,0 +1,65 @@
use serde::{Deserialize, Deserializer};
#[derive(Deserialize, Clone, Copy)]
pub struct RawNum<T> {
pub(crate) raw: Option<T>,
}
pub fn from_raw<T>(raw: Option<RawNum<T>>) -> Option<T> {
raw.and_then(|n| n.raw)
}
pub fn from_raw_u32_round(r: Option<RawNum<f64>>) -> Option<u32> {
r.and_then(|n| n.raw).and_then(|v| {
let rounded = v.round();
if rounded >= 0.0 && rounded <= f64::from(u32::MAX) {
// This cast is safe as we check the bounds of rounded.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
Some(rounded as u32)
} else {
None
}
})
}
#[derive(Deserialize, Clone, Copy)]
pub struct RawDate {
pub(crate) raw: Option<i64>,
}
pub fn from_raw_date(r: Option<RawDate>) -> Option<i64> {
r.and_then(|d| d.raw)
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn de_u64_from_any_number<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum AnyNumber {
U64(u64),
F64(f64),
}
match Option::<AnyNumber>::deserialize(deserializer)? {
Some(AnyNumber::U64(u)) => Ok(Some(u)),
Some(AnyNumber::F64(f)) => {
if f.fract() == 0.0 && f >= 0.0 {
Ok(Some(f as u64))
} else {
Err(serde::de::Error::custom(format!(
"cannot convert float {f} to u64"
)))
}
}
None => Ok(None),
}
}
#[derive(Deserialize, Clone, Copy)]
pub struct RawNumU64 {
#[serde(deserialize_with = "de_u64_from_any_number")]
pub(crate) raw: Option<u64>,
}

View File

@@ -0,0 +1,442 @@
use futures::future::try_join_all;
use crate::{
core::client::{CacheMode, RetryConfig},
core::{Candle, HistoryResponse, Interval, Range, YfClient, YfError},
history::HistoryBuilder,
};
use paft::domain::{AssetKind, Instrument};
use paft::market::responses::download::{DownloadEntry, DownloadResponse};
use paft::money::Money;
use rust_decimal::prelude::{FromPrimitive, ToPrimitive};
type DateRange = (chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>);
type MaybeDateRange = Option<DateRange>;
/// A builder for downloading historical data for multiple symbols concurrently.
///
/// This provides a convenient way to fetch data for a list of tickers with the same
/// parameters in parallel, similar to `yfinance.download` in Python.
///
/// Many of the configuration methods mirror those on [`HistoryBuilder`].
#[allow(clippy::struct_excessive_bools)]
pub struct DownloadBuilder {
client: YfClient,
symbols: Vec<String>,
// date / time controls
range: Option<Range>,
period: Option<(i64, i64)>,
interval: Interval,
// behavior flags
auto_adjust: bool,
back_adjust: bool,
include_prepost: bool,
include_actions: bool,
keepna: bool,
rounding: bool,
repair: bool,
cache_mode: CacheMode,
retry_override: Option<RetryConfig>,
}
impl DownloadBuilder {
fn precompute_period_dt(&self) -> Result<MaybeDateRange, YfError> {
if let Some((p1, p2)) = self.period {
use chrono::{TimeZone, Utc};
let start = Utc
.timestamp_opt(p1, 0)
.single()
.ok_or_else(|| YfError::InvalidParams("invalid period1".into()))?;
let end = Utc
.timestamp_opt(p2, 0)
.single()
.ok_or_else(|| YfError::InvalidParams("invalid period2".into()))?;
Ok(Some((start, end)))
} else {
Ok(None)
}
}
fn build_history_for_symbol(
&self,
sym: &str,
period_dt: Option<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>,
need_adjust_in_fetch: bool,
) -> HistoryBuilder {
let mut hb: HistoryBuilder = HistoryBuilder::new(&self.client, sym.to_string())
.interval(self.interval)
.auto_adjust(need_adjust_in_fetch)
.prepost(self.include_prepost)
.actions(self.include_actions)
.keepna(self.keepna)
.cache_mode(self.cache_mode)
.retry_policy(self.retry_override.clone());
if let Some((start, end)) = period_dt {
hb = hb.between(start, end);
} else if let Some(r) = self.range {
hb = hb.range(r);
} else {
hb = hb.range(Range::M6);
}
hb
}
fn apply_back_adjust(&self, rows: &mut [Candle]) {
if !self.back_adjust {
return;
}
for c in rows.iter_mut() {
if let Some(rc) = c.close_unadj.as_ref()
&& rc.amount().to_f64().is_some_and(f64::is_finite)
{
c.close = rc.clone();
}
}
}
fn apply_rounding_if_enabled(&self, rows: &mut [Candle]) {
if !self.rounding {
return;
}
for c in rows {
if c.open.amount().to_f64().is_some_and(f64::is_finite) {
c.open = Money::new(
rust_decimal::Decimal::from_f64(round2(
c.open.amount().to_f64().unwrap_or(0.0),
))
.unwrap_or_default(),
c.open.currency().clone(),
)
.expect("currency metadata available");
}
if c.high.amount().to_f64().is_some_and(f64::is_finite) {
c.high = Money::new(
rust_decimal::Decimal::from_f64(round2(
c.high.amount().to_f64().unwrap_or(0.0),
))
.unwrap_or_default(),
c.high.currency().clone(),
)
.expect("currency metadata available");
}
if c.low.amount().to_f64().is_some_and(f64::is_finite) {
c.low = Money::new(
rust_decimal::Decimal::from_f64(round2(c.low.amount().to_f64().unwrap_or(0.0)))
.unwrap_or_default(),
c.low.currency().clone(),
)
.expect("currency metadata available");
}
if c.close.amount().to_f64().is_some_and(f64::is_finite) {
c.close = Money::new(
rust_decimal::Decimal::from_f64(round2(
c.close.amount().to_f64().unwrap_or(0.0),
))
.unwrap_or_default(),
c.close.currency().clone(),
)
.expect("currency metadata available");
}
}
}
fn maybe_repair(&self, rows: &mut [Candle]) {
if self.repair {
repair_scale_outliers(rows);
}
}
async fn process_joined_results(
&self,
joined: Vec<(String, HistoryResponse)>,
_need_adjust_in_fetch: bool,
) -> DownloadResponse {
let mut entries: Vec<DownloadEntry> = Vec::with_capacity(joined.len());
for (sym, mut resp) in joined {
// apply transforms to candles
self.apply_back_adjust(&mut resp.candles);
self.maybe_repair(&mut resp.candles);
self.apply_rounding_if_enabled(&mut resp.candles);
// get instrument from cache or fallback
let instrument = if let Some(inst) = self.client.cached_instrument(&sym).await {
inst
} else {
let kind = AssetKind::Equity;
let inst = Instrument::from_symbol(&sym, kind).expect("valid symbol");
self.client
.store_instrument(sym.clone(), inst.clone())
.await;
inst
};
entries.push(DownloadEntry {
instrument,
history: resp,
});
}
DownloadResponse { entries }
}
/// Creates a new `DownloadBuilder`.
#[must_use]
pub fn new(client: &YfClient) -> Self {
Self {
client: client.clone(),
symbols: Vec::new(),
range: Some(Range::M6),
period: None,
interval: Interval::D1,
auto_adjust: true,
back_adjust: false,
include_prepost: false,
include_actions: true,
keepna: false,
rounding: false,
repair: false,
cache_mode: CacheMode::Use,
retry_override: None,
}
}
/// Sets the cache mode for all API calls made by this builder.
#[must_use]
pub const fn cache_mode(mut self, mode: CacheMode) -> Self {
self.cache_mode = mode;
self
}
/// Overrides the default retry policy for all API calls made by this builder.
#[must_use]
pub fn retry_policy(mut self, cfg: Option<RetryConfig>) -> Self {
self.retry_override = cfg;
self
}
/// Replaces the current list of symbols with a new list.
#[must_use]
pub fn symbols<I, S>(mut self, syms: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.symbols = syms.into_iter().map(std::convert::Into::into).collect();
self
}
/// Adds a single symbol to the list of symbols to download.
#[must_use]
pub fn add_symbol(mut self, sym: impl Into<String>) -> Self {
self.symbols.push(sym.into());
self
}
/// Sets a relative time range for the request (e.g., `1y`, `6mo`).
#[must_use]
pub const fn range(mut self, range: Range) -> Self {
self.period = None;
self.range = Some(range);
self
}
/// Sets an absolute time period for the request using start and end timestamps.
#[must_use]
pub const fn between(
mut self,
start: chrono::DateTime<chrono::Utc>,
end: chrono::DateTime<chrono::Utc>,
) -> Self {
self.range = None;
self.period = Some((start.timestamp(), end.timestamp()));
self
}
/// Sets the time interval for each data point (candle).
#[must_use]
pub const fn interval(mut self, interval: Interval) -> Self {
self.interval = interval;
self
}
/// Sets whether to automatically adjust prices for splits and dividends. (Default: `true`)
#[must_use]
pub const fn auto_adjust(mut self, yes: bool) -> Self {
self.auto_adjust = yes;
self
}
/// Sets whether to back-adjust prices.
///
/// Back-adjustment adjusts the Open, High, and Low prices, but keeps the Close price as the
/// raw, unadjusted close. This forces an internal adjustment even if `auto_adjust` is false.
#[must_use]
pub const fn back_adjust(mut self, yes: bool) -> Self {
self.back_adjust = yes;
self
}
/// Sets whether to include pre-market and post-market data for intraday intervals. (Default: `false`)
#[must_use]
pub const fn prepost(mut self, yes: bool) -> Self {
self.include_prepost = yes;
self
}
/// Sets whether to include corporate actions (dividends and splits) in the result. (Default: `true`)
#[must_use]
pub const fn actions(mut self, yes: bool) -> Self {
self.include_actions = yes;
self
}
/// Sets whether to keep data rows that have missing OHLC values. (Default: `false`)
#[must_use]
pub const fn keepna(mut self, yes: bool) -> Self {
self.keepna = yes;
self
}
/// Sets whether to round prices to 2 decimal places. (Default: `false`)
#[must_use]
pub const fn rounding(mut self, yes: bool) -> Self {
self.rounding = yes;
self
}
/// Sets whether to attempt to repair obvious price outliers (e.g., 100x errors). (Default: `false`)
#[must_use]
pub const fn repair(mut self, yes: bool) -> Self {
self.repair = yes;
self
}
/// Executes the download by fetching data for all specified symbols concurrently.
///
/// # Errors
///
/// Returns an error if any of the underlying history requests fail.
pub async fn run(self) -> Result<DownloadResponse, YfError> {
if self.symbols.is_empty() {
return Err(YfError::InvalidParams("no symbols specified".into()));
}
let need_adjust_in_fetch = self.auto_adjust || self.back_adjust;
let period_dt = self.precompute_period_dt()?;
let futures = self.symbols.iter().map(|sym| {
let sym = sym.clone();
let hb = self.build_history_for_symbol(&sym, period_dt, need_adjust_in_fetch);
async move {
let full: HistoryResponse = hb.fetch_full().await?;
Ok::<(String, HistoryResponse), YfError>((sym, full))
}
});
let joined: Vec<(String, HistoryResponse)> = try_join_all(futures).await?;
Ok(self
.process_joined_results(joined, need_adjust_in_fetch)
.await)
}
}
/* ---------------- internal helpers ---------------- */
fn round2(x: f64) -> f64 {
(x * 100.0).round() / 100.0
}
/// Very lightweight "repair" pass:
/// If a bar's close is ~100× the average of its neighbors (or ~1/100),
/// scale that entire bar's OHLC accordingly. Volumes are left unchanged.
fn repair_scale_outliers(rows: &mut [Candle]) {
if rows.len() < 3 {
return;
}
for i in 1..rows.len() - 1 {
// Split rows at i, so left[..i] and right[i..] don't overlap.
let (left, right) = rows.split_at_mut(i);
// prev is in the left side (immutable is fine)
let prev = &left[i - 1];
// Now split the right side so we can mutably borrow the “current” bar
// and (immutably) the remainder where “next” lives, without overlap.
let (cur, rem) = right.split_first_mut().expect("right has at least 1");
let next = &rem[0]; // safe because len >= 2 overall ⇒ rem has at least one
let p = &prev.close;
let n = &next.close;
let c = &cur.close;
if !(p.amount().to_f64().is_some_and(f64::is_finite)
&& n.amount().to_f64().is_some_and(f64::is_finite)
&& c.amount().to_f64().is_some_and(f64::is_finite))
{
continue;
}
let p_val = p.amount().to_f64().unwrap_or(0.0);
let n_val = n.amount().to_f64().unwrap_or(0.0);
let c_val = c.amount().to_f64().unwrap_or(0.0);
let baseline = f64::midpoint(p_val, n_val);
if baseline <= 0.0 {
continue;
}
let ratio = c_val / baseline;
// ~100× high
if ratio > 50.0 && ratio < 200.0 {
let scale = if (80.0..125.0).contains(&ratio) {
0.01
} else {
1.0 / ratio
};
scale_row_prices(cur, scale);
continue;
}
// ~100× low
if ratio > 0.0 && ratio < 0.02 {
let scale = if (0.008..0.0125).contains(&ratio) {
100.0
} else {
1.0 / ratio
};
scale_row_prices(cur, scale);
}
}
}
fn scale_row_prices(c: &mut Candle, scale: f64) {
if c.open.amount().to_f64().is_some_and(f64::is_finite) {
c.open = c
.open
.try_mul(rust_decimal::Decimal::from_f64_retain(scale).unwrap_or_default())
.expect("currency metadata available");
}
if c.high.amount().to_f64().is_some_and(f64::is_finite) {
c.high = c
.high
.try_mul(rust_decimal::Decimal::from_f64_retain(scale).unwrap_or_default())
.expect("currency metadata available");
}
if c.low.amount().to_f64().is_some_and(f64::is_finite) {
c.low = c
.low
.try_mul(rust_decimal::Decimal::from_f64_retain(scale).unwrap_or_default())
.expect("currency metadata available");
}
if c.close.amount().to_f64().is_some_and(f64::is_finite) {
c.close = c
.close
.try_mul(rust_decimal::Decimal::from_f64_retain(scale).unwrap_or_default())
.expect("currency metadata available");
}
}

View File

@@ -0,0 +1,70 @@
use crate::{
core::{
YfClient, YfError,
client::{CacheMode, RetryConfig},
quotesummary,
wire::from_raw,
},
esg::wire::V10Result,
};
use paft::fundamentals::esg::{EsgInvolvement, EsgScores, EsgSummary};
pub(super) async fn fetch_esg_scores(
client: &YfClient,
symbol: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<EsgSummary, YfError> {
let root: V10Result = quotesummary::fetch_module_result(
client,
symbol,
"esgScores",
"esg",
cache_mode,
retry_override,
)
.await?;
let esg = root
.esg_scores
.ok_or_else(|| YfError::MissingData("esgScores module missing from response".into()))?;
// Map to paft types: paft::fundamentals::EsgScores now has only environmental/social/governance.
let scores = EsgScores {
environmental: from_raw(esg.environment_score),
social: from_raw(esg.social_score),
governance: from_raw(esg.governance_score),
};
// Collect involvement booleans as individual entries with simple categories.
let mut involvement: Vec<EsgInvolvement> = Vec::new();
let mut push_flag = |name: &str, val: Option<bool>| {
if val.unwrap_or(false) {
involvement.push(EsgInvolvement {
category: name.to_string(),
score: None,
});
}
};
push_flag("adult", esg.adult);
push_flag("alcoholic", esg.alcoholic);
push_flag("animal_testing", esg.animal_testing);
push_flag("catholic", esg.catholic);
push_flag("controversial_weapons", esg.controversial_weapons);
push_flag("small_arms", esg.small_arms);
push_flag("fur_leather", esg.fur_leather);
push_flag("gambling", esg.gambling);
push_flag("gmo", esg.gmo);
push_flag("military_contract", esg.military_contract);
push_flag("nuclear", esg.nuclear);
push_flag("palm_oil", esg.palm_oil);
push_flag("pesticides", esg.pesticides);
push_flag("thermal_coal", esg.thermal_coal);
push_flag("tobacco", esg.tobacco);
// Return scores together with involvement in a single summary
Ok(EsgSummary {
scores: Some(scores),
involvement,
})
}

View File

@@ -0,0 +1,59 @@
mod api;
mod model;
mod wire;
pub use model::{EsgInvolvement, EsgScores, EsgSummary};
use crate::{
YfClient, YfError,
core::client::{CacheMode, RetryConfig},
};
/// A builder for fetching ESG (Environmental, Social, and Governance) data for a specific symbol.
pub struct EsgBuilder {
client: YfClient,
symbol: String,
cache_mode: CacheMode,
retry_override: Option<RetryConfig>,
}
impl EsgBuilder {
/// Creates a new `EsgBuilder` for a given symbol.
pub fn new(client: &YfClient, symbol: impl Into<String>) -> Self {
Self {
client: client.clone(),
symbol: symbol.into(),
cache_mode: CacheMode::Use,
retry_override: None,
}
}
/// Sets the cache mode for this specific API call.
#[must_use]
pub const fn cache_mode(mut self, mode: CacheMode) -> Self {
self.cache_mode = mode;
self
}
/// Overrides the default retry policy for this specific API call.
#[must_use]
pub fn retry_policy(mut self, cfg: Option<RetryConfig>) -> Self {
self.retry_override = cfg;
self
}
/// Fetches the ESG scores and involvement data for the symbol.
///
/// # Errors
///
/// Returns a `YfError` if the network request fails or the API response cannot be parsed.
pub async fn fetch(self) -> Result<EsgSummary, YfError> {
api::fetch_esg_scores(
&self.client,
&self.symbol,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
}

View File

@@ -0,0 +1,2 @@
// Re-export types from paft
pub use paft::fundamentals::esg::{EsgInvolvement, EsgScores, EsgSummary};

View File

@@ -0,0 +1,44 @@
use crate::core::wire::RawNum;
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct V10Result {
#[serde(rename = "esgScores")]
pub(crate) esg_scores: Option<EsgScoresNode>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EsgScoresNode {
// These are objects: { "raw": ... }
#[allow(dead_code)]
pub(crate) total_esg: Option<RawNum<f64>>,
pub(crate) environment_score: Option<RawNum<f64>>,
pub(crate) social_score: Option<RawNum<f64>>,
pub(crate) governance_score: Option<RawNum<f64>>,
// These are primitives
#[allow(dead_code)]
pub(crate) percentile: Option<f64>,
#[allow(dead_code)]
pub(crate) highest_controversy: Option<f64>, // Use f64 to match JSON `2.0`
// Involvement flags
pub(crate) adult: Option<bool>,
pub(crate) alcoholic: Option<bool>,
pub(crate) animal_testing: Option<bool>,
pub(crate) catholic: Option<bool>,
pub(crate) controversial_weapons: Option<bool>,
pub(crate) small_arms: Option<bool>,
pub(crate) fur_leather: Option<bool>,
pub(crate) gambling: Option<bool>,
pub(crate) gmo: Option<bool>,
pub(crate) military_contract: Option<bool>,
pub(crate) nuclear: Option<bool>,
pub(crate) palm_oil: Option<bool>,
pub(crate) pesticides: Option<bool>,
#[serde(rename = "coal")] // JSON key is "coal", map to our more descriptive field name
pub(crate) thermal_coal: Option<bool>,
pub(crate) tobacco: Option<bool>,
}

View File

@@ -0,0 +1,619 @@
use chrono::{Duration, Utc};
use std::collections::BTreeMap;
use crate::{
core::{
YfClient, YfError,
client::{CacheMode, RetryConfig},
conversions::{f64_to_money_with_currency, i64_to_datetime, string_to_period},
},
fundamentals::wire::{TimeseriesData, TimeseriesEnvelope},
};
use paft::fundamentals::profile::ShareCount;
use paft::money::Currency;
use super::fetch::fetch_modules;
use super::{
BalanceSheetRow, CashflowRow, Earnings, EarningsQuarter, EarningsQuarterEps, EarningsYear,
IncomeStatementRow,
};
/// Generic helper function to fetch and process timeseries data from the fundamentals API.
///
/// This function handles the common pattern of:
/// 1. Constructing the URL for the /ws/fundamentals-timeseries endpoint
/// 2. Making the request with caching logic
/// 3. Parsing the `TimeseriesEnvelope`
/// 4. Processing the data into a `BTreeMap`
///
/// The `process_item` closure is responsible for processing each timeseries item
/// and updating the rows map accordingly.
#[allow(clippy::too_many_arguments)]
async fn fetch_timeseries_data<T, F>(
client: &YfClient,
symbol: &str,
quarterly: bool,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
keys: &[&str],
endpoint_name: &str,
_create_default_row: fn(i64) -> T,
process_item: F,
) -> Result<Vec<T>, YfError>
where
F: Fn(&str, &serde_json::Value, &mut BTreeMap<i64, T>, &[i64], &str) -> Result<(), YfError>,
{
let prefix = if quarterly { "quarterly" } else { "annual" };
let types: Vec<String> = keys.iter().map(|k| format!("{prefix}{k}")).collect();
let type_str = types.join(",");
let end_ts = Utc::now().timestamp();
let start_ts = Utc::now()
.checked_sub_signed(Duration::days(365 * 5))
.map_or(0, |dt| dt.timestamp());
let mut url = client.base_timeseries().join(symbol)?;
url.query_pairs_mut()
.append_pair("symbol", symbol)
.append_pair("type", &type_str)
.append_pair("period1", &start_ts.to_string())
.append_pair("period2", &end_ts.to_string());
client.ensure_credentials().await?;
if let Some(crumb) = client.crumb().await {
url.query_pairs_mut().append_pair("crumb", &crumb);
}
let body = if cache_mode == CacheMode::Use {
if let Some(cached) = client.cache_get(&url).await {
cached
} else {
let resp = client
.send_with_retry(client.http().get(url.clone()), retry_override)
.await?;
let endpoint = format!("timeseries_{endpoint_name}_{prefix}");
let text = crate::core::net::get_text(resp, &endpoint, symbol, "json").await?;
if cache_mode != CacheMode::Bypass {
client.cache_put(&url, &text, None).await;
}
text
}
} else {
let resp = client
.send_with_retry(client.http().get(url.clone()), retry_override)
.await?;
let endpoint = format!("timeseries_{endpoint_name}_{prefix}");
let text = crate::core::net::get_text(resp, &endpoint, symbol, "json").await?;
if cache_mode != CacheMode::Bypass {
client.cache_put(&url, &text, None).await;
}
text
};
let envelope: TimeseriesEnvelope = serde_json::from_str(&body).map_err(YfError::Json)?;
let result_vec = envelope
.timeseries
.and_then(|ts| ts.result)
.unwrap_or_default();
if result_vec.is_empty() {
return Ok(vec![]);
}
let mut rows_map = BTreeMap::<i64, T>::new();
for item in result_vec {
if let (Some(timestamps), Some((key, values_json))) =
(item.timestamp, item.values.into_iter().next())
{
// Process the item using the provided closure
process_item(&key, &values_json, &mut rows_map, &timestamps, prefix)?;
}
}
Ok(rows_map.into_values().rev().collect())
}
pub(super) async fn income_statement(
client: &YfClient,
symbol: &str,
quarterly: bool,
currency: Currency,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<Vec<IncomeStatementRow>, YfError> {
use serde::Deserialize;
use crate::core::wire::RawNum;
#[derive(Deserialize)]
struct TimeseriesValueF64 {
#[serde(rename = "reportedValue")]
reported_value: Option<RawNum<f64>>,
}
let keys = [
"TotalRevenue",
"GrossProfit",
"OperatingIncome",
"NetIncome",
];
let endpoint_name = "income_statement";
let create_default_row = |period_end: i64| IncomeStatementRow {
period: string_to_period(&i64_to_datetime(period_end).format("%Y-%m-%d").to_string()),
total_revenue: None,
gross_profit: None,
operating_income: None,
net_income: None,
};
let process_item = |key: &str,
values_json: &serde_json::Value,
rows_map: &mut BTreeMap<i64, IncomeStatementRow>,
timestamps: &[i64],
prefix: &str|
-> Result<(), YfError> {
if let Ok(values) = serde_json::from_value::<Vec<TimeseriesValueF64>>(values_json.clone()) {
for (i, ts) in timestamps.iter().enumerate() {
let row = rows_map
.entry(*ts)
.or_insert_with(|| create_default_row(*ts));
let value = values
.get(i)
.and_then(|v| v.reported_value.and_then(|rv| rv.raw));
if key == format!("{prefix}TotalRevenue") {
row.total_revenue =
value.map(|v| f64_to_money_with_currency(v, currency.clone()));
} else if key == format!("{prefix}GrossProfit") {
row.gross_profit =
value.map(|v| f64_to_money_with_currency(v, currency.clone()));
} else if key == format!("{prefix}OperatingIncome") {
row.operating_income =
value.map(|v| f64_to_money_with_currency(v, currency.clone()));
} else if key == format!("{prefix}NetIncome") {
row.net_income = value.map(|v| f64_to_money_with_currency(v, currency.clone()));
}
}
}
Ok(())
};
let result = fetch_timeseries_data(
client,
symbol,
quarterly,
cache_mode,
retry_override,
&keys,
endpoint_name,
create_default_row,
process_item,
)
.await?;
Ok(result)
}
#[allow(clippy::too_many_lines)]
#[allow(clippy::cognitive_complexity)]
pub(super) async fn balance_sheet(
client: &YfClient,
symbol: &str,
quarterly: bool,
currency: Currency,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<Vec<BalanceSheetRow>, YfError> {
use serde::Deserialize;
use crate::core::wire::{RawNum, RawNumU64};
#[derive(Deserialize)]
struct TimeseriesValueF64 {
#[serde(rename = "reportedValue")]
reported_value: Option<RawNum<f64>>,
}
#[derive(Deserialize)]
struct TimeseriesValueU64 {
#[serde(rename = "reportedValue")]
reported_value: Option<RawNumU64>,
}
let keys = [
"TotalAssets",
"TotalLiabilitiesNetMinorityInterest",
"StockholdersEquity",
"CashAndCashEquivalents",
"LongTermDebt",
"OrdinarySharesNumber",
];
let endpoint_name = "balance_sheet";
let create_default_row = |period_end: i64| BalanceSheetRow {
period: string_to_period(&i64_to_datetime(period_end).format("%Y-%m-%d").to_string()),
total_assets: None,
total_liabilities: None,
total_equity: None,
cash: None,
long_term_debt: None,
shares_outstanding: None,
};
let process_item = |key: &str,
values_json: &serde_json::Value,
rows_map: &mut BTreeMap<i64, BalanceSheetRow>,
timestamps: &[i64],
prefix: &str|
-> Result<(), YfError> {
if key.ends_with("OrdinarySharesNumber") {
if let Ok(values) =
serde_json::from_value::<Vec<TimeseriesValueU64>>(values_json.clone())
{
for (i, ts) in timestamps.iter().enumerate() {
let row = rows_map
.entry(*ts)
.or_insert_with(|| create_default_row(*ts));
row.shares_outstanding = values
.get(i)
.and_then(|v| v.reported_value.and_then(|rv| rv.raw));
}
}
} else if let Ok(values) =
serde_json::from_value::<Vec<TimeseriesValueF64>>(values_json.clone())
{
for (i, ts) in timestamps.iter().enumerate() {
let row = rows_map
.entry(*ts)
.or_insert_with(|| create_default_row(*ts));
let value = values
.get(i)
.and_then(|v| v.reported_value.and_then(|rv| rv.raw));
if key == format!("{prefix}TotalAssets") {
row.total_assets =
value.map(|v| f64_to_money_with_currency(v, currency.clone()));
} else if key == format!("{prefix}TotalLiabilitiesNetMinorityInterest") {
row.total_liabilities =
value.map(|v| f64_to_money_with_currency(v, currency.clone()));
} else if key == format!("{prefix}StockholdersEquity") {
row.total_equity =
value.map(|v| f64_to_money_with_currency(v, currency.clone()));
} else if key == format!("{prefix}CashAndCashEquivalents") {
row.cash = value.map(|v| f64_to_money_with_currency(v, currency.clone()));
} else if key == format!("{prefix}LongTermDebt") {
row.long_term_debt =
value.map(|v| f64_to_money_with_currency(v, currency.clone()));
}
}
}
Ok(())
};
fetch_timeseries_data(
client,
symbol,
quarterly,
cache_mode,
retry_override,
&keys,
endpoint_name,
create_default_row,
process_item,
)
.await
}
#[allow(clippy::too_many_lines)]
pub(super) async fn cashflow(
client: &YfClient,
symbol: &str,
quarterly: bool,
currency: Currency,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<Vec<CashflowRow>, YfError> {
use serde::Deserialize;
use crate::core::wire::RawNum;
#[derive(Deserialize)]
struct TimeseriesValueF64 {
#[serde(rename = "reportedValue")]
reported_value: Option<RawNum<f64>>,
}
let keys = [
"OperatingCashFlow",
"CapitalExpenditure",
"FreeCashFlow",
"NetIncome",
];
let endpoint_name = "cash_flow";
let create_default_row = |period_end: i64| CashflowRow {
period: string_to_period(&i64_to_datetime(period_end).format("%Y-%m-%d").to_string()),
operating_cashflow: None,
capital_expenditures: None,
free_cash_flow: None,
net_income: None,
};
let process_item = |key: &str,
values_json: &serde_json::Value,
rows_map: &mut BTreeMap<i64, CashflowRow>,
timestamps: &[i64],
prefix: &str|
-> Result<(), YfError> {
if let Ok(values) = serde_json::from_value::<Vec<TimeseriesValueF64>>(values_json.clone()) {
for (i, ts) in timestamps.iter().enumerate() {
let row = rows_map
.entry(*ts)
.or_insert_with(|| create_default_row(*ts));
let value = values
.get(i)
.and_then(|v| v.reported_value.and_then(|rv| rv.raw));
if key == format!("{prefix}OperatingCashFlow") {
row.operating_cashflow =
value.map(|v| f64_to_money_with_currency(v, currency.clone()));
} else if key == format!("{prefix}CapitalExpenditure") {
row.capital_expenditures =
value.map(|v| f64_to_money_with_currency(v, currency.clone()));
} else if key == format!("{prefix}FreeCashFlow") {
row.free_cash_flow =
value.map(|v| f64_to_money_with_currency(v, currency.clone()));
} else if key == format!("{prefix}NetIncome") {
row.net_income = value.map(|v| f64_to_money_with_currency(v, currency.clone()));
}
}
}
Ok(())
};
let mut result = fetch_timeseries_data(
client,
symbol,
quarterly,
cache_mode,
retry_override,
&keys,
endpoint_name,
create_default_row,
process_item,
)
.await?;
// After filling values, calculate FCF if it's missing.
for row in &mut result {
if row.free_cash_flow.is_none()
&& let (Some(ocf), Some(capex)) = (
row.operating_cashflow.clone(),
row.capital_expenditures.clone(),
)
{
// In timeseries API, capex is negative for cash outflow.
row.free_cash_flow = ocf.try_add(&capex).ok();
}
}
Ok(result)
}
pub(super) async fn earnings(
client: &YfClient,
symbol: &str,
currency: Currency,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<Earnings, YfError> {
let root = fetch_modules(client, symbol, "earnings", cache_mode, retry_override).await?;
let e = root
.earnings
.ok_or_else(|| YfError::MissingData("earnings missing".into()))?;
let yearly = e
.financials_chart
.as_ref()
.and_then(|fc| fc.yearly.as_ref())
.map(|v| {
v.iter()
.filter_map(|y| {
y.date.and_then(|date| {
i32::try_from(date).ok().map(|year| EarningsYear {
year,
revenue: y.revenue.as_ref().and_then(|x| {
x.raw
.map(|v| f64_to_money_with_currency(v, currency.clone()))
}),
earnings: y.earnings.as_ref().and_then(|x| {
x.raw
.map(|v| f64_to_money_with_currency(v, currency.clone()))
}),
})
})
})
.collect()
})
.unwrap_or_default();
let quarterly = e
.financials_chart
.as_ref()
.and_then(|fc| fc.quarterly.as_ref())
.map(|v| {
v.iter()
.map(|q| EarningsQuarter {
period: string_to_period(&q.date.clone().unwrap_or_default()),
revenue: q.revenue.as_ref().and_then(|x| {
x.raw
.map(|v| f64_to_money_with_currency(v, currency.clone()))
}),
earnings: q.earnings.as_ref().and_then(|x| {
x.raw
.map(|v| f64_to_money_with_currency(v, currency.clone()))
}),
})
.collect()
})
.unwrap_or_default();
let quarterly_eps = e
.earnings_chart
.as_ref()
.and_then(|ec| ec.quarterly.as_ref())
.map(|v| {
v.iter()
.map(|q| EarningsQuarterEps {
period: string_to_period(&q.date.clone().unwrap_or_default()),
actual: q.actual.as_ref().and_then(|x| {
x.raw
.map(|v| f64_to_money_with_currency(v, currency.clone()))
}),
estimate: q.estimate.as_ref().and_then(|x| {
x.raw
.map(|v| f64_to_money_with_currency(v, currency.clone()))
}),
})
.collect()
})
.unwrap_or_default();
Ok(Earnings {
yearly,
quarterly,
quarterly_eps,
})
}
pub(super) async fn calendar(
client: &YfClient,
symbol: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<super::Calendar, YfError> {
let root = fetch_modules(client, symbol, "calendarEvents", cache_mode, retry_override).await?;
let calendar_events = root
.calendar_events
.ok_or_else(|| YfError::MissingData("calendarEvents missing".into()))?;
let earnings_dates = calendar_events
.earnings
.and_then(|e| e.earnings_date)
.unwrap_or_default()
.into_iter()
.filter_map(|d| d.raw.map(i64_to_datetime))
.collect();
Ok(super::Calendar {
earnings_dates,
ex_dividend_date: calendar_events
.ex_dividend_date
.and_then(|x| x.raw.map(i64_to_datetime)),
dividend_payment_date: calendar_events
.dividend_date
.and_then(|x| x.raw.map(i64_to_datetime)),
})
}
pub(super) async fn shares(
client: &YfClient,
symbol: &str,
start: Option<chrono::DateTime<Utc>>,
end: Option<chrono::DateTime<Utc>>,
quarterly: bool,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<Vec<ShareCount>, YfError> {
let end_ts = end.unwrap_or_else(Utc::now).timestamp();
let start_ts = start
.unwrap_or_else(|| Utc::now() - Duration::days(548))
.timestamp();
let type_key = if quarterly {
"quarterlyBasicAverageShares"
} else {
"annualBasicAverageShares"
};
let mut url = client.base_timeseries().join(symbol)?;
url.query_pairs_mut()
.append_pair("symbol", symbol)
.append_pair("type", type_key)
.append_pair("period1", &start_ts.to_string())
.append_pair("period2", &end_ts.to_string());
client.ensure_credentials().await?;
if let Some(crumb) = client.crumb().await {
url.query_pairs_mut().append_pair("crumb", &crumb);
}
let body = if cache_mode == CacheMode::Use {
if let Some(cached) = client.cache_get(&url).await {
cached
} else {
let resp = client
.send_with_retry(client.http().get(url.clone()), retry_override)
.await?;
let endpoint = format!("timeseries_{type_key}");
let text = crate::core::net::get_text(resp, &endpoint, symbol, "json").await?;
if cache_mode != CacheMode::Bypass {
client.cache_put(&url, &text, None).await;
}
text
}
} else {
let resp = client
.send_with_retry(client.http().get(url.clone()), retry_override)
.await?;
let endpoint = format!("timeseries_{type_key}");
let text = crate::core::net::get_text(resp, &endpoint, symbol, "json").await?;
if cache_mode != CacheMode::Bypass {
client.cache_put(&url, &text, None).await;
}
text
};
let envelope: TimeseriesEnvelope = serde_json::from_str(&body).map_err(YfError::Json)?;
let result_data: Option<TimeseriesData> = envelope
.timeseries
.and_then(|ts| ts.result)
.and_then(|mut v| v.pop());
let Some(TimeseriesData {
timestamp: Some(timestamps),
values: mut values_map,
..
}) = result_data
else {
return Ok(vec![]);
};
let Some(values_json) = values_map.remove(type_key) else {
return Ok(vec![]);
};
let values: Vec<super::wire::TimeseriesValue> =
serde_json::from_value(values_json).map_err(YfError::Json)?;
let counts = timestamps
.into_iter()
.zip(values.into_iter())
.filter_map(|(ts, val)| {
val.reported_value
.and_then(|rv| rv.raw)
.map(|shares| ShareCount {
date: i64_to_datetime(ts),
shares,
})
})
.collect();
Ok(counts)
}

View File

@@ -0,0 +1,26 @@
use super::wire::V10Result;
use crate::core::{
YfClient, YfError,
client::{CacheMode, RetryConfig},
quotesummary,
};
/* ---------- Single focused fetch with crumb + retry ---------- */
pub(super) async fn fetch_modules(
client: &YfClient,
symbol: &str,
modules: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<V10Result, YfError> {
quotesummary::fetch_module_result(
client,
symbol,
modules,
"fundamentals",
cache_mode,
retry_override,
)
.await
}

View File

@@ -0,0 +1,196 @@
mod api;
mod model;
mod fetch;
mod wire;
pub use model::{
BalanceSheetRow, Calendar, CashflowRow, Earnings, EarningsQuarter, EarningsQuarterEps,
EarningsYear, IncomeStatementRow, ShareCount,
};
use crate::core::{
YfClient, YfError,
client::{CacheMode, RetryConfig},
};
use paft::money::Currency;
/// A builder for fetching fundamental financial data (statements, earnings, etc.).
pub struct FundamentalsBuilder {
client: YfClient,
symbol: String,
cache_mode: CacheMode,
retry_override: Option<RetryConfig>,
}
impl FundamentalsBuilder {
/// Creates a new `FundamentalsBuilder` for a given symbol.
pub fn new(client: &YfClient, symbol: impl Into<String>) -> Self {
Self {
client: client.clone(),
symbol: symbol.into(),
cache_mode: CacheMode::Use,
retry_override: None,
}
}
/// Sets the cache mode for this specific API call.
#[must_use]
pub const fn cache_mode(mut self, mode: CacheMode) -> Self {
self.cache_mode = mode;
self
}
/// Overrides the default retry policy for this specific API call.
#[must_use]
pub fn retry_policy(mut self, cfg: Option<RetryConfig>) -> Self {
self.retry_override = cfg;
self
}
/// Fetches the income statement.
///
/// Set `quarterly` to `true` to get quarterly reports, or `false` for annual reports.
/// Provide `Some(currency)` to override the inferred reporting currency; pass `None`
/// to use the cached profile-based heuristic.
///
/// # Errors
///
/// Returns a `YfError` if the network request fails or the API response cannot be parsed.
pub async fn income_statement(
&self,
quarterly: bool,
override_currency: Option<Currency>,
) -> Result<Vec<IncomeStatementRow>, YfError> {
let currency = self
.client
.reporting_currency(&self.symbol, override_currency)
.await;
api::income_statement(
&self.client,
&self.symbol,
quarterly,
currency,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
/// Fetches the balance sheet.
///
/// Set `quarterly` to `true` to get quarterly reports, or `false` for annual reports.
/// Provide `Some(currency)` to override the inferred reporting currency; pass `None`
/// to use the cached profile-based heuristic.
///
/// # Errors
///
/// Returns a `YfError` if the network request fails or the API response cannot be parsed.
pub async fn balance_sheet(
&self,
quarterly: bool,
override_currency: Option<Currency>,
) -> Result<Vec<BalanceSheetRow>, YfError> {
let currency = self
.client
.reporting_currency(&self.symbol, override_currency)
.await;
api::balance_sheet(
&self.client,
&self.symbol,
quarterly,
currency,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
/// Fetches the cash flow statement.
///
/// Set `quarterly` to `true` to get quarterly reports, or `false` for annual reports.
/// Provide `Some(currency)` to override the inferred reporting currency; pass `None`
/// to use the cached profile-based heuristic.
///
/// # Errors
///
/// Returns a `YfError` if the network request fails or the API response cannot be parsed.
pub async fn cashflow(
&self,
quarterly: bool,
override_currency: Option<Currency>,
) -> Result<Vec<CashflowRow>, YfError> {
let currency = self
.client
.reporting_currency(&self.symbol, override_currency)
.await;
api::cashflow(
&self.client,
&self.symbol,
quarterly,
currency,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
/// Fetches earnings history and estimates.
///
/// # Errors
///
/// Returns a `YfError` if the network request fails or the API response cannot be parsed.
pub async fn earnings(&self, override_currency: Option<Currency>) -> Result<Earnings, YfError> {
let currency = self
.client
.reporting_currency(&self.symbol, override_currency)
.await;
api::earnings(
&self.client,
&self.symbol,
currency,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
/// Fetches corporate calendar events like earnings dates.
///
/// # Errors
///
/// Returns a `YfError` if the network request fails or the API response cannot be parsed.
pub async fn calendar(&self) -> Result<Calendar, YfError> {
api::calendar(
&self.client,
&self.symbol,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
/// Fetches the historical number of shares outstanding.
///
/// If `quarterly` is true, fetches quarterly data, otherwise annual data is fetched.
///
/// # Errors
///
/// Returns a `YfError` if the network request fails or the API response cannot be parsed.
pub async fn shares(&self, quarterly: bool) -> Result<Vec<ShareCount>, YfError> {
api::shares(
&self.client,
&self.symbol,
None,
None,
quarterly,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
}

View File

@@ -0,0 +1,8 @@
// Re-export types from paft without using prelude
pub use paft::fundamentals::analysis::{
Earnings, EarningsQuarter, EarningsQuarterEps, EarningsYear,
};
pub use paft::fundamentals::profile::ShareCount;
pub use paft::fundamentals::statements::{
BalanceSheetRow, Calendar, CashflowRow, IncomeStatementRow,
};

View File

@@ -0,0 +1,131 @@
use crate::core::wire::{RawDate, RawNum, RawNumU64};
use serde::Deserialize;
/* ---------------- Serde mapping (only what we need) ---------------- */
#[derive(Deserialize)]
pub struct V10Result {
/* income */
#[allow(dead_code)]
#[serde(rename = "incomeStatementHistory")]
pub(crate) income_statement_history: Option<IncomeHistoryNode>,
#[allow(dead_code)]
#[serde(rename = "incomeStatementHistoryQuarterly")]
pub(crate) income_statement_history_quarterly: Option<IncomeHistoryNode>,
/* earnings + calendar */
pub(crate) earnings: Option<EarningsNode>,
#[serde(rename = "calendarEvents")]
pub(crate) calendar_events: Option<CalendarEventsNode>,
}
/* --- income --- */
#[derive(Deserialize)]
pub struct IncomeHistoryNode {
#[allow(dead_code)]
#[serde(rename = "incomeStatementHistory")]
pub(crate) income_statement_history: Option<Vec<IncomeRowNode>>,
}
#[derive(Deserialize)]
pub struct IncomeRowNode {
#[allow(dead_code)]
#[serde(rename = "endDate")]
pub(crate) end_date: Option<RawDate>,
#[allow(dead_code)]
#[serde(rename = "totalRevenue")]
pub(crate) total_revenue: Option<RawNum<f64>>,
#[allow(dead_code)]
#[serde(rename = "grossProfit")]
pub(crate) gross_profit: Option<RawNum<f64>>,
#[allow(dead_code)]
#[serde(rename = "operatingIncome")]
pub(crate) operating_income: Option<RawNum<f64>>,
#[allow(dead_code)]
#[serde(rename = "netIncome")]
pub(crate) net_income: Option<RawNum<f64>>,
}
/* --- earnings --- */
#[derive(Deserialize)]
pub struct EarningsNode {
#[serde(rename = "financialsChart")]
pub(crate) financials_chart: Option<FinancialsChartNode>,
#[serde(rename = "earningsChart")]
pub(crate) earnings_chart: Option<EarningsChartNode>,
}
#[derive(Deserialize)]
pub struct FinancialsChartNode {
pub(crate) yearly: Option<Vec<FinancialYearNode>>,
pub(crate) quarterly: Option<Vec<FinancialQuarterNode>>,
}
#[derive(Deserialize)]
pub struct FinancialYearNode {
pub(crate) date: Option<i64>,
pub(crate) revenue: Option<RawNum<f64>>,
pub(crate) earnings: Option<RawNum<f64>>,
}
#[derive(Deserialize)]
pub struct FinancialQuarterNode {
pub(crate) date: Option<String>,
pub(crate) revenue: Option<RawNum<f64>>,
pub(crate) earnings: Option<RawNum<f64>>,
}
#[derive(Deserialize)]
pub struct EarningsChartNode {
pub(crate) quarterly: Option<Vec<EpsQuarterNode>>,
}
#[derive(Deserialize)]
pub struct EpsQuarterNode {
pub(crate) date: Option<String>,
pub(crate) actual: Option<RawNum<f64>>,
pub(crate) estimate: Option<RawNum<f64>>,
}
/* --- calendar --- */
#[derive(Deserialize)]
pub struct CalendarEventsNode {
pub(crate) earnings: Option<CalendarEarningsNode>,
#[serde(rename = "exDividendDate")]
pub(crate) ex_dividend_date: Option<RawDate>,
#[serde(rename = "dividendDate")]
pub(crate) dividend_date: Option<RawDate>,
}
#[derive(Deserialize)]
#[allow(clippy::struct_field_names)]
pub struct CalendarEarningsNode {
#[serde(rename = "earningsDate")]
pub(crate) earnings_date: Option<Vec<RawDate>>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TimeseriesEnvelope {
pub(crate) timeseries: Option<TimeseriesResult>,
}
#[derive(Deserialize)]
pub struct TimeseriesResult {
pub(crate) result: Option<Vec<TimeseriesData>>,
}
#[derive(Deserialize)]
pub struct TimeseriesData {
pub(crate) timestamp: Option<Vec<i64>>,
#[allow(dead_code)]
meta: serde_json::Value,
#[serde(flatten)]
pub(crate) values: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Deserialize)]
pub struct TimeseriesValue {
#[serde(rename = "reportedValue")]
pub(crate) reported_value: Option<RawNumU64>,
}

View File

@@ -0,0 +1,241 @@
mod actions;
mod adjust;
mod assemble;
mod fetch;
use crate::core::client::{CacheMode, RetryConfig};
// use crate::core::conversions::f64_to_money_with_currency_str;
use crate::core::{YfClient, YfError};
use crate::history::wire::MetaNode;
use chrono_tz::Tz;
use paft::market::action::Action;
use paft::market::requests::history::{Interval, Range};
use paft::market::responses::history::{Candle, HistoryMeta, HistoryResponse};
use actions::extract_actions;
use adjust::cumulative_split_after;
use assemble::assemble_candles;
use fetch::fetch_chart;
/// A builder for fetching historical price data for a single symbol.
///
/// This builder provides fine-grained control over the parameters for a historical
/// data request, including the time range, interval, and data adjustments.
#[derive(Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct HistoryBuilder {
#[doc(hidden)]
pub(crate) client: YfClient,
#[doc(hidden)]
pub(crate) symbol: String,
#[doc(hidden)]
pub(crate) range: Option<Range>,
#[doc(hidden)]
pub(crate) period: Option<(i64, i64)>,
#[doc(hidden)]
pub(crate) interval: Interval,
#[doc(hidden)]
pub(crate) auto_adjust: bool,
#[doc(hidden)]
pub(crate) include_prepost: bool,
#[doc(hidden)]
pub(crate) include_actions: bool,
#[doc(hidden)]
pub(crate) keepna: bool,
#[doc(hidden)]
pub(crate) cache_mode: CacheMode,
#[doc(hidden)]
pub(crate) retry_override: Option<RetryConfig>,
}
impl HistoryBuilder {
/// Creates a new `HistoryBuilder` for a given symbol.
pub fn new(client: &YfClient, symbol: impl Into<String>) -> Self {
Self {
client: client.clone(),
symbol: symbol.into(),
range: Some(Range::M6),
period: None,
interval: Interval::D1,
auto_adjust: true,
include_prepost: false,
include_actions: true,
keepna: false,
cache_mode: CacheMode::Use,
retry_override: None,
}
}
/// Sets the cache mode for this specific API call.
#[must_use]
pub const fn cache_mode(mut self, mode: CacheMode) -> Self {
self.cache_mode = mode;
self
}
/// Overrides the default retry policy for this specific API call.
#[must_use]
pub fn retry_policy(mut self, cfg: Option<RetryConfig>) -> Self {
self.retry_override = cfg;
self
}
/// Sets a relative time range for the request (e.g., `1y`, `6mo`).
///
/// This will override any previously set period using `between()`.
#[must_use]
pub const fn range(mut self, range: Range) -> Self {
self.period = None;
self.range = Some(range);
self
}
/// Sets an absolute time period for the request using start and end timestamps.
///
/// This will override any previously set range using `range()`.
#[must_use]
pub const fn between(
mut self,
start: chrono::DateTime<chrono::Utc>,
end: chrono::DateTime<chrono::Utc>,
) -> Self {
self.range = None;
self.period = Some((start.timestamp(), end.timestamp()));
self
}
/// Sets the time interval for each data point (candle).
#[must_use]
pub const fn interval(mut self, interval: Interval) -> Self {
self.interval = interval;
self
}
/// Sets whether to automatically adjust prices for splits and dividends. (Default: `true`)
#[must_use]
pub const fn auto_adjust(mut self, yes: bool) -> Self {
self.auto_adjust = yes;
self
}
/// Sets whether to include pre-market and post-market data for intraday intervals. (Default: `false`)
#[must_use]
pub const fn prepost(mut self, yes: bool) -> Self {
self.include_prepost = yes;
self
}
/// Sets whether to include corporate actions (dividends and splits) in the response. (Default: `true`)
#[must_use]
pub const fn actions(mut self, yes: bool) -> Self {
self.include_actions = yes;
self
}
/// Sets whether to keep data rows that have missing OHLC values. (Default: `false`)
///
/// If `true`, missing values are represented as `f64::NAN`. If `false`, rows with any missing
/// OHLC values are dropped.
#[must_use]
pub const fn keepna(mut self, yes: bool) -> Self {
self.keepna = yes;
self
}
/// Executes the request and returns only the price candles.
///
/// # Errors
///
/// Returns a `YfError` if the network request fails or the API response cannot be parsed.
pub async fn fetch(self) -> Result<Vec<Candle>, YfError> {
let resp = self.fetch_full().await?;
Ok(resp.candles)
}
/// Executes the request and returns the full response, including candles, actions, and metadata.
///
/// # Errors
///
/// Returns a `YfError` if the network request fails, the API returns an error,
/// or the response cannot be parsed.
#[cfg_attr(
feature = "tracing",
tracing::instrument(
skip(self),
err,
fields(
symbol = %self.symbol,
interval = %format!("{:?}", self.interval),
range = %self
.range
.as_ref()
.map_or_else(|| "period".into(), |r| format!("{r:?}"))
)
)
)]
pub async fn fetch_full(self) -> Result<HistoryResponse, YfError> {
// 1) Fetch and parse the /chart payload into owned blocks
let fetched = fetch_chart(
&self.client,
&self.symbol,
self.range,
self.period,
self.interval,
self.include_actions,
self.include_prepost,
self.cache_mode,
self.retry_override.as_ref(),
)
.await?;
// 2) Corporate actions & split ratios
let reporting_currency = self.client.reporting_currency(&self.symbol, None).await;
let (mut actions_out, split_events) =
extract_actions(fetched.events.as_ref(), &reporting_currency);
// 3) Cumulative split factors after each bar
let cum_split_after = cumulative_split_after(&fetched.ts, &split_events);
// 4) Assemble candles (+ raw close) with/without adjustments
let currency = fetched.meta.as_ref().and_then(|m| m.currency.as_deref());
let candles = assemble_candles(
&fetched.ts,
&fetched.quote,
&fetched.adjclose,
self.auto_adjust,
self.keepna,
&cum_split_after,
currency,
);
// ensure actions sorted (extract_actions already sorts, keep consistent)
actions_out.sort_by_key(|a| match a {
Action::Dividend { ts, .. }
| Action::Split { ts, .. }
| Action::CapitalGain { ts, .. } => ts.timestamp(),
});
// 5) Map metadata
let meta_out = map_meta(fetched.meta.as_ref());
Ok(HistoryResponse {
candles,
actions: actions_out,
adjusted: self.auto_adjust,
meta: meta_out,
})
}
}
/* --- tiny private helper --- */
fn map_meta(m: Option<&MetaNode>) -> Option<HistoryMeta> {
m.as_ref().map(|mm| HistoryMeta {
timezone: mm
.timezone
.as_ref()
.and_then(|tz_str| tz_str.parse::<Tz>().ok()),
utc_offset_seconds: mm.gmtoffset,
})
}

View File

@@ -0,0 +1,79 @@
use crate::core::conversions::{f64_to_money_with_currency, i64_to_datetime};
use crate::history::wire::Events;
use paft::market::action::Action;
use paft::money::Currency;
#[allow(clippy::cast_possible_truncation)]
pub fn extract_actions(
events: Option<&Events>,
currency: &Currency,
) -> (Vec<Action>, Vec<(i64, f64)>) {
let mut out: Vec<Action> = Vec::new();
let mut split_events: Vec<(i64, f64)> = Vec::new();
let Some(ev) = events else {
return (out, split_events);
};
if let Some(divs) = ev.dividends.as_ref() {
for (k, d) in divs {
let ts = k.parse::<i64>().unwrap_or_else(|_| d.date.unwrap_or(0));
if let Some(amount) = d.amount {
out.push(Action::Dividend {
ts: i64_to_datetime(ts),
amount: f64_to_money_with_currency(amount, currency.clone()),
});
}
}
}
if let Some(gains) = ev.capital_gains.as_ref() {
for (k, g) in gains {
let ts = k.parse::<i64>().unwrap_or_else(|_| g.date.unwrap_or(0));
if let Some(gain) = g.amount {
out.push(Action::CapitalGain {
ts: i64_to_datetime(ts),
gain: f64_to_money_with_currency(gain, currency.clone()),
});
}
}
}
if let Some(splits) = ev.splits.as_ref() {
for (k, s) in splits {
let ts = k.parse::<i64>().unwrap_or_else(|_| s.date.unwrap_or(0));
let (num, den) = if let (Some(n), Some(d)) = (s.numerator, s.denominator) {
(n as u32, d as u32)
} else if let Some(r) = s.split_ratio.as_deref() {
let mut it = r.split('/');
let n = it.next().and_then(|x| x.parse::<u32>().ok()).unwrap_or(1);
let d = it.next().and_then(|x| x.parse::<u32>().ok()).unwrap_or(1);
(n, d)
} else {
(1, 1)
};
out.push(Action::Split {
ts: i64_to_datetime(ts),
numerator: num,
denominator: den,
});
let ratio = if den == 0 {
1.0
} else {
f64::from(num) / f64::from(den)
};
split_events.push((ts, ratio));
}
}
out.sort_by_key(|a| match a {
Action::Dividend { ts, .. } | Action::Split { ts, .. } | Action::CapitalGain { ts, .. } => {
ts.timestamp()
}
});
split_events.sort_by_key(|(ts, _)| *ts);
(out, split_events)
}

View File

@@ -0,0 +1,30 @@
pub fn cumulative_split_after(ts: &[i64], split_events: &[(i64, f64)]) -> Vec<f64> {
let mut out = vec![1.0; ts.len()];
if split_events.is_empty() || ts.is_empty() {
return out;
}
let mut sp_idx = split_events.len();
let mut running: f64 = 1.0;
for i in (0..ts.len()).rev() {
while sp_idx > 0 && split_events[sp_idx - 1].0 > ts[i] {
sp_idx -= 1;
running *= split_events[sp_idx].1;
}
out[i] = running;
}
out
}
pub fn price_factor_for_row(
i: usize,
adjclose_i: Option<f64>,
close_i: Option<f64>,
cum_split_after: &[f64],
) -> f64 {
match (adjclose_i, close_i) {
(Some(adj), Some(c)) if c != 0.0 => adj / c,
_ => 1.0 / cum_split_after[i].max(1e-12),
}
}

View File

@@ -0,0 +1,116 @@
use crate::core::conversions::{f64_to_money_with_currency_str, i64_to_datetime};
use crate::history::wire::QuoteBlock;
use paft::market::responses::history::Candle;
use super::adjust::price_factor_for_row;
pub fn assemble_candles(
ts: &[i64],
q: &QuoteBlock,
adj: &[Option<f64>],
auto_adjust: bool,
keepna: bool,
cum_split_after: &[f64],
currency: Option<&str>,
) -> Vec<Candle> {
let mut out = Vec::new();
for (i, &t) in ts.iter().enumerate() {
let getter_f64 = |v: &Vec<Option<f64>>| v.get(i).and_then(|x| *x);
let mut open = getter_f64(&q.open);
let mut high = getter_f64(&q.high);
let mut low = getter_f64(&q.low);
let mut close = getter_f64(&q.close);
let volume0 = q.volume.get(i).and_then(|x| *x);
let raw_close_val = close.unwrap_or(f64::NAN);
if auto_adjust {
let pf = price_factor_for_row(i, adj.get(i).and_then(|x| *x), close, cum_split_after);
if let Some(v) = open.as_mut() {
*v *= pf;
}
if let Some(v) = high.as_mut() {
*v *= pf;
}
if let Some(v) = low.as_mut() {
*v *= pf;
}
if let Some(v) = close.as_mut() {
*v *= pf;
}
let volume_adj = volume0.map(|v| {
#[allow(clippy::cast_precision_loss)]
let v_adj = (v as f64) * cum_split_after[i];
if v_adj.is_finite() && v_adj >= 0.0 {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
(v_adj.round() as u64)
} else {
v
}
});
if let (Some(ov), Some(hv), Some(lv), Some(cv)) = (open, high, low, close) {
out.push(Candle {
ts: i64_to_datetime(t),
open: f64_to_money_with_currency_str(ov, currency),
high: f64_to_money_with_currency_str(hv, currency),
low: f64_to_money_with_currency_str(lv, currency),
close: f64_to_money_with_currency_str(cv, currency),
close_unadj: if raw_close_val.is_finite() {
Some(f64_to_money_with_currency_str(raw_close_val, currency))
} else {
None
},
volume: volume_adj,
});
} else if keepna {
out.push(Candle {
ts: i64_to_datetime(t),
open: f64_to_money_with_currency_str(open.unwrap_or(f64::NAN), currency),
high: f64_to_money_with_currency_str(high.unwrap_or(f64::NAN), currency),
low: f64_to_money_with_currency_str(low.unwrap_or(f64::NAN), currency),
close: f64_to_money_with_currency_str(close.unwrap_or(f64::NAN), currency),
close_unadj: if raw_close_val.is_finite() {
Some(f64_to_money_with_currency_str(raw_close_val, currency))
} else {
None
},
volume: volume0,
});
}
} else if let (Some(ov), Some(hv), Some(lv), Some(cv)) = (open, high, low, close) {
out.push(Candle {
ts: i64_to_datetime(t),
open: f64_to_money_with_currency_str(ov, currency),
high: f64_to_money_with_currency_str(hv, currency),
low: f64_to_money_with_currency_str(lv, currency),
close: f64_to_money_with_currency_str(cv, currency),
close_unadj: if raw_close_val.is_finite() {
Some(f64_to_money_with_currency_str(raw_close_val, currency))
} else {
None
},
volume: volume0,
});
} else if keepna {
out.push(Candle {
ts: i64_to_datetime(t),
open: f64_to_money_with_currency_str(open.unwrap_or(f64::NAN), currency),
high: f64_to_money_with_currency_str(high.unwrap_or(f64::NAN), currency),
low: f64_to_money_with_currency_str(low.unwrap_or(f64::NAN), currency),
close: f64_to_money_with_currency_str(close.unwrap_or(f64::NAN), currency),
close_unadj: if raw_close_val.is_finite() {
Some(f64_to_money_with_currency_str(raw_close_val, currency))
} else {
None
},
volume: volume0,
});
}
}
out
}

View File

@@ -0,0 +1,130 @@
use crate::core::client::{CacheMode, RetryConfig};
use crate::history::wire::{Events, MetaNode, QuoteBlock};
pub struct Fetched {
pub ts: Vec<i64>,
pub quote: QuoteBlock,
pub adjclose: Vec<Option<f64>>,
pub events: Option<Events>,
pub meta: Option<MetaNode>,
}
#[allow(clippy::too_many_arguments)]
pub async fn fetch_chart(
client: &crate::core::YfClient,
symbol: &str,
range: Option<crate::core::Range>,
period: Option<(i64, i64)>,
interval: crate::core::Interval,
include_actions: bool,
include_prepost: bool,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<Fetched, crate::core::YfError> {
let mut url = client.base_chart().join(symbol)?;
{
let mut qp = url.query_pairs_mut();
if let Some((p1, p2)) = period {
if p1 >= p2 {
return Err(crate::core::YfError::InvalidDates);
}
qp.append_pair("period1", &p1.to_string());
qp.append_pair("period2", &p2.to_string());
} else if let Some(r) = range {
qp.append_pair("range", crate::core::models::range_as_str(r));
} else {
return Err(crate::core::YfError::InvalidParams(
"no range or period set".into(),
));
}
qp.append_pair("interval", crate::core::models::interval_as_str(interval));
if include_actions {
qp.append_pair("events", "div|split|capitalGains");
}
qp.append_pair(
"includePrePost",
if include_prepost { "true" } else { "false" },
);
}
if cache_mode == CacheMode::Use
&& let Some(body) = client.cache_get(&url).await
{
return decode_chart(&body);
}
let resp = client
.send_with_retry(client.http().get(url.clone()), retry_override)
.await?;
if !resp.status().is_success() {
let code = resp.status().as_u16();
let url_s = url.to_string();
return Err(match code {
404 => crate::core::YfError::NotFound { url: url_s },
429 => crate::core::YfError::RateLimited { url: url_s },
500..=599 => crate::core::YfError::ServerError {
status: code,
url: url_s,
},
_ => crate::core::YfError::Status {
status: code,
url: url_s,
},
});
}
let body = crate::core::net::get_text(resp, "history_chart", symbol, "json").await?;
if cache_mode != CacheMode::Bypass {
client.cache_put(&url, &body, None).await;
}
decode_chart(&body)
}
// NEW helper to keep fetch_chart compact
fn decode_chart(body: &str) -> Result<Fetched, crate::core::YfError> {
let envelope: crate::history::wire::ChartEnvelope =
serde_json::from_str(body).map_err(crate::core::YfError::Json)?;
let chart = envelope
.chart
.ok_or_else(|| crate::core::YfError::MissingData("missing chart".into()))?;
if let Some(error) = chart.error {
return Err(crate::core::YfError::Api(format!(
"chart error: {} - {}",
error.code, error.description
)));
}
let result = chart
.result
.ok_or_else(|| crate::core::YfError::MissingData("missing result".into()))?;
let first = result
.first()
.ok_or_else(|| crate::core::YfError::MissingData("empty result".into()))?;
let quote = first
.indicators
.quote
.first()
.ok_or_else(|| crate::core::YfError::MissingData("missing quote".into()))?;
let adjclose = first
.indicators
.adjclose
.first()
.map(|a| a.adjclose.clone())
.unwrap_or_default();
Ok(Fetched {
ts: first.timestamp.clone().unwrap_or_default(),
quote: quote.clone(),
adjclose,
events: first.events.clone(),
meta: first.meta.clone(),
})
}

View File

@@ -0,0 +1,47 @@
mod builder;
mod wire;
pub use builder::HistoryBuilder;
use crate::core::{HistoryRequest, HistoryResponse, HistoryService, YfClient, YfError};
use core::future::Future;
use core::pin::Pin;
impl HistoryService for YfClient {
fn fetch_full_history<'a>(
&'a self,
symbol: &'a str,
req: HistoryRequest,
) -> Pin<Box<dyn Future<Output = Result<HistoryResponse, YfError>> + Send + 'a>> {
// Own everything the async block needs:
let client = self.clone(); // YfClient: Clone
let symbol = symbol.to_owned(); // own the symbol
Box::pin(async move {
// HistoryBuilder::new(&YfClient, impl Into<String>) clones internally,
// so passing &client here is fine.
let mut hb = builder::HistoryBuilder::new(&client, &symbol)
.interval(req.interval)
.auto_adjust(req.auto_adjust)
.prepost(req.include_prepost)
.actions(req.include_actions)
.keepna(req.keepna);
if let Some((p1, p2)) = req.period {
use chrono::{TimeZone, Utc};
let start = Utc
.timestamp_opt(p1, 0)
.single()
.ok_or(YfError::InvalidParams("invalid period1".into()))?;
let end = Utc
.timestamp_opt(p2, 0)
.single()
.ok_or(YfError::InvalidParams("invalid period2".into()))?;
hb = hb.between(start, end);
} else if let Some(r) = req.range {
hb = hb.range(r);
}
hb.fetch_full().await
})
}
}

View File

@@ -0,0 +1,163 @@
use serde::Deserialize;
use serde::Deserializer;
use std::collections::BTreeMap;
#[derive(Deserialize)]
pub struct ChartEnvelope {
pub(crate) chart: Option<ChartNode>,
}
#[derive(Deserialize)]
pub struct ChartNode {
pub(crate) result: Option<Vec<ChartResult>>,
pub(crate) error: Option<ChartError>,
}
#[derive(Deserialize)]
pub struct ChartError {
pub(crate) code: String,
pub(crate) description: String,
}
#[derive(Deserialize)]
pub struct ChartResult {
#[serde(default)]
pub(crate) meta: Option<MetaNode>,
#[serde(default)]
pub(crate) timestamp: Option<Vec<i64>>,
pub(crate) indicators: Indicators,
#[serde(default)]
pub(crate) events: Option<Events>,
}
#[derive(Deserialize, Clone)]
pub struct MetaNode {
#[serde(default)]
pub(crate) timezone: Option<String>,
#[serde(default)]
pub(crate) gmtoffset: Option<i64>,
#[serde(default)]
pub(crate) currency: Option<String>,
}
#[derive(Deserialize)]
pub struct Indicators {
#[serde(default)]
pub(crate) quote: Vec<QuoteBlock>,
#[serde(default)]
pub(crate) adjclose: Vec<AdjCloseBlock>,
}
#[derive(Deserialize, Clone)]
pub struct QuoteBlock {
#[serde(default)]
pub(crate) open: Vec<Option<f64>>,
#[serde(default)]
pub(crate) high: Vec<Option<f64>>,
#[serde(default)]
pub(crate) low: Vec<Option<f64>>,
#[serde(default)]
pub(crate) close: Vec<Option<f64>>,
#[serde(default)]
pub(crate) volume: Vec<Option<u64>>,
}
#[derive(Deserialize, Clone)]
pub struct AdjCloseBlock {
#[serde(default)]
pub(crate) adjclose: Vec<Option<f64>>,
}
#[derive(Deserialize, Default, Clone)]
pub struct Events {
#[serde(default)]
pub(crate) dividends: Option<BTreeMap<String, DividendEvent>>,
#[serde(default)]
pub(crate) splits: Option<BTreeMap<String, SplitEvent>>,
#[serde(default, rename = "capitalGains")]
pub(crate) capital_gains: Option<BTreeMap<String, CapitalGainEvent>>,
}
#[derive(Deserialize, Clone)]
pub struct DividendEvent {
pub(crate) amount: Option<f64>,
pub(crate) date: Option<i64>,
}
#[derive(Deserialize, Clone)]
pub struct SplitEvent {
#[serde(default, deserialize_with = "de_opt_u64_from_mixed")]
pub(crate) numerator: Option<u64>,
#[serde(default, deserialize_with = "de_opt_u64_from_mixed")]
pub(crate) denominator: Option<u64>,
#[serde(rename = "splitRatio")]
pub(crate) split_ratio: Option<String>,
pub(crate) date: Option<i64>,
}
#[derive(Deserialize, Clone)]
pub struct CapitalGainEvent {
pub(crate) amount: Option<f64>,
pub(crate) date: Option<i64>,
}
/// Accepts u64, integer-like f64 (e.g., 4.0), numeric strings ("4"), or null/missing.
/// Rounds floats and rejects non-finite or clearly non-integer floats.
fn de_opt_u64_from_mixed<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;
use serde_json::Value;
let v = Option::<Value>::deserialize(deserializer)?;
let some = match v {
None | Some(Value::Null) => return Ok(None),
Some(Value::Number(n)) => {
if let Some(u) = n.as_u64() {
Some(u)
} else if let Some(f) = n.as_f64() {
if !f.is_finite() {
return Err(D::Error::custom("non-finite float for split field"));
}
let r = f.round();
// Require the float to be very close to an integer
if (f - r).abs() < 1e-9 && r >= 0.0 {
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
Some(r as u64)
} else {
return Err(D::Error::custom(format!(
"expected integer-like float for split field, got {f}"
)));
}
} else {
return Err(D::Error::custom("unsupported number type for split field"));
}
}
Some(Value::String(s)) => {
let s = s.trim();
if s.is_empty() {
None
} else {
match s.parse::<u64>() {
Ok(u) => Some(u),
Err(_) => {
return Err(D::Error::custom(format!(
"invalid numeric string '{s}' for split field"
)));
}
}
}
}
Some(other) => {
return Err(D::Error::custom(format!(
"unexpected JSON type for split field: {other}"
)));
}
};
Ok(some)
}

View File

@@ -0,0 +1,210 @@
use super::model::{
InsiderRosterHolder, InsiderTransaction, InstitutionalHolder, MajorHolder,
NetSharePurchaseActivity,
};
use super::wire::V10Result;
use crate::core::wire::{from_raw, from_raw_date};
use crate::core::{
YfClient, YfError,
client::{CacheMode, RetryConfig},
conversions::{
i64_to_datetime, string_to_insider_position, string_to_transaction_type,
u64_to_money_with_currency,
},
quotesummary,
};
use chrono::DateTime;
use paft::money::Currency;
#[inline]
#[allow(clippy::cast_precision_loss)]
const fn u64_to_f64(n: u64) -> f64 {
n as f64
}
const MODULES: &str = "institutionOwnership,fundOwnership,majorHoldersBreakdown,insiderTransactions,insiderHolders,netSharePurchaseActivity";
async fn fetch_holders_modules(
client: &YfClient,
symbol: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<V10Result, YfError> {
quotesummary::fetch_module_result(
client,
symbol,
MODULES,
"holders",
cache_mode,
retry_override,
)
.await
}
pub(super) async fn major_holders(
client: &YfClient,
symbol: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<Vec<MajorHolder>, YfError> {
let root = fetch_holders_modules(client, symbol, cache_mode, retry_override).await?;
let breakdown = root
.major_holders_breakdown
.ok_or_else(|| YfError::MissingData("majorHoldersBreakdown missing".into()))?;
let mut result = Vec::new();
if let Some(v) = from_raw(breakdown.insiders_percent_held) {
result.push(MajorHolder {
category: "% of Shares Held by All Insiders".into(),
value: v,
});
}
if let Some(v) = from_raw(breakdown.institutions_percent_held) {
result.push(MajorHolder {
category: "% of Shares Held by Institutions".into(),
value: v,
});
}
if let Some(v) = from_raw(breakdown.institutions_float_percent_held) {
result.push(MajorHolder {
category: "% of Float Held by Institutions".into(),
value: v,
});
}
if let Some(v) = from_raw(breakdown.institutions_count) {
result.push(MajorHolder {
category: "Number of Institutions Holding Shares".into(),
value: u64_to_f64(v),
});
}
Ok(result)
}
fn map_ownership_list(
node: Option<super::wire::OwnershipNode>,
currency: &Currency,
) -> Vec<InstitutionalHolder> {
node.and_then(|n| n.ownership_list)
.unwrap_or_default()
.into_iter()
.map(|h| InstitutionalHolder {
holder: h.organization.unwrap_or_default(),
shares: from_raw(h.shares),
date_reported: from_raw_date(h.date_reported).map_or_else(
|| DateTime::from_timestamp(0, 0).unwrap_or_default(),
i64_to_datetime,
),
pct_held: from_raw(h.pct_held),
value: from_raw(h.value).map(|v| u64_to_money_with_currency(v, currency.clone())),
})
.collect()
}
pub(super) async fn institutional_holders(
client: &YfClient,
symbol: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<Vec<InstitutionalHolder>, YfError> {
let root = fetch_holders_modules(client, symbol, cache_mode, retry_override).await?;
let currency = client.reporting_currency(symbol, None).await;
Ok(map_ownership_list(root.institution_ownership, &currency))
}
pub(super) async fn mutual_fund_holders(
client: &YfClient,
symbol: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<Vec<InstitutionalHolder>, YfError> {
let root = fetch_holders_modules(client, symbol, cache_mode, retry_override).await?;
let currency = client.reporting_currency(symbol, None).await;
Ok(map_ownership_list(root.fund_ownership, &currency))
}
pub(super) async fn insider_transactions(
client: &YfClient,
symbol: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<Vec<InsiderTransaction>, YfError> {
let root = fetch_holders_modules(client, symbol, cache_mode, retry_override).await?;
let currency = client.reporting_currency(symbol, None).await;
let transactions = root
.insider_transactions
.and_then(|it| it.transactions)
.unwrap_or_default();
Ok(transactions
.into_iter()
.map(|t| InsiderTransaction {
insider: t.insider.unwrap_or_default(),
position: string_to_insider_position(&t.position.unwrap_or_default()),
transaction_type: string_to_transaction_type(&t.transaction.unwrap_or_default()),
shares: from_raw(t.shares),
value: from_raw(t.value).map(|v| u64_to_money_with_currency(v, currency.clone())),
transaction_date: from_raw_date(t.start_date).map_or_else(
|| DateTime::from_timestamp(0, 0).unwrap_or_default(),
i64_to_datetime,
),
url: t.url.unwrap_or_default(),
})
.collect())
}
pub(super) async fn insider_roster_holders(
client: &YfClient,
symbol: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<Vec<InsiderRosterHolder>, YfError> {
let root = fetch_holders_modules(client, symbol, cache_mode, retry_override).await?;
let holders = root
.insider_holders
.and_then(|ih| ih.holders)
.unwrap_or_default();
Ok(holders
.into_iter()
.map(|h| InsiderRosterHolder {
name: h.name.unwrap_or_default(),
position: string_to_insider_position(&h.relation.unwrap_or_default()),
most_recent_transaction: string_to_transaction_type(
&h.most_recent_transaction.unwrap_or_default(),
),
latest_transaction_date: from_raw_date(h.latest_transaction_date).map_or_else(
|| DateTime::from_timestamp(0, 0).unwrap_or_default(),
i64_to_datetime,
),
shares_owned_directly: from_raw(h.shares_owned_directly),
position_direct_date: from_raw_date(h.position_direct_date).map_or_else(
|| DateTime::from_timestamp(0, 0).unwrap_or_default(),
i64_to_datetime,
),
})
.collect())
}
pub(super) async fn net_share_purchase_activity(
client: &YfClient,
symbol: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<Option<NetSharePurchaseActivity>, YfError> {
let root = fetch_holders_modules(client, symbol, cache_mode, retry_override).await?;
Ok(root
.net_share_purchase_activity
.map(|n| NetSharePurchaseActivity {
period: crate::core::conversions::string_to_period(&n.period.unwrap_or_default()),
buy_shares: from_raw(n.buy_info_shares),
buy_count: from_raw(n.buy_info_count),
sell_shares: from_raw(n.sell_info_shares),
sell_count: from_raw(n.sell_info_count),
net_shares: from_raw(n.net_info_shares),
net_count: from_raw(n.net_info_count),
total_insider_shares: from_raw(n.total_insider_shares),
net_percent_insider_shares: from_raw(n.net_percent_insider_shares),
}))
}

View File

@@ -0,0 +1,139 @@
mod api;
mod model;
mod wire;
pub use model::{
InsiderRosterHolder, InsiderTransaction, InstitutionalHolder, MajorHolder,
NetSharePurchaseActivity,
};
use crate::{
YfClient, YfError,
core::client::{CacheMode, RetryConfig},
};
/// A builder for fetching holder data for a specific symbol.
pub struct HoldersBuilder {
client: YfClient,
symbol: String,
cache_mode: CacheMode,
retry_override: Option<RetryConfig>,
}
impl HoldersBuilder {
/// Creates a new `HoldersBuilder` for a given symbol.
pub fn new(client: &YfClient, symbol: impl Into<String>) -> Self {
Self {
client: client.clone(),
symbol: symbol.into(),
cache_mode: CacheMode::Use,
retry_override: None,
}
}
/// Sets the cache mode for this specific API call.
#[must_use]
pub const fn cache_mode(mut self, mode: CacheMode) -> Self {
self.cache_mode = mode;
self
}
/// Overrides the default retry policy for this specific API call.
#[must_use]
pub fn retry_policy(mut self, cfg: Option<RetryConfig>) -> Self {
self.retry_override = cfg;
self
}
/// Fetches the major holders breakdown (e.g., % insiders, % institutions).
///
/// # Errors
///
/// Returns a `YfError` if the network request fails or the API response cannot be parsed.
pub async fn major_holders(&self) -> Result<Vec<MajorHolder>, YfError> {
api::major_holders(
&self.client,
&self.symbol,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
/// Fetches a list of the top institutional holders.
///
/// # Errors
///
/// Returns a `YfError` if the network request fails or the API response cannot be parsed.
pub async fn institutional_holders(&self) -> Result<Vec<InstitutionalHolder>, YfError> {
api::institutional_holders(
&self.client,
&self.symbol,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
/// Fetches a list of the top mutual fund holders.
///
/// # Errors
///
/// Returns a `YfError` if the network request fails or the API response cannot be parsed.
pub async fn mutual_fund_holders(&self) -> Result<Vec<InstitutionalHolder>, YfError> {
api::mutual_fund_holders(
&self.client,
&self.symbol,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
/// Fetches a list of recent insider transactions.
///
/// # Errors
///
/// Returns a `YfError` if the network request fails or the API response cannot be parsed.
pub async fn insider_transactions(&self) -> Result<Vec<InsiderTransaction>, YfError> {
api::insider_transactions(
&self.client,
&self.symbol,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
/// Fetches a roster of company insiders and their holdings.
///
/// # Errors
///
/// Returns a `YfError` if the network request fails or the API response cannot be parsed.
pub async fn insider_roster_holders(&self) -> Result<Vec<InsiderRosterHolder>, YfError> {
api::insider_roster_holders(
&self.client,
&self.symbol,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
/// Fetches a summary of net insider purchase and sale activity.
///
/// # Errors
///
/// Returns a `YfError` if the network request fails or the API response cannot be parsed.
pub async fn net_share_purchase_activity(
&self,
) -> Result<Option<NetSharePurchaseActivity>, YfError> {
api::net_share_purchase_activity(
&self.client,
&self.symbol,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
}

View File

@@ -0,0 +1,5 @@
// Re-export types from paft without using prelude
pub use paft::fundamentals::holders::{
InsiderRosterHolder, InsiderTransaction, InstitutionalHolder, MajorHolder,
NetSharePurchaseActivity,
};

View File

@@ -0,0 +1,109 @@
use crate::core::wire::{RawDate, RawNum};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct V10Result {
#[serde(rename = "institutionOwnership")]
pub(crate) institution_ownership: Option<OwnershipNode>,
#[serde(rename = "fundOwnership")]
pub(crate) fund_ownership: Option<OwnershipNode>,
#[serde(rename = "majorHoldersBreakdown")]
pub(crate) major_holders_breakdown: Option<MajorHoldersBreakdownNode>,
#[serde(rename = "insiderTransactions")]
pub(crate) insider_transactions: Option<InsiderTransactionsNode>,
#[serde(rename = "insiderHolders")]
pub(crate) insider_holders: Option<InsiderHoldersNode>,
#[serde(rename = "netSharePurchaseActivity")]
pub(crate) net_share_purchase_activity: Option<NetSharePurchaseActivityNode>,
}
#[derive(Deserialize)]
pub struct OwnershipNode {
#[serde(rename = "ownershipList")]
pub(crate) ownership_list: Option<Vec<InstitutionalHolderNode>>,
}
#[derive(Deserialize)]
pub struct InstitutionalHolderNode {
pub(crate) organization: Option<String>,
#[serde(rename = "position")]
pub(crate) shares: Option<RawNum<u64>>,
#[serde(rename = "reportDate")]
pub(crate) date_reported: Option<RawDate>,
#[serde(rename = "pctHeld")]
pub(crate) pct_held: Option<RawNum<f64>>,
pub(crate) value: Option<RawNum<u64>>,
}
#[derive(Deserialize)]
pub struct MajorHoldersBreakdownNode {
#[serde(rename = "insidersPercentHeld")]
pub(crate) insiders_percent_held: Option<RawNum<f64>>,
#[serde(rename = "institutionsPercentHeld")]
pub(crate) institutions_percent_held: Option<RawNum<f64>>,
#[serde(rename = "institutionsFloatPercentHeld")]
pub(crate) institutions_float_percent_held: Option<RawNum<f64>>,
#[serde(rename = "institutionsCount")]
pub(crate) institutions_count: Option<RawNum<u64>>,
}
#[derive(Deserialize)]
pub struct InsiderTransactionsNode {
pub(crate) transactions: Option<Vec<InsiderTransactionNode>>,
}
#[derive(Deserialize)]
pub struct InsiderTransactionNode {
#[serde(rename = "filerName")]
pub(crate) insider: Option<String>,
#[serde(rename = "filerRelation")]
pub(crate) position: Option<String>,
#[serde(rename = "transactionText")]
pub(crate) transaction: Option<String>,
pub(crate) shares: Option<RawNum<u64>>,
pub(crate) value: Option<RawNum<u64>>,
#[serde(rename = "startDate")]
pub(crate) start_date: Option<RawDate>,
#[serde(rename = "filerUrl")]
pub(crate) url: Option<String>,
}
#[derive(Deserialize)]
pub struct InsiderHoldersNode {
pub(crate) holders: Option<Vec<InsiderRosterHolderNode>>,
}
#[derive(Deserialize)]
pub struct InsiderRosterHolderNode {
pub(crate) name: Option<String>,
pub(crate) relation: Option<String>,
#[serde(rename = "transactionDescription")]
pub(crate) most_recent_transaction: Option<String>,
#[serde(rename = "latestTransDate")]
pub(crate) latest_transaction_date: Option<RawDate>,
#[serde(rename = "positionDirect")]
pub(crate) shares_owned_directly: Option<RawNum<u64>>,
#[serde(rename = "positionDirectDate")]
pub(crate) position_direct_date: Option<RawDate>,
}
#[derive(Deserialize)]
pub struct NetSharePurchaseActivityNode {
pub(crate) period: Option<String>,
#[serde(rename = "buyInfoShares")]
pub(crate) buy_info_shares: Option<RawNum<u64>>,
#[serde(rename = "buyInfoCount")]
pub(crate) buy_info_count: Option<RawNum<u64>>,
#[serde(rename = "sellInfoShares")]
pub(crate) sell_info_shares: Option<RawNum<u64>>,
#[serde(rename = "sellInfoCount")]
pub(crate) sell_info_count: Option<RawNum<u64>>,
#[serde(rename = "netInfoShares")]
pub(crate) net_info_shares: Option<RawNum<i64>>,
#[serde(rename = "netInfoCount")]
pub(crate) net_info_count: Option<RawNum<i64>>,
#[serde(rename = "totalInsiderShares")]
pub(crate) total_insider_shares: Option<RawNum<u64>>,
#[serde(rename = "netPercentInsiderShares")]
pub(crate) net_percent_insider_shares: Option<RawNum<f64>>,
}

View File

@@ -0,0 +1,187 @@
//! # yfinance-rs
//!
//! An ergonomic, async-first Rust client for the unofficial Yahoo Finance API.
//!
//! This crate provides a simple and efficient way to fetch financial data from Yahoo Finance.
//! It is designed to feel familiar to users of the popular Python `yfinance` library, but
//! leverages Rust's powerful type system and async capabilities for performance and safety.
//!
//! ## Features
//!
//! ### Core Data
//! * **Historical Data**: Fetch daily, weekly, or monthly OHLCV data with automatic split/dividend adjustments.
//! * **Real-time Quotes**: Get live quote updates with detailed market information.
//! * **Fast Quotes**: Optimized quote fetching with essential data only (`fast_info`).
//! * **Multi-Symbol Downloads**: Concurrently download historical data for many symbols at once.
//! * **Batch Quotes**: Fetch quotes for multiple symbols efficiently.
//!
//! ### Corporate Actions & Dividends
//! * **Dividend History**: Fetch complete dividend payment history with amounts and dates.
//! * **Stock Splits**: Get stock split history with split ratios.
//! * **Capital Gains**: Retrieve capital gains distributions (especially for mutual funds).
//! * **All Corporate Actions**: Comprehensive access to dividends, splits, and capital gains in one call.
//!
//! ### Financial Statements & Fundamentals
//! * **Income Statements**: Access annual and quarterly income statements.
//! * **Balance Sheets**: Get annual and quarterly balance sheet data.
//! * **Cash Flow Statements**: Fetch annual and quarterly cash flow data.
//! * **Earnings Data**: Historical earnings, revenue estimates, and EPS data.
//! * **Shares Outstanding**: Historical data on shares outstanding (annual and quarterly).
//! * **Corporate Calendar**: Earnings dates, ex-dividend dates, and dividend payment dates.
//!
//! ### Options & Derivatives
//! * **Options Chains**: Fetch expiration dates and full option chains (calls and puts).
//! * **Option Contracts**: Detailed option contract information.
//!
//! ### Analysis & Research
//! * **Analyst Ratings**: Get price targets, recommendations, and upgrade/downgrade history.
//! * **Earnings Trends**: Detailed earnings and revenue estimates from analysts.
//! * **Recommendations Summary**: Summary of current analyst recommendations.
//! * **Upgrades/Downgrades**: History of analyst rating changes.
//!
//! ### Ownership & Holders
//! * **Major Holders**: Get major, institutional, and mutual fund holder data.
//! * **Institutional Holders**: Top institutional shareholders and their holdings.
//! * **Mutual Fund Holders**: Mutual fund ownership breakdown.
//! * **Insider Transactions**: Recent insider buying and selling activity.
//! * **Insider Roster**: Company insiders and their current holdings.
//! * **Net Share Activity**: Summary of insider purchase/sale activity.
//!
//! ### ESG & Sustainability
//! * **ESG Scores**: Fetch detailed Environmental, Social, and Governance ratings.
//! * **ESG Involvement**: Specific ESG involvement and controversy data.
//!
//! ### News & Information
//! * **Company News**: Retrieve the latest articles and press releases for a ticker.
//! * **Company Profiles**: Detailed information about companies, ETFs, and funds.
//! * **Search**: Find tickers by name or keyword.
//!
//! ### Real-time Streaming
//! * **WebSocket Streaming**: Get live quote updates using `WebSockets` (preferred method).
//! * **HTTP Polling**: Fallback polling method for real-time data.
//! * **Configurable Streaming**: Customize update frequency and change-only filtering.
//!
//! ### Advanced Features
//! * **Data Repair**: Automatic detection and repair of price outliers.
//! * **Data Rounding**: Control price precision and rounding.
//! * **Missing Data Handling**: Configurable handling of NA/missing values.
//! * **Back Adjustment**: Alternative price adjustment methods.
//! * **Historical Metadata**: Timezone and other metadata for historical data.
//! * **ISIN Lookup**: Get International Securities Identification Numbers.
//!
//! ### Developer Experience
//! * **Async API**: Built on `tokio` and `reqwest` for non-blocking I/O.
//! * **High-Level `Ticker` Interface**: A convenient, yfinance-like struct for accessing all data for a single symbol.
//! * **Builder Pattern**: Fluent builders for constructing complex queries.
//! * **Configurable Retries**: Automatic retries with exponential backoff for transient network errors.
//! * **Caching**: Configurable caching behavior for API responses.
//! * **Custom Timeouts**: Configurable request timeouts and connection settings.
//!
//! ## Quick Start
//!
//! To get started, add `yfinance-rs` to your `Cargo.toml`:
//!
//! ```toml
//! [dependencies]
//! yfinance-rs = "0.7.2"
//! tokio = { version = "1", features = ["full"] }
//! ```
//!
//! Then, create a `YfClient` and use a `Ticker` to fetch data.
//!
//! ```no_run
//! use yfinance_rs::{Interval, Range, Ticker, YfClient};
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let client = YfClient::default();
//! let ticker = Ticker::new(&client, "AAPL");
//!
//! // Get the latest quote
//! let quote = ticker.quote().await?;
//! println!("Latest price for AAPL: ${:.2}", quote.price.as_ref().map(|p| yfinance_rs::core::conversions::money_to_f64(p)).unwrap_or(0.0));
//!
//! // Get historical data for the last 6 months
//! let history = ticker.history(Some(Range::M6), Some(Interval::D1), false).await?;
//! if let Some(last_bar) = history.last() {
//! println!("Last closing price: ${:.2} on timestamp {}", yfinance_rs::core::conversions::money_to_f64(&last_bar.close), last_bar.ts);
//! }
//!
//! // Get analyst recommendations
//! let recs = ticker.recommendations().await?;
//! if let Some(latest_rec) = recs.first() {
//! println!("Latest recommendation period: {}", latest_rec.period);
//! }
//!
//! Ok(())
//! }
//! ```
#![warn(missing_docs)]
/// Core components, including the `YfClient` and `YfError`.
pub mod core;
// --- feature modules ---
/// Fetch analyst ratings, price targets, and upgrade/downgrade history.
pub mod analysis;
/// Download historical data for multiple symbols concurrently.
pub mod download;
/// Fetch ESG (Environmental, Social, Governance) scores and involvement data.
pub mod esg;
/// Fetch financial statements (income, balance sheet, cash flow) and earnings data.
pub mod fundamentals;
/// Fetch historical OHLCV data for a single symbol.
pub mod history;
/// Fetch holder information, including major, institutional, and insider holders.
pub mod holders;
/// Fetch news articles for a ticker.
pub mod news;
/// Retrieve company or fund profile information.
pub mod profile;
/// Fetch quotes for multiple symbols.
pub mod quote;
/// Search for tickers by name or keyword.
pub mod search;
/// Stream real-time quote updates via `WebSockets` or polling.
pub mod stream;
/// A high-level interface for a single ticker, providing access to all data types.
pub mod ticker;
// --- re-exports (public API remains the same names as before) ---
// Core types that are provider-specific
pub use core::client::ApiPreference;
pub use core::{CacheMode, RetryConfig, YfClient, YfClientBuilder, YfError};
// Provider-specific builders and utilities
pub use download::DownloadBuilder;
pub use esg::EsgBuilder;
pub use fundamentals::FundamentalsBuilder;
pub use history::HistoryBuilder;
pub use holders::HoldersBuilder;
pub use news::{NewsBuilder, NewsTab};
pub use paft::market::responses::download::{DownloadEntry, DownloadResponse};
pub use quote::{QuotesBuilder, quotes};
pub use search::{SearchBuilder, search};
pub use stream::{StreamBuilder, StreamConfig, StreamHandle, StreamMethod};
pub use ticker::{FastInfo, Info, Ticker};
/// Initialize a default tracing subscriber for tests/examples when the
/// `tracing-subscriber` feature is enabled. No-op otherwise.
#[cfg(feature = "tracing-subscriber")]
#[doc(hidden)]
pub fn init_tracing_for_tests() {
use tracing_subscriber::{EnvFilter, fmt};
let filter = std::env::var("RUST_LOG")
.ok()
.and_then(|s| EnvFilter::try_new(s).ok())
.unwrap_or_else(|| EnvFilter::new("info"));
let _ = fmt::Subscriber::builder()
.with_env_filter(filter)
.with_target(true)
.with_ansi(true)
.try_init();
}
// Explicitly re-export selected paft core types commonly used by users of this crate
pub use crate::core::{Action, Candle, HistoryMeta, HistoryResponse, Quote};
pub use crate::core::{Interval, Range};

View File

@@ -0,0 +1,107 @@
use serde::Serialize;
use crate::{
core::{
YfClient, YfError,
client::{CacheMode, RetryConfig},
conversions::i64_to_datetime,
net,
},
news::{NewsTab, model::NewsArticle, tab_as_str, wire},
};
#[derive(Serialize)]
struct ServiceConfig<'a> {
#[serde(rename = "snippetCount")]
snippet_count: u32,
s: &'a [&'a str],
}
#[derive(Serialize)]
struct NewsPayload<'a> {
#[serde(rename = "serviceConfig")]
service_config: ServiceConfig<'a>,
}
pub(super) async fn fetch_news(
client: &YfClient,
symbol: &str,
count: u32,
tab: NewsTab,
_cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<Vec<NewsArticle>, YfError> {
let mut url = client.base_news().join("xhr/ncp")?;
url.query_pairs_mut()
.append_pair("queryRef", tab_as_str(tab))
.append_pair("serviceKey", "ncp_fin");
let payload = NewsPayload {
service_config: ServiceConfig {
snippet_count: count,
s: &[symbol],
},
};
// Note: The client's built-in cache is URL-based and doesn't support POST bodies.
// Caching for this endpoint would require a more complex keying strategy.
let req = client.http().post(url).json(&payload);
let resp = client.send_with_retry(req, retry_override).await?;
if !resp.status().is_success() {
let code = resp.status().as_u16();
let url_s = resp.url().to_string();
return Err(match code {
404 => YfError::NotFound { url: url_s },
429 => YfError::RateLimited { url: url_s },
500..=599 => YfError::ServerError {
status: code,
url: url_s,
},
_ => YfError::Status {
status: code,
url: url_s,
},
});
}
let endpoint = format!("news_{}", tab_as_str(tab));
let body = net::get_text(resp, &endpoint, symbol, "json").await?;
let envelope: wire::NewsEnvelope = serde_json::from_str(&body).map_err(YfError::Json)?;
let articles = envelope
.data
.and_then(|d| d.ticker_stream)
.and_then(|ts| ts.stream)
.unwrap_or_default();
let results = articles
.into_iter()
.filter_map(|raw_item| {
// Filter out ads or items that are not valid articles
if raw_item.ad.is_some() {
return None;
}
let content = raw_item.content?;
let title = content.title?;
let pub_date_str = content.pub_date?;
// Parse the RFC3339 string to a timestamp
let timestamp = chrono::DateTime::parse_from_rfc3339(&pub_date_str)
.ok()?
.timestamp();
Some(NewsArticle {
uuid: raw_item.id,
title,
publisher: content.provider.and_then(|p| p.display_name),
link: content.canonical_url.and_then(|u| u.url),
published_at: i64_to_datetime(timestamp),
})
})
.collect();
Ok(results)
}

View File

@@ -0,0 +1,102 @@
mod api;
mod model;
mod wire;
use serde::{Deserialize, Serialize};
/// Tabs for filtering the Yahoo Finance news endpoint.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum NewsTab {
/// Only editorial news items.
#[default]
News,
/// All items including press releases.
All,
/// Only press releases.
PressReleases,
}
pub use model::NewsArticle;
use crate::{
YfClient, YfError,
core::client::{CacheMode, RetryConfig},
};
pub(crate) const fn tab_as_str(tab: NewsTab) -> &'static str {
match tab {
NewsTab::News => "latestNews",
NewsTab::All => "newsAll",
NewsTab::PressReleases => "pressRelease",
}
}
/// A builder for fetching news articles for a specific symbol.
pub struct NewsBuilder {
client: YfClient,
symbol: String,
count: u32,
tab: NewsTab,
cache_mode: CacheMode,
retry_override: Option<RetryConfig>,
}
impl NewsBuilder {
/// Creates a new `NewsBuilder` for a given symbol.
pub fn new(client: &YfClient, symbol: impl Into<String>) -> Self {
Self {
client: client.clone(),
symbol: symbol.into(),
count: 10,
tab: NewsTab::default(),
cache_mode: CacheMode::Use,
retry_override: None,
}
}
/// Sets the cache mode for this specific API call.
/// Note: Caching is not currently implemented for news requests.
#[must_use]
pub const fn cache_mode(mut self, mode: CacheMode) -> Self {
self.cache_mode = mode;
self
}
/// Overrides the default retry policy for this specific API call.
#[must_use]
pub fn retry_policy(mut self, cfg: Option<RetryConfig>) -> Self {
self.retry_override = cfg;
self
}
/// Sets the maximum number of news articles to return.
#[must_use]
pub const fn count(mut self, count: u32) -> Self {
self.count = count;
self
}
/// Sets the category of news to fetch.
#[must_use]
pub const fn tab(mut self, tab: NewsTab) -> Self {
self.tab = tab;
self
}
/// Executes the request and fetches the news articles.
///
/// # Errors
///
/// Returns a `YfError` if the request to the Yahoo Finance API fails,
/// if the response cannot be parsed, or if there's a network issue.
pub async fn fetch(self) -> Result<Vec<NewsArticle>, YfError> {
api::fetch_news(
&self.client,
&self.symbol,
self.count,
self.tab,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
}

View File

@@ -0,0 +1,2 @@
// Re-export types from paft without using prelude
pub use paft::market::news::NewsArticle;

View File

@@ -0,0 +1,46 @@
use serde::Deserialize;
#[derive(Deserialize)]
pub struct NewsEnvelope {
pub(crate) data: Option<NewsData>,
}
#[derive(Deserialize)]
pub struct NewsData {
#[serde(rename = "tickerStream")]
pub(crate) ticker_stream: Option<TickerStream>,
}
#[derive(Deserialize)]
pub struct TickerStream {
pub(crate) stream: Option<Vec<StreamItem>>,
}
#[derive(Deserialize)]
pub struct StreamItem {
pub(crate) id: String,
pub(crate) content: Option<Content>,
// The python 'ad' check might be for a field at this level.
pub(crate) ad: Option<serde_json::Value>,
}
#[derive(Deserialize)]
pub struct Content {
pub(crate) title: Option<String>,
#[serde(rename = "pubDate")]
pub(crate) pub_date: Option<String>,
pub(crate) provider: Option<Provider>,
#[serde(rename = "canonicalUrl")]
pub(crate) canonical_url: Option<CanonicalUrl>,
}
#[derive(Deserialize)]
pub struct Provider {
#[serde(rename = "displayName")]
pub(crate) display_name: Option<String>,
}
#[derive(Deserialize)]
pub struct CanonicalUrl {
pub(crate) url: Option<String>,
}

View File

@@ -0,0 +1,129 @@
//! quoteSummary v10 API path for profiles.
use crate::{
YfClient, YfError,
core::{client::CacheMode, conversions::string_to_fund_kind, quotesummary},
};
use paft::domain::Isin;
use serde::Deserialize;
use super::{Address, Company, Fund, Profile};
pub async fn load_from_quote_summary_api(
client: &YfClient,
symbol: &str,
) -> Result<Profile, YfError> {
let first: V10Result = quotesummary::fetch_module_result(
client,
symbol,
"assetProfile,quoteType,fundProfile",
"profile",
CacheMode::Use,
None,
)
.await?;
let kind = first
.quote_type
.as_ref()
.and_then(|q| q.quote_type.as_deref())
.unwrap_or("");
let name = first
.quote_type
.as_ref()
.and_then(|q| q.long_name.clone().or_else(|| q.short_name.clone()))
.unwrap_or_else(|| symbol.to_string());
match kind {
"EQUITY" => {
let sp = first
.asset_profile
.ok_or_else(|| YfError::MissingData("assetProfile missing".into()))?;
let address = Address {
street1: sp.address1,
street2: sp.address2,
city: sp.city,
state: sp.state,
country: sp.country,
zip: sp.zip,
};
// Validate ISIN if present, return None if invalid
let validated_isin = sp.isin.and_then(|isin_str| Isin::new(&isin_str).ok());
Ok(Profile::Company(Company {
name,
sector: sp.sector,
industry: sp.industry,
website: sp.website,
summary: sp.long_business_summary,
address: Some(address),
isin: validated_isin,
}))
}
"ETF" => {
let fp = first
.fund_profile
.ok_or_else(|| YfError::MissingData("fundProfile missing".into()))?;
// Validate ISIN if present, return None if invalid
let validated_isin = fp.isin.and_then(|isin_str| Isin::new(&isin_str).ok());
Ok(Profile::Fund(Fund {
name,
family: fp.family,
kind: string_to_fund_kind(fp.legal_type).unwrap_or_default(),
isin: validated_isin,
}))
}
other => Err(YfError::InvalidParams(format!(
"unsupported quoteType: {other}"
))),
}
}
/* --------- Minimal serde mapping for the API JSON --------- */
#[derive(Deserialize)]
struct V10Result {
#[serde(rename = "assetProfile")]
asset_profile: Option<V10AssetProfile>,
#[serde(rename = "fundProfile")]
fund_profile: Option<V10FundProfile>,
#[serde(rename = "quoteType")]
quote_type: Option<V10QuoteType>,
}
#[derive(Deserialize)]
struct V10AssetProfile {
address1: Option<String>,
address2: Option<String>,
city: Option<String>,
state: Option<String>,
country: Option<String>,
zip: Option<String>,
sector: Option<String>,
industry: Option<String>,
website: Option<String>,
#[serde(rename = "longBusinessSummary")]
long_business_summary: Option<String>,
isin: Option<String>,
}
#[derive(Deserialize)]
struct V10FundProfile {
#[serde(rename = "legalType")]
legal_type: Option<String>,
family: Option<String>,
isin: Option<String>,
}
#[derive(Deserialize)]
struct V10QuoteType {
#[serde(rename = "quoteType")]
quote_type: Option<String>,
#[serde(rename = "longName")]
long_name: Option<String>,
#[serde(rename = "shortName")]
short_name: Option<String>,
}

View File

@@ -0,0 +1,199 @@
//! Debug dump helpers for development / troubleshooting.
use crate::profile::scrape::utils::{escape_html, iter_json_scripts, parse_jsonish_string};
use serde_json::Value;
use std::fmt::Write as _;
use std::io::Write; // for writing to files // for write!(..) into String
pub fn debug_dump_extracted_json(symbol: &str, json: &str) -> std::io::Result<()> {
let path = std::env::temp_dir().join(format!("yfinance_rs-profile-{symbol}-extracted.json"));
let mut f = std::fs::File::create(&path)?;
if let Ok(val) = serde_json::from_str::<Value>(json)
&& let Ok(pretty) = serde_json::to_string_pretty(&val)
{
let _ = f.write_all(pretty.as_bytes());
eprintln!(
"yfinance-rs(debug-dumps): wrote pretty-printed extracted JSON to {}",
path.display()
);
return Ok(());
}
let _ = f.write_all(json.as_bytes());
eprintln!(
"yfinance-rs(debug-dumps): wrote raw extracted JSON to {}",
path.display()
);
Ok(())
}
#[allow(clippy::too_many_lines)]
pub fn debug_dump_html(symbol: &str, html: &str) -> std::io::Result<()> {
use std::{fs, io::Write};
fn pretty_limit(v: &Value, max_chars: usize) -> String {
let s = serde_json::to_string_pretty(v).unwrap_or_else(|_| format!("{v:?}"));
if s.chars().count() <= max_chars {
return s;
}
let mut out = String::new();
for (n, ch) in s.chars().enumerate() {
if n >= max_chars {
break;
}
out.push(ch);
}
out.push_str("\n… [truncated]");
out
}
fn extract_title(html: &str) -> Option<String> {
let lt = "<title>";
let rt = "</title>";
let i = html.find(lt)?;
let j = html[i + lt.len()..].find(rt)? + i + lt.len();
Some(html[i + lt.len()..j].to_string())
}
fn extract_js_object_after(pattern: &str, s: &str) -> Option<String> {
let start = s.find(pattern)? + pattern.len();
let bytes = s.as_bytes();
let mut i = start;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b'{' {
return None;
}
let mut j = i;
let mut depth = 0i32;
while j < bytes.len() {
match bytes[j] {
b'{' => {
depth += 1;
}
b'}' => {
depth -= 1;
if depth == 0 {
j += 1;
break;
}
}
b'"' => {
j += 1;
while j < bytes.len() {
if bytes[j] == b'\\' {
j += 2;
continue;
}
if bytes[j] == b'"' {
j += 1;
break;
}
j += 1;
}
continue;
}
_ => {}
}
j += 1;
}
if j <= i {
return None;
}
Some(s[i..j].to_string())
}
let tmp = std::env::temp_dir();
let base = format!("yfinance_rs-profile-{symbol}");
let min_path = tmp.join(format!("{base}-min.html"));
let next_path = tmp.join(format!("{base}-next.json"));
let rootapp_path = tmp.join(format!("{base}-rootapp.json"));
let mut min_html = String::new();
min_html.push_str("<!doctype html><meta charset=\"utf-8\">\n<style>pre{white-space:pre-wrap;font:12px/1.3 ui-monospace,monospace}</style>\n");
if let Some(t) = extract_title(html) {
let _ = writeln!(min_html, "<h1>title</h1><pre>{}</pre>", escape_html(&t));
}
for (attrs, inner) in iter_json_scripts(html) {
let parsed = serde_json::from_str::<Value>(inner).ok();
let pretty;
if let Some(v) = parsed.as_ref() {
if let Some(u) = v
.get("body")
.and_then(|b| b.as_str())
.and_then(parse_jsonish_string)
{
pretty = pretty_limit(&u, 5_000);
} else {
pretty = pretty_limit(v, 5_000);
}
} else if let Some(v) = parse_jsonish_string(inner) {
pretty = pretty_limit(&v, 5_000);
} else {
continue;
}
let data_url = attrs
.split_whitespace()
.find(|p| p.starts_with("data-url="))
.map_or("", |p| p.trim_start_matches("data-url=").trim_matches('"'));
let label = if attrs.contains("data-sveltekit-fetched") {
format!("sveltekit-fetched {data_url}",)
} else if attrs.contains("id=\"__NEXT_DATA__\"") {
"__NEXT_DATA__".to_string()
} else {
"application/json script".to_string()
};
let _ = writeln!(
min_html,
"<h2>{}</h2><pre>{}</pre>",
escape_html(&label),
escape_html(&pretty)
);
}
if let Some((_, inner)) = iter_json_scripts(html)
.into_iter()
.find(|(attrs, _)| attrs.contains("id=\"__NEXT_DATA__\""))
&& let Ok(v) = serde_json::from_str::<Value>(inner)
{
let mut f = fs::File::create(&next_path)?;
let s = serde_json::to_string_pretty(&v).unwrap_or_else(|_| inner.to_string());
f.write_all(s.as_bytes())?;
eprintln!("yfinance-rs(debug-dumps): wrote {}", next_path.display());
}
if let Some(js_obj) = extract_js_object_after("root.App.main =", html) {
if let Ok(v) = serde_json::from_str::<Value>(&js_obj) {
let mut f = fs::File::create(&rootapp_path)?;
let s = serde_json::to_string_pretty(&v).unwrap_or_else(|_| js_obj.clone());
f.write_all(s.as_bytes())?;
eprintln!("yfinance-rs(debug-dumps): wrote {}", rootapp_path.display());
}
if let Ok(v) = serde_json::from_str::<Value>(&js_obj) {
min_html.push_str("<h2>root.App.main (snippet)</h2>\n");
let pretty = pretty_limit(&v, 5_000);
let _ = writeln!(min_html, "<pre>{}</pre>", escape_html(&pretty));
}
}
let mut f = std::fs::File::create(&min_path)?;
f.write_all(min_html.as_bytes())?;
eprintln!("yfinance-rs(debug-dumps): wrote {}", min_path.display());
Ok(())
}
pub fn debug_dump_api(symbol: &str, body: &str) -> std::io::Result<()> {
use std::io::Write;
let path = std::env::temp_dir().join(format!("yfinance_rs-quoteSummary-{symbol}.json"));
let mut f = std::fs::File::create(&path)?;
let _ = f.write_all(body.as_bytes());
eprintln!("yfinance-rs(debug-dumps): wrote {}", path.display());
Ok(())
}

View File

@@ -0,0 +1,65 @@
//! Public profile types + loading strategy (API first, then scrape).
//!
//! Internals are split into:
//! - `api`:    quoteSummary v10 API path
//! - `scrape`: HTML scrape + JSON extraction path
//! - `internal`: common utilities for both API and scrape
//! - `debug`:  optional debug dump helpers (only in debug builds or with `debug-dumps` feature)
mod api;
mod scrape;
#[cfg(feature = "debug-dumps")]
pub(crate) mod debug;
use crate::{YfClient, YfError};
mod model;
pub use model::{Address, Company, Fund, Profile};
/// Helper to contain the API->Scrape fallback logic.
#[cfg_attr(feature = "tracing", tracing::instrument(skip(client), err, fields(symbol = %symbol)))]
async fn load_with_fallback(client: &YfClient, symbol: &str) -> Result<Profile, YfError> {
client.ensure_credentials().await?;
match api::load_from_quote_summary_api(client, symbol).await {
Ok(p) => Ok(p),
Err(e) => {
if std::env::var("YF_DEBUG").ok().as_deref() == Some("1") {
eprintln!("YF_DEBUG: API call failed ({e}), falling back to scrape.");
}
#[cfg(feature = "tracing")]
tracing::event!(tracing::Level::WARN, error = %e, "profile: API failed; falling back to scrape");
scrape::load_from_scrape(client, symbol).await
}
}
}
/// Loads the profile for a given symbol.
///
/// This function will try to load the profile from the quote summary API first,
/// and fall back to scraping the quote page if the API fails.
///
/// # Errors
///
/// Returns `YfError` if the network request fails, the response cannot be parsed,
/// or the data for the symbol is not available.
pub async fn load_profile(client: &YfClient, symbol: &str) -> Result<Profile, YfError> {
#[cfg(not(feature = "test-mode"))]
{
load_with_fallback(client, symbol).await
}
#[cfg(feature = "test-mode")]
{
use crate::core::client::ApiPreference;
match client.api_preference() {
ApiPreference::ApiThenScrape => load_with_fallback(client, symbol).await,
ApiPreference::ApiOnly => {
client.ensure_credentials().await?;
api::load_from_quote_summary_api(client, symbol).await
}
ApiPreference::ScrapeOnly => scrape::load_from_scrape(client, symbol).await,
}
}
}

View File

@@ -0,0 +1,4 @@
// Re-export types from paft without using prelude
pub use paft::fundamentals::profile::{
Address, CompanyProfile as Company, FundProfile as Fund, Profile,
};

View File

@@ -0,0 +1,143 @@
use serde_json::Value;
pub fn truncate(s: &str, n: usize) -> String {
if s.len() <= n {
s.to_string()
} else {
let mut out = String::with_capacity(n + 16);
out.push_str(&s[..n]);
out.push_str(" …[trunc]");
out
}
}
pub fn extract_store_like_from_quote_summary_value(qs_val: &Value) -> Option<Value> {
let debug = std::env::var("YF_DEBUG").ok().as_deref() == Some("1");
// Accept either {..., quoteSummary: {...}} or a quoteSummary node directly.
let summary = qs_val.get("quoteSummary").unwrap_or(qs_val);
// Take the first result element if present.
let result0 = summary
.get("result")
.and_then(|r| r.as_array())
.and_then(|arr| arr.first())
.cloned();
if result0.is_none() {
if debug {
eprintln!(
"YF_DEBUG [extract_store_like]: quoteSummary.result[0] missing or not an array."
);
}
return None;
}
let result0 = result0.unwrap();
// Sanity checks that this looks like a profile payload.
let has_quote_type = result0.get("quoteType").is_some();
let has_profile =
result0.get("assetProfile").is_some() || result0.get("summaryProfile").is_some();
let has_fund = result0.get("fundProfile").is_some();
if debug {
eprintln!(
"YF_DEBUG [extract_store_like]: has_quoteType={has_quote_type}, has_profile={has_profile}, has_fund={has_fund}"
);
}
if !(has_quote_type || has_profile || has_fund) {
if debug {
eprintln!("YF_DEBUG [extract_store_like]: shape not acceptable.");
}
return None;
}
let norm = normalize_store_like(result0);
if debug {
let keys = norm
.as_object()
.map(|m| {
let mut v: Vec<_> = m.keys().cloned().collect();
v.sort();
v.join(",")
})
.unwrap_or_default();
eprintln!("YF_DEBUG [extract_store_like]: SUCCESS; normalized keys={keys}");
}
Some(norm)
}
pub fn find_quote_summary_store_in_value(v: &Value) -> Option<&Value> {
match v {
Value::Object(map) => {
if let Some(qss) = map.get("QuoteSummaryStore")
&& qss.is_object()
{
return Some(qss);
}
if let Some(stores) = map.get("stores")
&& let Some(qss) = stores.get("QuoteSummaryStore")
&& qss.is_object()
{
return Some(qss);
}
for child in map.values() {
if let Some(found) = find_quote_summary_store_in_value(child) {
return Some(found);
}
}
None
}
Value::Array(arr) => {
for child in arr {
if let Some(found) = find_quote_summary_store_in_value(child) {
return Some(found);
}
}
None
}
_ => None,
}
}
pub fn find_quote_summary_value_in_value(v: &Value) -> Option<&Value> {
match v {
Value::Object(map) => {
if let Some(qs) = map.get("quoteSummary") {
return Some(qs);
}
for child in map.values() {
if let Some(found) = find_quote_summary_value_in_value(child) {
return Some(found);
}
}
None
}
Value::Array(arr) => {
for child in arr {
if let Some(found) = find_quote_summary_value_in_value(child) {
return Some(found);
}
}
None
}
_ => None,
}
}
pub fn normalize_store_like(mut store_like: Value) -> Value {
if let Some(obj) = store_like.as_object_mut()
&& let Some(ap) = obj.remove("assetProfile")
{
// Normalize to what the rest of the code expects.
obj.insert("summaryProfile".to_string(), ap);
}
store_like
}
pub fn wrap_store_like(store_like: &Value) -> Result<String, crate::YfError> {
let store_json = serde_json::to_string(&store_like).map_err(crate::YfError::Json)?;
Ok(format!(
r#"{{"context":{{"dispatcher":{{"stores":{{"QuoteSummaryStore":{store_json}}}}}}}}}"#
))
}

View File

@@ -0,0 +1,86 @@
mod helpers;
mod strategies;
use helpers::truncate;
use strategies::{
try_generic_json_scripts, try_quote_summary_store_literal, try_root_app_main,
try_sveltekit_json,
};
pub fn extract_bootstrap_json(body: &str) -> Result<String, crate::YfError> {
let debug = std::env::var("YF_DEBUG").ok().as_deref() == Some("1");
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: starting, body.len()={}",
body.len()
);
}
/* Strategy A: legacy root.App.main = {...}; */
if debug {
eprintln!("YF_DEBUG [extract_bootstrap_json]: Strategy A (root.App.main)...");
}
if let Some(json_str) = try_root_app_main(body, debug) {
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: Strategy A hit; json.len={} preview=`{}`",
json_str.len(),
truncate(&json_str, 160)
);
}
return Ok(json_str);
}
/* Strategy B: literal "QuoteSummaryStore": { ... } object */
if debug {
eprintln!("YF_DEBUG [extract_bootstrap_json]: Strategy B (QuoteSummaryStore literal)...");
}
if let Some(wrapped) = try_quote_summary_store_literal(body, debug) {
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: Strategy B hit; wrapped.len={} preview=`{}`",
wrapped.len(),
truncate(&wrapped, 160)
);
}
return Ok(wrapped);
}
/* Strategy C: SvelteKit data-sveltekit-fetched blobs. */
if debug {
eprintln!("YF_DEBUG [extract_bootstrap_json]: Strategy C (SvelteKit fetched JSON)...");
}
if let Some(wrapped) = try_sveltekit_json(body, debug) {
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: Strategy C hit; wrapped.len={} preview=`{}`",
wrapped.len(),
truncate(&wrapped, 160)
);
}
return Ok(wrapped);
}
/* Strategy D: generic scan of all application/json scripts */
if debug {
eprintln!("YF_DEBUG [extract_bootstrap_json]: Strategy D (generic JSON scan)...");
}
if let Some(wrapped) = try_generic_json_scripts(body, debug) {
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: Strategy D hit; wrapped.len={} preview=`{}`",
wrapped.len(),
truncate(&wrapped, 160)
);
}
return Ok(wrapped);
}
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: All strategies exhausted; bootstrap not found."
);
}
Err(crate::YfError::MissingData("bootstrap not found".into()))
}

View File

@@ -0,0 +1,288 @@
use serde_json::Value;
use super::helpers::{
extract_store_like_from_quote_summary_value, find_quote_summary_store_in_value,
find_quote_summary_value_in_value, normalize_store_like, truncate, wrap_store_like,
};
use crate::profile::scrape::utils::{find_matching_brace, iter_json_scripts};
/// Strategy A: look for `root.App.main = {...};`
pub fn try_root_app_main(body: &str, debug: bool) -> Option<String> {
if let Some(start) = body.find("root.App.main") {
let after = &body[start..];
if let Some(eq) = after.find('=') {
let mut payload = &after[eq + 1..];
payload = payload.trim_start();
let end_script = payload.find("</script>").unwrap_or(payload.len());
let segment = &payload[..end_script];
if let Some(semi) = segment.rfind(';') {
let json_str = segment[..semi].trim();
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: Strategy A preview=`{}`",
truncate(json_str, 160)
);
}
return Some(json_str.to_string());
}
}
}
None
}
/// Strategy B: find literal `"QuoteSummaryStore" : { ... }` and wrap it.
pub fn try_quote_summary_store_literal(body: &str, debug: bool) -> Option<String> {
let key = "\"QuoteSummaryStore\"";
if let Some(pos) = body.find(key) {
let after = &body[pos + key.len()..];
if let Some(brace_rel) = after.find('{') {
let obj_start = pos + key.len() + brace_rel;
if let Some(obj_end) = find_matching_brace(body, obj_start) {
let obj = &body[obj_start..=obj_end];
let wrapped = format!(
r#"{{"context":{{"dispatcher":{{"stores":{{"QuoteSummaryStore":{obj}}}}}}}}}"#
);
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: Strategy B obj.len={} preview=`{}`",
obj.len(),
truncate(obj, 160)
);
}
return Some(wrapped);
} else if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: Strategy B found start but failed to match closing brace."
);
}
}
}
None
}
/// Strategy C: scan `SvelteKit` `data-sveltekit-fetched` JSON blobs.
#[allow(clippy::too_many_lines)]
pub fn try_sveltekit_json(body: &str, debug: bool) -> Option<String> {
let scripts = iter_json_scripts(body);
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: Strategy C inspecting {} JSON scripts...",
scripts.len()
);
}
for (i, (tag_attrs, inner_json)) in scripts.iter().enumerate() {
let is_svelte = tag_attrs.contains("data-sveltekit-fetched");
if !is_svelte {
continue;
}
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: C[{}] attrs=`{}` inner.len={} preview=`{}`",
i,
truncate(tag_attrs, 160),
inner_json.len(),
truncate(inner_json, 120)
);
}
// Case C1: array of objects having nodes[].data
if let Ok(outer_array) = serde_json::from_str::<Vec<Value>>(inner_json) {
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: C[{}] parsed as ARRAY (len={})",
i,
outer_array.len()
);
}
for (ai, outer_obj) in outer_array.into_iter().enumerate() {
if let Some(nodes) = outer_obj.get("nodes").and_then(|n| n.as_array()) {
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: C[{}][{}] nodes.len={}",
i,
ai,
nodes.len()
);
}
for (ni, node) in nodes.iter().enumerate() {
if let Some(data) = node.get("data")
&& let Some(store_like) =
extract_store_like_from_quote_summary_value(data)
&& let Ok(wrapped) = wrap_store_like(&store_like)
{
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: C[{}][{}] SUCCESS via nodes[{}].data -> wrapped.len={}",
i,
ai,
ni,
wrapped.len()
);
}
return Some(wrapped);
}
}
}
}
}
// Case C2: object with "body" either JSON string or inline JSON
let parsed_obj = match serde_json::from_str::<Value>(inner_json) {
Ok(v @ Value::Object(_)) => Some(v),
Ok(_) => None,
Err(e) => {
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: C[{i}] parse-as-OBJECT failed: {e}"
);
}
None
}
};
if let Some(mut outer_obj) = parsed_obj {
let body_val_opt = { outer_obj.get_mut("body").map(serde_json::Value::take) };
if let Some(body_val) = body_val_opt {
let payload_opt = match body_val {
Value::String(s) => serde_json::from_str::<Value>(&s).ok(),
Value::Object(_) | Value::Array(_) => Some(body_val),
_ => None,
};
if let Some(payload) = payload_opt {
if let Some(qss) = find_quote_summary_store_in_value(&payload) {
let store_like = normalize_store_like(qss.clone());
if let Ok(wrapped) = wrap_store_like(&store_like) {
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: C[{}] SUCCESS via QuoteSummaryStore path; wrapped.len={}",
i,
wrapped.len()
);
}
return Some(wrapped);
}
}
if let Some(qs_val) = find_quote_summary_value_in_value(&payload)
&& let Some(store_like) =
extract_store_like_from_quote_summary_value(qs_val)
&& let Ok(wrapped) = wrap_store_like(&store_like)
{
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: C[{}] SUCCESS via quoteSummary->result; wrapped.len={}",
i,
wrapped.len()
);
}
return Some(wrapped);
}
}
}
}
}
None
}
/// Strategy D: generic scan of *all* application/json scripts with multiple fallbacks.
pub fn try_generic_json_scripts(body: &str, debug: bool) -> Option<String> {
let scripts = iter_json_scripts(body);
for (i, (_attrs, inner_json)) in scripts.iter().enumerate() {
let val = match serde_json::from_str::<Value>(inner_json) {
Ok(v) => v,
Err(e) => {
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: D[{}] parse failed: {} (preview=`{}`)",
i,
e,
if inner_json.len() > 120 {
&inner_json[..120]
} else {
inner_json
}
);
}
continue;
}
};
// D1: direct QuoteSummaryStore object
if let Some(qss) = find_quote_summary_store_in_value(&val) {
let store_like = normalize_store_like(qss.clone());
if let Ok(wrapped) = wrap_store_like(&store_like) {
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: D[{}] SUCCESS via QuoteSummaryStore; wrapped.len={}",
i,
wrapped.len()
);
}
return Some(wrapped);
}
}
// D2: quoteSummary -> result[0]
if let Some(qs_val) = find_quote_summary_value_in_value(&val)
&& let Some(store_like) = extract_store_like_from_quote_summary_value(qs_val)
&& let Ok(wrapped) = wrap_store_like(&store_like)
{
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: D[{}] SUCCESS via quoteSummary->result; wrapped.len={}",
i,
wrapped.len()
);
}
return Some(wrapped);
}
// D3: value has a "body" which itself is a JSON string/object/array
if let Some(body_val) = val.get("body") {
let payload_opt = match body_val {
Value::String(s) => serde_json::from_str::<Value>(s).ok(),
Value::Object(_) | Value::Array(_) => Some(body_val.clone()),
_ => None,
};
if let Some(payload) = payload_opt {
if let Some(qss) = find_quote_summary_store_in_value(&payload) {
let store_like = normalize_store_like(qss.clone());
if let Ok(wrapped) = wrap_store_like(&store_like) {
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: D[{}] SUCCESS via body->QuoteSummaryStore; wrapped.len={}",
i,
wrapped.len()
);
}
return Some(wrapped);
}
}
if let Some(qs_val) = find_quote_summary_value_in_value(&payload)
&& let Some(store_like) = extract_store_like_from_quote_summary_value(qs_val)
&& let Ok(wrapped) = wrap_store_like(&store_like)
{
if debug {
eprintln!(
"YF_DEBUG [extract_bootstrap_json]: D[{}] SUCCESS via body->quoteSummary->result; wrapped.len={}",
i,
wrapped.len()
);
}
return Some(wrapped);
}
}
}
}
None
}

View File

@@ -0,0 +1,225 @@
//! Scrape the Yahoo quote HTML and extract profile data.
use crate::{YfClient, YfError};
use paft::domain::Isin;
use serde::Deserialize;
use super::{Address, Company, Fund, Profile};
#[cfg(feature = "debug-dumps")]
use crate::profile::debug::{debug_dump_extracted_json, debug_dump_html};
pub mod extract;
pub mod utils;
use extract::extract_bootstrap_json;
#[allow(clippy::too_many_lines)]
pub async fn load_from_scrape(client: &YfClient, symbol: &str) -> Result<Profile, YfError> {
let debug = std::env::var("YF_DEBUG").ok().as_deref() == Some("1");
let mut url = client.base_quote().join(symbol)?;
{
let mut qp = url.query_pairs_mut();
qp.append_pair("p", symbol);
}
let body = if let Some(body) = client.cache_get(&url).await {
body
} else {
let req = client.http().get(url.clone());
let quote_page_resp = client.send_with_retry(req, None).await?;
if !quote_page_resp.status().is_success() {
return Err(YfError::Status {
status: quote_page_resp.status().as_u16(),
url: url.to_string(),
});
}
let body =
crate::core::net::get_text(quote_page_resp, "profile_html", symbol, "html").await?;
client.cache_put(&url, &body, None).await;
body
};
#[cfg(feature = "debug-dumps")]
{
let _ = debug_dump_html(symbol, &body);
}
let json_str = extract_bootstrap_json(&body)?;
#[cfg(feature = "debug-dumps")]
{
let _ = debug_dump_extracted_json(symbol, &json_str);
}
let boot: Bootstrap = serde_json::from_str(&json_str).map_err(YfError::Json)?;
let store = boot.context.dispatcher.stores.quote_summary_store;
let name = store
.quote_type
.as_ref()
.and_then(|qt| qt.long_name.clone().or_else(|| qt.short_name.clone()))
.or_else(|| {
store
.price
.as_ref()
.and_then(|p| p.long_name.clone().or_else(|| p.short_name.clone()))
})
.unwrap_or_else(|| symbol.to_string());
let inferred_kind = if store.fund_profile.is_some() {
Some("ETF")
} else if store.summary_profile.is_some() {
Some("EQUITY")
} else {
None
};
let kind = store
.quote_type
.as_ref()
.and_then(|qt| qt.kind.as_deref())
.or(inferred_kind)
.unwrap_or("");
if debug {
eprintln!(
"YF_DEBUG [load_from_scrape]: resolved kind=`{}`, name=`{}` (quote_type_present={}, price_present={}, has_summary_profile={}, has_fund_profile={})",
kind,
name,
store.quote_type.is_some(),
store.price.is_some(),
store.summary_profile.is_some(),
store.fund_profile.is_some()
);
}
match kind {
"EQUITY" => {
let sp = store
.summary_profile
.ok_or_else(|| YfError::MissingData("summaryProfile missing".into()))?;
let address = Address {
street1: sp.address1,
street2: sp.address2,
city: sp.city,
state: sp.state,
country: sp.country,
zip: sp.zip,
};
// Validate ISIN if present, return None if invalid
let validated_isin = sp.isin.and_then(|isin_str| Isin::new(&isin_str).ok());
Ok(Profile::Company(Company {
name,
sector: sp.sector,
industry: sp.industry,
website: sp.website,
summary: sp.long_business_summary,
address: Some(address),
isin: validated_isin,
}))
}
"ETF" => {
let fp = store
.fund_profile
.ok_or_else(|| YfError::MissingData("fundProfile missing".into()))?;
// Validate ISIN if present, return None if invalid
let validated_isin = fp.isin.and_then(|isin_str| Isin::new(&isin_str).ok());
Ok(Profile::Fund(Fund {
name,
family: fp.family,
kind: crate::core::conversions::string_to_fund_kind(fp.legal_type)
.unwrap_or_default(),
isin: validated_isin,
}))
}
other => Err(YfError::InvalidParams(format!(
"unsupported or unknown quoteType: {other}"
))),
}
}
/* --------- Minimal serde mapping for the bootstrap JSON --------- */
#[derive(Deserialize)]
struct Bootstrap {
context: Ctx,
}
#[derive(Deserialize)]
struct Ctx {
dispatcher: Dispatch,
}
#[derive(Deserialize)]
struct Dispatch {
stores: Stores,
}
#[derive(Deserialize)]
struct Stores {
#[serde(rename = "QuoteSummaryStore")]
quote_summary_store: QuoteSummaryStore,
}
#[derive(Deserialize)]
struct QuoteSummaryStore {
#[serde(rename = "quoteType")]
quote_type: Option<QuoteTypeNode>,
#[serde(default)]
price: Option<PriceNode>,
#[serde(rename = "summaryProfile")]
summary_profile: Option<SummaryProfileNode>,
#[serde(rename = "fundProfile")]
fund_profile: Option<FundProfileNode>,
}
#[derive(Deserialize)]
struct QuoteTypeNode {
#[serde(rename = "quoteType")]
kind: Option<String>,
#[serde(rename = "longName")]
long_name: Option<String>,
#[serde(rename = "shortName")]
short_name: Option<String>,
}
#[derive(Deserialize)]
struct PriceNode {
#[serde(rename = "longName")]
long_name: Option<String>,
#[serde(rename = "shortName")]
short_name: Option<String>,
}
#[derive(Deserialize)]
struct SummaryProfileNode {
address1: Option<String>,
address2: Option<String>,
city: Option<String>,
state: Option<String>,
country: Option<String>,
zip: Option<String>,
sector: Option<String>,
industry: Option<String>,
#[serde(rename = "longBusinessSummary")]
long_business_summary: Option<String>,
website: Option<String>,
isin: Option<String>,
}
#[derive(Deserialize)]
struct FundProfileNode {
#[serde(rename = "legalType")]
legal_type: Option<String>,
family: Option<String>,
isin: Option<String>,
}

View File

@@ -0,0 +1,136 @@
pub fn iter_json_scripts(html: &str) -> Vec<(&str, &str)> {
let debug = std::env::var("YF_DEBUG").ok().as_deref() == Some("1");
if debug {
eprintln!(
"YF_DEBUG [iter_json_scripts]: html.len()={}; scanning for <script> blocks...",
html.len()
);
}
let mut res = Vec::new();
let mut pos = 0usize;
let mut total_scripts = 0usize;
let mut total_json_scripts = 0usize;
let mut total_svelte_fetched = 0usize;
while let Some(si) = html[pos..].find("<script") {
let si = pos + si;
total_scripts += 1;
let open_end = match html[si..].find('>') {
Some(x) => si + x,
None => break,
};
let tag_open = &html[si..=open_end];
let is_json = tag_open.contains("type=\"application/json\"");
if is_json {
total_json_scripts += 1;
if tag_open.contains("data-sveltekit-fetched") {
total_svelte_fetched += 1;
}
}
let close = match html[open_end + 1..].find("</script>") {
Some(x) => open_end + 1 + x,
None => break,
};
let inner = &html[open_end + 1..close];
if is_json {
res.push((tag_open, inner));
}
pos = close + "</script>".len();
}
if debug {
eprintln!(
"YF_DEBUG [iter_json_scripts]: total_scripts={total_scripts}, total_json_scripts={total_json_scripts}, svelte_fetched={total_svelte_fetched}"
);
if let Some((attrs, body)) = res.first() {
let a = if attrs.len() > 180 {
&attrs[..180]
} else {
attrs
};
let b = if body.len() > 120 { &body[..120] } else { body };
eprintln!(
"YF_DEBUG [iter_json_scripts]: first JSON script attrs[trunc]=`{a}` body[trunc]=`{b}`"
);
}
}
res
}
/// Exposed for debug helpers as well.
pub fn find_matching_brace(s: &str, start: usize) -> Option<usize> {
let bytes = s.as_bytes();
let i = start;
if bytes.get(i).copied()? != b'{' {
return None;
}
let mut depth = 0usize;
let mut in_str = false;
let mut j = i;
while j < bytes.len() {
let c = bytes[j];
if in_str {
if c == b'\\' {
j += 2;
continue;
} else if c == b'"' {
in_str = false;
}
j += 1;
continue;
}
match c {
b'"' => {
in_str = true;
}
b'{' => {
depth += 1;
}
b'}' => {
depth -= 1;
if depth == 0 {
return Some(j);
}
}
_ => {}
}
j += 1;
}
None
}
/// Exposed for debug helpers as well.
#[cfg(feature = "debug-dumps")]
pub fn parse_jsonish_string(s: &str) -> Option<serde_json::Value> {
let t = s.trim();
if t.starts_with('{') || t.starts_with('[') {
serde_json::from_str::<serde_json::Value>(t).ok()
} else {
None
}
}
#[cfg(feature = "debug-dumps")]
pub fn escape_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
_ => out.push(ch),
}
}
out
}

View File

@@ -0,0 +1,98 @@
use crate::core::client::CacheMode;
use crate::core::client::RetryConfig;
use crate::core::{Quote, YfClient, YfError, quotes as core_quotes};
/// Fetches quotes for multiple symbols.
///
/// # Errors
///
/// Returns `YfError` if the network request fails, the response cannot be parsed,
/// or the data for the symbols is not available.
pub async fn quotes<I, S>(client: &YfClient, symbols: I) -> Result<Vec<Quote>, YfError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
QuotesBuilder::new(client.clone())
.symbols(symbols)
.fetch()
.await
}
/// A builder for fetching quotes for one or more symbols.
pub struct QuotesBuilder {
client: YfClient,
symbols: Vec<String>,
cache_mode: CacheMode,
retry_override: Option<RetryConfig>,
}
impl QuotesBuilder {
/// Creates a new `QuotesBuilder`.
#[must_use]
pub const fn new(client: YfClient) -> Self {
Self {
client,
symbols: Vec::new(),
cache_mode: CacheMode::Use,
retry_override: None,
}
}
/// Sets the cache mode for this specific API call.
#[must_use]
pub const fn cache_mode(mut self, mode: CacheMode) -> Self {
self.cache_mode = mode;
self
}
/// Overrides the default retry policy for this specific API call.
#[must_use]
pub fn retry_policy(mut self, cfg: Option<RetryConfig>) -> Self {
self.retry_override = cfg;
self
}
/// Replaces the current list of symbols with a new list.
#[must_use]
pub fn symbols<I, S>(mut self, syms: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.symbols = syms.into_iter().map(Into::into).collect();
self
}
/// Adds a single symbol to the list.
#[must_use]
pub fn add_symbol(mut self, sym: impl Into<String>) -> Self {
self.symbols.push(sym.into());
self
}
/// Fetches the quotes for the configured symbols.
///
/// # Errors
///
/// Returns `YfError` if no symbols were provided, the network request fails,
/// the response cannot be parsed, or data for the symbols is not available.
pub async fn fetch(self) -> Result<Vec<crate::core::Quote>, crate::core::YfError> {
if self.symbols.is_empty() {
return Err(crate::core::YfError::InvalidParams(
"symbols list cannot be empty".into(),
));
}
let symbol_slices: Vec<&str> = self.symbols.iter().map(AsRef::as_ref).collect();
let results = core_quotes::fetch_v7_quotes(
&self.client,
&symbol_slices,
self.cache_mode,
self.retry_override.as_ref(),
)
.await?;
Ok(results.into_iter().map(Into::into).collect())
}
}

View File

@@ -0,0 +1,335 @@
use paft::domain::{AssetKind, Exchange};
use paft::market::responses::search::{SearchResponse, SearchResult};
use serde::Deserialize;
use url::Url;
use crate::core::client::CacheMode;
use crate::core::client::RetryConfig;
use crate::{YfClient, YfError};
fn parse_search_body(body: &str) -> Result<SearchResponse, YfError> {
let env: V1SearchEnvelope = serde_json::from_str(body).map_err(YfError::Json)?;
let quotes = env.quotes.unwrap_or_default();
let results = quotes
.into_iter()
.filter_map(|q| {
let sym = q.symbol.unwrap_or_default();
paft::domain::Symbol::try_from(sym)
.ok()
.map(|symbol| SearchResult {
symbol,
name: q.shortname.or(q.longname),
exchange: q.exchange.and_then(|s| s.parse::<Exchange>().ok()),
kind: q
.quote_type
.and_then(|s| s.parse::<AssetKind>().ok())
.unwrap_or_default(),
})
})
.collect();
Ok(SearchResponse { results })
}
/* ---------------- Public API ---------------- */
/// Searches for symbols matching a query.
///
/// # Errors
///
/// Returns `YfError` if the network request fails or the response cannot be parsed.
pub async fn search(client: &YfClient, query: &str) -> Result<SearchResponse, YfError> {
SearchBuilder::new(client, query).fetch().await
}
/// A builder for searching for tickers and other assets on Yahoo Finance.
#[derive(Debug)]
pub struct SearchBuilder {
client: YfClient,
base: Url,
query: String,
quotes_count: Option<u32>,
news_count: Option<u32>,
lists_count: Option<u32>,
lang: Option<String>,
region: Option<String>,
cache_mode: CacheMode,
retry_override: Option<RetryConfig>,
}
impl SearchBuilder {
/// Creates a new `SearchBuilder` for a given search query.
///
/// # Panics
///
/// This function will panic if the hardcoded `DEFAULT_BASE_SEARCH_V1` constant
/// is not a valid URL. This indicates a bug within the crate itself.
pub fn new(client: &YfClient, query: impl Into<String>) -> Self {
Self {
client: client.clone(),
base: Url::parse(DEFAULT_BASE_SEARCH_V1).unwrap(),
query: query.into(),
quotes_count: Some(10),
news_count: Some(0),
lists_count: Some(0),
lang: None,
region: None,
cache_mode: CacheMode::Use,
retry_override: None,
}
}
/// Sets the cache mode for this specific API call.
#[must_use]
pub const fn cache_mode(mut self, mode: CacheMode) -> Self {
self.cache_mode = mode;
self
}
/// Overrides the default retry policy for this specific API call.
#[must_use]
pub fn retry_policy(mut self, cfg: Option<RetryConfig>) -> Self {
self.retry_override = cfg;
self
}
/// (For testing) Overrides the base URL for the search API.
#[must_use]
pub fn search_base(mut self, base: Url) -> Self {
self.base = base;
self
}
/// Sets the maximum number of quote results to return.
#[must_use]
pub const fn quotes_count(mut self, n: u32) -> Self {
self.quotes_count = Some(n);
self
}
/// Sets the maximum number of news results to return.
#[must_use]
pub const fn news_count(mut self, n: u32) -> Self {
self.news_count = Some(n);
self
}
/// Sets the maximum number of screener list results to return.
#[must_use]
pub const fn lists_count(mut self, n: u32) -> Self {
self.lists_count = Some(n);
self
}
/// Sets the language for the search results.
#[must_use]
pub fn lang(mut self, s: impl Into<String>) -> Self {
self.lang = Some(s.into());
self
}
/// Sets the region for the search results.
#[must_use]
pub fn region(mut self, s: impl Into<String>) -> Self {
self.region = Some(s.into());
self
}
/// Returns the configured language parameter, if any.
#[must_use]
pub fn lang_ref(&self) -> Option<&str> {
self.lang.as_deref()
}
/// Returns the configured region parameter, if any.
#[must_use]
pub fn region_ref(&self) -> Option<&str> {
self.region.as_deref()
}
/// Executes the search request.
///
/// # Errors
///
/// This method will return an error if the network request fails, the API returns a
/// non-successful status code, or the response body cannot be parsed as a valid search result.
#[allow(clippy::too_many_lines)]
pub async fn fetch(self) -> Result<SearchResponse, crate::core::YfError> {
let mut url = self.base.clone();
Self::append_query_params(
&mut url,
&self.query,
self.quotes_count,
self.news_count,
self.lists_count,
self.lang.as_deref(),
self.region.as_deref(),
);
if self.cache_mode == CacheMode::Use
&& let Some(body) = self.client.cache_get(&url).await
{
return parse_search_body(&body);
}
let http = self.client.http().clone();
let mut resp = self
.client
.send_with_retry(
http.get(url.clone()).header("accept", "application/json"),
self.retry_override.as_ref(),
)
.await?;
if !resp.status().is_success() {
let code = resp.status().as_u16();
if code == 401 || code == 403 {
self.client.ensure_credentials().await?;
let crumb = self
.client
.crumb()
.await
.ok_or_else(|| crate::core::YfError::Auth("Crumb is not set".into()))?;
let mut url2 = self.base.clone();
Self::append_query_params(
&mut url2,
&self.query,
self.quotes_count,
self.news_count,
self.lists_count,
self.lang.as_deref(),
self.region.as_deref(),
);
url2.query_pairs_mut().append_pair("crumb", &crumb);
resp = self
.client
.send_with_retry(
http.get(url2.clone()).header("accept", "application/json"),
self.retry_override.as_ref(),
)
.await?;
if !resp.status().is_success() {
let code = resp.status().as_u16();
let url_s = url2.to_string();
return Err(match code {
404 => crate::core::YfError::NotFound { url: url_s },
429 => crate::core::YfError::RateLimited { url: url_s },
500..=599 => crate::core::YfError::ServerError {
status: code,
url: url_s,
},
_ => crate::core::YfError::Status {
status: code,
url: url_s,
},
});
}
let body =
crate::core::net::get_text(resp, "search_v1", &self.query, "json").await?;
if self.cache_mode != CacheMode::Bypass {
self.client.cache_put(&url2, &body, None).await;
}
return parse_search_body(&body);
}
let url_s = url.to_string();
return Err(match code {
404 => crate::core::YfError::NotFound { url: url_s },
429 => crate::core::YfError::RateLimited { url: url_s },
500..=599 => crate::core::YfError::ServerError {
status: code,
url: url_s,
},
_ => crate::core::YfError::Status {
status: code,
url: url_s,
},
});
}
let body = crate::core::net::get_text(resp, "search_v1", &self.query, "json").await?;
if self.cache_mode != CacheMode::Bypass {
self.client.cache_put(&url, &body, None).await;
}
parse_search_body(&body)
}
fn append_query_params(
url: &mut Url,
query: &str,
quotes_count: Option<u32>,
news_count: Option<u32>,
lists_count: Option<u32>,
lang: Option<&str>,
region: Option<&str>,
) {
let mut qp = url.query_pairs_mut();
qp.append_pair("q", query);
if let Some(n) = quotes_count {
qp.append_pair("quotesCount", &n.to_string());
}
if let Some(n) = news_count {
qp.append_pair("newsCount", &n.to_string());
}
if let Some(n) = lists_count {
qp.append_pair("listsCount", &n.to_string());
}
if let Some(l) = lang {
qp.append_pair("lang", l);
}
if let Some(r) = region {
qp.append_pair("region", r);
}
}
}
/* ---------------- Types returned by this module ---------------- */
// Local types removed in favor of paft::market::responses::search::{SearchResponse, SearchResult}
const DEFAULT_BASE_SEARCH_V1: &str = "https://query2.finance.yahoo.com/v1/finance/search";
/* ------------- Minimal serde mapping of /v1/finance/search ------------- */
#[derive(Deserialize)]
struct V1SearchEnvelope {
#[allow(dead_code)]
explains: Option<serde_json::Value>,
#[allow(dead_code)]
count: Option<i64>,
quotes: Option<Vec<V1SearchQuote>>,
#[allow(dead_code)]
news: Option<serde_json::Value>,
#[allow(dead_code)]
nav: Option<serde_json::Value>,
#[allow(dead_code)]
lists: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct V1SearchQuote {
#[serde(default)]
symbol: Option<String>,
#[serde(default)]
shortname: Option<String>,
#[serde(default)]
longname: Option<String>,
#[serde(rename = "quoteType")]
#[serde(default)]
quote_type: Option<String>,
#[serde(default)]
exchange: Option<String>,
#[allow(dead_code)]
#[serde(rename = "exchDisp")]
#[serde(default)]
exch_disp: Option<String>,
#[allow(dead_code)]
#[serde(rename = "typeDisp")]
#[serde(default)]
type_disp: Option<String>,
}

View File

@@ -0,0 +1,680 @@
use base64::{Engine as _, engine::general_purpose};
use chrono::{DateTime, Utc};
use futures_util::{SinkExt, StreamExt};
use prost::Message;
use serde::Serialize;
use std::time::Duration;
use tokio::{
select,
sync::{mpsc, oneshot},
task::JoinHandle,
};
use tokio_tungstenite::{
connect_async,
tungstenite::{
handshake::client::{Request, generate_key},
protocol::Message as WsMessage,
},
};
use crate::{
YfClient, YfError,
core::client::{CacheMode, RetryConfig},
core::conversions::f64_to_money_with_currency_str,
};
use paft::market::quote::QuoteUpdate;
// Yahoo Finance websocket wire types (generated from `yaticker.proto`).
mod wire_ws {
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct PricingData {
#[prost(string, tag = "1")]
pub id: String,
#[prost(float, tag = "2")]
pub price: f32,
#[prost(sint64, tag = "3")]
pub time: i64,
#[prost(string, tag = "4")]
pub currency: String,
#[prost(string, tag = "5")]
pub exchange: String,
#[prost(int32, tag = "6")]
pub quote_type: i32,
#[prost(int32, tag = "7")]
pub market_hours: i32,
#[prost(float, tag = "8")]
pub change_percent: f32,
#[prost(sint64, tag = "9")]
pub day_volume: i64,
#[prost(float, tag = "10")]
pub day_high: f32,
#[prost(float, tag = "11")]
pub day_low: f32,
#[prost(float, tag = "12")]
pub change: f32,
#[prost(string, tag = "13")]
pub short_name: String,
#[prost(sint64, tag = "14")]
pub expire_date: i64,
#[prost(float, tag = "15")]
pub open_price: f32,
#[prost(float, tag = "16")]
pub previous_close: f32,
#[prost(float, tag = "17")]
pub strike_price: f32,
#[prost(string, tag = "18")]
pub underlying_symbol: String,
#[prost(sint64, tag = "19")]
pub open_interest: i64,
#[prost(sint64, tag = "20")]
pub options_type: i64,
#[prost(sint64, tag = "21")]
pub mini_option: i64,
#[prost(sint64, tag = "22")]
pub last_size: i64,
#[prost(float, tag = "23")]
pub bid: f32,
#[prost(sint64, tag = "24")]
pub bid_size: i64,
#[prost(float, tag = "25")]
pub ask: f32,
#[prost(sint64, tag = "26")]
pub ask_size: i64,
#[prost(sint64, tag = "27")]
pub price_hint: i64,
#[prost(sint64, tag = "28")]
pub vol_24hr: i64,
#[prost(sint64, tag = "29")]
pub vol_all_currencies: i64,
#[prost(string, tag = "30")]
pub from_currency: String,
#[prost(string, tag = "31")]
pub last_market: String,
#[prost(double, tag = "32")]
pub circulating_supply: f64,
#[prost(double, tag = "33")]
pub market_cap: f64,
}
}
// Use paft's QuoteUpdate which carries Money and DateTime<Utc>
// pub use paft::market::quote::QuoteUpdate; (imported above)
// Streaming quotes
//
// Volume semantics:
// - Yahoo sends cumulative intraday volume (`day_volume`). This crate converts it into
// per-update deltas when producing `QuoteUpdate`s.
// - For each symbol, the first observed tick (or after a detected reset where current < last)
// has `volume = None` (no delta yet). Subsequent ticks set `volume = Some(current - last)`.
// - This applies to both WebSocket and Polling streams. The JSON/base64 decoder helper
// (`decode_and_map_message`) is stateless and always returns `volume = None`.
//
// Implications:
// - If you need cumulative volume, accumulate the per-update `volume` values yourself or
// use the `day_volume` from quote endpoints.
// - Expect `None` for the first message per symbol and after rollovers.
/// Configuration for a polling-based quote stream.
#[derive(Debug, Clone)]
pub struct StreamConfig {
/// The interval at which to poll for new quote data.
pub interval: Duration,
/// If `true`, only emit updates when the price has changed.
pub diff_only: bool,
}
impl Default for StreamConfig {
fn default() -> Self {
Self {
interval: Duration::from_secs(1),
diff_only: true,
}
}
}
/// A handle to a running quote stream, used to stop it gracefully.
pub struct StreamHandle {
join: JoinHandle<()>,
stop_tx: Option<oneshot::Sender<()>>,
}
impl StreamHandle {
/// Stops the stream and waits for the background task to complete.
pub async fn stop(mut self) {
if let Some(tx) = self.stop_tx.take() {
let _ = tx.send(());
}
let _ = self.join.await;
}
/// Aborts the background task immediately.
pub fn abort(self) {
self.join.abort();
}
}
/// Defines the transport method for streaming quote data.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StreamMethod {
/// Attempt to use `WebSockets`, and fall back to polling if the connection fails. (Default)
#[default]
WebsocketWithFallback,
/// Use `WebSockets` only. This is the preferred method for real-time data. The stream will fail if a WebSocket connection cannot be established.
Websocket,
/// Use polling over HTTP. This is a less efficient fallback option.
Polling,
}
/// Builds and starts a real-time quote stream.
pub struct StreamBuilder {
client: YfClient,
symbols: Vec<String>,
cfg: StreamConfig,
method: StreamMethod,
cache_mode: CacheMode,
retry_override: Option<RetryConfig>,
}
impl StreamBuilder {
/// Creates a new `StreamBuilder`.
#[must_use]
pub fn new(client: &YfClient) -> Self {
Self {
client: client.clone(),
symbols: Vec::new(),
cfg: StreamConfig::default(),
method: StreamMethod::default(),
cache_mode: CacheMode::Use,
retry_override: None,
}
}
/// Sets the cache mode for this specific API call (only affects polling mode).
#[must_use]
pub const fn cache_mode(mut self, mode: CacheMode) -> Self {
self.cache_mode = mode;
self
}
/// Overrides the default retry policy for this specific API call (only affects polling mode).
#[must_use]
pub fn retry_policy(mut self, cfg: Option<RetryConfig>) -> Self {
self.retry_override = cfg;
self
}
/// Sets the symbols to stream.
#[must_use]
pub fn symbols<I, S>(mut self, syms: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.symbols = syms.into_iter().map(std::convert::Into::into).collect();
self
}
/// Adds a single symbol to the stream.
#[must_use]
pub fn add_symbol(mut self, sym: impl Into<String>) -> Self {
self.symbols.push(sym.into());
self
}
/// Sets the streaming transport method.
#[must_use]
pub const fn method(mut self, method: StreamMethod) -> Self {
self.method = method;
self
}
/// Sets the polling interval. (Only used for `Polling` and `WebsocketWithFallback` methods).
#[must_use]
pub const fn interval(mut self, dur: Duration) -> Self {
self.cfg.interval = dur;
self
}
/// If `true`, only emit updates when the price changes. (Only used for `Polling` method).
#[must_use]
pub const fn diff_only(mut self, yes: bool) -> Self {
self.cfg.diff_only = yes;
self
}
/// Starts the stream, returning a handle to control it and a channel receiver for quote updates.
///
/// # Errors
///
/// This method will return an error if no symbols have been added to the builder.
pub fn start(
self,
) -> Result<(StreamHandle, tokio::sync::mpsc::Receiver<QuoteUpdate>), crate::core::YfError>
{
if self.symbols.is_empty() {
return Err(crate::core::YfError::InvalidParams(
"symbols list cannot be empty".into(),
));
}
let (tx, rx) = tokio::sync::mpsc::channel::<QuoteUpdate>(1024);
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>();
let join = tokio::spawn({
let client = self.client;
let symbols = self.symbols.clone();
let cfg = self.cfg.clone();
let mut stop_rx = stop_rx;
// NEW:
let cache_mode = self.cache_mode;
let retry_override = self.retry_override.clone();
async move {
match self.method {
StreamMethod::Websocket => {
if let Err(e) =
run_websocket_stream(&client, symbols, tx, &mut stop_rx).await
&& std::env::var("YF_DEBUG").ok().as_deref() == Some("1")
{
eprintln!("YF_DEBUG(stream): websocket stream failed: {e}");
}
}
StreamMethod::WebsocketWithFallback => {
if let Err(e) =
run_websocket_stream(&client, symbols.clone(), tx.clone(), &mut stop_rx)
.await
{
if std::env::var("YF_DEBUG").ok().as_deref() == Some("1") {
eprintln!(
"YF_DEBUG(stream): websocket failed ({e}), falling back to polling."
);
}
run_polling_stream(
client,
symbols,
cfg,
tx,
&mut stop_rx,
cache_mode,
retry_override.as_ref(),
)
.await;
}
}
StreamMethod::Polling => {
run_polling_stream(
client,
symbols,
cfg,
tx,
&mut stop_rx,
cache_mode,
retry_override.as_ref(),
)
.await;
}
}
}
});
Ok((
StreamHandle {
join,
stop_tx: Some(stop_tx),
},
rx,
))
}
}
#[derive(Serialize)]
struct WsSubscribe<'a> {
subscribe: &'a [String],
}
#[allow(clippy::too_many_lines)]
async fn run_websocket_stream(
client: &YfClient,
symbols: Vec<String>,
tx: mpsc::Sender<QuoteUpdate>,
stop_rx: &mut oneshot::Receiver<()>,
) -> Result<(), YfError> {
let base = client.base_stream();
let host = base
.host_str()
.ok_or_else(|| YfError::InvalidParams("URL has no host".into()))?;
let request = Request::builder()
.uri(base.as_str())
.header("Host", host)
.header("Origin", "https://finance.yahoo.com")
.header("User-Agent", client.user_agent())
.header("Upgrade", "websocket")
.header("Connection", "Upgrade")
.header("Sec-WebSocket-Key", generate_key())
.header("Sec-WebSocket-Version", "13")
.body(())
.map_err(|e| YfError::InvalidParams(format!("Failed to build websocket request: {e}")))?;
let (ws_stream, _) = connect_async(request).await?;
let (mut write, mut read) = ws_stream.split();
let sub_msg = serde_json::to_string(&WsSubscribe {
subscribe: &symbols,
})
.map_err(YfError::Json)?;
write.send(WsMessage::Text(sub_msg.into())).await?;
#[cfg(feature = "test-mode")]
let mut recorded = false;
let mut last_day_volume: std::collections::HashMap<String, u64> =
std::collections::HashMap::new();
let mut last_ts: std::collections::HashMap<String, DateTime<Utc>> =
std::collections::HashMap::new();
loop {
select! {
msg = read.next() => {
match msg {
Some(Ok(WsMessage::Text(text))) => {
#[cfg(feature = "test-mode")]
{
if !recorded && std::env::var("YF_RECORD").ok().as_deref() == Some("1") {
if let Err(e) = crate::core::fixtures::record_fixture("stream_ws", "MULTI", "b64", &text) {
eprintln!("YF_RECORD: failed to write stream fixture: {e}");
}
recorded = true;
}
}
match decode_ws_pricing(&text) {
Ok(ticker) => {
if let Some(update) = map_ws_pricing_to_update_with_delta(&ticker, &mut last_day_volume, &mut last_ts)
&& tx.send(update).await.is_err() { break; }
},
Err(e) => {
if std::env::var("YF_DEBUG").ok().as_deref() == Some("1") {
eprintln!("YF_DEBUG(stream): ws text decode error: {e}");
}
// Non-price frames (acks/heartbeats) may lack "message"; ignore.
}
}
}
Some(Ok(WsMessage::Binary(bin))) => {
// Try to interpret as UTF-8 JSON-wrapped base64 first
let handled = if let Ok(as_text) = std::str::from_utf8(&bin) {
if let Ok(ticker) = decode_ws_pricing(as_text) {
if let Some(update) = map_ws_pricing_to_update_with_delta(&ticker, &mut last_day_volume, &mut last_ts)
&& tx.send(update).await.is_err() { break; }
true
} else { false }
} else { false };
// If not handled, treat as raw protobuf bytes
if !handled {
match wire_ws::PricingData::decode(&*bin) {
Ok(ticker) => {
if let Some(update) = map_ws_pricing_to_update_with_delta(&ticker, &mut last_day_volume, &mut last_ts)
&& tx.send(update).await.is_err() { break; }
}
Err(e) => {
if std::env::var("YF_DEBUG").ok().as_deref() == Some("1") {
eprintln!("YF_DEBUG(stream): ws binary decode error: {e}");
}
}
}
}
}
Some(Ok(WsMessage::Ping(_) | WsMessage::Pong(_) | _)) => { /* catch-all for variants like Frame(_) */ }
Some(Err(e)) => return Err(e.into()),
None => break,
}
},
_ = &mut *stop_rx => {
break;
}
}
}
Ok(())
}
fn decode_ws_pricing(text: &str) -> Result<wire_ws::PricingData, YfError> {
let s = text.trim();
let b64_cow: std::borrow::Cow<str> = if s.starts_with('{') {
match serde_json::from_str::<serde_json::Value>(s) {
Ok(v) => {
let msg = v.get("message").and_then(|m| m.as_str()).ok_or_else(|| {
YfError::MissingData("ws json message missing 'message' field".into())
})?;
std::borrow::Cow::Owned(msg.to_string())
}
Err(_) => std::borrow::Cow::Borrowed(s),
}
} else {
std::borrow::Cow::Borrowed(s)
};
let decoded = general_purpose::STANDARD
.decode(b64_cow.as_ref())
.map_err(YfError::Base64)?;
let ticker = wire_ws::PricingData::decode(&*decoded)?;
Ok(ticker)
}
fn map_ws_pricing_to_update_with_delta(
ticker: &wire_ws::PricingData,
last_vol: &mut std::collections::HashMap<String, u64>,
last_ts: &mut std::collections::HashMap<String, DateTime<Utc>>,
) -> Option<QuoteUpdate> {
let currency_str = Some(ticker.currency.as_str());
let Ok(symbol) = paft::domain::Symbol::new(&ticker.id) else {
if std::env::var("YF_DEBUG").ok().as_deref() == Some("1") {
eprintln!(
"YF_DEBUG(stream): skipping ws update with invalid symbol: {}",
ticker.id
);
}
return None;
};
let Some(timestamp) = DateTime::from_timestamp_millis(ticker.time) else {
if std::env::var("YF_DEBUG").ok().as_deref() == Some("1") {
eprintln!(
"YF_DEBUG(stream): skipping ws update with invalid timestamp: {}",
ticker.time
);
}
return None;
};
// If out-of-order, emit but don't mutate state; volume=None
if let Some(prev_ts) = last_ts.get(&ticker.id)
&& timestamp < *prev_ts
{
return Some(QuoteUpdate {
symbol,
price: Some(f64_to_money_with_currency_str(
f64::from(ticker.price),
currency_str,
)),
previous_close: Some(f64_to_money_with_currency_str(
f64::from(ticker.previous_close),
currency_str,
)),
ts: timestamp,
volume: None,
});
}
let cur_vol = u64::try_from(ticker.day_volume).unwrap_or(0);
let prev_vol = last_vol.get(&ticker.id).copied();
let volume = match prev_vol {
Some(p) if cur_vol >= p => Some(cur_vol - p),
// First observation or reset forward in time → no delta
_ => None,
};
// Update state only for in-order ticks
last_ts.insert(ticker.id.clone(), timestamp);
last_vol.insert(ticker.id.clone(), cur_vol);
Some(QuoteUpdate {
symbol,
price: Some(f64_to_money_with_currency_str(
f64::from(ticker.price),
currency_str,
)),
previous_close: Some(f64_to_money_with_currency_str(
f64::from(ticker.previous_close),
currency_str,
)),
ts: timestamp,
volume,
})
}
/// Decodes a single base64-encoded protobuf message from the Yahoo Finance WebSocket stream.
#[doc(hidden)]
pub fn decode_and_map_message(text: &str) -> Result<QuoteUpdate, YfError> {
// Support both:
// 1) Raw base64 string
// 2) JSON wrapper: {"message":"<base64...>"} (Yahoo's current format)
let s = text.trim();
// Use Cow to avoid borrowing from a temporary JSON value
let b64_cow: std::borrow::Cow<str> = if s.starts_with('{') {
match serde_json::from_str::<serde_json::Value>(s) {
Ok(v) => {
let msg = v.get("message").and_then(|m| m.as_str()).ok_or_else(|| {
YfError::MissingData("ws json message missing 'message' field".into())
})?;
std::borrow::Cow::Owned(msg.to_string())
}
// If it's not valid JSON, treat the whole thing as raw base64
Err(_) => std::borrow::Cow::Borrowed(s),
}
} else {
std::borrow::Cow::Borrowed(s)
};
let decoded = general_purpose::STANDARD
.decode(b64_cow.as_ref())
.map_err(YfError::Base64)?;
let ticker = wire_ws::PricingData::decode(&*decoded)?;
let currency_str = Some(ticker.currency.as_str());
let symbol = paft::domain::Symbol::new(&ticker.id)
.map_err(|_| YfError::InvalidParams(format!("ws symbol invalid: {}", ticker.id)))?;
let Some(timestamp) = DateTime::from_timestamp_millis(ticker.time) else {
// Log the error and return an error from this function
if std::env::var("YF_DEBUG").ok().as_deref() == Some("1") {
eprintln!(
"YF_DEBUG(stream): received ws update with invalid timestamp millis: {}",
ticker.time
);
}
#[cfg(feature = "tracing")]
tracing::warn!(timestamp_millis = ticker.time, symbol = %ticker.id, "received ws update with invalid timestamp");
// Return an error instead of default
return Err(YfError::InvalidParams(format!(
"Invalid timestamp in stream message: {}",
ticker.time
)));
};
Ok(QuoteUpdate {
symbol,
price: Some(f64_to_money_with_currency_str(
f64::from(ticker.price),
currency_str,
)),
previous_close: Some(f64_to_money_with_currency_str(
f64::from(ticker.previous_close),
currency_str,
)),
ts: timestamp,
volume: None,
})
}
#[allow(clippy::too_many_arguments)]
async fn run_polling_stream(
client: crate::core::YfClient,
symbols: Vec<String>,
cfg: StreamConfig,
tx: tokio::sync::mpsc::Sender<QuoteUpdate>,
stop_rx: &mut tokio::sync::oneshot::Receiver<()>,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) {
let mut ticker = tokio::time::interval(cfg.interval);
let mut last_price: std::collections::HashMap<String, Option<f64>> =
std::collections::HashMap::new();
let mut last_day_volume: std::collections::HashMap<String, u64> =
std::collections::HashMap::new();
let symbol_slices: Vec<&str> = symbols.iter().map(AsRef::as_ref).collect();
loop {
tokio::select! {
_ = ticker.tick() => {
if tx.is_closed() { break; }
let ts: DateTime<Utc> = chrono::Utc::now();
match crate::core::quotes::fetch_v7_quotes(&client, &symbol_slices, cache_mode, retry_override).await {
Ok(quotes) => {
for q in quotes {
let sym_s = q.symbol.clone().unwrap_or_default();
let lp = q.regular_market_price.or(q.regular_market_previous_close);
// Track price changes when diff_only is enabled
let price_changed = if cfg.diff_only {
let prev = last_price.insert(sym_s.clone(), lp);
prev != Some(lp)
} else {
true
};
// Compute volume delta and detect changes, including resets (cur < prev)
let (vol_delta, vol_changed) = q.regular_market_volume.map_or((None, false), |cur| {
let prev = last_day_volume.insert(sym_s.clone(), cur);
match prev {
Some(p) if cur >= p => {
let d = cur - p;
(Some(d), d > 0)
}
Some(p) if cur < p => (None, true), // reset detected
_ => (None, false), // first observation; no delta
}
});
// With diff_only, emit if either price OR volume changed
if cfg.diff_only && !price_changed && !vol_changed {
continue;
}
let currency_str = q.currency.as_deref();
let Ok(symbol) = paft::domain::Symbol::new(&sym_s) else { continue };
if tx.send(QuoteUpdate {
symbol,
price: lp.map(|v| f64_to_money_with_currency_str(v, currency_str)),
previous_close: q.regular_market_previous_close.map(|v| f64_to_money_with_currency_str(v, currency_str)),
ts,
volume: vol_delta,
}).await.is_err() {
// Break outer loop if receiver is dropped
break;
}
}
}
Err(e) => {
if std::env::var("YF_DEBUG").ok().as_deref() == Some("1") {
eprintln!("YF_DEBUG(stream): fetch error: {e}");
}
}
}
if tx.is_closed() { break; }
}
_ = &mut *stop_rx => { break; }
}
}
}

View File

@@ -0,0 +1,38 @@
syntax = "proto3";
package Yaticker;
message PricingData {
string id = 1;
float price = 2;
sint64 time = 3;
string currency = 4;
string exchange = 5;
int32 quote_type = 6;
int32 market_hours = 7;
float change_percent = 8;
sint64 day_volume = 9;
float day_high = 10;
float day_low = 11;
float change = 12;
string short_name = 13;
sint64 expire_date = 14;
float open_price = 15;
float previous_close = 16;
float strike_price = 17;
string underlying_symbol = 18;
sint64 open_interest = 19;
sint64 options_type = 20;
sint64 mini_option = 21;
sint64 last_size = 22;
float bid = 23;
sint64 bid_size = 24;
float ask = 25;
sint64 ask_size = 26;
sint64 price_hint = 27;
sint64 vol_24hr = 28;
sint64 vol_all_currencies = 29;
string from_currency = 30;
string last_market = 31;
double circulating_supply = 32;
double market_cap = 33;
}

View File

@@ -0,0 +1,134 @@
use crate::{
YfClient, YfError, analysis,
core::client::{CacheMode, RetryConfig},
core::conversions::i64_to_datetime,
esg,
profile::Profile,
};
use paft::aggregates::Info;
/// Private helper to handle optional async results, logging errors in debug mode.
fn log_err_async<T>(res: Result<T, YfError>, name: &str, symbol: &str) -> Option<T> {
match res {
Ok(data) => Some(data),
Err(e) => {
if std::env::var("YF_DEBUG").ok().as_deref() == Some("1") {
eprintln!("YF_DEBUG(info): failed to fetch '{name}' for {symbol}: {e}");
}
None
}
}
}
pub(super) async fn fetch_info(
client: &YfClient,
symbol: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<Info, YfError> {
let (quote, profile, price_target, rec_summary, esg_summary) =
Box::pin(fetch_info_parts(client, symbol, cache_mode, retry_override)).await?;
let isin = extract_isin(&profile);
Ok(assemble_info(
symbol,
quote.as_ref(),
isin,
price_target,
rec_summary,
esg_summary.and_then(|s| s.scores),
))
}
async fn fetch_info_parts(
client: &YfClient,
symbol: &str,
cache_mode: CacheMode,
retry_override: Option<&RetryConfig>,
) -> Result<
(
Option<crate::Quote>,
Profile,
Option<paft::fundamentals::analysis::PriceTarget>,
Option<paft::fundamentals::analysis::RecommendationSummary>,
Option<paft::fundamentals::esg::EsgSummary>,
),
YfError,
> {
let (quote_res, profile_res, price_target_res, rec_summary_res, esg_res) = tokio::join!(
crate::ticker::quote::fetch_quote(client, symbol, cache_mode, retry_override),
crate::profile::load_profile(client, symbol),
analysis::AnalysisBuilder::new(client, symbol)
.cache_mode(cache_mode)
.retry_policy(retry_override.cloned())
.analyst_price_target(None),
analysis::AnalysisBuilder::new(client, symbol)
.cache_mode(cache_mode)
.retry_policy(retry_override.cloned())
.recommendations_summary(),
esg::EsgBuilder::new(client, symbol)
.cache_mode(cache_mode)
.retry_policy(retry_override.cloned())
.fetch()
);
let profile = profile_res?;
let quote = log_err_async(quote_res, "quote", symbol);
let price_target = log_err_async(price_target_res, "price_target", symbol);
let rec_summary = log_err_async(rec_summary_res, "recommendations_summary", symbol);
let esg_summary = log_err_async(esg_res, "esg_scores", symbol);
Ok((quote, profile, price_target, rec_summary, esg_summary))
}
fn extract_isin(profile: &Profile) -> Option<paft::domain::Isin> {
match profile {
Profile::Company(c) => c.isin.clone(),
Profile::Fund(f) => f.isin.clone(),
}
}
fn assemble_info(
symbol: &str,
quote: Option<&crate::Quote>,
isin: Option<paft::domain::Isin>,
price_target: Option<paft::fundamentals::analysis::PriceTarget>,
rec_summary: Option<paft::fundamentals::analysis::RecommendationSummary>,
esg_scores: Option<paft::fundamentals::esg::EsgScores>,
) -> Info {
Info {
symbol: quote.map_or_else(
|| paft::domain::Symbol::new(symbol).expect("invalid symbol"),
|q| q.symbol.clone(),
),
name: quote.and_then(|q| q.shortname.clone()),
isin,
exchange: quote.and_then(|q| q.exchange.clone()),
market_state: quote.and_then(|q| q.market_state),
currency: quote.and_then(|q| {
q.price
.as_ref()
.map(|m| m.currency().clone())
.or_else(|| q.previous_close.as_ref().map(|m| m.currency().clone()))
}),
last: quote.and_then(|q| q.price.clone()),
open: None,
high: None,
low: None,
previous_close: quote.and_then(|q| q.previous_close.clone()),
day_range_low: None,
day_range_high: None,
fifty_two_week_low: None,
fifty_two_week_high: None,
volume: quote.and_then(|q| q.day_volume),
average_volume: None,
market_cap: None,
shares_outstanding: None,
eps_ttm: None,
pe_ttm: None,
dividend_yield: None,
ex_dividend_date: None,
as_of: Some(i64_to_datetime(chrono::Utc::now().timestamp())),
price_target,
recommendation_summary: rec_summary,
esg_scores,
}
}

View File

@@ -0,0 +1,274 @@
use crate::{
YfClient, YfError,
core::{client::RetryConfig, net},
};
use serde::Deserialize;
#[derive(Deserialize)]
struct FlatSuggest {
#[serde(alias = "Value", alias = "value")]
value: Option<String>,
#[serde(alias = "Symbol", alias = "symbol")]
symbol: Option<String>,
#[serde(alias = "Isin", alias = "isin", alias = "ISIN")]
isin: Option<String>,
}
pub(super) async fn fetch_isin(
client: &YfClient,
symbol: &str,
retry_override: Option<&RetryConfig>,
) -> Result<Option<String>, YfError> {
let Some(body) = fetch_isin_body(client, symbol, retry_override).await? else {
return Ok(None);
};
let debug = std::env::var("YF_DEBUG").ok().as_deref() == Some("1");
let input_norm = normalize_sym(symbol);
if let Some(isin) = parse_as_json_value(&body, &input_norm, debug) {
return Ok(Some(isin));
}
if let Some(isin) = parse_as_flat_suggest(&body, &input_norm) {
return Ok(Some(isin));
}
if let Some(isin) = scan_raw_body(&body, debug) {
return Ok(Some(isin));
}
if debug {
eprintln!("YF_DEBUG(isin): No matching ISIN found in any response shape.");
}
Ok(None)
}
async fn fetch_isin_body(
client: &YfClient,
symbol: &str,
retry_override: Option<&RetryConfig>,
) -> Result<Option<String>, YfError> {
let mut url = client.base_insider_search().clone();
url.query_pairs_mut()
.append_pair("max_results", "5")
.append_pair("query", symbol);
let req = client.http().get(url.clone());
let resp = client.send_with_retry(req, retry_override).await?;
if !resp.status().is_success() {
return Ok(None);
}
Ok(Some(
net::get_text(resp, "isin_search", symbol, "json").await?,
))
}
fn parse_as_json_value(body: &str, input_norm: &str, debug: bool) -> Option<String> {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(body) {
if let Some(hit) = extract_from_json_value(&val, input_norm) {
if debug {
eprintln!("YF_DEBUG(isin): ISIN extracted from JSON structures: {hit}",);
}
return Some(hit);
}
} else if debug {
eprintln!("YF_DEBUG(isin): failed to parse JSON response for query '{input_norm}'",);
}
None
}
fn parse_as_flat_suggest(body: &str, input_norm: &str) -> Option<String> {
if let Ok(raw_arr) = serde_json::from_str::<Vec<FlatSuggest>>(body) {
for r in &raw_arr {
if let Some(isin) = r.isin.as_deref()
&& looks_like_isin(isin)
&& r.symbol.as_deref().map(normalize_sym) == Some(input_norm.to_string())
{
return Some(isin.to_uppercase());
}
if let Some(value) = r.value.as_deref() {
let parts: Vec<String> = value
.split('|')
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect();
if let Some(isin) = pick_from_parts(&parts, input_norm) {
return Some(isin);
}
}
}
for r in &raw_arr {
if let Some(isin) = r.isin.as_deref()
&& looks_like_isin(isin)
{
return Some(isin.to_uppercase());
}
if let Some(value) = r.value.as_deref()
&& let Some(tok) = value
.split('|')
.map(str::trim)
.find(|tok| looks_like_isin(tok))
{
return Some((*tok).to_uppercase());
}
}
}
None
}
fn scan_raw_body(body: &str, debug: bool) -> Option<String> {
let mut token = String::new();
for ch in body.chars() {
if ch.is_ascii_alphanumeric() {
token.push(ch);
if token.len() > 12 {
token.remove(0);
}
if token.len() == 12 && looks_like_isin(&token) {
if debug {
eprintln!("YF_DEBUG(isin): Fallback raw scan found ISIN: {token}");
}
return Some(token.to_uppercase());
}
} else {
token.clear();
}
}
None
}
fn extract_from_json_value(v: &serde_json::Value, target_norm: &str) -> Option<String> {
let mut arrays: Vec<&serde_json::Value> = Vec::new();
match v {
serde_json::Value::Array(_) => arrays.push(v),
serde_json::Value::Object(map) => {
for key in [
"Suggestions",
"suggestions",
"items",
"results",
"Result",
"data",
] {
if let Some(val) = map.get(key)
&& val.is_array()
{
arrays.push(val);
}
}
if arrays.is_empty() {
for (_, val) in map {
if val.is_array() {
arrays.push(val);
} else if let Some(obj) = val.as_object() {
for (_, inner) in obj {
if inner.is_array() {
arrays.push(inner);
}
}
}
}
}
}
_ => {}
}
for arr in arrays {
if let Some(a) = arr.as_array() {
for item in a {
if let Some(obj) = item.as_object() {
for k in ["Isin", "isin", "ISIN"] {
if let Some(isin_val) = obj.get(k).and_then(|x| x.as_str())
&& looks_like_isin(isin_val)
{
let sym = obj
.get("Symbol")
.and_then(|x| x.as_str())
.or_else(|| obj.get("symbol").and_then(|x| x.as_str()))
.unwrap_or("");
if sym.is_empty() || normalize_sym(sym) == target_norm {
return Some(isin_val.to_uppercase());
}
}
}
let value_str = obj
.get("Value")
.and_then(|x| x.as_str())
.or_else(|| obj.get("value").and_then(|x| x.as_str()))
.unwrap_or("");
if !value_str.is_empty() {
let parts: Vec<String> = value_str
.split('|')
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect();
if let Some(isin) = pick_from_parts(&parts, target_norm) {
return Some(isin);
}
}
if let Some(sym) = obj
.get("Symbol")
.and_then(|x| x.as_str())
.or_else(|| obj.get("symbol").and_then(|x| x.as_str()))
&& normalize_sym(sym) == target_norm
{
for (_k, v) in obj {
if let Some(s) = v.as_str()
&& looks_like_isin(s)
{
return Some(s.to_uppercase());
}
}
}
}
}
}
}
None
}
fn normalize_sym(s: &str) -> String {
let mut t = s.trim().replace('-', ".");
for sep in ['.', ':', ' ', '\t', '\n', '\r'] {
if let Some(idx) = t.find(sep) {
t.truncate(idx);
break;
}
}
t.to_ascii_lowercase()
}
fn looks_like_isin(s: &str) -> bool {
let t = s.trim();
if t.len() != 12 {
return false;
}
let b = t.as_bytes();
if !(b[0].is_ascii_alphabetic() && b[1].is_ascii_alphabetic()) {
return false;
}
if !t[2..11].chars().all(|c| c.is_ascii_alphanumeric()) {
return false;
}
b[11].is_ascii_digit()
}
fn pick_from_parts(parts: &[String], target_norm: &str) -> Option<String> {
if let Some(first) = parts.first()
&& normalize_sym(first) == target_norm
&& let Some(isin) = parts
.iter()
.map(std::string::String::as_str)
.find(|s| looks_like_isin(s))
{
return Some(isin.to_uppercase());
}
None
}

View File

@@ -0,0 +1,691 @@
mod info;
mod isin;
mod model;
mod options;
mod quote;
pub use model::{Info, OptionChain, OptionContract};
pub use paft::aggregates::FastInfo;
use crate::core::{Action, Candle, HistoryMeta, Interval, Quote, Range};
use crate::fundamentals::{Calendar, ShareCount};
use crate::holders::{
InsiderRosterHolder, InsiderTransaction, InstitutionalHolder, MajorHolder,
NetSharePurchaseActivity,
};
use crate::news::NewsArticle;
use crate::{
EsgBuilder,
core::client::RetryConfig,
core::conversions::{datetime_to_i64, money_to_currency_str, money_to_f64},
core::{CacheMode, YfClient, YfError},
holders::HoldersBuilder,
news::NewsBuilder,
};
use crate::{
analysis::AnalysisBuilder, fundamentals::FundamentalsBuilder, history::HistoryBuilder,
};
use paft::fundamentals::analysis::{
Earnings, EarningsTrendRow, PriceTarget, RecommendationRow, RecommendationSummary,
UpgradeDowngradeRow,
};
use paft::fundamentals::statements::{BalanceSheetRow, CashflowRow, IncomeStatementRow};
use paft::money::Currency;
/// A high-level interface for a single ticker symbol, providing convenient access to all available data.
///
/// This struct is designed to be the primary entry point for users who want to fetch
/// various types of financial data for a specific security, similar to the `Ticker`
/// object in the Python `yfinance` library.
///
/// A `Ticker` is created with a [`YfClient`] and a symbol. It then provides methods
/// to fetch quotes, historical prices, options chains, financials, and more.
///
/// # Example
///
/// ```no_run
/// # use yfinance_rs::{Ticker, YfClient};
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = YfClient::default();
/// let ticker = Ticker::new(&client, "TSLA");
///
/// // Get the latest quote
/// let quote = ticker.quote().await?;
/// println!("Tesla's last price: {}", quote.price.as_ref().map(|p| yfinance_rs::core::conversions::money_to_f64(p)).unwrap_or(0.0));
///
/// // Get historical prices for the last year
/// let history = ticker.history(Some(yfinance_rs::Range::Y1), None, false).await?;
/// println!("Fetched {} days of history.", history.len());
/// # Ok(())
/// # }
/// ```
pub struct Ticker {
#[doc(hidden)]
pub(crate) client: YfClient,
#[doc(hidden)]
pub(crate) symbol: String,
#[doc(hidden)]
cache_mode: CacheMode,
retry_override: Option<RetryConfig>,
}
impl Ticker {
/// Creates a new `Ticker` for a given symbol.
///
/// This is the standard way to create a ticker instance with default API endpoints.
pub fn new(client: &YfClient, symbol: impl Into<String>) -> Self {
Self {
client: client.clone(),
symbol: symbol.into(),
cache_mode: CacheMode::Use,
retry_override: None,
}
}
/// Sets the cache mode for all subsequent API calls made by this `Ticker` instance.
///
/// This allows you to override the client's default cache behavior for a specific ticker.
#[must_use]
pub const fn cache_mode(mut self, mode: CacheMode) -> Self {
self.cache_mode = mode;
self
}
/// Overrides the client's default retry policy for all subsequent API calls made by this `Ticker` instance.
#[must_use]
pub fn retry_policy(mut self, cfg: Option<RetryConfig>) -> Self {
self.retry_override = cfg;
self
}
/// Fetches a comprehensive `Info` struct containing quote, profile, analysis, and ESG data.
///
/// This method conveniently aggregates data from multiple endpoints into a single struct,
/// similar to the `.info` attribute in the Python `yfinance` library. It makes several
/// API calls concurrently to gather the data efficiently.
///
/// If a non-essential part of the data fails to load (e.g., ESG scores), the corresponding
/// fields in the `Info` struct will be `None`. A failure to load the core profile
/// will result in an error.
///
/// # Errors
///
/// This method will return an error if the core profile data cannot be fetched.
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), err, fields(symbol = %self.symbol)))]
pub async fn info(&self) -> Result<Info, YfError> {
Box::pin(info::fetch_info(
&self.client,
&self.symbol,
self.cache_mode,
self.retry_override.as_ref(),
))
.await
}
/* ---------------- Quotes ---------------- */
/// Fetches a detailed quote for the ticker.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), err, fields(symbol = %self.symbol)))]
pub async fn quote(&self) -> Result<Quote, YfError> {
quote::fetch_quote(
&self.client,
&self.symbol,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
/// Fetches a "fast" info quote, containing the most essential price and market data.
///
/// # Errors
///
/// This method will return an error if the request fails, the response cannot be parsed,
/// or if the last/previous price is not available in the quote.
pub async fn fast_info(&self) -> Result<FastInfo, YfError> {
let q = self.quote().await?;
Ok(FastInfo {
symbol: q.symbol,
name: q.shortname.clone(),
exchange: q.exchange,
market_state: q.market_state,
currency: q
.price
.as_ref()
.and_then(money_to_currency_str)
.or_else(|| q.previous_close.as_ref().and_then(money_to_currency_str))
.and_then(|code| code.parse().ok()),
last: q.price,
previous_close: q.previous_close,
volume: q.day_volume,
})
}
/* ---------------- News convenience ---------------- */
/// Returns a `NewsBuilder` to construct a query for news articles.
#[must_use]
pub fn news_builder(&self) -> NewsBuilder {
NewsBuilder::new(&self.client, &self.symbol)
.cache_mode(self.cache_mode)
.retry_policy(self.retry_override.clone())
}
/// Fetches the latest news articles for the ticker.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn news(&self) -> Result<Vec<NewsArticle>, YfError> {
self.news_builder().fetch().await
}
/* ---------------- History helpers ---------------- */
/// Returns a `HistoryBuilder` to construct a detailed query for historical price data.
#[must_use]
pub fn history_builder(&self) -> HistoryBuilder {
HistoryBuilder::new(&self.client, &self.symbol)
}
/// Fetches historical price candles with default settings.
///
/// Prices are automatically adjusted for splits and dividends. For more control, use [`history_builder`].
///
/// # Arguments
/// * `range` - The relative time range for the data (e.g., `1y`, `6mo`). Defaults to `6mo` if `None`.
/// * `interval` - The time interval for each candle (e.g., `1d`, `1wk`). Defaults to `1d` if `None`.
/// * `prepost` - Whether to include pre-market and post-market data for intraday intervals.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), err, fields(symbol = %self.symbol)))]
pub async fn history(
&self,
range: Option<Range>,
interval: Option<Interval>,
prepost: bool,
) -> Result<Vec<Candle>, crate::core::YfError> {
let mut hb = self.history_builder();
if let Some(r) = range {
hb = hb.range(r);
}
if let Some(i) = interval {
hb = hb.interval(i);
}
hb = hb
.auto_adjust(true)
.prepost(prepost)
.actions(true)
.cache_mode(self.cache_mode)
.retry_policy(self.retry_override.clone());
hb.fetch().await
}
/// Fetches all corporate actions (dividends and splits) for the given range.
///
/// Defaults to the maximum available range if `None`.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), err, fields(symbol = %self.symbol)))]
pub async fn actions(&self, range: Option<Range>) -> Result<Vec<Action>, YfError> {
let mut hb = self.history_builder();
hb = hb.range(range.unwrap_or(Range::Max));
let resp = hb.auto_adjust(true).actions(true).fetch_full().await?;
let mut actions = resp.actions;
actions.sort_by_key(|a| match *a {
Action::Dividend { ts, .. }
| Action::Split { ts, .. }
| Action::CapitalGain { ts, .. } => ts,
});
Ok(actions)
}
/// Fetches all dividend payments for the given range.
///
/// Returns a `Vec` of tuples containing `(timestamp, amount)`.
/// Defaults to the maximum available range if `None`.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), err, fields(symbol = %self.symbol)))]
pub async fn dividends(&self, range: Option<Range>) -> Result<Vec<(i64, f64)>, YfError> {
let acts = self.actions(range).await?;
Ok(acts
.into_iter()
.filter_map(|a| match a {
Action::Dividend { ts, amount } => {
Some((datetime_to_i64(ts), money_to_f64(&amount)))
}
_ => None,
})
.collect())
}
/// Fetches all stock splits for the given range.
///
/// Returns a `Vec` of tuples containing `(timestamp, numerator, denominator)`.
/// Defaults to the maximum available range if `None`.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), err, fields(symbol = %self.symbol)))]
pub async fn splits(&self, range: Option<Range>) -> Result<Vec<(i64, u32, u32)>, YfError> {
let acts = self.actions(range).await?;
Ok(acts
.into_iter()
.filter_map(|a| match a {
Action::Split {
ts,
numerator,
denominator,
} => Some((datetime_to_i64(ts), numerator, denominator)),
_ => None,
})
.collect())
}
/// Fetches the metadata associated with the ticker's historical data, such as timezone.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), err, fields(symbol = %self.symbol)))]
pub async fn get_history_metadata(
&self,
range: Option<Range>,
) -> Result<Option<HistoryMeta>, crate::core::YfError> {
let mut hb = self
.history_builder()
.cache_mode(self.cache_mode)
.retry_policy(self.retry_override.clone());
if let Some(r) = range {
hb = hb.range(r);
}
let resp = hb.fetch_full().await?;
Ok(resp.meta)
}
/// Fetches the ISIN for the ticker by searching on markets.businessinsider.com.
///
/// This mimics the approach used by the Python `yfinance` library.
/// It returns `None` for assets that don't have an ISIN, such as indices.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn isin(&self) -> Result<Option<String>, YfError> {
if self.symbol.contains('^') {
return Ok(None);
}
isin::fetch_isin(&self.client, &self.symbol, self.retry_override.as_ref()).await
}
/// Retrieves historical capital gain events for the ticker (typically for mutual funds).
///
/// A time `range` can be optionally specified. Defaults to the maximum available range.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn capital_gains(&self, range: Option<Range>) -> Result<Vec<(i64, f64)>, YfError> {
let acts = self.actions(range).await?;
Ok(acts
.into_iter()
.filter_map(|a| match a {
Action::CapitalGain { ts, gain } => {
Some((datetime_to_i64(ts), money_to_f64(&gain)))
}
_ => None,
})
.collect())
}
/* ---------------- Options ---------------- */
/// Fetches the available expiration dates for the ticker's options as Unix timestamps.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn options(&self) -> Result<Vec<i64>, YfError> {
options::expiration_dates(
&self.client,
&self.symbol,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
/// Fetches the full option chain (calls and puts) for a specific expiration date.
///
/// If `date` is `None`, fetches the chain for the nearest expiration date.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn option_chain(&self, date: Option<i64>) -> Result<OptionChain, YfError> {
options::option_chain(
&self.client,
&self.symbol,
date,
self.cache_mode,
self.retry_override.as_ref(),
)
.await
}
/* ---------------- Holders convenience ---------------- */
fn holders_builder(&self) -> HoldersBuilder {
HoldersBuilder::new(&self.client, &self.symbol)
.cache_mode(self.cache_mode)
.retry_policy(self.retry_override.clone())
}
/// Fetches the major holders breakdown (e.g., % insiders, % institutions).
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn major_holders(&self) -> Result<Vec<MajorHolder>, YfError> {
self.holders_builder().major_holders().await
}
/// Fetches a list of the top institutional holders.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn institutional_holders(&self) -> Result<Vec<InstitutionalHolder>, YfError> {
self.holders_builder().institutional_holders().await
}
/// Fetches a list of the top mutual fund holders.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn mutual_fund_holders(&self) -> Result<Vec<InstitutionalHolder>, YfError> {
self.holders_builder().mutual_fund_holders().await
}
/// Fetches a list of recent insider transactions.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn insider_transactions(&self) -> Result<Vec<InsiderTransaction>, YfError> {
self.holders_builder().insider_transactions().await
}
/// Fetches a roster of company insiders and their holdings.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn insider_roster_holders(&self) -> Result<Vec<InsiderRosterHolder>, YfError> {
self.holders_builder().insider_roster_holders().await
}
/// Fetches a summary of net insider purchase and sale activity.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn net_share_purchase_activity(
&self,
) -> Result<Option<NetSharePurchaseActivity>, YfError> {
self.holders_builder().net_share_purchase_activity().await
}
/* ---------------- Analysis convenience ---------------- */
fn analysis_builder(&self) -> AnalysisBuilder {
AnalysisBuilder::new(&self.client, &self.symbol)
.cache_mode(self.cache_mode)
.retry_policy(self.retry_override.clone())
}
/// Fetches the analyst recommendation trend.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn recommendations(&self) -> Result<Vec<RecommendationRow>, YfError> {
self.analysis_builder().recommendations().await
}
/// Fetches a summary of the latest analyst recommendations.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn recommendations_summary(&self) -> Result<RecommendationSummary, YfError> {
self.analysis_builder().recommendations_summary().await
}
/// Fetches the history of analyst upgrades and downgrades.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn upgrades_downgrades(&self) -> Result<Vec<UpgradeDowngradeRow>, YfError> {
self.analysis_builder().upgrades_downgrades().await
}
/// Fetches the analyst price target.
///
/// Provide `Some(currency)` to override the inferred reporting currency; pass `None`
/// to use the cached profile-based heuristic.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn analyst_price_target(
&self,
override_currency: Option<Currency>,
) -> Result<PriceTarget, YfError> {
self.analysis_builder()
.analyst_price_target(override_currency)
.await
}
/// Fetches earnings trend data for the ticker.
///
/// This includes earnings estimates, revenue estimates, EPS trends, and EPS revisions for various periods.
///
/// Provide `Some(currency)` to override the inferred reporting currency; pass `None`
/// to use the cached profile-based heuristic.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn earnings_trend(
&self,
override_currency: Option<Currency>,
) -> Result<Vec<EarningsTrendRow>, YfError> {
self.analysis_builder()
.earnings_trend(override_currency)
.await
}
/* ---------------- ESG / Sustainability ---------------- */
fn esg_builder(&self) -> EsgBuilder {
EsgBuilder::new(&self.client, &self.symbol)
.cache_mode(self.cache_mode)
.retry_policy(self.retry_override.clone())
}
/// Fetches the ESG (Environmental, Social, Governance) scores for the ticker.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn sustainability(&self) -> Result<paft::fundamentals::esg::EsgSummary, YfError> {
self.esg_builder().fetch().await
}
/* ---------------- Fundamentals convenience ---------------- */
fn fundamentals_builder(&self) -> FundamentalsBuilder {
FundamentalsBuilder::new(&self.client, &self.symbol)
.cache_mode(self.cache_mode)
.retry_policy(self.retry_override.clone())
}
/// Fetches the annual income statement.
///
/// Provide `Some(currency)` to override the inferred reporting currency; pass `None`
/// to use the cached profile-based heuristic.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn income_stmt(
&self,
override_currency: Option<Currency>,
) -> Result<Vec<IncomeStatementRow>, YfError> {
self.fundamentals_builder()
.income_statement(false, override_currency)
.await
}
/// Fetches the quarterly income statement.
///
/// Provide `Some(currency)` to override the inferred reporting currency; pass `None`
/// to use the cached profile-based heuristic.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn quarterly_income_stmt(
&self,
override_currency: Option<Currency>,
) -> Result<Vec<IncomeStatementRow>, YfError> {
self.fundamentals_builder()
.income_statement(true, override_currency)
.await
}
/// Fetches the annual balance sheet.
///
/// Provide `Some(currency)` to override the inferred reporting currency; pass `None`
/// to use the cached profile-based heuristic.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn balance_sheet(
&self,
override_currency: Option<Currency>,
) -> Result<Vec<BalanceSheetRow>, YfError> {
self.fundamentals_builder()
.balance_sheet(false, override_currency)
.await
}
/// Fetches the quarterly balance sheet.
///
/// Provide `Some(currency)` to override the inferred reporting currency; pass `None`
/// to use the cached profile-based heuristic.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn quarterly_balance_sheet(
&self,
override_currency: Option<Currency>,
) -> Result<Vec<BalanceSheetRow>, YfError> {
self.fundamentals_builder()
.balance_sheet(true, override_currency)
.await
}
/// Fetches the annual cash flow statement.
///
/// Provide `Some(currency)` to override the inferred reporting currency; pass `None`
/// to use the cached profile-based heuristic.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn cashflow(
&self,
override_currency: Option<Currency>,
) -> Result<Vec<CashflowRow>, YfError> {
self.fundamentals_builder()
.cashflow(false, override_currency)
.await
}
/// Fetches the quarterly cash flow statement.
///
/// Provide `Some(currency)` to override the inferred reporting currency; pass `None`
/// to use the cached profile-based heuristic.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn quarterly_cashflow(
&self,
override_currency: Option<Currency>,
) -> Result<Vec<CashflowRow>, YfError> {
self.fundamentals_builder()
.cashflow(true, override_currency)
.await
}
/// Fetches earnings history and estimates.
///
/// Provide `Some(currency)` to override the inferred reporting currency; pass `None`
/// to use the cached profile-based heuristic.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn earnings(&self, override_currency: Option<Currency>) -> Result<Earnings, YfError> {
self.fundamentals_builder()
.earnings(override_currency)
.await
}
/// Fetches corporate calendar events like earnings dates.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn calendar(&self) -> Result<Calendar, YfError> {
self.fundamentals_builder().calendar().await
}
/// Fetches historical annual shares outstanding.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn shares(&self) -> Result<Vec<ShareCount>, YfError> {
self.fundamentals_builder().shares(false).await
}
/// Fetches historical quarterly shares outstanding.
///
/// # Errors
///
/// This method will return an error if the request fails or the response cannot be parsed.
pub async fn quarterly_shares(&self) -> Result<Vec<ShareCount>, YfError> {
self.fundamentals_builder().shares(true).await
}
}

Some files were not shown because too many files have changed in this diff Show More