1+ use crate :: utils:: retry_with_backoff;
12use anyhow:: { anyhow, Context , Result } ;
23use chrono:: { DateTime , Utc } ;
34use serde:: Deserialize ;
5+ use std:: {
6+ collections:: HashMap ,
7+ sync:: { Arc , Mutex } ,
8+ time:: { Duration , Instant } ,
9+ } ;
10+
11+ /// How long a current-price result stays fresh in the in-memory cache. Short
12+ /// enough to feel live on a dashboard yet long enough to absorb bursts
13+ /// (dashboard refresh + alerts evaluate + portfolio render all hit the same
14+ /// ticker within milliseconds). Intraday movement inside this window is
15+ /// acceptable for the callers that use the PriceProvider.
16+ const PRICE_CACHE_TTL : Duration = Duration :: from_secs ( 30 ) ;
17+ /// Max attempts for Yahoo Finance retries. Yahoo's public chart API returns
18+ /// the occasional 5xx under load; three tries with jittered exponential
19+ /// backoff recovers the vast majority of those without user-visible errors.
20+ const PRICE_FETCH_ATTEMPTS : u32 = 3 ;
421
522#[ derive( Clone ) ]
623pub struct PriceProvider {
724 client : reqwest:: Client ,
25+ current_cache : Arc < Mutex < HashMap < String , ( Instant , PriceData ) > > > ,
826}
927
1028#[ derive( Debug , Clone ) ]
@@ -27,35 +45,50 @@ impl PriceProvider {
2745 . build ( )
2846 . context ( "failed to build reqwest client" ) ?;
2947
30- Ok ( Self { client } )
48+ Ok ( Self {
49+ client,
50+ current_cache : Arc :: new ( Mutex :: new ( HashMap :: new ( ) ) ) ,
51+ } )
3152 }
3253
33- /// Fetch current price for a ticker using Yahoo Finance API
54+ /// Fetch current price for a ticker using Yahoo Finance API. Served from
55+ /// a 30-second in-memory cache to absorb duplicate calls from the same
56+ /// dashboard render and retried with exponential backoff on transient
57+ /// upstream errors.
3458 pub async fn get_current_price ( & self , ticker : & str ) -> Result < PriceData > {
35- // Use Yahoo Finance query API
59+ let key = ticker. to_uppercase ( ) ;
60+ if let Some ( cached) = self . cached_current ( & key) {
61+ return Ok ( cached) ;
62+ }
63+ // URL-encode the ticker so symbols like `BRK.B` / `BF-B` behave and
64+ // callers can't inject path segments.
3665 let url = format ! (
3766 "https://query1.finance.yahoo.com/v8/finance/chart/{}?interval=1d&range=1d" ,
38- ticker
67+ urlencoding_encode ( ticker)
3968 ) ;
4069
41- let response = self
42- . client
43- . get ( & url)
44- . send ( )
45- . await
46- . context ( "failed to fetch price from Yahoo Finance" ) ?;
47-
48- if !response. status ( ) . is_success ( ) {
49- return Err ( anyhow ! (
50- "Yahoo Finance returned status {}" ,
51- response. status( )
52- ) ) ;
53- }
54-
55- let body: YahooChartResponse = response
56- . json ( )
57- . await
58- . context ( "failed to parse Yahoo Finance response" ) ?;
70+ let body: YahooChartResponse = retry_with_backoff (
71+ || async {
72+ let response = self
73+ . client
74+ . get ( & url)
75+ . send ( )
76+ . await
77+ . context ( "failed to fetch price from Yahoo Finance" ) ?;
78+ if !response. status ( ) . is_success ( ) {
79+ return Err ( anyhow ! (
80+ "Yahoo Finance returned status {}" ,
81+ response. status( )
82+ ) ) ;
83+ }
84+ response
85+ . json :: < YahooChartResponse > ( )
86+ . await
87+ . context ( "failed to parse Yahoo Finance response" )
88+ } ,
89+ PRICE_FETCH_ATTEMPTS ,
90+ )
91+ . await ?;
5992
6093 let chart = body
6194 . chart
@@ -107,16 +140,34 @@ impl PriceProvider {
107140 . map ( |dt| dt. date_naive ( ) )
108141 . unwrap_or_else ( || Utc :: now ( ) . date_naive ( ) ) ;
109142
110- Ok ( PriceData {
111- ticker : ticker . to_uppercase ( ) ,
143+ let data = PriceData {
144+ ticker : key . clone ( ) ,
112145 date,
113146 open,
114147 close,
115148 high,
116149 low,
117- volume : volume as i64 ,
150+ volume : volume. trunc ( ) as i64 ,
118151 adjusted_close : meta. regular_market_price . unwrap_or ( close) ,
119- } )
152+ } ;
153+ self . store_current ( key, data. clone ( ) ) ;
154+ Ok ( data)
155+ }
156+
157+ fn cached_current ( & self , key : & str ) -> Option < PriceData > {
158+ let guard = self . current_cache . lock ( ) . ok ( ) ?;
159+ let ( ts, data) = guard. get ( key) ?;
160+ if ts. elapsed ( ) <= PRICE_CACHE_TTL {
161+ Some ( data. clone ( ) )
162+ } else {
163+ None
164+ }
165+ }
166+
167+ fn store_current ( & self , key : String , data : PriceData ) {
168+ if let Ok ( mut guard) = self . current_cache . lock ( ) {
169+ guard. insert ( key, ( Instant :: now ( ) , data) ) ;
170+ }
120171 }
121172
122173 /// Fetch historical prices for a ticker
@@ -143,27 +194,33 @@ impl PriceProvider {
143194
144195 let url = format ! (
145196 "https://query1.finance.yahoo.com/v8/finance/chart/{}?period1={}&period2={}&interval=1d" ,
146- ticker, period1, period2
197+ urlencoding_encode( ticker) ,
198+ period1,
199+ period2
147200 ) ;
148201
149- let response = self
150- . client
151- . get ( & url)
152- . send ( )
153- . await
154- . context ( "failed to fetch historical prices from Yahoo Finance" ) ?;
155-
156- if !response. status ( ) . is_success ( ) {
157- return Err ( anyhow ! (
158- "Yahoo Finance returned status {}" ,
159- response. status( )
160- ) ) ;
161- }
162-
163- let body: YahooChartResponse = response
164- . json ( )
165- . await
166- . context ( "failed to parse Yahoo Finance response" ) ?;
202+ let body: YahooChartResponse = retry_with_backoff (
203+ || async {
204+ let response = self
205+ . client
206+ . get ( & url)
207+ . send ( )
208+ . await
209+ . context ( "failed to fetch historical prices from Yahoo Finance" ) ?;
210+ if !response. status ( ) . is_success ( ) {
211+ return Err ( anyhow ! (
212+ "Yahoo Finance returned status {}" ,
213+ response. status( )
214+ ) ) ;
215+ }
216+ response
217+ . json :: < YahooChartResponse > ( )
218+ . await
219+ . context ( "failed to parse Yahoo Finance response" )
220+ } ,
221+ PRICE_FETCH_ATTEMPTS ,
222+ )
223+ . await ?;
167224
168225 let chart = body
169226 . chart
@@ -211,7 +268,7 @@ impl PriceProvider {
211268 close,
212269 high,
213270 low,
214- volume : volume as i64 ,
271+ volume : volume. trunc ( ) as i64 ,
215272 adjusted_close : close,
216273 } ) ;
217274 }
@@ -220,6 +277,23 @@ impl PriceProvider {
220277 }
221278}
222279
280+ /// Minimal URL path-segment encoder that preserves alphanumerics, dot, dash,
281+ /// underscore, and tilde (the unreserved set per RFC 3986) and percent-encodes
282+ /// everything else. We avoid pulling in the full `percent-encoding` crate for
283+ /// what amounts to ticker symbols.
284+ fn urlencoding_encode ( input : & str ) -> String {
285+ let mut out = String :: with_capacity ( input. len ( ) ) ;
286+ for byte in input. as_bytes ( ) {
287+ let b = * byte;
288+ if b. is_ascii_alphanumeric ( ) || matches ! ( b, b'-' | b'_' | b'.' | b'~' ) {
289+ out. push ( b as char ) ;
290+ } else {
291+ out. push_str ( & format ! ( "%{:02X}" , b) ) ;
292+ }
293+ }
294+ out
295+ }
296+
223297#[ derive( Debug , Deserialize ) ]
224298struct YahooChartResponse {
225299 chart : YahooChart ,
0 commit comments