1use 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#[derive(Debug, Clone, Copy)]
48pub struct CustomQueryOpts<'a> {
49 pub asset_type: &'a str,
51 pub fields: Option<&'a str>,
53 pub filters: Option<&'a str>,
55 pub sort_by: Option<&'a str>,
57 pub ascending: bool,
59 pub limit: u32,
61}
62
63pub 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#[derive(Debug, Clone, Copy)]
91pub struct SearchStocksOpts<'a> {
92 pub min_price: Option<f64>,
94 pub max_price: Option<f64>,
96 pub min_market_cap_billions: Option<f64>,
98 pub max_market_cap_billions: Option<f64>,
100 pub sectors: Option<&'a str>,
102 pub sort_by: &'a str,
104 pub limit: u32,
106}
107
108pub 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 §or.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
166pub 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
198pub 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
221pub 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#[derive(Debug, Clone, Copy)]
265pub struct PayloadPreviewOpts<'a> {
266 pub asset_type: &'a str,
268 pub fields: Option<&'a str>,
270 pub filters: Option<&'a str>,
272 pub sort_by: Option<&'a str>,
274 pub ascending: bool,
276 pub limit: u32,
278 pub indices: Option<&'a str>,
280 pub markets: Option<&'a str>,
282}
283
284pub 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#[derive(Debug, Clone, Copy)]
322pub struct BuildPayloadOpts<'a> {
323 pub asset_type: &'a str,
325 pub fields: Option<&'a str>,
327 pub filters: Option<&'a str>,
329 pub sort_by: Option<&'a str>,
331 pub indices: Option<&'a str>,
333 pub markets: Option<&'a str>,
335}
336
337pub 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
356pub 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}