tradingview-mcp 26.3.0__tar.gz → 26.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/PKG-INFO +1 -1
  2. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/pyproject.toml +1 -1
  3. tradingview_mcp-26.3.1/src/tradingview_mcp/tools/search.py +350 -0
  4. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/tools/technical.py +27 -4
  5. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/utils.py +62 -3
  6. tradingview_mcp-26.3.0/src/tradingview_mcp/tools/search.py +0 -326
  7. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/.gitignore +0 -0
  8. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/LICENSE +0 -0
  9. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/README.md +0 -0
  10. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/__init__.py +0 -0
  11. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/column.py +0 -0
  12. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/constants.py +0 -0
  13. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/__init__.py +0 -0
  14. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/column_display_names.json +0 -0
  15. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/extracted/__init__.py +0 -0
  16. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/extracted/ai_quick_reference.json +0 -0
  17. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/extracted/common_fields.json +0 -0
  18. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/extracted/fields_by_market.json +0 -0
  19. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/extracted/screener_code_examples.json +0 -0
  20. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/extracted/stock_screener_presets.json +0 -0
  21. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/markets.json +0 -0
  22. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/metainfo/bond.json +0 -0
  23. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/metainfo/bonds.json +0 -0
  24. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/metainfo/cfd.json +0 -0
  25. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/metainfo/coin.json +0 -0
  26. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/metainfo/crypto.json +0 -0
  27. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/metainfo/economics2.json +0 -0
  28. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/metainfo/forex.json +0 -0
  29. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/metainfo/futures.json +0 -0
  30. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/metainfo/ireland.json +0 -0
  31. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/metainfo/options.json +0 -0
  32. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/metainfo/stocks.json +0 -0
  33. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/screeners/main_screeners.json +0 -0
  34. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/screeners/markets.json +0 -0
  35. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/screeners/stocks.json +0 -0
  36. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/data/screeners/stocks_failed.json +0 -0
  37. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/docs_data.py +0 -0
  38. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/models.py +0 -0
  39. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/query.py +0 -0
  40. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/resources.py +0 -0
  41. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/scanner.py +0 -0
  42. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/server.py +0 -0
  43. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/tools/__init__.py +0 -0
  44. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/tools/reference.py +0 -0
  45. {tradingview_mcp-26.3.0 → tradingview_mcp-26.3.1}/src/tradingview_mcp/tools/screener.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tradingview-mcp
3
- Version: 26.3.0
3
+ Version: 26.3.1
4
4
  Summary: A comprehensive MCP server for TradingView market screening with integrated screener functionality
5
5
  Project-URL: Homepage, https://github.com/k73a/tradingview-mcp
6
6
  Project-URL: Documentation, https://github.com/k73a/tradingview-mcp#readme
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tradingview-mcp"
3
- version = "26.3.0"
3
+ version = "26.3.1"
4
4
  description = "A comprehensive MCP server for TradingView market screening with integrated screener functionality"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -0,0 +1,350 @@
