Skip to main content

tvscreener/mcp/
query.rs

1// Copyright 2026 tvscreener-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Screener query helpers for MCP tools.
5
6use crate::core::crypto::CryptoScreener;
7use crate::core::forex::ForexScreener;
8use crate::core::stock::StockScreener;
9use crate::core::Screener;
10use crate::error::{Result, TvscreenerError};
11use crate::field::{Asset, FieldDef};
12use crate::filter::{FieldCondition, FilterOperator};
13use crate::query_config::{apply_filters_json, apply_sort, parse_filters_arg, require_resolved};
14use crate::ScreenerRow;
15use serde_json::{json, Value};
16
17use super::format::format_rows_markdown;
18use super::resolve::{apply_stock_index_markets, parse_asset, parse_csv_tokens};
19
20fn where_gte(field: impl Into<String>, value: Value) -> FieldCondition {
21    FieldCondition::new(field, FilterOperator::AboveOrEqual, value)
22}
23
24fn where_lte(field: impl Into<String>, value: Value) -> FieldCondition {
25    FieldCondition::new(field, FilterOperator::BelowOrEqual, value)
26}
27
28fn apply_select(screener: &mut Screener, asset: Asset, fields: &[String]) -> Result<()> {
29    let mut selected = Vec::new();
30    for name in fields {
31        selected.push(require_resolved(asset, name)?);
32    }
33    if !selected.is_empty() {
34        screener.select(selected);
35    }
36    Ok(())
37}
38
39async fn run_on_screener<F>(asset: Asset, configure: F) -> Result<Vec<ScreenerRow>>
40where
41    F: FnOnce(&mut Screener) -> Result<()>,
42{
43    crate::core::get_for_asset(asset, configure).await
44}
45
46/// Options for [`custom_query`].
47#[derive(Debug, Clone, Copy)]
48pub struct CustomQueryOpts<'a> {
49    /// Asset key (`stock`, `crypto`, …).
50    pub asset_type: &'a str,
51    /// Comma-separated field const names.
52    pub fields: Option<&'a str>,
53    /// JSON array of `{field, op, value}` filters.
54    pub filters: Option<&'a str>,
55    /// Sort field const name.
56    pub sort_by: Option<&'a str>,
57    /// Sort ascending when true.
58    pub ascending: bool,
59    /// Row limit (clamped 1..=100).
60    pub limit: u32,
61}
62
63/// Runs a flexible custom screener query and returns Markdown.
64///
65/// # Errors
66///
67/// Propagates invalid args and HTTP/scan failures.
68pub async fn custom_query(opts: &CustomQueryOpts<'_>) -> Result<String> {
69    let asset = parse_asset(opts.asset_type)?;
70    let limit = opts.limit.clamp(1, 100);
71    let field_list = parse_csv_tokens(opts.fields);
72    let filter_list = parse_filters_arg(opts.filters)?;
73    let sort_by = opts.sort_by.map(str::trim).filter(|s| !s.is_empty());
74
75    let rows = run_on_screener(asset, |s| {
76        if !field_list.is_empty() {
77            apply_select(s, asset, &field_list)?;
78        }
79        apply_filters_json(s, asset, &filter_list)?;
80        apply_sort(s, asset, sort_by, opts.ascending)?;
81        s.set_range(0, limit);
82        Ok(())
83    })
84    .await?;
85
86    Ok(format_rows_markdown(&rows, limit as usize))
87}
88
89/// Options for [`search_stocks`].
90#[derive(Debug, Clone, Copy)]
91pub struct SearchStocksOpts<'a> {
92    /// Lower bound for price filter (inclusive).
93    pub min_price: Option<f64>,
94    /// Upper bound for price filter (inclusive).
95    pub max_price: Option<f64>,
96    /// Lower bound for market cap in billions (inclusive).
97    pub min_market_cap_billions: Option<f64>,
98    /// Upper bound for market cap in billions (inclusive).
99    pub max_market_cap_billions: Option<f64>,
100    /// Comma-separated sector names for `SECTOR match` filters.
101    pub sectors: Option<&'a str>,
102    /// Sort key: `"price"`, `"volume"`, `"change"`, or `"market_cap"` (default).
103    pub sort_by: &'a str,
104    /// Row limit (clamped 1..=100).
105    pub limit: u32,
106}
107
108/// Screens stocks with common price / market-cap / sector filters.
109///
110/// # Errors
111///
112/// Propagates scan failures.
113pub async fn search_stocks(opts: &SearchStocksOpts<'_>) -> Result<String> {
114    let limit = opts.limit.clamp(1, 100);
115    let mut ss = StockScreener::new();
116    let select = [
117        require_resolved(Asset::Stock, "NAME")?,
118        require_resolved(Asset::Stock, "PRICE")?,
119        require_resolved(Asset::Stock, "CHANGE_PERCENT")?,
120        require_resolved(Asset::Stock, "VOLUME")?,
121        require_resolved(Asset::Stock, "MARKET_CAPITALIZATION")?,
122        require_resolved(Asset::Stock, "PRICE_TO_EARNINGS_RATIO_TTM")?,
123        require_resolved(Asset::Stock, "SECTOR")?,
124    ];
125    ss.select(select);
126
127    let price = require_resolved(Asset::Stock, "PRICE")?;
128    let mcap = require_resolved(Asset::Stock, "MARKET_CAPITALIZATION")?;
129    let sector = require_resolved(Asset::Stock, "SECTOR")?;
130
131    if let Some(min) = opts.min_price {
132        ss.where_condition(where_gte(&price.field_name, json!(min)))?;
133    }
134    if let Some(max) = opts.max_price {
135        ss.where_condition(where_lte(&price.field_name, json!(max)))?;
136    }
137    if let Some(min_b) = opts.min_market_cap_billions {
138        ss.where_condition(where_gte(&mcap.field_name, json!(min_b * 1e9)))?;
139    }
140    if let Some(max_b) = opts.max_market_cap_billions {
141        ss.where_condition(where_lte(&mcap.field_name, json!(max_b * 1e9)))?;
142    }
143    if let Some(sectors) = opts.sectors {
144        for wire in crate::mcp::resolve::resolve_sector_wires(Some(sectors))? {
145            ss.where_condition(FieldCondition::new(
146                &sector.field_name,
147                FilterOperator::Match,
148                json!(wire),
149            ))?;
150        }
151    }
152
153    let sort_key = match opts.sort_by {
154        "price" => "PRICE",
155        "volume" => "VOLUME",
156        "change" => "CHANGE_PERCENT",
157        _ => "MARKET_CAPITALIZATION",
158    };
159    let sort_field = require_resolved(Asset::Stock, sort_key)?;
160    ss.inner_mut().sort_by_field(&sort_field, false);
161    ss.set_range(0, limit);
162    let rows = ss.get().await?;
163    Ok(format_rows_markdown(&rows, limit as usize))
164}
165
166/// Screens crypto with volume / market-cap filters.
167///
168/// # Errors
169///
170/// Propagates scan failures.
171pub async fn search_crypto(
172    min_volume_millions: Option<f64>,
173    min_market_cap_billions: Option<f64>,
174    limit: u32,
175) -> Result<String> {
176    let limit = limit.clamp(1, 100);
177    let mut cs = CryptoScreener::new();
178    cs.select([
179        require_resolved(Asset::Crypto, "NAME")?,
180        require_resolved(Asset::Crypto, "PRICE")?,
181        require_resolved(Asset::Crypto, "CHANGE_PERCENT")?,
182        require_resolved(Asset::Crypto, "VOLUME_24H_IN_USD")?,
183        require_resolved(Asset::Crypto, "MARKET_CAPITALIZATION")?,
184    ]);
185    if let Some(min_m) = min_volume_millions {
186        let vol = require_resolved(Asset::Crypto, "VOLUME_24H_IN_USD")?;
187        cs.where_condition(where_gte(vol.field_name, json!(min_m * 1e6)))?;
188    }
189    if let Some(min_b) = min_market_cap_billions {
190        let mcap = require_resolved(Asset::Crypto, "MARKET_CAPITALIZATION")?;
191        cs.where_condition(where_gte(mcap.field_name, json!(min_b * 1e9)))?;
192    }
193    cs.set_range(0, limit);
194    let rows = cs.get().await?;
195    Ok(format_rows_markdown(&rows, limit as usize))
196}
197
198/// Screens forex pairs.
199///
200/// # Errors
201///
202/// Propagates scan failures.
203pub async fn search_forex(min_volume_millions: Option<f64>, limit: u32) -> Result<String> {
204    let limit = limit.clamp(1, 100);
205    let mut fs = ForexScreener::new();
206    fs.select([
207        require_resolved(Asset::Forex, "NAME")?,
208        require_resolved(Asset::Forex, "PRICE")?,
209        require_resolved(Asset::Forex, "CHANGE_PERCENT")?,
210        require_resolved(Asset::Forex, "VOLUME")?,
211    ]);
212    if let Some(min_m) = min_volume_millions {
213        let vol = require_resolved(Asset::Forex, "VOLUME")?;
214        fs.where_condition(where_gte(vol.field_name, json!(min_m * 1e6)))?;
215    }
216    fs.set_range(0, limit);
217    let rows = fs.get().await?;
218    Ok(format_rows_markdown(&rows, limit as usize))
219}
220
221/// Top gainers or losers for stock or crypto.
222///
223/// # Errors
224///
225/// Propagates scan failures.
226pub async fn get_top_movers(asset_type: &str, direction: &str, limit: u32) -> Result<String> {
227    let limit = limit.clamp(1, 50);
228    let ascending = direction.eq_ignore_ascii_case("losers");
229    let label = if ascending { "Losers" } else { "Gainers" };
230
231    let text = if asset_type.eq_ignore_ascii_case("crypto") {
232        let mut cs = CryptoScreener::new();
233        cs.select([
234            require_resolved(Asset::Crypto, "NAME")?,
235            require_resolved(Asset::Crypto, "PRICE")?,
236            require_resolved(Asset::Crypto, "CHANGE_PERCENT")?,
237            require_resolved(Asset::Crypto, "VOLUME_24H_IN_USD")?,
238        ]);
239        let change = require_resolved(Asset::Crypto, "CHANGE_PERCENT")?;
240        cs.inner_mut().sort_by_field(&change, ascending);
241        cs.set_range(0, limit);
242        let rows = cs.get().await?;
243        format_rows_markdown(&rows, limit as usize)
244    } else {
245        let mut ss = StockScreener::new();
246        ss.select([
247            require_resolved(Asset::Stock, "NAME")?,
248            require_resolved(Asset::Stock, "PRICE")?,
249            require_resolved(Asset::Stock, "CHANGE_PERCENT")?,
250            require_resolved(Asset::Stock, "VOLUME")?,
251            require_resolved(Asset::Stock, "MARKET_CAPITALIZATION")?,
252        ]);
253        let change = require_resolved(Asset::Stock, "CHANGE_PERCENT")?;
254        ss.inner_mut().sort_by_field(&change, ascending);
255        ss.set_range(0, limit);
256        let rows = ss.get().await?;
257        format_rows_markdown(&rows, limit as usize)
258    };
259
260    Ok(format!("Top {label}:\n\n{text}"))
261}
262
263/// Options for [`custom_query_payload_preview`] (dry-run scan payload).
264#[derive(Debug, Clone, Copy)]
265pub struct PayloadPreviewOpts<'a> {
266    /// Asset key (`stock`, `crypto`, …).
267    pub asset_type: &'a str,
268    /// Comma-separated field const names.
269    pub fields: Option<&'a str>,
270    /// JSON array of `{field, op, value}` filters.
271    pub filters: Option<&'a str>,
272    /// Sort field const name.
273    pub sort_by: Option<&'a str>,
274    /// Sort ascending when true.
275    pub ascending: bool,
276    /// Row limit (clamped 1..=100).
277    pub limit: u32,
278    /// Stock index CSV (`SP500` or `SP;SPX`).
279    pub indices: Option<&'a str>,
280    /// Stock markets CSV (`AMERICA` or `america`).
281    pub markets: Option<&'a str>,
282}
283
284/// Builds a filter-merge / select payload without sending HTTP.
285///
286/// Optional `indices` / `markets` CSV apply stock `set_index` / `set_markets`.
287///
288/// # Errors
289///
290/// Returns errors for unknown fields, indices, markets, or bad filter JSON.
291pub fn custom_query_payload_preview(opts: &PayloadPreviewOpts<'_>) -> Result<Value> {
292    let asset = parse_asset(opts.asset_type)?;
293    let limit = opts.limit.clamp(1, 100);
294    let field_list = parse_csv_tokens(opts.fields);
295    let filter_list = parse_filters_arg(opts.filters)?;
296    let sort_by = opts.sort_by.map(str::trim).filter(|s| !s.is_empty());
297
298    crate::core::with_asset_screener(asset, |screener| {
299        if field_list.is_empty() {
300            if screener.selected_fields().is_empty() {
301                screener.select([FieldDef::new("Name", "name")]);
302            }
303        } else {
304            apply_select(screener, asset, &field_list)?;
305        }
306        apply_filters_json(screener, asset, &filter_list)?;
307        apply_sort(screener, asset, sort_by, opts.ascending)?;
308        if asset == Asset::Stock {
309            apply_stock_index_markets(screener, opts.indices, opts.markets)?;
310        } else if opts.indices.is_some() || opts.markets.is_some() {
311            return Err(TvscreenerError::InvalidRequest(
312                "indices/markets only apply to asset_type=stock".into(),
313            ));
314        }
315        screener.set_range(0, limit);
316        screener.build_payload()
317    })
318}
319
320/// Options for [`build_payload`].
321#[derive(Debug, Clone, Copy)]
322pub struct BuildPayloadOpts<'a> {
323    /// Asset key (`stock`, `crypto`, …).
324    pub asset_type: &'a str,
325    /// Comma-separated field const names.
326    pub fields: Option<&'a str>,
327    /// JSON array of `{field, op, value}` filters.
328    pub filters: Option<&'a str>,
329    /// Sort field const name.
330    pub sort_by: Option<&'a str>,
331    /// Stock index CSV (`SP500` or `SP;SPX`).
332    pub indices: Option<&'a str>,
333    /// Stock markets CSV (`AMERICA` or `america`).
334    pub markets: Option<&'a str>,
335}
336
337/// Builds a filter-merge / select payload without sending HTTP.
338///
339/// # Errors
340///
341/// Returns errors for unknown fields or bad filter JSON.
342pub fn build_payload(opts: &BuildPayloadOpts<'_>) -> Result<String> {
343    let payload = custom_query_payload_preview(&PayloadPreviewOpts {
344        asset_type: opts.asset_type,
345        fields: opts.fields,
346        filters: opts.filters,
347        sort_by: opts.sort_by,
348        ascending: false,
349        limit: 25,
350        indices: opts.indices,
351        markets: opts.markets,
352    })?;
353    serde_json::to_string_pretty(&payload).map_err(TvscreenerError::from)
354}
355
356/// Live stock screen filtered by index symbolset(s) and optional markets.
357///
358/// # Errors
359///
360/// Propagates unknown indices/markets and scan failures.
361pub async fn search_by_index(
362    indices: &str,
363    markets: Option<&str>,
364    fields: Option<&str>,
365    sort_by: Option<&str>,
366    limit: u32,
367) -> Result<String> {
368    let limit = limit.clamp(1, 100);
369    let mut ss = StockScreener::new();
370    let field_list = parse_csv_tokens(fields);
371    if field_list.is_empty() {
372        ss.select([
373            require_resolved(Asset::Stock, "NAME")?,
374            require_resolved(Asset::Stock, "PRICE")?,
375            require_resolved(Asset::Stock, "CHANGE_PERCENT")?,
376            require_resolved(Asset::Stock, "VOLUME")?,
377            require_resolved(Asset::Stock, "MARKET_CAPITALIZATION")?,
378            require_resolved(Asset::Stock, "SECTOR")?,
379        ]);
380    } else {
381        apply_select(ss.inner_mut(), Asset::Stock, &field_list)?;
382    }
383    apply_stock_index_markets(ss.inner_mut(), Some(indices), markets)?;
384    apply_sort(
385        ss.inner_mut(),
386        Asset::Stock,
387        sort_by.map(str::trim).filter(|s| !s.is_empty()),
388        false,
389    )?;
390    ss.set_range(0, limit);
391    let rows = ss.get().await?;
392    Ok(format_rows_markdown(&rows, limit as usize))
393}