tradingview-mcp 26.3.0__py3-none-any.whl → 26.3.2__py3-none-any.whl

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.
@@ -49,8 +49,21 @@ def get_filter_example(type: str = "number") -> dict[str, Any]:
49
49
  """Get example filter format."""
50
50
  return get_filter_format(type)
51
51
 
52
+ from tradingview_mcp.utils import sanitize_market
53
+
52
54
  def check_market(market: str) -> dict[str, Any]:
53
55
  """Validate a market name."""
56
+ # Resolve alias first (e.g. 'tw' -> 'taiwan') for consistency with other tools
57
+ # We use strict=False because we want the default 'america' if fails? No, check_market should be explicit.
58
+ # sanitize_market returns default 'america' if invalid.
59
+ # We want to know if it's strictly valid or valid alias.
60
+
61
+ # Check if it's a known alias
62
+ clean = market.strip().lower()
63
+ from tradingview_mcp.utils import COUNTRY_ALIASES
64
+ if clean in COUNTRY_ALIASES:
65
+ return validate_market(COUNTRY_ALIASES[clean])
66
+
54
67
  return validate_market(market)
55
68
 
56
69
  def get_help() -> dict[str, Any]:
@@ -15,7 +15,7 @@ def screen_market(
15
15
  filters: Optional[list[dict[str, Any]]] = None,
16
16
  ) -> dict[str, Any]:
17
17
  """Run a custom market screening query."""
18
- market = sanitize_market(market)
18
+ market = sanitize_market(market, strict=True)
19
19
  limit = max(1, min(limit, 500))
20
20
 
21
21
  # Ensure description is always included