1
+ from __future__ import annotations
2
+ from typing import Any
3
+ import concurrent.futures
4
+
5
+ from mcp.server.fastmcp import Context
6
+
7
+ from tradingview_mcp.column import Column
8
+ from tradingview_mcp.constants import EXCHANGE_SCREENER
9
+ from tradingview_mcp.docs_data import get_default_columns_for_market, STOCK_MARKETS
10
+ from tradingview_mcp.query import Query
11
+ from tradingview_mcp.utils import sanitize_market
12
+
13
+ # Common aliases for commodities/forex that don't match TV names
14
+ SYMBOL_ALIASES = {
15
+ # Precious Metals
16
+ "XAG": ["SILVER", "XAGUSD"],
17
+ "XAU": ["GOLD", "XAUUSD"],
18
+ "XPT": ["PLATINUM", "XPTUSD"],
19
+ "XPD": ["PALLADIUM", "XPDUSD"],
20
+
21
+ # Energy
22
+ "OIL": ["USOIL", "UKOIL", "WTI", "BRENT"],
23
+ "WTI": ["USOIL"],
24
+ "BRENT": ["UKOIL"],
25
+ "NATGAS": ["NG1!", "NATURALGAS"],
26
+
27
+ # Others
28
+ "COPPER": ["HG1!", "XCUUSD"],
29
+ }
30
+
31
+ def search_symbols(
32
+ query: str,
33
+ market: str = "america",
34
+ limit: int = 25,
35
+ ) -> dict[str, Any]:
36
+ """
37
+ Search symbols by name or ticker. Returns strict locator format for downstream tools.
38
+
39
+ Output format includes 'locator': 'EXCHANGE:SYMBOL, market_name'.
40
+ Use this locator to call get_technical_analysis() or other tools precisely.
41
+ """
42
+ market = sanitize_market(market)
43
+ limit = max(1, min(limit, 100))
44
+
45
+ # Use dynamic default columns for this market
46
+ cols = get_default_columns_for_market(market)
47
+
48
+ # Determine sort column
49
+ sort_col = "market_cap_basic" if "market_cap_basic" in cols else "name"
50
+
51
+ # Expand query with aliases if available
52
+ queries = [query]
53
+ if query.upper() in SYMBOL_ALIASES:
54
+ queries.extend(SYMBOL_ALIASES[query.upper()])
55
+
56
+ try:
57
+ # Standard search in requested market
58
+ results = _execute_search(queries, market, limit, cols, sort_col)
59
+
60
+ # Smart Fallback: If default market ('america') yielded no results, try others
61
+ if not results["results"] and market == "america":
62
+
63
+ # 1. Asian Numeric Heuristic
64
+ if query.isdigit() and len(query) in [4, 5, 6]:
65
+ asian_markets = ["taiwan", "hongkong", "japan", "china", "korea"]
66
+ for asian_market in asian_markets:
67
+ asian_cols = get_default_columns_for_market(asian_market)
68
+ asian_sort = "volume"
69
+ asian_res = _execute_search(queries, asian_market, 5, asian_cols, asian_sort)
70
+ if asian_res["results"]:
71
+ return {**asian_res, "note": f"No results in 'america', using numeric heuristic for '{asian_market}'."}
72
+
73
+ # 2. True Global Search
74
+ try:
75
+ global_cols = ["name", "description", "close", "change", "volume", "market_cap_basic", "exchange", "type"]
76
+ q = (
77
+ Query()
78
+ .set_markets(*STOCK_MARKETS)
79
+ .select(*global_cols)
80
+ .where(Column("description").like(query))
81
+ .order_by("volume", ascending=False)
82
+ .limit(5)
83
+ )
84
+ _, df = q.get_scanner_data()
85
+
86
+ if df.empty:
87
+ q = (
88
+ Query()
89
+ .set_markets(*STOCK_MARKETS)
90
+ .select(*global_cols)
91
+ .where(Column("name").like(query))
92
+ .order_by("volume", ascending=False)
93
+ .limit(5)
94
+ )
95
+ _, df = q.get_scanner_data()
96
+
97
+ if not df.empty:
98
+ # Add locators for global results
99
+ records = df.to_dict("records")
100
+ for r in records:
101
+ _enrich_locator(r, "stock", query) # We can't know exact market easily from batch query, assume global context usage
102
+
103
+ return {
104
+ "query": query,
105
+ "market": "global",
106
+ "total_found": len(df),
107
+ "returned": len(df),
108
+ "results": records,
109
+ "note": "Found matches in global stock markets."
110
+ }
111
+ except Exception:
112
+ pass
113
+
114
+ # 3. Check Crypto/Forex/CFD
115
+ for other in ["crypto", "forex", "cfd"]:
116
+ if other == market: continue
117
+
118
+ cols = get_default_columns_for_market(other)
119
+ sort = "market_cap_basic" if "market_cap_basic" in cols else "name"
120
+ if other == "forex" or other == "cfd": sort = "name"
121
+
122
+ res = _execute_search(queries, other, 5, cols, sort)
123
+ if res["results"]:
124
+ return {**res, "note": f"Found matches in '{other}'."}
125
+
126
+ return results
127
+
128
+ except Exception as e:
129
+ return _search_symbols_fallback(query, market, limit, cols, str(e))
130
+
131
+
132
+ def _enrich_locator(record: dict, market: str, category: str = "stock"):
133
+ """Add standard locator string to a record."""
134
+ ex = record.get("exchange", "UNKNOWN")
135
+ name = record.get("name", "UNKNOWN")
136
+
137
+ # Try to deduce market if 'stock' is generic
138
+ # (This is hard without a map of Exchange->Country, but we do our best)
139
+
140
+ # Construct Locator
141
+ # Format: EXCHANGE:SYMBOL, Market
142
+ record["locator"] = f"{ex}:{name}, {market}"
143
+ record["market"] = market
144
+
145
+
146
+ def _execute_search(queries: list[str] | str, market: str, limit: int, cols: list[str], sort_col: str) -> dict[str, Any]:
147
+ """Execute search, supporting multiple query terms (aliases)."""
148
+ if isinstance(queries, str):
149
+ queries = [queries]
150
+
151
+ all_results = []
152
+
153
+ # Try each query term until we get enough results
154
+ for q_term in queries:
155
+ if len(all_results) >= limit: break
156
+
157
+ try:
158
+ # 1. Search description
159
+ q = (
160
+ Query()
161
+ .set_markets(market)
162
+ .select(*cols)
163
+ .where(Column("description").like(q_term))
164
+ .order_by(sort_col, ascending=False)
165
+ .limit(limit)
166
+ )
167
+ _, df = q.get_scanner_data()
168
+ if not df.empty:
169
+ all_results.extend(df.to_dict("records"))
170
+
171
+ # 2. Search name
172
+ if len(all_results) < limit:
173
+ q2 = (
174
+ Query()
175
+ .set_markets(market)
176
+ .select(*cols)
177
+ .where(Column("name").like(q_term))
178
+ .order_by(sort_col, ascending=False)
179
+ .limit(limit)
180
+ )
181
+ _, df2 = q2.get_scanner_data()
182
+ if not df2.empty:
183
+ all_results.extend(df2.to_dict("records"))
184
+ except:
185
+ pass
186
+
187
+ # Dedup
188
+ unique = {r.get("name")+r.get("exchange"): r for r in all_results}
189
+ results = list(unique.values())[:limit]
190
+
191
+ # Enhance with locator
192
+ for r in results:
193
+ _enrich_locator(r, market)
194
+
195
+ return {
196
+ "query": queries[0],
197
+ "market": market,
198
+ "total_found": len(results),
199
+ "returned": len(results),
200
+ "results": results,
201
+ }
202
+
203
+
204
+ def _search_symbols_fallback(query: str, market: str, limit: int, cols: list[str], error: str) -> dict[str, Any]:
205
+ return {"error": f"Search failed: {str(e)}", "original_error": error}
206
+
207
+
208
+ def get_symbol_info(symbol: str, include_technical: bool = False) -> dict[str, Any]:
209
+ """
210
+ Get detailed information for a symbol.
211
+ Returns matches with strict locators: 'EXCHANGE:SYMBOL, Market'.
212
+ """
213
+ technical_cols = [
214
+ "RSI", "RSI7", "MACD.macd", "MACD.signal", "SMA20", "SMA50", "SMA200",
215
+ "EMA20", "EMA50", "EMA200", "BB.upper", "BB.lower", "ATR", "ADX",
216
+ "Recommend.All", "Recommend.MA", "Recommend.Other"
217
+ ]
218
+
219
+ try:
220
+ # --- Strict Mode (Exchange Specified) ---
221
+ if ":" in symbol:
222
+ exchange, ticker = symbol.split(":", 1)
223
+ market = EXCHANGE_SCREENER.get(exchange.lower()) or "america"
224
+
225
+ # Allow override via sanitized lookup? No, specific exchange implies specific market logic usually.
226
+ # But let's be safe.
227
+
228
+ cols = get_default_columns_for_market(market)
229
+ if include_technical: cols.extend(technical_cols)
230
+ cols = list(dict.fromkeys(cols))
231
+
232
+ q = (
233
+ Query()
234
+ .set_markets(market)
235
+ .select(*cols)
236
+ .where(Column("name").isin([symbol, ticker, symbol.upper(), ticker.upper()]))
237
+ .limit(5)
238
+ )
239
+ _, df = q.get_scanner_data()
240
+ results = df.to_dict("records")
241
+
242
+ if results:
243
+ # Add strict locators
244
+ for r in results:
245
+ _enrich_locator(r, market)
246
+
247
+ if len(results) == 1:
248
+ return {"symbol": symbol, "found": True, "market": market, "data": results[0]}
249
+ return {"symbol": symbol, "found": True, "market": market, "matches": results}
250
+ else:
251
+ return {"symbol": symbol, "found": False, "hint": f"Symbol not found in {market} ({exchange}). Check format."}
252
+
253
+ # --- Universal Mode (Implicit Market) ---
254
+ all_matches = []
255
+
256
+ # Targets
257
+ targets = [symbol, symbol.upper(), f"{symbol}USDT", f"{symbol}USD"]
258
+ if symbol.upper() in SYMBOL_ALIASES:
259
+ targets.extend(SYMBOL_ALIASES[symbol.upper()])
260
+
261
+ # Helper
262
+ def run_search(market, col_getter):
263
+ try:
264
+ cols = col_getter(market)
265
+ if include_technical: cols.extend(technical_cols)
266
+ cols = list(dict.fromkeys(cols))
267
+
268
+ # We need to set markets properly
269
+ q = Query()
270
+ if market == "global_stocks":
271
+ q.set_markets(*STOCK_MARKETS)
272
+ else:
273
+ q.set_markets(market)
274
+
275
+ q.select(*cols).where(Column("name").isin(targets))
276
+
277
+ if market == "global_stocks":
278
+ q.order_by("market_cap_basic", ascending=False).limit(10)
279
+ else:
280
+ q.limit(5)
281
+
282
+ _, df = q.get_scanner_data()
283
+ if not df.empty:
284
+ matches = df.to_dict("records")
285
+ cat = "stock" if market == "global_stocks" else market
286
+
287
+ # Fixup locator logic
288
+ for m in matches:
289
+ m["_category"] = cat
290
+ # If global stock, we don't know exact country easily without map.
291
+ # But for now we pass 'global_stocks' or try to guess?
292
+ # It's better to tell AI "taiwan" if possible.
293
+ # But we queried ALL markets.
294
+ # HACK: We should query 'taiwan' explicitly if heuristics match?
295
+ # No, just return EXCHANGE:SYMBOL and let AI use 'search_symbols' if it needs precise market.
296
+ # Actually user wants "TWSE:0050, taiwan".
297
+ # If we used set_markets(*ALL), we lose the origin market info in the response unless we select 'market' column?
298
+ # TradingView API doesn't usually return 'market' column. It returns 'exchange'.
299
+ # We can map Exchange -> Market via EXCHANGE_SCREENER?
300
+ rec_market = EXCHANGE_SCREENER.get(m.get("exchange", "").lower(), cat)
301
+ _enrich_locator(m, rec_market)
302
+
303
+ return matches
304
+ except:
305
+ pass
306
+ return []
307
+
308
+ # Execute in parallel
309
+ with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
310
+ futures = [
311
+ executor.submit(run_search, "global_stocks", lambda m: ["name", "description", "close", "change", "volume", "market_cap_basic", "exchange", "type"]),
312
+ executor.submit(run_search, "crypto", get_default_columns_for_market),
313
+ executor.submit(run_search, "forex", get_default_columns_for_market),
314
+ executor.submit(run_search, "cfd", get_default_columns_for_market)
315
+ ]
316
+ for future in concurrent.futures.as_completed(futures):
317
+ all_matches.extend(future.result())
318
+
319
+ # --- Aggregate Results ---
320
+ if not all_matches:
321
+ return search_symbols(symbol, "america", 5)
322
+
323
+ # Remove duplicates
324
+ unique_matches = {}
325
+ for m in all_matches:
326
+ key = m.get("ticker", m.get("name"))
327
+ if key not in unique_matches:
328
+ unique_matches[key] = m
329
+
330
+ final_matches = list(unique_matches.values())
331
+
332
+ if len(final_matches) == 1:
333
+ first = final_matches[0]
334
+ cat = first.pop("_category", "global")
335
+ return {"symbol": symbol, "found": True, "market": cat, "data": first}
336
+
337
+ return {
338
+ "symbol": symbol,
339
+ "found": True,
340
+ "market": "global",
341
+ "count": len(final_matches),
342
+ "matches": final_matches,
343
+ "note": "Multiple matches found across global markets."
344
+ }
345
+
346
+ except Exception as e:
347
+ return {
348
+ "error": f"Failed to get symbol info: {str(e)}",
349
+ "hint": "Try using search_symbols to find the correct symbol format",
350
+ }
@@ -6,8 +6,25 @@ from tradingview_mcp.constants import TECHNICAL_COLUMNS
6
6
  from tradingview_mcp.query import Query
