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.
- tradingview_mcp/tools/reference.py +13 -0
- tradingview_mcp/tools/screener.py +4 -4
- tradingview_mcp/tools/search.py +247 -156
- tradingview_mcp/tools/technical.py +31 -8
- tradingview_mcp/utils.py +74 -6
- {tradingview_mcp-26.3.0.dist-info → tradingview_mcp-26.3.2.dist-info}/METADATA +1 -1
- {tradingview_mcp-26.3.0.dist-info → tradingview_mcp-26.3.2.dist-info}/RECORD +10 -10
- {tradingview_mcp-26.3.0.dist-info → tradingview_mcp-26.3.2.dist-info}/WHEEL +0 -0
- {tradingview_mcp-26.3.0.dist-info → tradingview_mcp-26.3.2.dist-info}/entry_points.txt +0 -0
- {tradingview_mcp-26.3.0.dist-info → tradingview_mcp-26.3.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -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)
|
tradingview_mcp/tools/search.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
138
|
+
# Fallback Logic (Only if truly empty)
|
|
36
139
|
if not results["results"] and market == "america":
|
|
37
140
|
|
|
38
|
-
# 1. Asian Numeric Heuristic
|
|
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"
|
|
44
|
-
asian_res = _execute_search(
|
|
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',
|
|
150
|
+
return {**asian_res, "note": f"No results in 'america', using numeric heuristic for '{asian_market}'."}
|
|
47
151
|
|
|
48
|
-
# 2. True Global Search
|
|
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
|
-
#
|
|
78
|
-
|
|
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":
|
|
185
|
+
"total_found": len(df),
|
|
83
186
|
"returned": len(df),
|
|
84
|
-
"results":
|
|
85
|
-
"note": "
|
|
187
|
+
"results": records,
|
|
188
|
+
"note": "Found matches in global stock markets."
|
|
86
189
|
}
|
|
87
|
-
|
|
88
190
|
except Exception:
|
|
89
|
-
pass
|
|
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
|
|
110
|
-
"""
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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("
|
|
230
|
+
.where(Column("description").like(q_term))
|
|
131
231
|
.order_by(sort_col, ascending=False)
|
|
132
232
|
.limit(limit)
|
|
133
233
|
)
|
|
134
|
-
|
|
135
|
-
|
|
234
|
+
_, df = q.get_scanner_data()
|
|
235
|
+
if not df.empty:
|
|
236
|
+
all_results.extend(df.to_dict("records"))
|
|
136
237
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
"""
|
|
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
|
-
#
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
.
|
|
227
|
-
|
|
228
|
-
.
|
|
229
|
-
|
|
230
|
-
)
|
|
231
|
-
|
|
232
|
-
if
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
371
|
+
except:
|
|
259
372
|
pass
|
|
260
|
-
|
|
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=
|
|
376
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
|
|
286
377
|
futures = [
|
|
287
|
-
executor.submit(
|
|
288
|
-
executor.submit(
|
|
289
|
-
executor.submit(
|
|
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
|
|
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
|
|
10
|
-
"""
|
|
11
|
-
|
|
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([
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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.
|
|
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=
|
|
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=
|
|
37
|
-
tradingview_mcp/tools/screener.py,sha256=
|
|
38
|
-
tradingview_mcp/tools/search.py,sha256=
|
|
39
|
-
tradingview_mcp/tools/technical.py,sha256=
|
|
40
|
-
tradingview_mcp-26.3.
|
|
41
|
-
tradingview_mcp-26.3.
|
|
42
|
-
tradingview_mcp-26.3.
|
|
43
|
-
tradingview_mcp-26.3.
|
|
44
|
-
tradingview_mcp-26.3.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|