返回 CodeWhale
finance.rs
根目录 / crates / tui / src / tools / finance.rs
1 //! Finance quote tool backed by Yahoo Finance-style public endpoints.
2 //!
3 //! The tool prefers Yahoo's quote endpoint and falls back to the chart endpoint
4 //! when quote access is unavailable or returns no data.
5
6 use std::time::Duration;
7
8 use async_trait::async_trait;
9 use reqwest::{Client, StatusCode};
10 use serde::{Deserialize, Serialize};
11 use serde_json::{Value, json};
12
13 use super::spec::{
14 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
15 optional_str, optional_u64,
16 };
17
18 const DEFAULT_TIMEOUT_MS: u64 = 10_000;
19 const MAX_TIMEOUT_MS: u64 = 60_000;
20 const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
21 const QUOTE_SOURCE: &str = "yahoo_quote";
22 const CHART_SOURCE: &str = "yahoo_chart";
23
24 #[derive(Debug, Clone)]
25 struct FinanceEndpoints {
26 quote_base: String,
27 chart_base: String,
28 }
29
30 impl Default for FinanceEndpoints {
31 fn default() -> Self {
32 Self {
33 quote_base: std::env::var("CODEWHALE_FINANCE_QUOTE_BASE_URL")
34 .or_else(|_| std::env::var("DEEPSEEK_FINANCE_QUOTE_BASE_URL"))
35 .unwrap_or_else(|_| "https://query1.finance.yahoo.com/v7/finance/quote".into()),
36 chart_base: std::env::var("CODEWHALE_FINANCE_CHART_BASE_URL")
37 .or_else(|_| std::env::var("DEEPSEEK_FINANCE_CHART_BASE_URL"))
38 .unwrap_or_else(|_| "https://query1.finance.yahoo.com/v8/finance/chart".into()),
39 }
40 }
41 }
42
43 impl FinanceEndpoints {
44 fn quote_url(&self, symbol: &str) -> String {
45 format!(
46 "{}?symbols={}",
47 self.quote_base.trim_end_matches('/'),
48 crate::utils::url_encode(symbol)
49 )
50 }
51
52 fn chart_url(&self, symbol: &str) -> String {
53 format!(
54 "{}/{}?interval=1d&range=5d",
55 self.chart_base.trim_end_matches('/'),
56 crate::utils::url_encode(symbol)
57 )
58 }
59 }
60
61 #[derive(Debug, Clone)]
62 struct FinanceRequest {
63 requested_ticker: String,
64 resolved_symbol: String,
65 }
66
67 #[derive(Debug, Clone, Serialize)]
68 struct FinanceQuoteResponse {
69 requested_ticker: String,
70 ticker: String,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 name: Option<String>,
73 price: f64,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 currency: Option<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 change: Option<f64>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 change_percent: Option<f64>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 previous_close: Option<f64>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 market_state: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 quote_type: Option<String>,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 exchange: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 market_time: Option<i64>,
90 source: String,
91 fallback_used: bool,
92 }
93
94 #[derive(Debug, Clone)]
95 enum AttemptFailureKind {
96 Timeout,
97 NotFound,
98 Upstream,
99 }
100
101 #[derive(Debug, Clone)]
102 struct AttemptFailure {
103 endpoint: &'static str,
104 kind: AttemptFailureKind,
105 detail: String,
106 }
107
108 impl AttemptFailure {
109 fn timeout(endpoint: &'static str) -> Self {
110 Self {
111 endpoint,
112 kind: AttemptFailureKind::Timeout,
113 detail: "request timed out".to_string(),
114 }
115 }
116
117 fn not_found(endpoint: &'static str, detail: impl Into<String>) -> Self {
118 Self {
119 endpoint,
120 kind: AttemptFailureKind::NotFound,
121 detail: detail.into(),
122 }
123 }
124
125 fn upstream(endpoint: &'static str, detail: impl Into<String>) -> Self {
126 Self {
127 endpoint,
128 kind: AttemptFailureKind::Upstream,
129 detail: detail.into(),
130 }
131 }
132
133 fn is_timeout(&self) -> bool {
134 matches!(self.kind, AttemptFailureKind::Timeout)
135 }
136
137 fn is_not_found(&self) -> bool {
138 matches!(self.kind, AttemptFailureKind::NotFound)
139 }
140
141 fn summary(&self) -> String {
142 format!("{}: {}", self.endpoint, self.detail)
143 }
144 }
145
146 pub struct FinanceTool {
147 endpoints: FinanceEndpoints,
148 client: Client,
149 }
150
151 impl FinanceTool {
152 #[must_use]
153 pub fn new() -> Self {
154 Self {
155 endpoints: FinanceEndpoints::default(),
156 client: crate::tls::reqwest_client_builder()
157 .user_agent(USER_AGENT)
158 .build()
159 .expect("failed to build HTTP client"),
160 }
161 }
162
163 #[cfg(test)]
164 fn with_endpoints(quote_base: impl Into<String>, chart_base: impl Into<String>) -> Self {
165 Self {
166 endpoints: FinanceEndpoints {
167 quote_base: quote_base.into(),
168 chart_base: chart_base.into(),
169 },
170 client: crate::tls::reqwest_client_builder()
171 .user_agent(USER_AGENT)
172 .build()
173 .expect("failed to build HTTP client"),
174 }
175 }
176 }
177
178 impl Default for FinanceTool {
179 fn default() -> Self {
180 Self::new()
181 }
182 }
183
184 #[async_trait]
185 impl ToolSpec for FinanceTool {
186 fn name(&self) -> &'static str {
187 "finance"
188 }
189
190 fn description(&self) -> &'static str {
191 "Fetch a live market quote for a stock, ETF, or crypto ticker using Yahoo Finance-style public endpoints."
192 }
193
194 fn input_schema(&self) -> Value {
195 json!({
196 "type": "object",
197 "properties": {
198 "ticker": {
199 "type": "string",
200 "description": "Ticker symbol to look up (for example: AAPL, SPY, BTC)."
201 },
202 "symbol": {
203 "type": "string",
204 "description": "Alias for ticker."
205 },
206 "type": {
207 "type": "string",
208 "description": "Optional asset type hint such as equity, fund, crypto, or index."
209 },
210 "market": {
211 "type": "string",
212 "description": "Optional market hint retained for compatibility with finance-style tool calls."
213 },
214 "timeout_ms": {
215 "type": "integer",
216 "description": "Request timeout in milliseconds (default: 10000, max: 60000)."
217 }
218 },
219 "anyOf": [
220 { "required": ["ticker"] },
221 { "required": ["symbol"] }
222 ],
223 "additionalProperties": false
224 })
225 }
226
227 fn capabilities(&self) -> Vec<ToolCapability> {
228 vec![
229 ToolCapability::ReadOnly,
230 ToolCapability::Network,
231 ToolCapability::Sandboxable,
232 ]
233 }
234
235 fn approval_requirement(&self) -> ApprovalRequirement {
236 ApprovalRequirement::Auto
237 }
238
239 fn supports_parallel(&self) -> bool {
240 true
241 }
242
243 async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
244 let raw_ticker = match optional_str(&input, "ticker")? {
245 Some(ticker) => Some(ticker),
246 None => optional_str(&input, "symbol")?,
247 }
248 .ok_or_else(|| ToolError::missing_field("ticker"))?
249 .trim();
250 if raw_ticker.is_empty() {
251 return Err(ToolError::invalid_input("ticker cannot be empty"));
252 }
253
254 let type_hint = optional_str(&input, "type")?.map(str::trim);
255 let _market_hint = optional_str(&input, "market")?.map(str::trim);
256 let timeout_ms =
257 optional_u64(&input, "timeout_ms", DEFAULT_TIMEOUT_MS)?.clamp(100, MAX_TIMEOUT_MS);
258
259 let request = normalize_request(raw_ticker, type_hint);
260 let timeout = Duration::from_millis(timeout_ms);
261
262 let quote_result =
263 fetch_quote_endpoint(&self.client, timeout, &self.endpoints, &request).await;
264 match quote_result {
265 Ok(result) => {
266 ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))
267 }
268 Err(first_failure) => {
269 match fetch_chart_endpoint(&self.client, timeout, &self.endpoints, &request).await {
270 Ok(result) => ToolResult::json(&result)
271 .map_err(|e| ToolError::execution_failed(e.to_string())),
272 Err(second_failure) => Err(finalize_failure(
273 &request,
274 timeout_ms,
275 &[first_failure, second_failure],
276 )),
277 }
278 }
279 }
280 }
281 }
282
283 fn normalize_request(raw_ticker: &str, type_hint: Option<&str>) -> FinanceRequest {
284 let requested_ticker = raw_ticker.trim().to_ascii_uppercase();
285 let resolved_symbol = if requested_ticker == "BTC" {
286 "BTC-USD".to_string()
287 } else if type_hint.is_some_and(|hint| hint.eq_ignore_ascii_case("crypto"))
288 && !requested_ticker.contains('-')
289 {
290 format!("{requested_ticker}-USD")
291 } else {
292 requested_ticker.clone()
293 };
294
295 FinanceRequest {
296 requested_ticker,
297 resolved_symbol,
298 }
299 }
300
301 async fn fetch_quote_endpoint(
302 client: &Client,
303 timeout: Duration,
304 endpoints: &FinanceEndpoints,
305 request: &FinanceRequest,
306 ) -> Result<FinanceQuoteResponse, AttemptFailure> {
307 let url = endpoints.quote_url(&request.resolved_symbol);
308 let body = fetch_response_body(client, timeout, &url, QUOTE_SOURCE).await?;
309 let parsed: QuoteEndpointResponse = serde_json::from_str(&body).map_err(|e| {
310 AttemptFailure::upstream(QUOTE_SOURCE, format!("invalid JSON response: {e}"))
311 })?;
312
313 let quote = parsed
314 .quote_response
315 .result
316 .into_iter()
317 .find(|item| item.symbol.eq_ignore_ascii_case(&request.resolved_symbol))
318 .ok_or_else(|| {
319 AttemptFailure::not_found(
320 QUOTE_SOURCE,
321 format!("no result for symbol '{}'", request.resolved_symbol),
322 )
323 })?;
324
325 let price = quote.regular_market_price.ok_or_else(|| {
326 AttemptFailure::upstream(QUOTE_SOURCE, "response missing regularMarketPrice")
327 })?;
328 let previous_close = quote.regular_market_previous_close;
329 let change = quote
330 .regular_market_change
331 .or_else(|| compute_change(price, previous_close));
332 let change_percent = quote
333 .regular_market_change_percent
334 .or_else(|| compute_change_percent(price, previous_close));
335
336 Ok(FinanceQuoteResponse {
337 requested_ticker: request.requested_ticker.clone(),
338 ticker: quote.symbol,
339 name: quote.long_name.or(quote.short_name),
340 price,
341 currency: quote.currency,
342 change,
343 change_percent,
344 previous_close,
345 market_state: quote.market_state,
346 quote_type: quote.quote_type,
347 exchange: quote.full_exchange_name.or(quote.exchange),
348 market_time: quote.regular_market_time,
349 source: QUOTE_SOURCE.to_string(),
350 fallback_used: false,
351 })
352 }
353
354 async fn fetch_chart_endpoint(
355 client: &Client,
356 timeout: Duration,
357 endpoints: &FinanceEndpoints,
358 request: &FinanceRequest,
359 ) -> Result<FinanceQuoteResponse, AttemptFailure> {
360 let url = endpoints.chart_url(&request.resolved_symbol);
361 let body = fetch_response_body(client, timeout, &url, CHART_SOURCE).await?;
362 let parsed: ChartEndpointResponse = serde_json::from_str(&body).map_err(|e| {
363 AttemptFailure::upstream(CHART_SOURCE, format!("invalid JSON response: {e}"))
364 })?;
365
366 if let Some(error) = parsed.chart.error {
367 let description = error
368 .description
369 .unwrap_or_else(|| "chart endpoint returned an error".to_string());
370 if error
371 .code
372 .as_deref()
373 .is_some_and(|code| code.eq_ignore_ascii_case("Not Found"))
374 || description.to_ascii_lowercase().contains("not found")
375 || description
376 .to_ascii_lowercase()
377 .contains("symbol may be delisted")
378 {
379 return Err(AttemptFailure::not_found(CHART_SOURCE, description));
380 }
381 return Err(AttemptFailure::upstream(CHART_SOURCE, description));
382 }
383
384 let result = parsed
385 .chart
386 .result
387 .and_then(|mut entries| entries.drain(..).next())
388 .ok_or_else(|| {
389 AttemptFailure::not_found(
390 CHART_SOURCE,
391 format!("no chart data for symbol '{}'", request.resolved_symbol),
392 )
393 })?;
394
395 let meta = result.meta;
396 let price = meta.regular_market_price.ok_or_else(|| {
397 AttemptFailure::upstream(CHART_SOURCE, "response missing regularMarketPrice")
398 })?;
399 let previous_close = meta.chart_previous_close.or(meta.previous_close);
400 let change = compute_change(price, previous_close);
401 let change_percent = compute_change_percent(price, previous_close);
402
403 Ok(FinanceQuoteResponse {
404 requested_ticker: request.requested_ticker.clone(),
405 ticker: meta.symbol,
406 name: meta.long_name.or(meta.short_name),
407 price,
408 currency: meta.currency,
409 change,
410 change_percent,
411 previous_close,
412 market_state: None,
413 quote_type: meta.instrument_type,
414 exchange: meta.full_exchange_name.or(meta.exchange_name),
415 market_time: meta.regular_market_time,
416 source: CHART_SOURCE.to_string(),
417 fallback_used: true,
418 })
419 }
420
421 async fn fetch_response_body(
422 client: &Client,
423 timeout: Duration,
424 url: &str,
425 endpoint: &'static str,
426 ) -> Result<String, AttemptFailure> {
427 let response = client
428 .get(url)
429 .timeout(timeout)
430 .send()
431 .await
432 .map_err(|err| {
433 if err.is_timeout() {
434 AttemptFailure::timeout(endpoint)
435 } else {
436 AttemptFailure::upstream(endpoint, format!("request failed: {err}"))
437 }
438 })?;
439
440 let status = response.status();
441 let body = response.text().await.map_err(|err| {
442 if err.is_timeout() {
443 AttemptFailure::timeout(endpoint)
444 } else {
445 AttemptFailure::upstream(endpoint, format!("failed to read response body: {err}"))
446 }
447 })?;
448
449 if !status.is_success() {
450 return Err(status_failure(endpoint, status, &body));
451 }
452
453 Ok(body)
454 }
455
456 fn status_failure(endpoint: &'static str, status: StatusCode, body: &str) -> AttemptFailure {
457 if endpoint == CHART_SOURCE && status == StatusCode::NOT_FOUND {
458 return AttemptFailure::not_found(endpoint, format!("HTTP {}", status.as_u16()));
459 }
460
461 let snippet = body.trim();
462 let detail = if snippet.is_empty() {
463 format!("HTTP {}", status.as_u16())
464 } else {
465 format!("HTTP {} ({})", status.as_u16(), truncate_for_error(snippet))
466 };
467
468 AttemptFailure::upstream(endpoint, detail)
469 }
470
471 fn finalize_failure(
472 request: &FinanceRequest,
473 timeout_ms: u64,
474 failures: &[AttemptFailure],
475 ) -> ToolError {
476 if failures.iter().all(AttemptFailure::is_not_found) {
477 return ToolError::invalid_input(format!(
478 "Unknown finance ticker '{}'",
479 request.requested_ticker
480 ));
481 }
482
483 if failures.iter().any(AttemptFailure::is_timeout) {
484 return ToolError::Timeout {
485 seconds: millis_to_timeout_seconds(timeout_ms),
486 };
487 }
488
489 let detail = failures
490 .iter()
491 .map(AttemptFailure::summary)
492 .collect::<Vec<_>>()
493 .join("; ");
494 ToolError::execution_failed(format!(
495 "Finance lookup failed for '{}': {}",
496 request.requested_ticker, detail
497 ))
498 }
499
500 fn compute_change(price: f64, previous_close: Option<f64>) -> Option<f64> {
501 previous_close.map(|prev| price - prev)
502 }
503
504 fn compute_change_percent(price: f64, previous_close: Option<f64>) -> Option<f64> {
505 previous_close.and_then(|prev| {
506 if prev.abs() < f64::EPSILON {
507 None
508 } else {
509 Some(((price - prev) / prev) * 100.0)
510 }
511 })
512 }
513
514 fn millis_to_timeout_seconds(timeout_ms: u64) -> u64 {
515 timeout_ms.saturating_add(999) / 1000
516 }
517
518 fn truncate_for_error(text: &str) -> String {
519 const MAX_ERROR_CHARS: usize = 120;
520 let mut out = String::new();
521 for ch in text.chars().take(MAX_ERROR_CHARS) {
522 out.push(ch);
523 }
524 if text.chars().count() > MAX_ERROR_CHARS {
525 out.push_str("...");
526 }
527 out
528 }
529
530 #[derive(Debug, Deserialize)]
531 #[serde(rename_all = "camelCase")]
532 struct QuoteEndpointResponse {
533 quote_response: QuoteResponseBody,
534 }
535
536 #[derive(Debug, Deserialize)]
537 struct QuoteResponseBody {
538 result: Vec<QuoteItem>,
539 }
540
541 #[derive(Debug, Deserialize)]
542 #[serde(rename_all = "camelCase")]
543 struct QuoteItem {
544 symbol: String,
545 #[serde(default)]
546 short_name: Option<String>,
547 #[serde(default)]
548 long_name: Option<String>,
549 #[serde(default)]
550 regular_market_price: Option<f64>,
551 #[serde(default)]
552 regular_market_change: Option<f64>,
553 #[serde(default)]
554 regular_market_change_percent: Option<f64>,
555 #[serde(default)]
556 regular_market_previous_close: Option<f64>,
557 #[serde(default)]
558 regular_market_time: Option<i64>,
559 #[serde(default)]
560 market_state: Option<String>,
561 #[serde(default)]
562 quote_type: Option<String>,
563 #[serde(default)]
564 currency: Option<String>,
565 #[serde(default)]
566 exchange: Option<String>,
567 #[serde(default)]
568 full_exchange_name: Option<String>,
569 }
570
571 #[derive(Debug, Deserialize)]
572 struct ChartEndpointResponse {
573 chart: ChartBody,
574 }
575
576 #[derive(Debug, Deserialize)]
577 struct ChartBody {
578 #[serde(default)]
579 result: Option<Vec<ChartResult>>,
580 #[serde(default)]
581 error: Option<ChartErrorBody>,
582 }
583
584 #[derive(Debug, Deserialize)]
585 struct ChartResult {
586 meta: ChartMeta,
587 }
588
589 #[derive(Debug, Deserialize)]
590 #[serde(rename_all = "camelCase")]
591 struct ChartMeta {
592 symbol: String,
593 #[serde(default)]
594 short_name: Option<String>,
595 #[serde(default)]
596 long_name: Option<String>,
597 #[serde(default)]
598 currency: Option<String>,
599 #[serde(default)]
600 regular_market_price: Option<f64>,
601 #[serde(default)]
602 regular_market_time: Option<i64>,
603 #[serde(default)]
604 chart_previous_close: Option<f64>,
605 #[serde(default)]
606 previous_close: Option<f64>,
607 #[serde(default)]
608 instrument_type: Option<String>,
609 #[serde(default)]
610 exchange_name: Option<String>,
611 #[serde(default)]
612 full_exchange_name: Option<String>,
613 }
614
615 #[derive(Debug, Deserialize)]
616 struct ChartErrorBody {
617 #[serde(default)]
618 code: Option<String>,
619 #[serde(default)]
620 description: Option<String>,
621 }
622
623 #[cfg(test)]
624 mod tests {
625 use super::*;
626 use tempfile::tempdir;
627 use wiremock::matchers::{method, path, query_param};
628 use wiremock::{Mock, MockServer, ResponseTemplate};
629
630 fn tool_with_server(server: &MockServer) -> FinanceTool {
631 FinanceTool::with_endpoints(
632 server.uri().to_string() + "/quote",
633 server.uri().to_string() + "/chart",
634 )
635 }
636
637 fn context() -> (ToolContext, tempfile::TempDir) {
638 let tmp = tempdir().expect("tempdir");
639 let path = tmp.path().to_path_buf();
640 let ctx = ToolContext::new(path);
641 (ctx, tmp)
642 }
643
644 #[tokio::test]
645 async fn finance_uses_quote_endpoint_when_available() {
646 let server = MockServer::start().await;
647 Mock::given(method("GET"))
648 .and(path("/quote"))
649 .and(query_param("symbols", "AAPL"))
650 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
651 "quoteResponse": {
652 "result": [{
653 "symbol": "AAPL",
654 "shortName": "Apple Inc.",
655 "regularMarketPrice": 189.23,
656 "regularMarketChange": 1.12,
657 "regularMarketChangePercent": 0.595,
658 "regularMarketPreviousClose": 188.11,
659 "regularMarketTime": 1_710_000_000,
660 "marketState": "REGULAR",
661 "quoteType": "EQUITY",
662 "currency": "USD",
663 "fullExchangeName": "NasdaqGS"
664 }]
665 }
666 })))
667 .mount(&server)
668 .await;
669
670 let tool = tool_with_server(&server);
671 let result = tool
672 .execute(json!({"ticker": "aapl"}), &context().0)
673 .await
674 .expect("finance quote should succeed");
675
676 let parsed: serde_json::Value =
677 serde_json::from_str(&result.content).expect("tool output should be json");
678 assert_eq!(parsed["requested_ticker"], "AAPL");
679 assert_eq!(parsed["ticker"], "AAPL");
680 assert_eq!(parsed["source"], QUOTE_SOURCE);
681 assert_eq!(parsed["fallback_used"], false);
682 assert_eq!(parsed["price"], 189.23);
683 }
684
685 #[tokio::test]
686 async fn finance_falls_back_to_chart_for_btc() {
687 let server = MockServer::start().await;
688 Mock::given(method("GET"))
689 .and(path("/quote"))
690 .and(query_param("symbols", "BTC-USD"))
691 .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
692 .mount(&server)
693 .await;
694 Mock::given(method("GET"))
695 .and(path("/chart/BTC-USD"))
696 .and(query_param("interval", "1d"))
697 .and(query_param("range", "5d"))
698 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
699 "chart": {
700 "result": [{
701 "meta": {
702 "symbol": "BTC-USD",
703 "longName": "Bitcoin USD",
704 "currency": "USD",
705 "regularMarketPrice": 73474.88,
706 "regularMarketTime": 1_710_000_001,
707 "chartPreviousClose": 72974.19,
708 "instrumentType": "CRYPTOCURRENCY",
709 "fullExchangeName": "CCC"
710 }
711 }],
712 "error": null
713 }
714 })))
715 .mount(&server)
716 .await;
717
718 let tool = tool_with_server(&server);
719 let result = tool
720 .execute(json!({"ticker": "BTC", "type": "crypto"}), &context().0)
721 .await
722 .expect("finance chart fallback should succeed");
723
724 let parsed: serde_json::Value =
725 serde_json::from_str(&result.content).expect("tool output should be json");
726 assert_eq!(parsed["requested_ticker"], "BTC");
727 assert_eq!(parsed["ticker"], "BTC-USD");
728 assert_eq!(parsed["source"], CHART_SOURCE);
729 assert_eq!(parsed["fallback_used"], true);
730 assert_eq!(parsed["quote_type"], "CRYPTOCURRENCY");
731 }
732
733 #[tokio::test]
734 async fn finance_reports_invalid_symbol() {
735 let server = MockServer::start().await;
736 Mock::given(method("GET"))
737 .and(path("/quote"))
738 .and(query_param("symbols", "NOTREAL"))
739 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
740 "quoteResponse": {
741 "result": []
742 }
743 })))
744 .mount(&server)
745 .await;
746 Mock::given(method("GET"))
747 .and(path("/chart/NOTREAL"))
748 .and(query_param("interval", "1d"))
749 .and(query_param("range", "5d"))
750 .respond_with(ResponseTemplate::new(404))
751 .mount(&server)
752 .await;
753
754 let tool = tool_with_server(&server);
755 let err = tool
756 .execute(json!({"ticker": "NOTREAL"}), &context().0)
757 .await
758 .expect_err("invalid symbol should error");
759
760 assert!(matches!(err, ToolError::InvalidInput { .. }));
761 assert!(err.to_string().contains("NOTREAL"));
762 }
763
764 #[tokio::test]
765 async fn finance_reports_upstream_failure_after_fallback() {
766 let server = MockServer::start().await;
767 Mock::given(method("GET"))
768 .and(path("/quote"))
769 .and(query_param("symbols", "SPY"))
770 .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
771 .mount(&server)
772 .await;
773 Mock::given(method("GET"))
774 .and(path("/chart/SPY"))
775 .and(query_param("interval", "1d"))
776 .and(query_param("range", "5d"))
777 .respond_with(ResponseTemplate::new(503).set_body_string("service unavailable"))
778 .mount(&server)
779 .await;
780
781 let tool = tool_with_server(&server);
782 let err = tool
783 .execute(json!({"ticker": "SPY"}), &context().0)
784 .await
785 .expect_err("double upstream failure should error");
786
787 match err {
788 ToolError::ExecutionFailed { message } => {
789 assert!(message.contains(QUOTE_SOURCE));
790 assert!(message.contains("HTTP 401"));
791 assert!(message.contains(CHART_SOURCE));
792 assert!(message.contains("HTTP 503"));
793 }
794 other => panic!("unexpected error: {other:?}"),
795 }
796 }
797
798 #[tokio::test]
799 async fn finance_does_not_mask_upstream_failure_with_chart_not_found() {
800 let server = MockServer::start().await;
801 Mock::given(method("GET"))
802 .and(path("/quote"))
803 .and(query_param("symbols", "SPY"))
804 .respond_with(ResponseTemplate::new(503).set_body_string("service unavailable"))
805 .mount(&server)
806 .await;
807 Mock::given(method("GET"))
808 .and(path("/chart/SPY"))
809 .and(query_param("interval", "1d"))
810 .and(query_param("range", "5d"))
811 .respond_with(ResponseTemplate::new(404))
812 .mount(&server)
813 .await;
814
815 let tool = tool_with_server(&server);
816 let err = tool
817 .execute(json!({"ticker": "SPY"}), &context().0)
818 .await
819 .expect_err("mixed upstream/not-found failures should not look like an invalid symbol");
820
821 match err {
822 ToolError::ExecutionFailed { message } => {
823 assert!(message.contains(QUOTE_SOURCE));
824 assert!(message.contains("HTTP 503"));
825 assert!(message.contains(CHART_SOURCE));
826 assert!(message.contains("HTTP 404"));
827 }
828 other => panic!("unexpected error: {other:?}"),
829 }
830 }
831
832 #[tokio::test]
833 async fn finance_does_not_mask_quote_auth_failure_with_unknown_symbol() {
834 let server = MockServer::start().await;
835 Mock::given(method("GET"))
836 .and(path("/quote"))
837 .and(query_param("symbols", "SPY"))
838 .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
839 .mount(&server)
840 .await;
841 Mock::given(method("GET"))
842 .and(path("/chart/SPY"))
843 .and(query_param("interval", "1d"))
844 .and(query_param("range", "5d"))
845 .respond_with(ResponseTemplate::new(404))
846 .mount(&server)
847 .await;
848
849 let tool = tool_with_server(&server);
850 let err = tool
851 .execute(json!({"ticker": "SPY"}), &context().0)
852 .await
853 .expect_err("quote auth failures should not collapse into invalid input");
854
855 match err {
856 ToolError::ExecutionFailed { message } => {
857 assert!(message.contains(QUOTE_SOURCE));
858 assert!(message.contains("HTTP 401"));
859 assert!(message.contains(CHART_SOURCE));
860 assert!(message.contains("HTTP 404"));
861 }
862 other => panic!("unexpected error: {other:?}"),
863 }
864 }
865
866 #[tokio::test]
867 async fn finance_reports_timeout_when_fallback_times_out() {
868 let server = MockServer::start().await;
869 Mock::given(method("GET"))
870 .and(path("/quote"))
871 .and(query_param("symbols", "AAPL"))
872 .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
873 .mount(&server)
874 .await;
875 Mock::given(method("GET"))
876 .and(path("/chart/AAPL"))
877 .and(query_param("interval", "1d"))
878 .and(query_param("range", "5d"))
879 .respond_with(
880 ResponseTemplate::new(200)
881 .set_delay(Duration::from_millis(250))
882 .set_body_json(json!({
883 "chart": {
884 "result": [{
885 "meta": {
886 "symbol": "AAPL",
887 "regularMarketPrice": 260.48,
888 "chartPreviousClose": 255.92
889 }
890 }],
891 "error": null
892 }
893 })),
894 )
895 .mount(&server)
896 .await;
897
898 let tool = tool_with_server(&server);
899 let err = tool
900 .execute(json!({"ticker": "AAPL", "timeout_ms": 1}), &context().0)
901 .await
902 .expect_err("timeout should surface cleanly");
903
904 assert!(matches!(err, ToolError::Timeout { .. }));
905 }
906
907 #[tokio::test]
908 async fn finance_prefers_timeout_over_unknown_symbol_when_any_attempt_times_out() {
909 let server = MockServer::start().await;
910 Mock::given(method("GET"))
911 .and(path("/quote"))
912 .and(query_param("symbols", "AAPL"))
913 .respond_with(
914 ResponseTemplate::new(200)
915 .set_delay(Duration::from_millis(250))
916 .set_body_json(json!({
917 "quoteResponse": {
918 "result": [{
919 "symbol": "AAPL",
920 "regularMarketPrice": 189.23
921 }]
922 }
923 })),
924 )
925 .mount(&server)
926 .await;
927 Mock::given(method("GET"))
928 .and(path("/chart/AAPL"))
929 .and(query_param("interval", "1d"))
930 .and(query_param("range", "5d"))
931 .respond_with(ResponseTemplate::new(404))
932 .mount(&server)
933 .await;
934
935 let tool = tool_with_server(&server);
936 let err = tool
937 .execute(json!({"ticker": "AAPL", "timeout_ms": 1}), &context().0)
938 .await
939 .expect_err("timeout should win over a later chart not-found");
940
941 assert!(matches!(err, ToolError::Timeout { .. }));
942 }
943
944 #[test]
945 fn finance_schema_allows_ticker_or_symbol() {
946 let schema = FinanceTool::new().input_schema();
947 let any_of = schema["anyOf"]
948 .as_array()
949 .expect("finance schema should advertise alternate required fields");
950
951 assert_eq!(any_of.len(), 2);
952 assert_eq!(any_of[0]["required"], json!(["ticker"]));
953 assert_eq!(any_of[1]["required"], json!(["symbol"]));
954 }
955 }
956
956 lines RUST