use serde_json::{json, Value};
use super::{external_http_client, extract_str, parse_json_input};
pub(super) fn schemas() -> Vec<Value> {
vec![json!({
"type": "function",
"function": {
"name": "tv_get_quote",
"description": "TradingView price + % change for stock/crypto/forex. Bare ticker (BTC, AAPL) or qualified (BINANCE:BTCUSDT).",
"parameters": {
"type": "object",
"properties": {
"symbol": { "type": "string", "description": "Ticker (bare or EXCHANGE:SYM)" },
"market": { "type": "string", "description": "america (default), crypto, forex, futures" }
},
"required": ["symbol"]
}
}
})]
}
pub(super) fn dispatch(name: &str, input: &str) -> Option<Result<String, String>> {
let result = match name {
"tv_get_quote" => run_tv_get_quote(input),
_ => return None,
};
Some(result)
}
fn tv_market_path(market: Option<&str>) -> &'static str {
match market.unwrap_or("america").to_lowercase().as_str() {
"crypto" | "cryptos" => "crypto",
"forex" | "fx" => "forex",
"futures" | "futures_contracts" => "futures",
_ => "america",
}
}
fn tv_scan_request(market: &str, body: &Value) -> Result<Vec<Value>, String> {
let url = format!("https://scanner.tradingview.com/{market}/scan");
let client = external_http_client()?;
let resp = client
.post(&url)
.json(body)
.send()
.map_err(|e| format!("tv_scan: request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().unwrap_or_default();
return Err(format!(
"tv_scan: HTTP {status}: {}",
text.chars().take(300).collect::<String>()
));
}
let data: Value = resp
.json()
.map_err(|e| format!("tv_scan: parse failed: {e}"))?;
Ok(data
.get("data")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default())
}
fn resolve_tv_symbol(
raw_symbol: &str,
market: &str,
columns: &Value,
) -> Result<(String, Vec<Value>), String> {
let raw = raw_symbol.trim().to_uppercase();
let sym = match raw.as_str() {
"GOLD" | "XAU" | "XAUUSD" => "TVC:GOLD".to_string(),
"SILVER" | "XAG" | "XAGUSD" => "TVC:SILVER".to_string(),
"OIL" | "USOIL" | "CRUDE" | "WTI" | "CL" => "TVC:USOIL".to_string(),
"BRENT" | "UKOIL" => "TVC:UKOIL".to_string(),
"NATGAS" | "NG" | "NATURALGAS" => "TVC:NATURALGAS".to_string(),
"NASDAQ" | "NDX" | "NDQ" | "QQQ" => "NASDAQ:NDX".to_string(),
"SPX" | "SP500" | "S&P500" | "S&P" => "SP:SPX".to_string(),
"DJI" | "DOW" | "DOWJONES" => "DJ:DJI".to_string(),
"DXY" | "DOLLAR" => "TVC:DXY".to_string(),
"VIX" => "TVC:VIX".to_string(),
"BTCUSD" | "BTCUSDT" => "BINANCE:BTCUSDT".to_string(),
"ETHUSD" | "ETHUSDT" => "BINANCE:ETHUSDT".to_string(),
"ALGOUSDT" | "ALGOUSI" | "ALGO" => "BINANCE:ALGOUSDT".to_string(),
"KASUSDT" | "KAS" | "KASPA" => "MEXC:KASUSDT".to_string(),
"ICPUSD" | "ICPUSDT" | "ICP" => "COINBASE:ICPUSD".to_string(),
"QNTUSDT" | "QNT" => "BINANCE:QNTUSDT".to_string(),
"BTCXAI" => "MEXC:BTCXAIUSDT".to_string(),
"EURUSD" => "FX:EURUSD".to_string(),
"USDJPY" => "FX:USDJPY".to_string(),
"GBPUSD" => "FX:GBPUSD".to_string(),
_ => raw,
};
if sym.contains(':') {
let body = json!({
"symbols": { "tickers": [&sym], "query": { "types": [] } },
"columns": columns,
});
let rows = tv_scan_request(market, &body)?;
if !rows.is_empty() {
return Ok((sym, rows));
}
return Err(format!("tv: symbol '{sym}' not found on market '{market}'"));
}
static CRYPTO_SUFFIXES: &[(&str, &str)] = &[
("BINANCE:", "USDT"),
("COINBASE:", "USD"),
("BINANCE:", "USD"),
];
static STOCK_PREFIXES: &[&str] = &["NASDAQ:", "NYSE:", "AMEX:"];
let candidates: Vec<String> = if market == "crypto" {
CRYPTO_SUFFIXES
.iter()
.map(|(prefix, suffix)| format!("{prefix}{sym}{suffix}"))
.collect()
} else {
STOCK_PREFIXES
.iter()
.map(|prefix| format!("{prefix}{sym}"))
.collect()
};
for candidate in &candidates {
let body = json!({
"symbols": { "tickers": [candidate], "query": { "types": [] } },
"columns": columns,
});
if let Ok(rows) = tv_scan_request(market, &body) {
if !rows.is_empty() {
return Ok((candidate.clone(), rows));
}
}
}
Err(format!(
"tv: symbol '{raw_symbol}' not found. Tried: {candidates:?}. \
Try qualifying with an exchange prefix (e.g. NASDAQ:AAPL, BINANCE:BTCUSDT)."
))
}
fn run_tv_get_quote(input: &str) -> Result<String, String> {
let v = parse_json_input(input, "tv_get_quote")?;
let raw_symbol = extract_str(&v, "symbol", "tv_get_quote")?.to_string();
let market = tv_market_path(v.get("market").and_then(Value::as_str));
let columns = json!([
"close",
"change",
"change_abs",
"volume",
"high",
"low",
"open"
]);
let (symbol, rows) = resolve_tv_symbol(&raw_symbol, market, &columns)?;
let row = rows
.into_iter()
.next()
.ok_or_else(|| format!("tv_get_quote: symbol '{symbol}' not found on market '{market}'"))?;
let cells = row
.get("d")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let get = |i: usize| cells.get(i).and_then(Value::as_f64);
Ok(json!({
"symbol": symbol,
"market": market,
"close": get(0),
"change_pct": get(1),
"change_abs": get(2),
"volume": get(3),
"high": get(4),
"low": get(5),
"open": get(6),
})
.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tv_market_path_defaults_to_america() {
assert_eq!(tv_market_path(None), "america");
assert_eq!(tv_market_path(Some("america")), "america");
assert_eq!(tv_market_path(Some("crypto")), "crypto");
assert_eq!(tv_market_path(Some("CRYPTO")), "crypto");
assert_eq!(tv_market_path(Some("forex")), "forex");
assert_eq!(tv_market_path(Some("futures")), "futures");
assert_eq!(tv_market_path(Some("klingon")), "america");
}
#[test]
fn tv_get_quote_rejects_missing_symbol() {
let err = run_tv_get_quote("{}").unwrap_err();
assert!(err.contains("missing"), "got: {err}");
}
#[test]
fn resolve_tv_symbol_qualified_returns_as_is_on_failure() {
let err = resolve_tv_symbol("FAKE:NOSUCH", "america", &json!(["close"])).unwrap_err();
assert!(err.contains("FAKE:NOSUCH"), "got: {err}");
assert!(err.contains("not found"), "got: {err}");
}
#[test]
fn resolve_tv_symbol_bare_crypto_tries_binance() {
let err = resolve_tv_symbol("FAKECOIN", "crypto", &json!(["close"])).unwrap_err();
assert!(err.contains("BINANCE:FAKECOINUSDT"), "got: {err}");
}
#[test]
fn resolve_tv_symbol_bare_stock_tries_nasdaq() {
let err = resolve_tv_symbol("FAKESTOCK", "america", &json!(["close"])).unwrap_err();
assert!(err.contains("NASDAQ:FAKESTOCK"), "got: {err}");
}
#[test]
fn resolve_tv_symbol_commodity_aliases() {
match resolve_tv_symbol("GOLD", "america", &json!(["close"])) {
Ok((sym, _)) => assert!(sym.contains("TVC:GOLD"), "got: {sym}"),
Err(e) => assert!(e.contains("TVC:GOLD"), "got: {e}"),
}
match resolve_tv_symbol("OIL", "america", &json!(["close"])) {
Ok((sym, _)) => assert!(sym.contains("TVC:USOIL"), "got: {sym}"),
Err(e) => assert!(e.contains("TVC:USOIL"), "got: {e}"),
}
match resolve_tv_symbol("NASDAQ", "america", &json!(["close"])) {
Ok((sym, _)) => assert!(sym.contains("NASDAQ:NDX"), "got: {sym}"),
Err(e) => assert!(e.contains("NASDAQ:NDX"), "got: {e}"),
}
}
#[test]
fn schemas_lists_one_tool() {
let schemas = schemas();
assert_eq!(schemas.len(), 1);
let names: Vec<&str> = schemas
.iter()
.filter_map(|v| v.pointer("/function/name").and_then(Value::as_str))
.collect();
assert_eq!(names, ["tv_get_quote"]);
}
}