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