7
7
  from tradingview_mcp.utils import sanitize_market
8
8
 
9
- def get_technical_analysis(symbol: str, market: str = "america") -> dict[str, Any]:
10
- """Get technical indicators for a symbol."""
9
+ def get_technical_analysis(symbol: str, market: str) -> dict[str, Any]:
10
+ """
11
+ Get technical indicators for a symbol.
12
+
13
+ IMPORTANT: You must use a strictly formatted symbol and market.
14
+ 1. Call `get_symbol_info(symbol)` first.
15
+ 2. Use the `locator` field from the response (e.g. "EXCHANGE:SYMBOL") and the exact `market`.
16
+ 3. Do NOT guess the market.
17
+ """
18
+ # Strict validation
19
+ if ":" not in symbol:
20
+ return {
21
+ "error": "Ambiguous symbol format. Missing exchange prefix.",
22
+ "hint": f"Please run get_symbol_info('{symbol}') first to find the correct 'EXCHANGE:{symbol}' locator and 'market'."
23
+ }
24
+
25
+ # Parse strictly formatted symbol
26
+ exchange, ticker = symbol.split(":", 1)
27
+
11
28
  market = sanitize_market(market)
12
29
 
13
30
  # Use standard technical columns
@@ -18,13 +35,19 @@ def get_technical_analysis(symbol: str, market: str = "america") -> dict[str, An
18
35
  Query()
19
36
  .set_markets(market)
20
37
  .select(*cols)
21
- .where(Column("name").isin([symbol, symbol.upper()]))
38
+ .where(Column("name").isin([ticker, ticker.upper()])) # Use ticker part for name lookup
22
39
  .limit(1)
23
40
  )
