返回 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, Serialize)]
63 struct FinanceRequest {
64 requested_ticker: String,
65 resolved_symbol: String,
66 }
67
68 #[derive(Debug, Clone, Serialize, Deserialize)]
69 struct FinanceQuoteResponse {
70 requested_ticker: String,
71 ticker: String,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 name: Option<String>,
74 #[serde(deserialize_with = "deserialize_host_float")]
75 price: f64,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 currency: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 #[serde(default, deserialize_with = "deserialize_host_optional")]
80 change: Option<f64>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 #[serde(default, deserialize_with = "deserialize_host_optional")]
83 change_percent: Option<f64>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 #[serde(default, deserialize_with = "deserialize_host_optional")]
86 previous_close: Option<f64>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 market_state: Option<String>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 quote_type: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 exchange: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 #[serde(default, deserialize_with = "deserialize_host_optional")]
95 market_time: Option<i64>,
96 source: String,
97 fallback_used: bool,
98 }
99
100 fn deserialize_host_float<'de, D: serde::Deserializer<'de>>(decoder: D) -> Result<f64, D::Error> {
101 let text = String::deserialize(decoder)?;
102 if text.len() > 32 {
103 return Err(serde::de::Error::custom(
104 "host numeric field exceeds its bound",
105 ));
106 }
107 text.parse().map_err(serde::de::Error::custom)
108 }
109 fn deserialize_host_optional<'de, D, T>(decoder: D) -> Result<Option<T>, D::Error>
110 where
111 D: serde::Deserializer<'de>,
112 T: std::str::FromStr,
113 T::Err: std::fmt::Display,
114 {
115 Option::<String>::deserialize(decoder)?
116 .map(|text| {
117 if text.len() > 32 {
118 return Err(serde::de::Error::custom(
119 "host numeric field exceeds its bound",
120 ));
121 }
122 text.parse().map_err(serde::de::Error::custom)
123 })
124 .transpose()
125 }
126 fn serialize_timestamp<S: serde::Serializer>(
127 value: &Option<i64>,
128 encoder: S,
129 ) -> Result<S::Ok, S::Error> {
130 value.map(|time| time.to_string()).serialize(encoder)
131 }
132
133 #[derive(Debug, Clone)]
134 enum AttemptFailureKind {
135 Timeout,
136 NotFound,
137 Upstream,
138 }
139
140 #[derive(Debug, Clone)]
141 struct AttemptFailure {
142 endpoint: &'static str,
143 kind: AttemptFailureKind,
144 detail: String,
145 }
146
147 impl AttemptFailure {
148 fn timeout(endpoint: &'static str) -> Self {
149 Self {
150 endpoint,
151 kind: AttemptFailureKind::Timeout,
152 detail: "request timed out".to_string(),
153 }
154 }
155
156 fn not_found(endpoint: &'static str, detail: impl Into<String>) -> Self {
157 Self {
158 endpoint,
159 kind: AttemptFailureKind::NotFound,
160 detail: detail.into(),
161 }
162 }
163
164 fn upstream(endpoint: &'static str, detail: impl Into<String>) -> Self {
165 Self {
166 endpoint,
167 kind: AttemptFailureKind::Upstream,
168 detail: detail.into(),
169 }
170 }
171
172 fn is_timeout(&self) -> bool {
173 matches!(self.kind, AttemptFailureKind::Timeout)
174 }
175
176 fn is_not_found(&self) -> bool {
177 matches!(self.kind, AttemptFailureKind::NotFound)
178 }
179
180 fn summary(&self) -> String {
181 format!("{}: {}", self.endpoint, self.detail)
182 }
183 }
184
185 pub struct FinanceTool {
186 endpoints: FinanceEndpoints,
187 client: Client,
188 }
189
190 impl FinanceTool {
191 #[must_use]
192 pub fn new() -> Self {
193 Self {
194 endpoints: FinanceEndpoints::default(),
195 client: crate::tls::reqwest_client_builder()
196 .user_agent(USER_AGENT)
197 .build()
198 .expect("failed to build HTTP client"),
199 }
200 }
201
202 #[cfg(test)]
203 fn with_endpoints(quote_base: impl Into<String>, chart_base: impl Into<String>) -> Self {
204 Self {
205 endpoints: FinanceEndpoints {
206 quote_base: quote_base.into(),
207 chart_base: chart_base.into(),
208 },
209 client: crate::tls::reqwest_client_builder()
210 .user_agent(USER_AGENT)
211 .build()
212 .expect("failed to build HTTP client"),
213 }
214 }
215 }
216
217 impl Default for FinanceTool {
218 fn default() -> Self {
219 Self::new()
220 }
221 }
222
223 #[async_trait]
224 impl ToolSpec for FinanceTool {
225 fn name(&self) -> &'static str {
226 "finance"
227 }
228
229 fn description(&self) -> &'static str {
230 "Fetch live stock, ETF or crypto quotes via Yahoo-style endpoints under the session network policy."
231 }
232
233 fn input_schema(&self) -> Value {
234 json!({
235 "type": "object",
236 "properties": {
237 "ticker": {
238 "type": "string",
239 "description": "Ticker symbol to look up (for example: AAPL, SPY, BTC)."
240 },
241 "symbol": {
242 "type": "string",
243 "description": "Alias for ticker."
244 },
245 "type": {
246 "type": "string",
247 "description": "Optional asset type hint such as equity, fund, crypto, or index."
248 },
249 "timeout_ms": {
250 "type": "integer",
251 "description": "Total lookup timeout in milliseconds, shared by the quote request and its chart fallback (default: 10000, max: 60000)."
252 }
253 },
254 "anyOf": [
255 { "required": ["ticker"] },
256 { "required": ["symbol"] }
257 ],
258 "additionalProperties": false
259 })
260 }
261
262 fn capabilities(&self) -> Vec<ToolCapability> {
263 vec![
264 ToolCapability::ReadOnly,
265 ToolCapability::Network,
266 ToolCapability::Sandboxable,
267 ]
268 }
269
270 fn approval_requirement(&self) -> ApprovalRequirement {
271 ApprovalRequirement::Auto
272 }
273
274 fn supports_parallel(&self) -> bool {
275 true
276 }
277
278 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
279 let raw_ticker = match optional_str(&input, "ticker")? {
280 Some(ticker) => Some(ticker),
281 None => optional_str(&input, "symbol")?,
282 }
283 .ok_or_else(|| ToolError::missing_field("ticker"))?
284 .trim();
285 if raw_ticker.is_empty() {
286 return Err(ToolError::invalid_input("ticker cannot be empty"));
287 }
288
289 let type_hint = optional_str(&input, "type")?.map(str::trim);
290 let timeout_ms =
291 optional_u64(&input, "timeout_ms", DEFAULT_TIMEOUT_MS)?.clamp(100, MAX_TIMEOUT_MS);
292
293 let request = normalize_request(raw_ticker, type_hint);
294 let timeout = Duration::from_millis(timeout_ms);
295
296 // #135: quote and chart hosts are both vetted before any transport
297 // fires, so a tightened session (e.g. network.default = "deny")
298 // cannot leak a request through the chart fallback.
299 check_network_policy(context, &self.endpoints)?;
300
301 // One budget covers the quote request and its chart fallback
302 // (#6557 D03-m3): the fallback gets only what the first attempt left,
303 // so `timeout_ms` — and the Timeout error that reports it — is the
304 // real wall-clock bound rather than half of it.
305 let deadline = std::time::Instant::now() + timeout;
306 let host = context
307 .features
308 .enabled(crate::features::Feature::FinanceHost);
309 let quote_result = if host {
310 fetch_host_endpoint(
311 &self.client,
312 timeout,
313 &self.endpoints,
314 &request,
315 context,
316 deadline,
317 false,
318 )
319 .await
320 .map_err(|error| normalize_host_error(error, timeout_ms))?
321 } else {
322 fetch_quote_endpoint(&self.client, timeout, &self.endpoints, &request).await
323 };
324 match quote_result {
325 Ok(result) => {
326 ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))
327 }
328 Err(first_failure) => {
329 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
330 let chart_result = if remaining.is_zero() {
331 Err(AttemptFailure::timeout(CHART_SOURCE))
332 } else {
333 if host {
334 fetch_host_endpoint(
335 &self.client,
336 remaining,
337 &self.endpoints,
338 &request,
339 context,
340 deadline,
341 true,
342 )
343 .await
344 .map_err(|error| normalize_host_error(error, timeout_ms))?
345 } else {
346 fetch_chart_endpoint(&self.client, remaining, &self.endpoints, &request)
347 .await
348 }
349 };
350 match chart_result {
351 Ok(result) => ToolResult::json(&result)
352 .map_err(|e| ToolError::execution_failed(e.to_string())),
353 Err(second_failure) => Err(finalize_failure(
354 &request,
355 timeout_ms,
356 &[first_failure, second_failure],
357 )),
358 }
359 }
360 }
361 }
362 }
363
364 /// Fail closed when the session network policy denies (or has not approved)
365 /// either endpoint host. Mirrors the Web/web_search/speech family: `Deny`
366 /// and an undecided `Prompt` both stop before any request is made; no
367 /// attached policy falls through permissively for back-compat.
368 fn check_network_policy(
369 context: &ToolContext,
370 endpoints: &FinanceEndpoints,
371 ) -> Result<(), ToolError> {
372 let Some(decider) = context.network_policy.as_ref() else {
373 return Ok(());
374 };
375 for base in [&endpoints.quote_base, &endpoints.chart_base] {
376 let Some(host) = crate::network_policy::host_from_url(base) else {
377 continue;
378 };
379 match decider.evaluate(&host, "finance") {
380 Decision::Allow => {}
381 Decision::Deny => {
382 return Err(ToolError::permission_denied(format!(
383 "finance lookup to '{host}' blocked by network policy"
384 )));
385 }
386 Decision::Prompt => {
387 return Err(ToolError::permission_denied(format!(
388 "finance lookup to '{host}' requires approval; \
389 re-run after `/network allow {host}` or set network.default = \"allow\" in config"
390 )));
391 }
392 }
393 }
394 Ok(())
395 }
396
397 fn normalize_request(raw_ticker: &str, type_hint: Option<&str>) -> FinanceRequest {
398 let requested_ticker = raw_ticker.trim().to_ascii_uppercase();
399 let resolved_symbol = if requested_ticker == "BTC" {
400 "BTC-USD".to_string()
401 } else if type_hint.is_some_and(|hint| hint.eq_ignore_ascii_case("crypto"))
402 && !requested_ticker.contains('-')
403 {
404 format!("{requested_ticker}-USD")
405 } else {
406 requested_ticker.clone()
407 };
408
409 FinanceRequest {
410 requested_ticker,
411 resolved_symbol,
412 }
413 }
414
415 fn normalize_host_error(error: ToolError, timeout_ms: u64) -> ToolError {
416 match error {
417 ToolError::Timeout { .. } => ToolError::Timeout {
418 seconds: millis_to_timeout_seconds(timeout_ms),
419 },
420 error => error,
421 }
422 }
423
424 /// The inner error is an upstream/business failure and may take the existing
425 /// chart fallback. Host admission/runtime/result errors are outer errors and
426 /// leave immediately, so an enabled Host failure never runs Rust normalization.
427 async fn fetch_host_endpoint(
428 client: &Client,
429 timeout: Duration,
430 endpoints: &FinanceEndpoints,
431 request: &FinanceRequest,
432 context: &ToolContext,
433 deadline: std::time::Instant,
434 chart: bool,
435 ) -> Result<Result<FinanceQuoteResponse, AttemptFailure>, ToolError> {
436 let endpoint = if chart { CHART_SOURCE } else { QUOTE_SOURCE };
437 let url = if chart {
438 endpoints.chart_url(&request.resolved_symbol)
439 } else {
440 endpoints.quote_url(&request.resolved_symbol)
441 };
442 let body = tokio::select! {
443 biased;
444 _ = async { if let Some(cancel) = context.cancel_token.as_ref() { cancel.cancelled().await } else { std::future::pending::<()>().await } } => return Err(ToolError::not_available("finance cancelled")),
445 body = fetch_response_body_bounded(client, timeout, &url, endpoint) => match body { Ok(body) => body, Err(error) => return Ok(Err(error)) },
446 };
447 let parsed = if chart {
448 serde_json::from_str::<ChartEndpointResponse>(&body).and_then(serde_json::to_value)
449 } else {
450 serde_json::from_str::<QuoteEndpointResponse>(&body).and_then(serde_json::to_value)
451 };
452 let parsed = match parsed {
453 Ok(parsed) => parsed,
454 Err(error) => {
455 return Ok(Err(AttemptFailure::upstream(
456 endpoint,
457 format!("invalid JSON response: {error}"),
458 )));
459 }
460 };
461 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
462 if remaining.is_zero() {
463 return Ok(Err(AttemptFailure::timeout(endpoint)));
464 }
465 let result = crate::extension_host::manager()
466 .execute_stock(
467 if chart {
468 crate::extension_host::StockOperation::FinanceChart
469 } else {
470 crate::extension_host::StockOperation::FinanceQuote
471 },
472 json!({"request":request,"parsed":parsed}),
473 context,
474 remaining,
475 )
476 .await?;
477 let metadata = result
478 .metadata
479 .ok_or_else(|| ToolError::execution_failed("Host finance result omitted metadata"))?;
480 if result.success {
481 let quote: FinanceQuoteResponse = serde_json::from_value(metadata)
482 .map_err(|_| ToolError::execution_failed("Host finance result malformed"))?;
483 if quote.requested_ticker != request.requested_ticker
484 || quote.source != endpoint
485 || quote.fallback_used != chart
486 || !quote.price.is_finite()
487 {
488 return Err(ToolError::execution_failed(
489 "Host finance result changed its captured request",
490 ));
491 }
492 Ok(Ok(quote))
493 } else {
494 if metadata.get("endpoint").and_then(Value::as_str) != Some(endpoint) {
495 return Err(ToolError::execution_failed(
496 "Host finance failure changed its endpoint",
497 ));
498 }
499 let detail = metadata
500 .get("detail")
501 .and_then(Value::as_str)
502 .ok_or_else(|| ToolError::execution_failed("Host finance failure omitted detail"))?;
503 let failure = match metadata.get("kind").and_then(Value::as_str) {
504 Some("not_found") => AttemptFailure::not_found(endpoint, detail),
505 Some("upstream") => AttemptFailure::upstream(endpoint, detail),
506 _ => {
507 return Err(ToolError::execution_failed(
508 "Host finance failure malformed",
509 ));
510 }
511 };
512 Ok(Err(failure))
513 }
514 }
515
516 /// Host captures are bounded before JSON parsing and before broker admission.
517 /// The request's existing timeout includes every streamed body chunk.
518 async fn fetch_response_body_bounded(
519 client: &Client,
520 timeout: Duration,
521 url: &str,
522 endpoint: &'static str,
523 ) -> Result<String, AttemptFailure> {
524 const MAX_BODY: usize = 1024 * 1024;
525 let mut response = client
526 .get(url)
527 .timeout(timeout)
528 .send()
529 .await
530 .map_err(|error| {
531 if error.is_timeout() {
532 AttemptFailure::timeout(endpoint)
533 } else {
534 AttemptFailure::upstream(endpoint, format!("request failed: {error}"))
535 }
536 })?;
537 let status = response.status();
538 let mut bytes = Vec::new();
539 while let Some(chunk) = response.chunk().await.map_err(|error| {
540 if error.is_timeout() {
541 AttemptFailure::timeout(endpoint)
542 } else {
543 AttemptFailure::upstream(endpoint, format!("failed to read response body: {error}"))
544 }
545 })? {
546 if chunk.len() > MAX_BODY.saturating_sub(bytes.len()) {
547 return Err(AttemptFailure::upstream(
548 endpoint,
549 "Host finance response exceeds 1 MiB",
550 ));
551 }
552 bytes.extend_from_slice(&chunk);
553 }
554 let body = String::from_utf8_lossy(&bytes).into_owned();
555 if !status.is_success() {
556 return Err(status_failure(endpoint, status, &body));
557 }
558 Ok(body)
559 }
560
561 async fn fetch_quote_endpoint(
562 client: &Client,
563 timeout: Duration,
564 endpoints: &FinanceEndpoints,
565 request: &FinanceRequest,
566 ) -> Result<FinanceQuoteResponse, AttemptFailure> {
567 let url = endpoints.quote_url(&request.resolved_symbol);
568 let body = fetch_response_body(client, timeout, &url, QUOTE_SOURCE).await?;
569 let parsed: QuoteEndpointResponse = serde_json::from_str(&body).map_err(|e| {
570 AttemptFailure::upstream(QUOTE_SOURCE, format!("invalid JSON response: {e}"))
571 })?;
572
573 let quote = parsed
574 .quote_response
575 .result
576 .into_iter()
577 .find(|item| item.symbol.eq_ignore_ascii_case(&request.resolved_symbol))
578 .ok_or_else(|| {
579 AttemptFailure::not_found(
580 QUOTE_SOURCE,
581 format!("no result for symbol '{}'", request.resolved_symbol),
582 )
583 })?;
584
585 let price = quote.regular_market_price.ok_or_else(|| {
586 AttemptFailure::upstream(QUOTE_SOURCE, "response missing regularMarketPrice")
587 })?;
588 let previous_close = quote.regular_market_previous_close;
589 let change = quote
590 .regular_market_change
591 .or_else(|| compute_change(price, previous_close));
592 let change_percent = quote
593 .regular_market_change_percent
594 .or_else(|| compute_change_percent(price, previous_close));
595
596 Ok(FinanceQuoteResponse {
597 requested_ticker: request.requested_ticker.clone(),
598 ticker: quote.symbol,
599 name: quote.long_name.or(quote.short_name),
600 price,
601 currency: quote.currency,
602 change,
603 change_percent,
604 previous_close,
605 market_state: quote.market_state,
606 quote_type: quote.quote_type,
607 exchange: quote.full_exchange_name.or(quote.exchange),
608 market_time: quote.regular_market_time,
609 source: QUOTE_SOURCE.to_string(),
610 fallback_used: false,
611 })
612 }
613
614 async fn fetch_chart_endpoint(
615 client: &Client,
616 timeout: Duration,
617 endpoints: &FinanceEndpoints,
618 request: &FinanceRequest,
619 ) -> Result<FinanceQuoteResponse, AttemptFailure> {
620 let url = endpoints.chart_url(&request.resolved_symbol);
621 let body = fetch_response_body(client, timeout, &url, CHART_SOURCE).await?;
622 let parsed: ChartEndpointResponse = serde_json::from_str(&body).map_err(|e| {
623 AttemptFailure::upstream(CHART_SOURCE, format!("invalid JSON response: {e}"))
624 })?;
625
626 if let Some(error) = parsed.chart.error {
627 let description = error
628 .description
629 .unwrap_or_else(|| "chart endpoint returned an error".to_string());
630 if error
631 .code
632 .as_deref()
633 .is_some_and(|code| code.eq_ignore_ascii_case("Not Found"))
634 || description.to_ascii_lowercase().contains("not found")
635 || description
636 .to_ascii_lowercase()
637 .contains("symbol may be delisted")
638 {
639 return Err(AttemptFailure::not_found(CHART_SOURCE, description));
640 }
641 return Err(AttemptFailure::upstream(CHART_SOURCE, description));
642 }
643
644 let result = parsed
645 .chart
646 .result
647 .and_then(|mut entries| entries.drain(..).next())
648 .ok_or_else(|| {
649 AttemptFailure::not_found(
650 CHART_SOURCE,
651 format!("no chart data for symbol '{}'", request.resolved_symbol),
652 )
653 })?;
654
655 let meta = result.meta;
656 let price = meta.regular_market_price.ok_or_else(|| {
657 AttemptFailure::upstream(CHART_SOURCE, "response missing regularMarketPrice")
658 })?;
659 let previous_close = meta.chart_previous_close.or(meta.previous_close);
660 let change = compute_change(price, previous_close);
661 let change_percent = compute_change_percent(price, previous_close);
662
663 Ok(FinanceQuoteResponse {
664 requested_ticker: request.requested_ticker.clone(),
665 ticker: meta.symbol,
666 name: meta.long_name.or(meta.short_name),
667 price,
668 currency: meta.currency,
669 change,
670 change_percent,
671 previous_close,
672 market_state: None,
673 quote_type: meta.instrument_type,
674 exchange: meta.full_exchange_name.or(meta.exchange_name),
675 market_time: meta.regular_market_time,
676 source: CHART_SOURCE.to_string(),
677 fallback_used: true,
678 })
679 }
680
681 async fn fetch_response_body(
682 client: &Client,
683 timeout: Duration,
684 url: &str,
685 endpoint: &'static str,
686 ) -> Result<String, AttemptFailure> {
687 let response = client
688 .get(url)
689 .timeout(timeout)
690 .send()
691 .await
692 .map_err(|err| {
693 if err.is_timeout() {
694 AttemptFailure::timeout(endpoint)
695 } else {
696 AttemptFailure::upstream(endpoint, format!("request failed: {err}"))
697 }
698 })?;
699
700 let status = response.status();
701 let body = response.text().await.map_err(|err| {
702 if err.is_timeout() {
703 AttemptFailure::timeout(endpoint)
704 } else {
705 AttemptFailure::upstream(endpoint, format!("failed to read response body: {err}"))
706 }
707 })?;
708
709 if !status.is_success() {
710 return Err(status_failure(endpoint, status, &body));
711 }
712
713 Ok(body)
714 }
715
716 fn status_failure(endpoint: &'static str, status: StatusCode, body: &str) -> AttemptFailure {
717 if endpoint == CHART_SOURCE && status == StatusCode::NOT_FOUND {
718 return AttemptFailure::not_found(endpoint, format!("HTTP {}", status.as_u16()));
719 }
720
721 let snippet = body.trim();
722 let detail = if snippet.is_empty() {
723 format!("HTTP {}", status.as_u16())
724 } else {
725 format!("HTTP {} ({})", status.as_u16(), truncate_for_error(snippet))
726 };
727
728 AttemptFailure::upstream(endpoint, detail)
729 }
730
731 fn finalize_failure(
732 request: &FinanceRequest,
733 timeout_ms: u64,
734 failures: &[AttemptFailure],
735 ) -> ToolError {
736 if failures.iter().all(AttemptFailure::is_not_found) {
737 return ToolError::invalid_input(format!(
738 "Unknown finance ticker '{}'",
739 request.requested_ticker
740 ));
741 }
742
743 if failures.iter().any(AttemptFailure::is_timeout) {
744 return ToolError::Timeout {
745 seconds: millis_to_timeout_seconds(timeout_ms),
746 };
747 }
748
749 let detail = failures
750 .iter()
751 .map(AttemptFailure::summary)
752 .collect::<Vec<_>>()
753 .join("; ");
754 ToolError::execution_failed(format!(
755 "Finance lookup failed for '{}': {}",
756 request.requested_ticker, detail
757 ))
758 }
759
760 fn compute_change(price: f64, previous_close: Option<f64>) -> Option<f64> {
761 previous_close.map(|prev| price - prev)
762 }
763
764 fn compute_change_percent(price: f64, previous_close: Option<f64>) -> Option<f64> {
765 previous_close.and_then(|prev| {
766 if prev.abs() < f64::EPSILON {
767 None
768 } else {
769 Some(((price - prev) / prev) * 100.0)
770 }
771 })
772 }
773
774 fn millis_to_timeout_seconds(timeout_ms: u64) -> u64 {
775 timeout_ms.saturating_add(999) / 1000
776 }
777
778 fn truncate_for_error(text: &str) -> String {
779 const MAX_ERROR_CHARS: usize = 120;
780 let mut out = String::new();
781 for ch in text.chars().take(MAX_ERROR_CHARS) {
782 out.push(ch);
783 }
784 if text.chars().count() > MAX_ERROR_CHARS {
785 out.push_str("...");
786 }
787 out
788 }
789
790 #[derive(Debug, Deserialize, Serialize)]
791 #[serde(rename_all = "camelCase")]
792 struct QuoteEndpointResponse {
793 quote_response: QuoteResponseBody,
794 }
795
796 #[derive(Debug, Deserialize, Serialize)]
797 struct QuoteResponseBody {
798 result: Vec<QuoteItem>,
799 }
800
801 #[derive(Debug, Deserialize, Serialize)]
802 #[serde(rename_all = "camelCase")]
803 struct QuoteItem {
804 symbol: String,
805 #[serde(default)]
806 short_name: Option<String>,
807 #[serde(default)]
808 long_name: Option<String>,
809 #[serde(default)]
810 regular_market_price: Option<f64>,
811 #[serde(default)]
812 regular_market_change: Option<f64>,
813 #[serde(default)]
814 regular_market_change_percent: Option<f64>,
815 #[serde(default)]
816 regular_market_previous_close: Option<f64>,
817 #[serde(default)]
818 #[serde(serialize_with = "serialize_timestamp")]
819 regular_market_time: Option<i64>,
820 #[serde(default)]
821 market_state: Option<String>,
822 #[serde(default)]
823 quote_type: Option<String>,
824 #[serde(default)]
825 currency: Option<String>,
826 #[serde(default)]
827 exchange: Option<String>,
828 #[serde(default)]
829 full_exchange_name: Option<String>,
830 }
831
832 #[derive(Debug, Deserialize, Serialize)]
833 struct ChartEndpointResponse {
834 chart: ChartBody,
835 }
836
837 #[derive(Debug, Deserialize, Serialize)]
838 struct ChartBody {
839 #[serde(default)]
840 result: Option<Vec<ChartResult>>,
841 #[serde(default)]
842 error: Option<ChartErrorBody>,
843 }
844
845 #[derive(Debug, Deserialize, Serialize)]
846 struct ChartResult {
847 meta: ChartMeta,
848 }
849
850 #[derive(Debug, Deserialize, Serialize)]
851 #[serde(rename_all = "camelCase")]
852 struct ChartMeta {
853 symbol: String,
854 #[serde(default)]
855 short_name: Option<String>,
856 #[serde(default)]
857 long_name: Option<String>,
858 #[serde(default)]
859 currency: Option<String>,
860 #[serde(default)]
861 regular_market_price: Option<f64>,
862 #[serde(default)]
863 #[serde(serialize_with = "serialize_timestamp")]
864 regular_market_time: Option<i64>,
865 #[serde(default)]
866 chart_previous_close: Option<f64>,
867 #[serde(default)]
868 previous_close: Option<f64>,
869 #[serde(default)]
870 instrument_type: Option<String>,
871 #[serde(default)]
872 exchange_name: Option<String>,
873 #[serde(default)]
874 full_exchange_name: Option<String>,
875 }
876
877 #[derive(Debug, Deserialize, Serialize)]
878 struct ChartErrorBody {
879 #[serde(default)]
880 code: Option<String>,
881 #[serde(default)]
882 description: Option<String>,
883 }
884
885 #[cfg(test)]
886 mod tests {
887 use super::*;
888 use tempfile::tempdir;
889 use wiremock::matchers::{method, path, query_param};
890 use wiremock::{Mock, MockServer, ResponseTemplate};
891
892 fn tool_with_server(server: &MockServer) -> FinanceTool {
893 FinanceTool::with_endpoints(
894 server.uri().to_string() + "/quote",
895 server.uri().to_string() + "/chart",
896 )
897 }
898
899 fn context() -> (ToolContext, tempfile::TempDir) {
900 let tmp = tempdir().expect("tempdir");
901 let path = tmp.path().to_path_buf();
902 let ctx = ToolContext::new(path);
903 (ctx, tmp)
904 }
905
906 #[tokio::test]
907 async fn finance_uses_quote_endpoint_when_available() {
908 let server = MockServer::start().await;
909 Mock::given(method("GET"))
910 .and(path("/quote"))
911 .and(query_param("symbols", "AAPL"))
912 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
913 "quoteResponse": {
914 "result": [{
915 "symbol": "AAPL",
916 "shortName": "Apple Inc.",
917 "regularMarketPrice": 189.23,
918 "regularMarketChange": 1.12,
919 "regularMarketChangePercent": 0.595,
920 "regularMarketPreviousClose": 188.11,
921 "regularMarketTime": 1_710_000_000,
922 "marketState": "REGULAR",
923 "quoteType": "EQUITY",
924 "currency": "USD",
925 "fullExchangeName": "NasdaqGS"
926 }]
927 }
928 })))
929 .mount(&server)
930 .await;
931
932 let tool = tool_with_server(&server);
933 let result = tool
934 .execute(json!({"ticker": "aapl"}), &context().0)
935 .await
936 .expect("finance quote should succeed");
937
938 let parsed: serde_json::Value =
939 serde_json::from_str(&result.content).expect("tool output should be json");
940 assert_eq!(parsed["requested_ticker"], "AAPL");
941 assert_eq!(parsed["ticker"], "AAPL");
942 assert_eq!(parsed["source"], QUOTE_SOURCE);
943 assert_eq!(parsed["fallback_used"], false);
944 assert_eq!(parsed["price"], 189.23);
945 }
946
947 #[tokio::test]
948 async fn finance_falls_back_to_chart_for_btc() {
949 let server = MockServer::start().await;
950 Mock::given(method("GET"))
951 .and(path("/quote"))
952 .and(query_param("symbols", "BTC-USD"))
953 .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
954 .mount(&server)
955 .await;
956 Mock::given(method("GET"))
957 .and(path("/chart/BTC-USD"))
958 .and(query_param("interval", "1d"))
959 .and(query_param("range", "5d"))
960 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
961 "chart": {
962 "result": [{
963 "meta": {
964 "symbol": "BTC-USD",
965 "longName": "Bitcoin USD",
966 "currency": "USD",
967 "regularMarketPrice": 73474.88,
968 "regularMarketTime": 1_710_000_001,
969 "chartPreviousClose": 72974.19,
970 "instrumentType": "CRYPTOCURRENCY",
971 "fullExchangeName": "CCC"
972 }
973 }],
974 "error": null
975 }
976 })))
977 .mount(&server)
978 .await;
979
980 let tool = tool_with_server(&server);
981 let result = tool
982 .execute(json!({"ticker": "BTC", "type": "crypto"}), &context().0)
983 .await
984 .expect("finance chart fallback should succeed");
985
986 let parsed: serde_json::Value =
987 serde_json::from_str(&result.content).expect("tool output should be json");
988 assert_eq!(parsed["requested_ticker"], "BTC");
989 assert_eq!(parsed["ticker"], "BTC-USD");
990 assert_eq!(parsed["source"], CHART_SOURCE);
991 assert_eq!(parsed["fallback_used"], true);
992 assert_eq!(parsed["quote_type"], "CRYPTOCURRENCY");
993 }
994
995 #[tokio::test]
996 async fn finance_reports_invalid_symbol() {
997 let server = MockServer::start().await;
998 Mock::given(method("GET"))
999 .and(path("/quote"))
1000 .and(query_param("symbols", "NOTREAL"))
1001 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1002 "quoteResponse": {
1003 "result": []
1004 }
1005 })))
1006 .mount(&server)
1007 .await;
1008 Mock::given(method("GET"))
1009 .and(path("/chart/NOTREAL"))
1010 .and(query_param("interval", "1d"))
1011 .and(query_param("range", "5d"))
1012 .respond_with(ResponseTemplate::new(404))
1013 .mount(&server)
1014 .await;
1015
1016 let tool = tool_with_server(&server);
1017 let err = tool
1018 .execute(json!({"ticker": "NOTREAL"}), &context().0)
1019 .await
1020 .expect_err("invalid symbol should error");
1021
1022 assert!(matches!(err, ToolError::InvalidInput { .. }));
1023 assert!(err.to_string().contains("NOTREAL"));
1024 }
1025
1026 #[tokio::test]
1027 async fn finance_reports_upstream_failure_after_fallback() {
1028 let server = MockServer::start().await;
1029 Mock::given(method("GET"))
1030 .and(path("/quote"))
1031 .and(query_param("symbols", "SPY"))
1032 .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
1033 .mount(&server)
1034 .await;
1035 Mock::given(method("GET"))
1036 .and(path("/chart/SPY"))
1037 .and(query_param("interval", "1d"))
1038 .and(query_param("range", "5d"))
1039 .respond_with(ResponseTemplate::new(503).set_body_string("service unavailable"))
1040 .mount(&server)
1041 .await;
1042
1043 let tool = tool_with_server(&server);
1044 let err = tool
1045 .execute(json!({"ticker": "SPY"}), &context().0)
1046 .await
1047 .expect_err("double upstream failure should error");
1048
1049 match err {
1050 ToolError::ExecutionFailed { message, .. } => {
1051 assert!(message.contains(QUOTE_SOURCE));
1052 assert!(message.contains("HTTP 401"));
1053 assert!(message.contains(CHART_SOURCE));
1054 assert!(message.contains("HTTP 503"));
1055 }
1056 other => panic!("unexpected error: {other:?}"),
1057 }
1058 }
1059
1060 #[tokio::test]
1061 async fn finance_does_not_mask_upstream_failure_with_chart_not_found() {
1062 let server = MockServer::start().await;
1063 Mock::given(method("GET"))
1064 .and(path("/quote"))
1065 .and(query_param("symbols", "SPY"))
1066 .respond_with(ResponseTemplate::new(503).set_body_string("service unavailable"))
1067 .mount(&server)
1068 .await;
1069 Mock::given(method("GET"))
1070 .and(path("/chart/SPY"))
1071 .and(query_param("interval", "1d"))
1072 .and(query_param("range", "5d"))
1073 .respond_with(ResponseTemplate::new(404))
1074 .mount(&server)
1075 .await;
1076
1077 let tool = tool_with_server(&server);
1078 let err = tool
1079 .execute(json!({"ticker": "SPY"}), &context().0)
1080 .await
1081 .expect_err("mixed upstream/not-found failures should not look like an invalid symbol");
1082
1083 match err {
1084 ToolError::ExecutionFailed { message, .. } => {
1085 assert!(message.contains(QUOTE_SOURCE));
1086 assert!(message.contains("HTTP 503"));
1087 assert!(message.contains(CHART_SOURCE));
1088 assert!(message.contains("HTTP 404"));
1089 }
1090 other => panic!("unexpected error: {other:?}"),
1091 }
1092 }
1093
1094 #[tokio::test]
1095 async fn finance_does_not_mask_quote_auth_failure_with_unknown_symbol() {
1096 let server = MockServer::start().await;
1097 Mock::given(method("GET"))
1098 .and(path("/quote"))
1099 .and(query_param("symbols", "SPY"))
1100 .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
1101 .mount(&server)
1102 .await;
1103 Mock::given(method("GET"))
1104 .and(path("/chart/SPY"))
1105 .and(query_param("interval", "1d"))
1106 .and(query_param("range", "5d"))
1107 .respond_with(ResponseTemplate::new(404))
1108 .mount(&server)
1109 .await;
1110
1111 let tool = tool_with_server(&server);
1112 let err = tool
1113 .execute(json!({"ticker": "SPY"}), &context().0)
1114 .await
1115 .expect_err("quote auth failures should not collapse into invalid input");
1116
1117 match err {
1118 ToolError::ExecutionFailed { message, .. } => {
1119 assert!(message.contains(QUOTE_SOURCE));
1120 assert!(message.contains("HTTP 401"));
1121 assert!(message.contains(CHART_SOURCE));
1122 assert!(message.contains("HTTP 404"));
1123 }
1124 other => panic!("unexpected error: {other:?}"),
1125 }
1126 }
1127
1128 #[tokio::test]
1129 async fn finance_reports_timeout_when_fallback_times_out() {
1130 let server = MockServer::start().await;
1131 Mock::given(method("GET"))
1132 .and(path("/quote"))
1133 .and(query_param("symbols", "AAPL"))
1134 .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
1135 .mount(&server)
1136 .await;
1137 Mock::given(method("GET"))
1138 .and(path("/chart/AAPL"))
1139 .and(query_param("interval", "1d"))
1140 .and(query_param("range", "5d"))
1141 .respond_with(
1142 ResponseTemplate::new(200)
1143 .set_delay(Duration::from_millis(250))
1144 .set_body_json(json!({
1145 "chart": {
1146 "result": [{
1147 "meta": {
1148 "symbol": "AAPL",
1149 "regularMarketPrice": 260.48,
1150 "chartPreviousClose": 255.92
1151 }
1152 }],
1153 "error": null
1154 }
1155 })),
1156 )
1157 .mount(&server)
1158 .await;
1159
1160 let tool = tool_with_server(&server);
1161 let err = tool
1162 .execute(json!({"ticker": "AAPL", "timeout_ms": 1}), &context().0)
1163 .await
1164 .expect_err("timeout should surface cleanly");
1165
1166 assert!(matches!(err, ToolError::Timeout { .. }));
1167 }
1168
1169 /// #6557 D03-m3: the chart fallback used to get a fresh full timeout, so
1170 /// a lookup could run for twice `timeout_ms`. Here the quote attempt
1171 /// spends the whole budget; the fallback, which would answer in 150ms,
1172 /// must not be given a second budget.
1173 #[tokio::test]
1174 async fn finance_fallback_shares_one_timeout_budget() {
1175 let server = MockServer::start().await;
1176 Mock::given(method("GET"))
1177 .and(path("/quote"))
1178 .and(query_param("symbols", "AAPL"))
1179 .respond_with(
1180 ResponseTemplate::new(200)
1181 .set_delay(Duration::from_millis(3_000))
1182 .set_body_json(json!({"quoteResponse": {"result": []}})),
1183 )
1184 .mount(&server)
1185 .await;
1186 Mock::given(method("GET"))
1187 .and(path("/chart/AAPL"))
1188 .and(query_param("interval", "1d"))
1189 .and(query_param("range", "5d"))
1190 .respond_with(
1191 ResponseTemplate::new(200)
1192 .set_delay(Duration::from_millis(150))
1193 .set_body_json(json!({
1194 "chart": {
1195 "result": [{
1196 "meta": {
1197 "symbol": "AAPL",
1198 "regularMarketPrice": 260.48,
1199 "chartPreviousClose": 255.92
1200 }
1201 }],
1202 "error": null
1203 }
1204 })),
1205 )
1206 .mount(&server)
1207 .await;
1208
1209 let tool = tool_with_server(&server);
1210 let started = std::time::Instant::now();
1211 let err = tool
1212 .execute(json!({"ticker": "AAPL", "timeout_ms": 500}), &context().0)
1213 .await
1214 .expect_err("the shared budget is spent before the fallback can answer");
1215 assert!(matches!(err, ToolError::Timeout { seconds: 1 }), "{err:?}");
1216 assert!(
1217 started.elapsed() < Duration::from_millis(2_500),
1218 "lookup overran its budget: {:?}",
1219 started.elapsed()
1220 );
1221 }
1222
1223 /// JSON cannot carry NaN or infinity, and an overflowing literal is a
1224 /// parse error rather than an infinite price.
1225 #[tokio::test]
1226 async fn finance_rejects_an_overflowing_price_literal() {
1227 let server = MockServer::start().await;
1228 Mock::given(method("GET"))
1229 .and(path("/quote"))
1230 .and(query_param("symbols", "AAPL"))
1231 .respond_with(ResponseTemplate::new(200).set_body_raw(
1232 r#"{"quoteResponse":{"result":[{"symbol":"AAPL","regularMarketPrice":1e400}]}}"#,
1233 "application/json",
1234 ))
1235 .mount(&server)
1236 .await;
1237 Mock::given(method("GET"))
1238 .and(path("/chart/AAPL"))
1239 .respond_with(ResponseTemplate::new(404))
1240 .mount(&server)
1241 .await;
1242
1243 let err = tool_with_server(&server)
1244 .execute(json!({"ticker": "AAPL"}), &context().0)
1245 .await
1246 .expect_err("an overflowing price is not a quote");
1247 assert!(err.to_string().contains("invalid JSON response"), "{err}");
1248 }
1249
1250 #[tokio::test]
1251 async fn finance_prefers_timeout_over_unknown_symbol_when_any_attempt_times_out() {
1252 let server = MockServer::start().await;
1253 Mock::given(method("GET"))
1254 .and(path("/quote"))
1255 .and(query_param("symbols", "AAPL"))
1256 .respond_with(
1257 ResponseTemplate::new(200)
1258 .set_delay(Duration::from_millis(250))
1259 .set_body_json(json!({
1260 "quoteResponse": {
1261 "result": [{
1262 "symbol": "AAPL",
1263 "regularMarketPrice": 189.23
1264 }]
1265 }
1266 })),
1267 )
1268 .mount(&server)
1269 .await;
1270 Mock::given(method("GET"))
1271 .and(path("/chart/AAPL"))
1272 .and(query_param("interval", "1d"))
1273 .and(query_param("range", "5d"))
1274 .respond_with(ResponseTemplate::new(404))
1275 .mount(&server)
1276 .await;
1277
1278 let tool = tool_with_server(&server);
1279 let err = tool
1280 .execute(json!({"ticker": "AAPL", "timeout_ms": 1}), &context().0)
1281 .await
1282 .expect_err("timeout should win over a later chart not-found");
1283
1284 assert!(matches!(err, ToolError::Timeout { .. }));
1285 }
1286
1287 #[test]
1288 fn finance_schema_allows_ticker_or_symbol() {
1289 let schema = FinanceTool::new().input_schema();
1290 let any_of = schema["anyOf"]
1291 .as_array()
1292 .expect("finance schema should advertise alternate required fields");
1293
1294 assert_eq!(any_of.len(), 2);
1295 assert_eq!(any_of[0]["required"], json!(["ticker"]));
1296 assert_eq!(any_of[1]["required"], json!(["symbol"]));
1297 }
1298
1299 fn denied_context_for(host: &str) -> (ToolContext, tempfile::TempDir) {
1300 use crate::network_policy::{NetworkPolicy, NetworkPolicyDecider};
1301 let (ctx, tmp) = context();
1302 let policy = NetworkPolicy {
1303 default: Decision::Allow.into(),
1304 allow: Vec::new(),
1305 deny: vec![host.to_string()],
1306 proxy: Vec::new(),
1307 proxy_fake_ip_cidrs: Vec::new(),
1308 audit: false,
1309 };
1310 (
1311 ctx.with_network_policy(NetworkPolicyDecider::new(policy, None)),
1312 tmp,
1313 )
1314 }
1315
1316 #[tokio::test]
1317 async fn finance_fails_closed_when_network_policy_denies_endpoint_host() {
1318 let server = MockServer::start().await;
1319 Mock::given(method("GET"))
1320 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1321 "quoteResponse": {"result": []}
1322 })))
1323 .mount(&server)
1324 .await;
1325
1326 let host = reqwest::Url::parse(&server.uri())
1327 .expect("mock server URL")
1328 .host_str()
1329 .expect("mock server host")
1330 .to_string();
1331 let (blocked, _tmp) = denied_context_for(&host);
1332
1333 let tool = tool_with_server(&server);
1334 let error = tool
1335 .execute(json!({"ticker": "AAPL"}), &blocked)
1336 .await
1337 .expect_err("denied host must fail closed");
1338 assert!(
1339 error.to_string().contains("blocked by network policy"),
1340 "{error}"
1341 );
1342 assert_eq!(
1343 server
1344 .received_requests()
1345 .await
1346 .expect("recorded requests")
1347 .len(),
1348 0,
1349 "no request may leave before the policy check"
1350 );
1351 }
1352
1353 #[tokio::test]
1354 async fn finance_fails_closed_on_prompt_when_default_is_prompt() {
1355 let server = MockServer::start().await;
1356
1357 // default = prompt with no allow list: the undecided host must fail
1358 // closed with the approval hint, never with a silent request.
1359 let (ctx, tmp) = context();
1360 use crate::network_policy::{NetworkPolicy, NetworkPolicyDecider};
1361 let policy = NetworkPolicy {
1362 default: Decision::Prompt.into(),
1363 allow: Vec::new(),
1364 deny: Vec::new(),
1365 proxy: Vec::new(),
1366 proxy_fake_ip_cidrs: Vec::new(),
1367 audit: false,
1368 };
1369 let blocked = ctx.with_network_policy(NetworkPolicyDecider::new(policy, None));
1370 drop(tmp);
1371
1372 let tool = tool_with_server(&server);
1373 let error = tool
1374 .execute(json!({"ticker": "AAPL"}), &blocked)
1375 .await
1376 .expect_err("undecided host must not reach the endpoint");
1377 assert!(
1378 error.to_string().contains("requires approval"),
1379 "unexpected error: {error}"
1380 );
1381 assert_eq!(
1382 server
1383 .received_requests()
1384 .await
1385 .expect("recorded requests")
1386 .len(),
1387 0
1388 );
1389 }
1390 }
1391
1392 #[cfg(test)]
1393 #[path = "stock_host_tests.rs"]
1394 mod stock_host_tests;
1395
1395 lines RUST