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