41
+
24
42
  _, df = q.get_scanner_data()
25
43
 
26
44
  if df.empty:
27
- return {"error": f"Symbol {symbol} not found in {market}"}
45
+ # Check if user maybe used wrong market?
46
+ # But we want to be strict.
47
+ return {
48
+ "error": f"Symbol '{symbol}' not found in market '{market}'.",
49
+ "hint": "Ensure you are using the correct market from get_symbol_info()."
50
+ }
28
51
 
29
52
  data = df.iloc[0].to_dict()
30
53
  return {
@@ -54,12 +54,57 @@ def sanitize_exchange(ex: str | None, default: str = "america") -> str:
54
54
  return default
55
55
 
56
56
 
57
+ # Country Code mappings
58
+ COUNTRY_ALIASES = {
59
+ "us": "america",
60
+ "usa": "america",
61
+ "uk": "uk",
62
+ "gb": "uk",
63
+ "de": "germany",
64
+ "fr": "france",
65
+ "it": "italy",
66
+ "es": "spain",
67
+ "pt": "portugal",
68
+ "ch": "switzerland",
69
+ "se": "sweden",
70
+ "no": "norway",
71
+ "fi": "finland",
72
+ "nl": "netherlands",
73
+ "be": "belgium",
74
+ "at": "austria",
75
+ "ie": "ireland",
76
+ "ru": "russia",
77
+ "cn": "china",
78
+ "jp": "japan",
79
+ "kr": "korea",
80
+ "in": "india",
81
+ "id": "indonesia",
82
+ "my": "malaysia",
83
+ "th": "thailand",
84
+ "vn": "vietnam",
85
+ "tw": "taiwan",
86
+ "sg": "singapore",
87
+ "hk": "hongkong",
88
+ "au": "australia",
89
+ "nz": "newzealand",
90
+ "ca": "canada",
91
+ "br": "brazil",
92
+ "mx": "mexico",
93
+ "ar": "argentina",
94
+ "cl": "chile",
95
+ "co": "colombia",
96
+ "pe": "peru",
97
+ "eg": "egypt",
98
+ "tr": "turkey",
99
+ "za": "rsa", # South Africa
100
+ }
101
+
57
102
  def sanitize_market(market: str | None, default: str = "america") -> str:
58
103
  """
59
104
  Validate and sanitize a market name.
60
-
105
+
61
106
  Args:
62
- market: Market to validate
107
+ market: Market to validate (e.g. 'america', 'tw', 'crypto')
63
108
  default: Default value if invalid
64
109
 
65
110
  Returns:
@@ -67,8 +112,22 @@ def sanitize_market(market: str | None, default: str = "america") -> str:
67
112
  """
68
113
  if not market:
69
114
  return default
115
+
70
116
  market = market.strip().lower()
71
- return market if market in MARKETS else default
117
+
118
+ # Check exact match
119
+ if market in MARKETS:
120
+ return market
121
+
122
+ # Check alias match
123
+ if market in COUNTRY_ALIASES:
124
+ return COUNTRY_ALIASES[market]
125
+
126
+ # Check if user passed exchange (e.g. 'nasdaq') by mistake
127
+ if market in EXCHANGE_SCREENER:
128
+ return EXCHANGE_SCREENER[market]
129
+
130
+ return default
72
131
 
73
132
 
74
133
  def timeframe_to_resolution(tf: str) -> str:
@@ -1,326 +0,0 @@
1
- from __future__ import annotations
2
- from typing import Any
3
- import concurrent.futures
4
-
5
- from mcp.server.fastmcp import Context
6
-
7
- from tradingview_mcp.column import Column
8
- from tradingview_mcp.constants import EXCHANGE_SCREENER
9
- from tradingview_mcp.docs_data import get_default_columns_for_market, STOCK_MARKETS
10
- from tradingview_mcp.query import Query
11
- from tradingview_mcp.utils import sanitize_market
12
-
13
- def search_symbols(
14
- query: str,
15
- market: str = "america",
16
- limit: int = 25,
17
- ) -> dict[str, Any]:
18
- """
19
- Search symbols by name or ticker.
20
- If default market returns no results, automatically searches crypto and forex.
21
- """
22
- market = sanitize_market(market)
23
- limit = max(1, min(limit, 100))
24
-
25
- # Use dynamic default columns for this market
26
- cols = get_default_columns_for_market(market)
27
-
28
- # Determine sort column
29
- sort_col = "market_cap_basic" if "market_cap_basic" in cols else "name"
30
-
31
- try:
32
- # Standard search in requested market
33
- results = _execute_search(query, market, limit, cols, sort_col)
34
-
35
- # Smart Fallback: If default market ('america') yielded no results, try others
36
- if not results["results"] and market == "america":
37
-
38
- # 1. Asian Numeric Heuristic (Prioritized for speed/accuracy)
39
- if query.isdigit() and len(query) in [4, 5, 6]:
40
- asian_markets = ["taiwan", "hongkong", "japan", "china", "korea"]
41
- for asian_market in asian_markets:
42
- asian_cols = get_default_columns_for_market(asian_market)
43
- asian_sort = "volume" # Volume is good for general Asian scanners
44
- asian_res = _execute_search(query, asian_market, 5, asian_cols, asian_sort)
45
- if asian_res["results"]:
46
- return {**asian_res, "note": f"No results in 'america', found matches in '{asian_market}' (numeric heuristic)"}
47
-
48
- # 2. True Global Search (Check ALL 68+ Stock Markets)
49
- try:
50
- # Use standard global columns safe for all
51
- global_cols = ["name", "description", "close", "change", "volume", "market_cap_basic", "exchange", "type"]
52
-
53
- # Check Description First (often better for "Maybank")
54
- q = (
55
- Query()
56
- .set_markets(*STOCK_MARKETS)
57
- .select(*global_cols)
58
- .where(Column("description").like(query))
59
- .order_by("volume", ascending=False)
60
- .limit(5)
61
- )
62
- _, df = q.get_scanner_data()
63
-
64
- if df.empty:
65
- # Check Name (Ticker)
66
- q = (
67
- Query()
68
- .set_markets(*STOCK_MARKETS)
69
- .select(*global_cols)
70
- .where(Column("name").like(query))
71
- .order_by("volume", ascending=False)
72
- .limit(5)
73
- )
74
- _, df = q.get_scanner_data()
75
-
76
- if not df.empty:
77
- # Basic Global Result
78
- total = len(df) # Roughly
79
- return {
80
- "query": query,
81
- "market": "global",
82
- "total_found": total,
83
- "returned": len(df),
84
- "results": df.to_dict("records"),
85
- "note": "No results in 'america', found matches in global stock markets."
86
- }
87
-
88
- except Exception:
89
- pass # Continue to other fallbacks
90
-
91
- # 3. Check Crypto/Forex
92
- for other in ["crypto", "forex"]:
93
- if other == market: continue
94
-
95
- cols = get_default_columns_for_market(other)
96
- sort = "market_cap_basic" if "market_cap_basic" in cols else "name"
97
- if other == "forex": sort = "name"
98
-
99
- res = _execute_search(query, other, 5, cols, sort)
100
- if res["results"]:
101
- return {**res, "note": f"No results in stocks, found matches in '{other}'"}
102
-
103
- return results
104
-
105
- except Exception as e:
106
- return _search_symbols_fallback(query, market, limit, cols, str(e))
107
-
108
-
109
- def _execute_search(query: str, market: str, limit: int, cols: list[str], sort_col: str) -> dict[str, Any]:
110
- """Execute a single search query against a market."""
111
- try:
112
- # 1. Search description (company name)
113
- query_obj = (
114
- Query()
115
- .set_markets(market)
116
- .select(*cols)
117
- .where(Column("description").like(query))
118
- .order_by(sort_col, ascending=False)
119
- .limit(limit)
120
- )
121
- total, df = query_obj.get_scanner_data()
122
- results = df.to_dict("records")
123
-
124
- # 2. If nothing found, search name (ticker)
125
- if not results:
126
- query_obj2 = (
127
- Query()
128
- .set_markets(market)
129
- .select(*cols)
130
- .where(Column("name").like(query))
131
- .order_by(sort_col, ascending=False)
132
- .limit(limit)
133
- )
134
- total, df = query_obj2.get_scanner_data()
135
- results = df.to_dict("records")
136
-
137
- return {
138
- "query": query,
139
- "market": market,
140
- "total_found": total if results else 0,
141
- "returned": len(results),
142
- "results": results,
143
- "hint": "Use 'name' field as symbol for other tools" if results else "No matches found.",
144
- }
145
- except Exception:
146
- return {"results": [], "market": market}
147
-
148
-
149
- def _search_symbols_fallback(query: str, market: str, limit: int, cols: list[str], error: str) -> dict[str, Any]:
150
- """Fallback search when API operations fail."""
151
- try:
152
- q = Query().set_markets(market).select(*cols).limit(100)
153
- _, df = q.get_scanner_data()
154
- q_lower = query.lower()
155
- mask = df["name"].str.lower().str.contains(q_lower) | df["description"].str.lower().str.contains(q_lower)
156
- df_filtered = df[mask].head(limit)
157
- return {
158
- "query": query,
159
- "market": market,
160
- "returned": len(df_filtered),
161
- "results": df_filtered.to_dict("records"),
162
- "note": "Used fallback local search.",
163
- "original_error": error,
164
- }
165
- except Exception as e:
166
- return {"error": f"Search failed: {str(e)}", "original_error": error}
167
-
168
-
169
- def get_symbol_info(symbol: str, include_technical: bool = False) -> dict[str, Any]:
170
- """
171
- Get detailed information for a symbol.
172
-
173
- Modes:
174
- 1. Strict: 'EXCHANGE:SYMBOL' (e.g. 'NASDAQ:AAPL') - searches only that exchange/market.
175
- 2. Universal: 'SYMBOL' (e.g. 'AAA') - searches ALL global markets, Crypto, and Forex.
176
- Returns ALL matches found to verify ambiguity.
177
- """
178
- technical_cols = [
179
- "RSI", "RSI7", "MACD.macd", "MACD.signal", "SMA20", "SMA50", "SMA200",
180
- "EMA20", "EMA50", "EMA200", "BB.upper", "BB.lower", "ATR", "ADX",
181
- "Recommend.All", "Recommend.MA", "Recommend.Other"
182
- ]
183
-
184
- try:
185
- # --- Strict Mode (Exchange Specified) ---
186
- if ":" in symbol:
187
- exchange, ticker = symbol.split(":", 1)
188
- market = EXCHANGE_SCREENER.get(exchange.lower()) or "america"
189
-
190
- # Setup Columns
191
- cols = get_default_columns_for_market(market)
192
- if include_technical: cols.extend(technical_cols)
193
- cols = list(dict.fromkeys(cols))
194
-
195
- # Query
196
- q = (
197
- Query()
198
- .set_markets(market)
199
- .select(*cols)
200
- .where(Column("name").isin([symbol, ticker, symbol.upper(), ticker.upper()]))
201
- .limit(5)
202
- )
203
- _, df = q.get_scanner_data()
204
- results = df.to_dict("records")
205
-
206
- if results:
207
- if len(results) == 1:
208
- return {"symbol": symbol, "found": True, "market": market, "data": results[0]}
209
- return {"symbol": symbol, "found": True, "market": market, "matches": results}
210
- else:
211
- return {"symbol": symbol, "found": False, "hint": f"Symbol not found in {market} ({exchange}). Check format."}
212
-
213
- # --- Universal Mode (Implicit Market) ---
214
- # We search Stocks (Global), Crypto, vs Forex in parallel using ThreadPoolExecutor
215
- all_matches = []
216
-
217
- def check_global_stocks():
218
- try:
219
- stock_cols = ["name", "description", "close", "change", "volume", "market_cap_basic", "exchange", "type"]
220
- if include_technical: stock_cols.extend(technical_cols)
221
- stock_cols = list(dict.fromkeys(stock_cols))
222
-
223
- q_stocks = (
224
- Query()
225
- .set_markets(*STOCK_MARKETS)
226
- .select(*stock_cols)
227
- .where(Column("name").isin([symbol, symbol.upper()]))
228
- .order_by("market_cap_basic", ascending=False)
229
- .limit(10)
230
- )
231
- _, df_stocks = q_stocks.get_scanner_data()
232
- if not df_stocks.empty:
233
- matches = df_stocks.to_dict("records")
234
- for m in matches: m["_category"] = "stock"
235
- return matches
236
- except Exception:
237
- pass
238
- return []
239
-
240
- def check_crypto():
241
- try:
242
- crypto_cols = get_default_columns_for_market("crypto")
243
- if include_technical: crypto_cols.extend(technical_cols)
244
- crypto_cols = list(dict.fromkeys(crypto_cols))
245
-
246
- q_crypto = (
247
- Query()
248
- .set_markets("crypto")
249
- .select(*crypto_cols)
250
- .where(Column("name").isin([symbol, symbol.upper(), f"{symbol}USDT", f"{symbol}USD"]))
251
- .limit(5)
252
- )
253
- _, df_crypto = q_crypto.get_scanner_data()
254
- if not df_crypto.empty:
255
- matches = df_crypto.to_dict("records")
256
- for m in matches: m["_category"] = "crypto"
257
- return matches
258
- except Exception:
259
- pass
260
- return []
261
-
262
- def check_forex():
263
- try:
264
- forex_cols = get_default_columns_for_market("forex")
265
- if include_technical: forex_cols.extend(technical_cols)
266
- forex_cols = list(dict.fromkeys(forex_cols))
267
-
268
- q_forex = (
269
- Query()
270
- .set_markets("forex")
271
- .select(*forex_cols)
272
- .where(Column("name").isin([symbol, symbol.upper()]))
273
- .limit(5)
274
- )
275
- _, df_forex = q_forex.get_scanner_data()
276
- if not df_forex.empty:
277
- matches = df_forex.to_dict("records")
278
- for m in matches: m["_category"] = "forex"
279
- return matches
280
- except Exception:
281
- pass
282
- return []
283
-
284
- # Execute in parallel
285
- with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
286
- futures = [
287
- executor.submit(check_global_stocks),
288
- executor.submit(check_crypto),
289
- executor.submit(check_forex)
290
- ]
291
- for future in concurrent.futures.as_completed(futures):
292
- all_matches.extend(future.result())
293
-
294
- # --- Aggregate Results ---
295
- if not all_matches:
296
- # Try search_symbols as last resort (fuzzy search)
297
- return search_symbols(symbol, "america", 5)
298
-
299
- # Remove duplicates based on ticker
300
- unique_matches = {}
301
- for m in all_matches:
302
- key = m.get("ticker", m.get("name"))
303
- if key not in unique_matches:
304
- unique_matches[key] = m
305
-
306
- final_matches = list(unique_matches.values())
307
-
308
- if len(final_matches) == 1:
309
- first = final_matches[0]
310
- cat = first.pop("_category", "global")
311
- return {"symbol": symbol, "found": True, "market": cat, "data": first}
312
-
313
- return {
314
- "symbol": symbol,
315
- "found": True,
316
- "market": "global",
317
- "count": len(final_matches),
318
- "matches": final_matches,
319
- "note": "Multiple matches found across global markets."
320
- }
321
-
322
- except Exception as e:
323
- return {
324
- "error": f"Failed to get symbol info: {str(e)}",
325
- "hint": "Try using search_symbols to find the correct symbol format",
326
- }