@@ -71,17 +71,17 @@ def screen_market(
71
71
 
72
72
  def get_top_gainers(market: str = "america", limit: int = 25) -> dict[str, Any]:
73
73
  """Get top gainers for a market."""
74
- market = sanitize_market(market)
74
+ market = sanitize_market(market, strict=True)
75
75
  return screen_market(market, sort_by="change", ascending=False, limit=limit)
76
76
 
77
77
 
78
78
  def get_top_losers(market: str = "america", limit: int = 25) -> dict[str, Any]:
79
79
  """Get top losers for a market."""
80
- market = sanitize_market(market)
80
+ market = sanitize_market(market, strict=True)
81
81
  return screen_market(market, sort_by="change", ascending=True, limit=limit)
82
82
 
83
83
 
84
84
  def get_most_active(market: str = "america", limit: int = 25) -> dict[str, Any]:
85
85
  """Get most active symbols by volume."""
86
- market = sanitize_market(market)
86
+ market = sanitize_market(market, strict=True)
87
87
  return screen_market(market, sort_by="volume", ascending=False, limit=limit)
@@ -10,14 +10,34 @@ from tradingview_mcp.docs_data import get_default_columns_for_market, STOCK_MARK
10
10
  from tradingview_mcp.query import Query
11
11
  from tradingview_mcp.utils import sanitize_market
12
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
+
13
31
  def search_symbols(
14
32
  query: str,
15
33
  market: str = "america",
16
34
  limit: int = 25,
17
35
  ) -> dict[str, Any]:
18
36
  """
19
- Search symbols by name or ticker.
20
- If default market returns no results, automatically searches crypto and forex.
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.
21
41
  """
22
42
  market = sanitize_market(market)
23
43
  limit = max(1, min(limit, 100))
@@ -28,29 +48,110 @@ def search_symbols(
28
48
  # Determine sort column
29
49
  sort_col = "market_cap_basic" if "market_cap_basic" in cols else "name"
30
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
+
31
56
  try:
32
57
  # Standard search in requested market
33
- results = _execute_search(query, market, limit, cols, sort_col)
58
+ # We start this async to allow parallel peeking
59
+ with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
60
+ future_main = executor.submit(_execute_search, queries, market, limit, cols, sort_col)
61
+
62
+ # Smart Peek: Always check CFD/Forex/Crypto for EXACT matches or high relevance
63
+ # This solves the "Silver" problem: "Silver" exists in America (Stocks), but user might want "SILVER" (CFD)
64
+ # We don't want to wait for main search to fail (lazy fallback), we want to augment results.
65
+ peek_markets = ["cfd", "crypto", "forex"]
66
+ if market in peek_markets:
67
+ peek_markets.remove(market)
68
+
69
+ future_peeks = []
70
+ for pm in peek_markets:
71
+ # For peeking, we only care about high relevance, so limit is small
72
+ p_cols = get_default_columns_for_market(pm)
73
+ p_sort = "name" # Sort by name match
74
+ future_peeks.append(executor.submit(_execute_search, queries, pm, 5, p_cols, p_sort))
75
+
76
+ # Gather Main Results
77
+ results = future_main.result()
78
+
79
+ # Gather Peek Results
80
+ extra_matches = []
81
+ for f in future_peeks:
82
+ try:
83
+ res = f.result()
84
+ if res.get("results"):
85
+ extra_matches.extend(res["results"])
86
+ except:
87
+ pass
88
+
89
+ # Merge Logic
90
+ # 1. Start with Main Results
91
+ final_list = results.get("results", [])
92
+
93
+ # 2. Inject Exact Matches from Peeks at the TOP
94
+ # (e.g. if query="Silver" and we found "SILVER" in CFD, put it first)
95
+ high_priority = []
96
+ low_priority = []
97
+
98
+ query_upper = query.upper()
99
+
100
+ for m in extra_matches:
101
+ # Check for exact ticker/name match
102
+ n = m.get("name", "").upper()
103
+ tick = m.get("ticker", "").split(":")[-1].upper()
104
+
105
+ if n == query_upper or tick == query_upper or f"{query_upper}USD" in n:
106
+ high_priority.append(m)
107
+ else:
108
+ low_priority.append(m)
109
+
110
+ # specific uniqueness check
111
+ seen = {f"{r.get('exchange')}:{r.get('name')}" for r in final_list}
112
+
113
+ merged = []
114
+ # Add High Priority Peeks (if new)
115
+ for r in high_priority:
116
+ k = f"{r.get('exchange')}:{r.get('name')}"
117
+ if k not in seen:
118
+ merged.append(r)
119
+ seen.add(k)
120
+
121
+ # Add Main Results
122
+ merged.extend(final_list)
123
+
124
+ # Add Low Priority Peeks (only if we have space or main list is empty)
125
+ # Actually, let's append them if main list is small, or specialized matching
126
+ if not final_list:
127
+ for r in low_priority:
128
+ k = f"{r.get('exchange')}:{r.get('name')}"
129
+ if k not in seen:
130
+ merged.append(r)
131
+ seen.add(k)
132
+
133
+ # Update results
134
+ results["results"] = merged
135
+ results["total_found"] = len(merged)
136
+ results["returned"] = len(merged)
34
137
 
35
- # Smart Fallback: If default market ('america') yielded no results, try others
138
+ # Fallback Logic (Only if truly empty)
36
139
  if not results["results"] and market == "america":
37
140
 
38
- # 1. Asian Numeric Heuristic (Prioritized for speed/accuracy)
141
+ # 1. Asian Numeric Heuristic
39
142
  if query.isdigit() and len(query) in [4, 5, 6]:
143
+ # ... existing heuristic code ...
40
144
  asian_markets = ["taiwan", "hongkong", "japan", "china", "korea"]
41
145
  for asian_market in asian_markets:
42
146
  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)
147
+ asian_sort = "volume"
148
+ asian_res = _execute_search(queries, asian_market, 5, asian_cols, asian_sort)
45
149
  if asian_res["results"]:
46
- return {**asian_res, "note": f"No results in 'america', found matches in '{asian_market}' (numeric heuristic)"}
150
+ return {**asian_res, "note": f"No results in 'america', using numeric heuristic for '{asian_market}'."}
47
151
 
48
- # 2. True Global Search (Check ALL 68+ Stock Markets)
152
+ # 2. True Global Search
49
153
  try:
50
- # Use standard global columns safe for all
51
154
  global_cols = ["name", "description", "close", "change", "volume", "market_cap_basic", "exchange", "type"]
52
-
53
- # Check Description First (often better for "Maybank")
54
155
  q = (
55
156
  Query()
56
157
  .set_markets(*STOCK_MARKETS)
@@ -62,7 +163,6 @@ def search_symbols(
62
163
  _, df = q.get_scanner_data()
63
164
 
64
165
  if df.empty:
65
- # Check Name (Ticker)
66
166
  q = (
67
167
  Query()
68
168
  .set_markets(*STOCK_MARKETS)
@@ -74,31 +174,21 @@ def search_symbols(
74
174
  _, df = q.get_scanner_data()
75
175
 
76
176
  if not df.empty:
77
- # Basic Global Result
78
- total = len(df) # Roughly
177
+ # Add locators for global results
178
+ records = df.to_dict("records")
179
+ for r in records:
180
+ _enrich_locator(r, "stock", query)
181
+
79
182
  return {
80
183
  "query": query,
81
184
  "market": "global",
82
- "total_found": total,
185
+ "total_found": len(df),
83
186
  "returned": len(df),
84
- "results": df.to_dict("records"),
85
- "note": "No results in 'america', found matches in global stock markets."
187
+ "results": records,
188
+ "note": "Found matches in global stock markets."
86
189
  }
87
-
88
190
  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}'"}
191
+ pass
102
192
 
103
193
  return results
104
194
 
@@ -106,74 +196,86 @@ def search_symbols(
106
196
  return _search_symbols_fallback(query, market, limit, cols, str(e))
107
197
 
108
198
 
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")
199
+ def _enrich_locator(record: dict, market: str, category: str = "stock"):
200
+ """Add standard locator string to a record."""
201
+ ex = record.get("exchange", "UNKNOWN")
202
+ name = record.get("name", "UNKNOWN")
203
+
204
+ # Try to deduce market if 'stock' is generic
205
+ # (This is hard without a map of Exchange->Country, but we do our best)
206
+
207
+ # Construct Locator
208
+ # Format: EXCHANGE:SYMBOL, Market
209
+ record["locator"] = f"{ex}:{name}, {market}"
210
+ record["market"] = market
211
+
212
+
213
+ def _execute_search(queries: list[str] | str, market: str, limit: int, cols: list[str], sort_col: str) -> dict[str, Any]:
214
+ """Execute search, supporting multiple query terms (aliases)."""
215
+ if isinstance(queries, str):
216
+ queries = [queries]
123
217
 
124
- # 2. If nothing found, search name (ticker)
125
- if not results:
126
- query_obj2 = (
218
+ all_results = []
219
+
220
+ # Try each query term until we get enough results
221
+ for q_term in queries:
222
+ if len(all_results) >= limit: break
223
+
224
+ try:
225
+ # 1. Search description
226
+ q = (
127
227
  Query()
128
228
  .set_markets(market)
129
229
  .select(*cols)
130
- .where(Column("name").like(query))
230
+ .where(Column("description").like(q_term))
131
231
  .order_by(sort_col, ascending=False)
132
232
  .limit(limit)
133
233
  )
134
- total, df = query_obj2.get_scanner_data()
135
- results = df.to_dict("records")
234
+ _, df = q.get_scanner_data()
235
+ if not df.empty:
236
+ all_results.extend(df.to_dict("records"))
136
237
 
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}
238
+ # 2. Search name
239
+ if len(all_results) < limit:
240
+ q2 = (
241
+ Query()
242
+ .set_markets(market)
243
+ .select(*cols)
244
+ .where(Column("name").like(q_term))
245
+ .order_by(sort_col, ascending=False)
246
+ .limit(limit)
247
+ )
248
+ _, df2 = q2.get_scanner_data()
249
+ if not df2.empty:
250
+ all_results.extend(df2.to_dict("records"))
251
+ except:
252
+ pass
253
+
254
+ # Dedup
255
+ unique = {r.get("name")+r.get("exchange"): r for r in all_results}
256
+ results = list(unique.values())[:limit]
257
+
258
+ # Enhance with locator
259
+ for r in results:
260
+ _enrich_locator(r, market)
261
+
262
+ return {
263
+ "query": queries[0],
264
+ "market": market,
265
+ "total_found": len(results),
266
+ "returned": len(results),
267
+ "results": results,
268
+ }
147
269
 
148
270
 
149
271
  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}
272
+ return {"error": f"Search failed: {str(e)}", "original_error": error}
167
273
 
168
274
 
169
275
  def get_symbol_info(symbol: str, include_technical: bool = False) -> dict[str, Any]:
170
276
  """
171
277
  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.
278
+ Returns matches with strict locators: 'EXCHANGE:SYMBOL, Market'.
177
279
  """
178
280
  technical_cols = [
179
281
  "RSI", "RSI7", "MACD.macd", "MACD.signal", "SMA20", "SMA50", "SMA200",
@@ -187,12 +289,13 @@ def get_symbol_info(symbol: str, include_technical: bool = False) -> dict[str, A
187
289
  exchange, ticker = symbol.split(":", 1)
188
290
  market = EXCHANGE_SCREENER.get(exchange.lower()) or "america"
189
291
 
190
- # Setup Columns
292
+ # Allow override via sanitized lookup? No, specific exchange implies specific market logic usually.
293
+ # But let's be safe.
294
+
191
295
  cols = get_default_columns_for_market(market)
192
296
  if include_technical: cols.extend(technical_cols)
193
297
  cols = list(dict.fromkeys(cols))
194
298
 
195
- # Query
196
299
  q = (
197
300
  Query()
198
301
  .set_markets(market)
@@ -204,6 +307,10 @@ def get_symbol_info(symbol: str, include_technical: bool = False) -> dict[str, A
204
307
  results = df.to_dict("records")
205
308
 
206
309
  if results:
310
+ # Add strict locators
311
+ for r in results:
312
+ _enrich_locator(r, market)
313
+
207
314
  if len(results) == 1:
208
315
  return {"symbol": symbol, "found": True, "market": market, "data": results[0]}
209
316
  return {"symbol": symbol, "found": True, "market": market, "matches": results}
@@ -211,92 +318,76 @@ def get_symbol_info(symbol: str, include_technical: bool = False) -> dict[str, A
211
318
  return {"symbol": symbol, "found": False, "hint": f"Symbol not found in {market} ({exchange}). Check format."}
212
319
 
213
320
  # --- Universal Mode (Implicit Market) ---
214
- # We search Stocks (Global), Crypto, vs Forex in parallel using ThreadPoolExecutor
215
321
  all_matches = []
216
322
 
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))
323
+ # Targets
324
+ targets = [symbol, symbol.upper(), f"{symbol}USDT", f"{symbol}USD"]
325
+ if symbol.upper() in SYMBOL_ALIASES:
326
+ targets.extend(SYMBOL_ALIASES[symbol.upper()])
327
+
328
+ # Helper
329
+ def run_search(market, col_getter):
330
+ try:
331
+ cols = col_getter(market)
332
+ if include_technical: cols.extend(technical_cols)
333
+ cols = list(dict.fromkeys(cols))
222
334
 
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"
335
+ # We need to set markets properly
336
+ q = Query()
337
+ if market == "global_stocks":
338
+ q.set_markets(*STOCK_MARKETS)
339
+ else:
340
+ q.set_markets(market)
341
+
342
+ q.select(*cols).where(Column("name").isin(targets))
343
+
344
+ if market == "global_stocks":
345
+ q.order_by("market_cap_basic", ascending=False).limit(10)
346
+ else:
347
+ q.limit(5)
348
+
349
+ _, df = q.get_scanner_data()
350
+ if not df.empty:
351
+ matches = df.to_dict("records")
352
+ cat = "stock" if market == "global_stocks" else market
353
+
354
+ # Fixup locator logic
355
+ for m in matches:
356
+ m["_category"] = cat
357
+ # If global stock, we don't know exact country easily without map.
358
+ # But for now we pass 'global_stocks' or try to guess?
359
+ # It's better to tell AI "taiwan" if possible.
360
+ # But we queried ALL markets.
361
+ # HACK: We should query 'taiwan' explicitly if heuristics match?
362
+ # No, just return EXCHANGE:SYMBOL and let AI use 'search_symbols' if it needs precise market.
363
+ # Actually user wants "TWSE:0050, taiwan".
364
+ # If we used set_markets(*ALL), we lose the origin market info in the response unless we select 'market' column?
365
+ # TradingView API doesn't usually return 'market' column. It returns 'exchange'.
366
+ # We can map Exchange -> Market via EXCHANGE_SCREENER?
367
+ rec_market = EXCHANGE_SCREENER.get(m.get("exchange", "").lower(), cat)
368
+ _enrich_locator(m, rec_market)
369
+
257
370
  return matches
258
- except Exception:
371
+ except:
259
372
  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 []
373
+ return []
283
374
 
284
375
  # Execute in parallel
285
- with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
376
+ with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
286
377
  futures = [
287
- executor.submit(check_global_stocks),
288
- executor.submit(check_crypto),
289
- executor.submit(check_forex)
378
+ executor.submit(run_search, "global_stocks", lambda m: ["name", "description", "close", "change", "volume", "market_cap_basic", "exchange", "type"]),
379
+ executor.submit(run_search, "crypto", get_default_columns_for_market),
380
+ executor.submit(run_search, "forex", get_default_columns_for_market),
381
+ executor.submit(run_search, "cfd", get_default_columns_for_market)
290
382
  ]
291
383
  for future in concurrent.futures.as_completed(futures):
292
384
  all_matches.extend(future.result())
293
385
 
294
386
  # --- Aggregate Results ---
295
387
  if not all_matches:
296
- # Try search_symbols as last resort (fuzzy search)
297
388
  return search_symbols(symbol, "america", 5)
298
389
 
299
- # Remove duplicates based on ticker
390
+ # Remove duplicates
300
391
  unique_matches = {}
301
392
  for m in all_matches:
302
393
  key = m.get("ticker", m.get("name"))
@@ -6,9 +6,26 @@ 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."""
11
- market = sanitize_market(market)
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
+
28
+ market = sanitize_market(market, strict=True)
12
29
 
13
30
  # Use standard technical columns
14
31
  cols = list(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 {
@@ -37,7 +60,7 @@ def get_technical_analysis(symbol: str, market: str = "america") -> dict[str, An
37
60
 
38
61
  def scan_rsi_extremes(market: str = "america", limit: int = 25) -> dict[str, Any]:
39
62
  """Find symbols with extreme RSI values (<30 or >70)."""
40
- market = sanitize_market(market)
63
+ market = sanitize_market(market, strict=True)
41
64
 
42
65
  q = (
43
66
  Query()
@@ -77,7 +100,7 @@ def scan_rsi_extremes(market: str = "america", limit: int = 25) -> dict[str, Any
77
100
 
78
101
  def scan_bollinger_bands(market: str = "america", limit: int = 25) -> dict[str, Any]:
79
102
  """Find symbols trading outside Bollinger Bands."""
80
- market = sanitize_market(market)
103
+ market = sanitize_market(market, strict=True)
81
104
  # This is complex to do with simple filters.
82
105
  # We'll just return basic BB values for top volume stocks.
83
106
 
@@ -97,7 +120,7 @@ def scan_bollinger_bands(market: str = "america", limit: int = 25) -> dict[str,
97
120
 
98
121
  def scan_macd_crossover(market: str = "america", limit: int = 25) -> dict[str, Any]:
99
122
  """scan for MACD crossovers."""
100
- market = sanitize_market(market)
123
+ market = sanitize_market(market, strict=True)
101
124
  q = (
102
125
  Query()
103
126
  .set_markets(market)
tradingview_mcp/utils.py CHANGED
@@ -54,21 +54,89 @@ def sanitize_exchange(ex: str | None, default: str = "america") -> str:
54
54
  return default
55
55
 
56
56
 
57
- def sanitize_market(market: str | None, default: str = "america") -> str:
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
+
102
+ def sanitize_market(market: str | None, default: str = "america", strict: bool = False) -> str:
58
103
  """
59
104
  Validate and sanitize a market name.
60
-
105
+
61
106
  Args:
62
- market: Market to validate
63
- default: Default value if invalid
107
+ market: Market to validate (e.g. 'america', 'tw', 'crypto')
108
+ default: Default value if invalid (only used if strict=False)
109
+ strict: If True, raise ValueError for invalid markets instead of returning default.
64
110
 
65
111
  Returns:
66
112
  Valid market string
113
+
114
+ Raises:
115
+ ValueError: If strict=True and market is invalid.
67
116
  """
68
117
  if not market:
118
+ if strict:
119
+ raise ValueError("Market parameter is required (e.g. 'america', 'taiwan').")
69
120
  return default
70
- market = market.strip().lower()
71
- return market if market in MARKETS else default
121
+
122
+ market_clean = market.strip().lower()
123
+
124
+ # Check exact match
125
+ if market_clean in MARKETS:
126
+ return market_clean
127
+
128
+ # Check alias match
129
+ if market_clean in COUNTRY_ALIASES:
130
+ return COUNTRY_ALIASES[market_clean]
131
+
132
+ # Check if user passed exchange (e.g. 'nasdaq') by mistake
133
+ if market_clean in EXCHANGE_SCREENER:
134
+ return EXCHANGE_SCREENER[market_clean]
135
+
136
+ if strict:
137
+ raise ValueError(f"Invalid market '{market}'. Please use a valid country code or name (e.g. 'america', 'taiwan', 'crypto').")
138
+
139
+ return default
72
140
 
73
141
 
74
142
  def timeframe_to_resolution(tf: str) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tradingview-mcp
3
- Version: 26.3.0
3
+ Version: 26.3.2
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
@@ -7,7 +7,7 @@ tradingview_mcp/query.py,sha256=gkC4t-2_jtuUK3KgzU62wrIcGoaOmY--1EwAshGQb0Q,1087
7
7
  tradingview_mcp/resources.py,sha256=6cbsPhXsq6SFh3bmmSnMtrVkyCx1_xpWWiyqNLCCULk,1910
8
8
  tradingview_mcp/scanner.py,sha256=oEfMzaYhQ7ggBdLlPLqKZCMxHkd6MBMaUMcm3p-YvPA,7649
9
9
  tradingview_mcp/server.py,sha256=opC61fKfchYn3-XL5ZlPGvsPRZZoLXe-VqGSulhrJHA,7165
10
- tradingview_mcp/utils.py,sha256=6O89qIaZ_eYoN3m12r2q4-D8VpJajP5WuwKQbj4ZVHo,10238
10
+ tradingview_mcp/utils.py,sha256=My7SdEz89xfAW8aDsxoK311PGCd6kMFmg2DW2udM098,12007
11
11
  tradingview_mcp/data/__init__.py,sha256=3kot_ZG4a6jbZgyksE9z56n3skI407lsd4y2ThHotKY,298
12
12
  tradingview_mcp/data/column_display_names.json,sha256=AFdo6Q2n-wSXWHi2wEExOd2pTItyjocRbrnxqYpCa94,30137
13
13
  tradingview_mcp/data/markets.json,sha256=CehqCeVdQhWeCkMX1HWOrgCRjOomWa3VH1v2ZvPnIck,1482
@@ -33,12 +33,12 @@ tradingview_mcp/data/screeners/markets.json,sha256=vpdrAKg4E_lgg4FxeX2GHNbnSsKQD
33
33
  tradingview_mcp/data/screeners/stocks.json,sha256=gCnntHTJKJ7n7IqY72fZ4EuBfthtnWUpfHvPqNOvnXo,8843
34
34
  tradingview_mcp/data/screeners/stocks_failed.json,sha256=RqBp9XCZ3xvdWZMwuVGysEE2Er_XOQvBZoUHo9uyj6k,1084574
35
35
  tradingview_mcp/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
- tradingview_mcp/tools/reference.py,sha256=BPip9ntzyRwIyE0SoD8TWqSGLYj7Evt-OM1SQ1uPG8o,2160
37
- tradingview_mcp/tools/screener.py,sha256=K6uOUFq4GzPeCE4w2oa1LkAHbodQdQcmexw0W3sofeo,3188
38
- tradingview_mcp/tools/search.py,sha256=40Wpcf-noEkcB8Yv3f1NPd0UtmixWEf_AZoGf_GGibM,12970
39
- tradingview_mcp/tools/technical.py,sha256=l5nhXLQu-5iGJ0G-pdVFAghQsXv7JqoEGlphqrEjxQ8,3620
40
- tradingview_mcp-26.3.0.dist-info/METADATA,sha256=pBPcg95atEn_vedXIz7dwJ3WWeVgCv6HCT1xWjsQ3bU,4818
41
- tradingview_mcp-26.3.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
42
- tradingview_mcp-26.3.0.dist-info/entry_points.txt,sha256=GZxjGqgVbUlWDp5OzFQoCN_g1UBLyOmfVqCR5uzscnU,57
43
- tradingview_mcp-26.3.0.dist-info/licenses/LICENSE,sha256=1Hdpp7qGWCXVw1BP6vpdAPO4KrgO0T_c0N3ipkYHKAo,1070
44
- tradingview_mcp-26.3.0.dist-info/RECORD,,
36
+ tradingview_mcp/tools/reference.py,sha256=Oyc-l2ogjk2Evvqwk4SzgpGFV0vKKkdKJwQJmJbLJrM,2751
37
+ tradingview_mcp/tools/screener.py,sha256=5n-p-yXSljm9uqEfQmeg3EpDq2alDywrptvaxJaYMz8,3240
38
+ tradingview_mcp/tools/search.py,sha256=3uSGKfmKpI6ysc8v4hOLin3XAAKcQHeTBzzFSudt1hw,16328
39
+ tradingview_mcp/tools/technical.py,sha256=8oIrag7qnGltdsbIOkLkKtAUOHRYrp7RMV7Er5X_jAY,4552
40
+ tradingview_mcp-26.3.2.dist-info/METADATA,sha256=OcI-yg571T3ToHOe0Zwf4KW09xAclsPqJab3c6hoy1g,4818
41
+ tradingview_mcp-26.3.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
42
+ tradingview_mcp-26.3.2.dist-info/entry_points.txt,sha256=GZxjGqgVbUlWDp5OzFQoCN_g1UBLyOmfVqCR5uzscnU,57
43
+ tradingview_mcp-26.3.2.dist-info/licenses/LICENSE,sha256=1Hdpp7qGWCXVw1BP6vpdAPO4KrgO0T_c0N3ipkYHKAo,1070
44
+ tradingview_mcp-26.3.2.dist-info/RECORD,,