tradingview-mcp 1.0.0__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/__init__.py +14 -0
- tradingview_mcp/column.py +231 -0
- tradingview_mcp/constants.py +425 -0
- tradingview_mcp/models.py +154 -0
- tradingview_mcp/query.py +367 -0
- tradingview_mcp/scanner.py +256 -0
- tradingview_mcp/server.py +1361 -0
- tradingview_mcp/utils.py +382 -0
- tradingview_mcp-1.0.0.dist-info/METADATA +182 -0
- tradingview_mcp-1.0.0.dist-info/RECORD +13 -0
- tradingview_mcp-1.0.0.dist-info/WHEEL +4 -0
- tradingview_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- tradingview_mcp-1.0.0.dist-info/licenses/LICENSE +23 -0
|
@@ -0,0 +1,1361 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TradingView MCP Server - Main server implementation.
|
|
3
|
+
|
|
4
|
+
Provides comprehensive MCP tools and resources for TradingView market screening.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
from mcp.server.fastmcp import FastMCP
|
|
15
|
+
|
|
16
|
+
from tradingview_mcp.column import Column
|
|
17
|
+
from tradingview_mcp.constants import (
|
|
18
|
+
COLUMNS,
|
|
19
|
+
CRYPTO_COLUMNS,
|
|
20
|
+
DEFAULT_COLUMNS,
|
|
21
|
+
EXCHANGE_SCREENER,
|
|
22
|
+
MARKETS,
|
|
23
|
+
POSTMARKET_COLUMNS,
|
|
24
|
+
PREMARKET_COLUMNS,
|
|
25
|
+
TECHNICAL_COLUMNS,
|
|
26
|
+
)
|
|
27
|
+
from tradingview_mcp.query import And, Or, Query, get_all_symbols
|
|
28
|
+
from tradingview_mcp.scanner import CryptoScanner, Scanner
|
|
29
|
+
from tradingview_mcp.utils import (
|
|
30
|
+
analyze_indicators,
|
|
31
|
+
compute_bollinger_band_width,
|
|
32
|
+
compute_bollinger_rating,
|
|
33
|
+
compute_percent_change,
|
|
34
|
+
format_symbol,
|
|
35
|
+
format_technical_rating,
|
|
36
|
+
sanitize_exchange,
|
|
37
|
+
sanitize_market,
|
|
38
|
+
sanitize_timeframe,
|
|
39
|
+
timeframe_to_resolution,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Initialize MCP Server
|
|
43
|
+
mcp = FastMCP(
|
|
44
|
+
name="TradingView Screener MCP",
|
|
45
|
+
instructions=(
|
|
46
|
+
"A comprehensive MCP server for TradingView market screening. "
|
|
47
|
+
"Provides tools for stock and cryptocurrency screening, technical analysis, "
|
|
48
|
+
"and market data retrieval. Supports 76+ markets and 250+ technical indicators."
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# =============================================================================
|
|
54
|
+
# MCP Resources
|
|
55
|
+
# =============================================================================
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@mcp.resource("markets://list")
|
|
59
|
+
def list_markets() -> str:
|
|
60
|
+
"""List all available markets for screening."""
|
|
61
|
+
categories = {
|
|
62
|
+
"crypto": ["crypto", "coin"],
|
|
63
|
+
"instruments": ["forex", "futures", "bonds", "cfd", "options", "economics2"],
|
|
64
|
+
"americas": ["america", "argentina", "brazil", "canada", "chile", "colombia", "mexico", "peru", "venezuela"],
|
|
65
|
+
"europe": [
|
|
66
|
+
"austria",
|
|
67
|
+
"belgium",
|
|
68
|
+
"cyprus",
|
|
69
|
+
"czech",
|
|
70
|
+
"denmark",
|
|
71
|
+
"estonia",
|
|
72
|
+
"finland",
|
|
73
|
+
"france",
|
|
74
|
+
"germany",
|
|
75
|
+
"greece",
|
|
76
|
+
"hungary",
|
|
77
|
+
"iceland",
|
|
78
|
+
"italy",
|
|
79
|
+
"latvia",
|
|
80
|
+
"lithuania",
|
|
81
|
+
"luxembourg",
|
|
82
|
+
"netherlands",
|
|
83
|
+
"norway",
|
|
84
|
+
"poland",
|
|
85
|
+
"portugal",
|
|
86
|
+
"romania",
|
|
87
|
+
"russia",
|
|
88
|
+
"serbia",
|
|
89
|
+
"slovakia",
|
|
90
|
+
"spain",
|
|
91
|
+
"sweden",
|
|
92
|
+
"switzerland",
|
|
93
|
+
"turkey",
|
|
94
|
+
"uk",
|
|
95
|
+
],
|
|
96
|
+
"asia_pacific": [
|
|
97
|
+
"australia",
|
|
98
|
+
"bangladesh",
|
|
99
|
+
"china",
|
|
100
|
+
"hongkong",
|
|
101
|
+
"india",
|
|
102
|
+
"indonesia",
|
|
103
|
+
"japan",
|
|
104
|
+
"korea",
|
|
105
|
+
"malaysia",
|
|
106
|
+
"newzealand",
|
|
107
|
+
"pakistan",
|
|
108
|
+
"philippines",
|
|
109
|
+
"singapore",
|
|
110
|
+
"srilanka",
|
|
111
|
+
"taiwan",
|
|
112
|
+
"thailand",
|
|
113
|
+
"vietnam",
|
|
114
|
+
],
|
|
115
|
+
"middle_east_africa": [
|
|
116
|
+
"bahrain",
|
|
117
|
+
"egypt",
|
|
118
|
+
"israel",
|
|
119
|
+
"kenya",
|
|
120
|
+
"ksa",
|
|
121
|
+
"kuwait",
|
|
122
|
+
"morocco",
|
|
123
|
+
"nigeria",
|
|
124
|
+
"qatar",
|
|
125
|
+
"rsa",
|
|
126
|
+
"tunisia",
|
|
127
|
+
"uae",
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
result = "# Available Markets\n\n"
|
|
132
|
+
for category, markets in categories.items():
|
|
133
|
+
result += f"## {category.replace('_', ' ').title()}\n"
|
|
134
|
+
result += ", ".join(sorted(markets)) + "\n\n"
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@mcp.resource("columns://list")
|
|
139
|
+
def list_columns() -> str:
|
|
140
|
+
"""List all available columns/indicators for screening."""
|
|
141
|
+
result = "# Available Columns\n\n"
|
|
142
|
+
|
|
143
|
+
# Group columns by category
|
|
144
|
+
categories = {
|
|
145
|
+
"Price & Change": [
|
|
146
|
+
"close",
|
|
147
|
+
"open",
|
|
148
|
+
"high",
|
|
149
|
+
"low",
|
|
150
|
+
"volume",
|
|
151
|
+
"change",
|
|
152
|
+
"change_abs",
|
|
153
|
+
"gap",
|
|
154
|
+
],
|
|
155
|
+
"Moving Averages": [
|
|
156
|
+
"SMA5",
|
|
157
|
+
"SMA10",
|
|
158
|
+
"SMA20",
|
|
159
|
+
"SMA50",
|
|
160
|
+
"SMA100",
|
|
161
|
+
"SMA200",
|
|
162
|
+
"EMA5",
|
|
163
|
+
"EMA10",
|
|
164
|
+
"EMA20",
|
|
165
|
+
"EMA50",
|
|
166
|
+
"EMA100",
|
|
167
|
+
"EMA200",
|
|
168
|
+
],
|
|
169
|
+
"Oscillators": [
|
|
170
|
+
"RSI",
|
|
171
|
+
"RSI7",
|
|
172
|
+
"MACD.macd",
|
|
173
|
+
"MACD.signal",
|
|
174
|
+
"Stoch.K",
|
|
175
|
+
"Stoch.D",
|
|
176
|
+
"CCI20",
|
|
177
|
+
"Mom",
|
|
178
|
+
"AO",
|
|
179
|
+
"UO",
|
|
180
|
+
],
|
|
181
|
+
"Volatility": ["BB.upper", "BB.lower", "ATR", "Volatility.D", "Volatility.W", "Volatility.M"],
|
|
182
|
+
"Trend": ["ADX", "ADX+DI", "ADX-DI", "Aroon.Up", "Aroon.Down", "P.SAR"],
|
|
183
|
+
"Volume": [
|
|
184
|
+
"volume",
|
|
185
|
+
"VWAP",
|
|
186
|
+
"VWMA",
|
|
187
|
+
"relative_volume_10d_calc",
|
|
188
|
+
"average_volume_10d_calc",
|
|
189
|
+
"average_volume_30d_calc",
|
|
190
|
+
],
|
|
191
|
+
"Pre/Post Market": [
|
|
192
|
+
"premarket_change",
|
|
193
|
+
"premarket_volume",
|
|
194
|
+
"premarket_gap",
|
|
195
|
+
"postmarket_change",
|
|
196
|
+
"postmarket_volume",
|
|
197
|
+
],
|
|
198
|
+
"Performance": ["Perf.W", "Perf.1M", "Perf.3M", "Perf.6M", "Perf.Y", "Perf.YTD", "Perf.All"],
|
|
199
|
+
"Fundamentals": [
|
|
200
|
+
"market_cap_basic",
|
|
201
|
+
"price_earnings_ttm",
|
|
202
|
+
"earnings_per_share_basic_ttm",
|
|
203
|
+
"dividend_yield_recent",
|
|
204
|
+
"price_book_fq",
|
|
205
|
+
],
|
|
206
|
+
"Ratings": ["Recommend.All", "Recommend.MA", "Recommend.Other"],
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for category, cols in categories.items():
|
|
210
|
+
result += f"## {category}\n"
|
|
211
|
+
result += ", ".join(cols) + "\n\n"
|
|
212
|
+
|
|
213
|
+
result += "\n## Full Column Mapping\n"
|
|
214
|
+
result += "Use human-readable names or API names:\n\n"
|
|
215
|
+
for human_name, api_name in sorted(COLUMNS.items())[:50]: # First 50
|
|
216
|
+
result += f"- `{human_name}` → `{api_name}`\n"
|
|
217
|
+
result += f"\n... and {len(COLUMNS) - 50} more columns available."
|
|
218
|
+
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@mcp.resource("exchanges://crypto")
|
|
223
|
+
def list_crypto_exchanges() -> str:
|
|
224
|
+
"""List supported cryptocurrency exchanges."""
|
|
225
|
+
crypto_exchanges = [ex for ex, screener in EXCHANGE_SCREENER.items() if screener == "crypto"]
|
|
226
|
+
return f"# Supported Crypto Exchanges\n\n" + ", ".join(sorted(crypto_exchanges))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@mcp.resource("scanners://presets")
|
|
230
|
+
def list_scanner_presets() -> str:
|
|
231
|
+
"""List available pre-built scanner presets."""
|
|
232
|
+
result = "# Scanner Presets\n\n"
|
|
233
|
+
|
|
234
|
+
result += "## Stock Scanners\n"
|
|
235
|
+
for name in Scanner.names():
|
|
236
|
+
result += f"- `{name}`\n"
|
|
237
|
+
|
|
238
|
+
result += "\n## Crypto Scanners\n"
|
|
239
|
+
for name in CryptoScanner.names():
|
|
240
|
+
result += f"- `{name}`\n"
|
|
241
|
+
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# =============================================================================
|
|
246
|
+
# MCP Tools - Basic Screening
|
|
247
|
+
# =============================================================================
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@mcp.tool()
|
|
251
|
+
def screen_market(
|
|
252
|
+
market: str = "america",
|
|
253
|
+
columns: Optional[list[str]] = None,
|
|
254
|
+
sort_by: str = "volume",
|
|
255
|
+
ascending: bool = False,
|
|
256
|
+
limit: int = 50,
|
|
257
|
+
filters: Optional[list[dict[str, Any]]] = None,
|
|
258
|
+
) -> list[dict[str, Any]]:
|
|
259
|
+
"""
|
|
260
|
+
Execute a custom market screening query.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
market: Market to screen (america, crypto, uk, etc.)
|
|
264
|
+
columns: Columns to return (default: name, close, volume, market_cap_basic)
|
|
265
|
+
sort_by: Column to sort by
|
|
266
|
+
ascending: Sort order (True=ascending, False=descending)
|
|
267
|
+
limit: Maximum results (1-500)
|
|
268
|
+
filters: List of filter conditions as dicts with keys: column, operation, value
|
|
269
|
+
Operations: gt, gte, lt, lte, eq, neq, between, isin
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
List of matching symbols with requested data
|
|
273
|
+
"""
|
|
274
|
+
market = sanitize_market(market)
|
|
275
|
+
limit = max(1, min(limit, 500))
|
|
276
|
+
cols = columns or DEFAULT_COLUMNS
|
|
277
|
+
|
|
278
|
+
query = Query().set_markets(market).select(*cols).order_by(sort_by, ascending=ascending).limit(limit)
|
|
279
|
+
|
|
280
|
+
# Apply filters if provided
|
|
281
|
+
if filters:
|
|
282
|
+
filter_expressions = []
|
|
283
|
+
for f in filters:
|
|
284
|
+
col = Column(f.get("column", "close"))
|
|
285
|
+
op = f.get("operation", "gt")
|
|
286
|
+
val = f.get("value")
|
|
287
|
+
|
|
288
|
+
if op == "gt":
|
|
289
|
+
filter_expressions.append(col > val)
|
|
290
|
+
elif op == "gte":
|
|
291
|
+
filter_expressions.append(col >= val)
|
|
292
|
+
elif op == "lt":
|
|
293
|
+
filter_expressions.append(col < val)
|
|
294
|
+
elif op == "lte":
|
|
295
|
+
filter_expressions.append(col <= val)
|
|
296
|
+
elif op == "eq":
|
|
297
|
+
filter_expressions.append(col == val)
|
|
298
|
+
elif op == "neq":
|
|
299
|
+
filter_expressions.append(col != val)
|
|
300
|
+
elif op == "between" and isinstance(val, (list, tuple)) and len(val) == 2:
|
|
301
|
+
filter_expressions.append(col.between(val[0], val[1]))
|
|
302
|
+
elif op == "isin" and isinstance(val, (list, tuple)):
|
|
303
|
+
filter_expressions.append(col.isin(val))
|
|
304
|
+
|
|
305
|
+
if filter_expressions:
|
|
306
|
+
query = query.where(*filter_expressions)
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
total_count, df = query.get_scanner_data()
|
|
310
|
+
results = df.to_dict("records")
|
|
311
|
+
|
|
312
|
+
return [{"total_count": total_count, "returned": len(results), "data": results}]
|
|
313
|
+
except Exception as e:
|
|
314
|
+
return [{"error": str(e)}]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@mcp.tool()
|
|
318
|
+
def get_top_gainers(market: str = "america", limit: int = 25) -> list[dict[str, Any]]:
|
|
319
|
+
"""
|
|
320
|
+
Get top gaining assets in a market.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
market: Market to scan (america, crypto, uk, etc.)
|
|
324
|
+
limit: Number of results (1-100)
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
List of top gainers with price and change data
|
|
328
|
+
"""
|
|
329
|
+
market = sanitize_market(market)
|
|
330
|
+
limit = max(1, min(limit, 100))
|
|
331
|
+
|
|
332
|
+
cols = ["name", "close", "volume", "change", "change_abs", "market_cap_basic"]
|
|
333
|
+
|
|
334
|
+
query = (
|
|
335
|
+
Query()
|
|
336
|
+
.set_markets(market)
|
|
337
|
+
.select(*cols)
|
|
338
|
+
.where(Column("change") > 0)
|
|
339
|
+
.order_by("change", ascending=False)
|
|
340
|
+
.limit(limit)
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
total, df = query.get_scanner_data()
|
|
345
|
+
return df.to_dict("records")
|
|
346
|
+
except Exception as e:
|
|
347
|
+
return [{"error": str(e)}]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@mcp.tool()
|
|
351
|
+
def get_top_losers(market: str = "america", limit: int = 25) -> list[dict[str, Any]]:
|
|
352
|
+
"""
|
|
353
|
+
Get top losing assets in a market.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
market: Market to scan (america, crypto, uk, etc.)
|
|
357
|
+
limit: Number of results (1-100)
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
List of top losers with price and change data
|
|
361
|
+
"""
|
|
362
|
+
market = sanitize_market(market)
|
|
363
|
+
limit = max(1, min(limit, 100))
|
|
364
|
+
|
|
365
|
+
cols = ["name", "close", "volume", "change", "change_abs", "market_cap_basic"]
|
|
366
|
+
|
|
367
|
+
query = (
|
|
368
|
+
Query()
|
|
369
|
+
.set_markets(market)
|
|
370
|
+
.select(*cols)
|
|
371
|
+
.where(Column("change") < 0)
|
|
372
|
+
.order_by("change", ascending=True)
|
|
373
|
+
.limit(limit)
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
total, df = query.get_scanner_data()
|
|
378
|
+
return df.to_dict("records")
|
|
379
|
+
except Exception as e:
|
|
380
|
+
return [{"error": str(e)}]
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@mcp.tool()
|
|
384
|
+
def get_most_active(market: str = "america", limit: int = 25) -> list[dict[str, Any]]:
|
|
385
|
+
"""
|
|
386
|
+
Get most actively traded assets by volume.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
market: Market to scan
|
|
390
|
+
limit: Number of results (1-100)
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
List of most active assets by volume
|
|
394
|
+
"""
|
|
395
|
+
market = sanitize_market(market)
|
|
396
|
+
limit = max(1, min(limit, 100))
|
|
397
|
+
|
|
398
|
+
cols = ["name", "close", "volume", "change", "relative_volume_10d_calc", "market_cap_basic"]
|
|
399
|
+
|
|
400
|
+
query = Query().set_markets(market).select(*cols).order_by("volume", ascending=False).limit(limit)
|
|
401
|
+
|
|
402
|
+
try:
|
|
403
|
+
total, df = query.get_scanner_data()
|
|
404
|
+
return df.to_dict("records")
|
|
405
|
+
except Exception as e:
|
|
406
|
+
return [{"error": str(e)}]
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@mcp.tool()
|
|
410
|
+
def get_premarket_movers(
|
|
411
|
+
scan_type: str = "gainers", limit: int = 25
|
|
412
|
+
) -> list[dict[str, Any]]:
|
|
413
|
+
"""
|
|
414
|
+
Get pre-market movers (US market only).
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
scan_type: Type of scan - 'gainers', 'losers', 'most_active', 'gappers'
|
|
418
|
+
limit: Number of results (1-100)
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
List of pre-market movers
|
|
422
|
+
"""
|
|
423
|
+
limit = max(1, min(limit, 100))
|
|
424
|
+
|
|
425
|
+
scanner_map = {
|
|
426
|
+
"gainers": Scanner.premarket_gainers,
|
|
427
|
+
"losers": Scanner.premarket_losers,
|
|
428
|
+
"most_active": Scanner.premarket_most_active,
|
|
429
|
+
"gappers": Scanner.premarket_gappers,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
scanner = scanner_map.get(scan_type, Scanner.premarket_gainers).copy()
|
|
433
|
+
scanner = scanner.limit(limit)
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
total, df = scanner.get_scanner_data()
|
|
437
|
+
return df.to_dict("records")
|
|
438
|
+
except Exception as e:
|
|
439
|
+
return [{"error": str(e)}]
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@mcp.tool()
|
|
443
|
+
def get_postmarket_movers(
|
|
444
|
+
scan_type: str = "gainers", limit: int = 25
|
|
445
|
+
) -> list[dict[str, Any]]:
|
|
446
|
+
"""
|
|
447
|
+
Get post-market movers (US market only).
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
scan_type: Type of scan - 'gainers', 'losers', 'most_active'
|
|
451
|
+
limit: Number of results (1-100)
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
List of post-market movers
|
|
455
|
+
"""
|
|
456
|
+
limit = max(1, min(limit, 100))
|
|
457
|
+
|
|
458
|
+
scanner_map = {
|
|
459
|
+
"gainers": Scanner.postmarket_gainers,
|
|
460
|
+
"losers": Scanner.postmarket_losers,
|
|
461
|
+
"most_active": Scanner.postmarket_most_active,
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
scanner = scanner_map.get(scan_type, Scanner.postmarket_gainers).copy()
|
|
465
|
+
scanner = scanner.limit(limit)
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
total, df = scanner.get_scanner_data()
|
|
469
|
+
return df.to_dict("records")
|
|
470
|
+
except Exception as e:
|
|
471
|
+
return [{"error": str(e)}]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# =============================================================================
|
|
475
|
+
# MCP Tools - Technical Analysis Scanners
|
|
476
|
+
# =============================================================================
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
@mcp.tool()
|
|
480
|
+
def scan_rsi_extremes(
|
|
481
|
+
market: str = "america",
|
|
482
|
+
condition: str = "oversold",
|
|
483
|
+
threshold: float = 30,
|
|
484
|
+
limit: int = 25,
|
|
485
|
+
) -> list[dict[str, Any]]:
|
|
486
|
+
"""
|
|
487
|
+
Scan for RSI overbought/oversold conditions.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
market: Market to scan
|
|
491
|
+
condition: 'oversold' or 'overbought'
|
|
492
|
+
threshold: RSI threshold (default 30 for oversold, 70 for overbought)
|
|
493
|
+
limit: Number of results (1-100)
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
List of symbols matching RSI criteria
|
|
497
|
+
"""
|
|
498
|
+
market = sanitize_market(market)
|
|
499
|
+
limit = max(1, min(limit, 100))
|
|
500
|
+
|
|
501
|
+
cols = ["name", "close", "volume", "change", "RSI", "RSI7", "market_cap_basic"]
|
|
502
|
+
|
|
503
|
+
query = Query().set_markets(market).select(*cols).limit(limit)
|
|
504
|
+
|
|
505
|
+
if condition == "oversold":
|
|
506
|
+
threshold = min(threshold, 50)
|
|
507
|
+
query = query.where(Column("RSI") < threshold).order_by("RSI", ascending=True)
|
|
508
|
+
else: # overbought
|
|
509
|
+
threshold = max(threshold, 50)
|
|
510
|
+
query = query.where(Column("RSI") > threshold).order_by("RSI", ascending=False)
|
|
511
|
+
|
|
512
|
+
try:
|
|
513
|
+
total, df = query.get_scanner_data()
|
|
514
|
+
results = df.to_dict("records")
|
|
515
|
+
|
|
516
|
+
# Add signal interpretation
|
|
517
|
+
for r in results:
|
|
518
|
+
rsi = r.get("RSI", 50)
|
|
519
|
+
if rsi < 20:
|
|
520
|
+
r["signal"] = "EXTREMELY_OVERSOLD"
|
|
521
|
+
elif rsi < 30:
|
|
522
|
+
r["signal"] = "OVERSOLD"
|
|
523
|
+
elif rsi > 80:
|
|
524
|
+
r["signal"] = "EXTREMELY_OVERBOUGHT"
|
|
525
|
+
elif rsi > 70:
|
|
526
|
+
r["signal"] = "OVERBOUGHT"
|
|
527
|
+
else:
|
|
528
|
+
r["signal"] = "NEUTRAL"
|
|
529
|
+
|
|
530
|
+
return results
|
|
531
|
+
except Exception as e:
|
|
532
|
+
return [{"error": str(e)}]
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@mcp.tool()
|
|
536
|
+
def scan_macd_crossover(
|
|
537
|
+
market: str = "america",
|
|
538
|
+
crossover_type: str = "bullish",
|
|
539
|
+
limit: int = 25,
|
|
540
|
+
) -> list[dict[str, Any]]:
|
|
541
|
+
"""
|
|
542
|
+
Scan for MACD crossover signals.
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
market: Market to scan
|
|
546
|
+
crossover_type: 'bullish' (MACD crosses above signal) or 'bearish'
|
|
547
|
+
limit: Number of results (1-100)
|
|
548
|
+
|
|
549
|
+
Returns:
|
|
550
|
+
List of symbols with MACD crossover
|
|
551
|
+
"""
|
|
552
|
+
market = sanitize_market(market)
|
|
553
|
+
limit = max(1, min(limit, 100))
|
|
554
|
+
|
|
555
|
+
cols = ["name", "close", "volume", "change", "MACD.macd", "MACD.signal", "market_cap_basic"]
|
|
556
|
+
|
|
557
|
+
query = Query().set_markets(market).select(*cols).limit(limit)
|
|
558
|
+
|
|
559
|
+
if crossover_type == "bullish":
|
|
560
|
+
query = query.where(Column("MACD.macd").crosses_above(Column("MACD.signal"))).order_by(
|
|
561
|
+
"change", ascending=False
|
|
562
|
+
)
|
|
563
|
+
else:
|
|
564
|
+
query = query.where(Column("MACD.macd").crosses_below(Column("MACD.signal"))).order_by(
|
|
565
|
+
"change", ascending=True
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
try:
|
|
569
|
+
total, df = query.get_scanner_data()
|
|
570
|
+
results = df.to_dict("records")
|
|
571
|
+
|
|
572
|
+
for r in results:
|
|
573
|
+
macd = r.get("MACD.macd", 0)
|
|
574
|
+
signal = r.get("MACD.signal", 0)
|
|
575
|
+
r["macd_histogram"] = round(macd - signal, 4) if macd and signal else None
|
|
576
|
+
r["crossover"] = crossover_type
|
|
577
|
+
|
|
578
|
+
return results
|
|
579
|
+
except Exception as e:
|
|
580
|
+
return [{"error": str(e)}]
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
@mcp.tool()
|
|
584
|
+
def scan_bollinger_bands(
|
|
585
|
+
market: str = "america",
|
|
586
|
+
condition: str = "squeeze",
|
|
587
|
+
limit: int = 25,
|
|
588
|
+
) -> list[dict[str, Any]]:
|
|
589
|
+
"""
|
|
590
|
+
Scan for Bollinger Band conditions.
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
market: Market to scan
|
|
594
|
+
condition: 'squeeze' (low volatility), 'above_upper', 'below_lower'
|
|
595
|
+
limit: Number of results (1-100)
|
|
596
|
+
|
|
597
|
+
Returns:
|
|
598
|
+
List of symbols matching BB criteria
|
|
599
|
+
"""
|
|
600
|
+
market = sanitize_market(market)
|
|
601
|
+
limit = max(1, min(limit, 100))
|
|
602
|
+
|
|
603
|
+
cols = ["name", "close", "volume", "change", "BB.upper", "BB.lower", "SMA20", "ATR", "Volatility.D"]
|
|
604
|
+
|
|
605
|
+
query = Query().set_markets(market).select(*cols).limit(limit)
|
|
606
|
+
|
|
607
|
+
if condition == "squeeze":
|
|
608
|
+
query = query.order_by("Volatility.D", ascending=True)
|
|
609
|
+
elif condition == "above_upper":
|
|
610
|
+
query = query.where(Column("close") > Column("BB.upper")).order_by("change", ascending=False)
|
|
611
|
+
elif condition == "below_lower":
|
|
612
|
+
query = query.where(Column("close") < Column("BB.lower")).order_by("change", ascending=True)
|
|
613
|
+
|
|
614
|
+
try:
|
|
615
|
+
total, df = query.get_scanner_data()
|
|
616
|
+
results = df.to_dict("records")
|
|
617
|
+
|
|
618
|
+
# Calculate BBW and add signals
|
|
619
|
+
for r in results:
|
|
620
|
+
bb_upper = r.get("BB.upper", 0)
|
|
621
|
+
bb_lower = r.get("BB.lower", 0)
|
|
622
|
+
sma20 = r.get("SMA20", 0)
|
|
623
|
+
close = r.get("close", 0)
|
|
624
|
+
|
|
625
|
+
if sma20 and bb_upper and bb_lower:
|
|
626
|
+
bbw = compute_bollinger_band_width(sma20, bb_upper, bb_lower)
|
|
627
|
+
r["bbw"] = round(bbw, 4) if bbw else None
|
|
628
|
+
|
|
629
|
+
rating, signal = compute_bollinger_rating(close, bb_upper, sma20, bb_lower)
|
|
630
|
+
r["bb_rating"] = rating
|
|
631
|
+
r["bb_signal"] = signal
|
|
632
|
+
|
|
633
|
+
return results
|
|
634
|
+
except Exception as e:
|
|
635
|
+
return [{"error": str(e)}]
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
@mcp.tool()
|
|
639
|
+
def scan_volume_breakout(
|
|
640
|
+
market: str = "america",
|
|
641
|
+
volume_multiplier: float = 2.0,
|
|
642
|
+
min_price_change: float = 3.0,
|
|
643
|
+
limit: int = 25,
|
|
644
|
+
) -> list[dict[str, Any]]:
|
|
645
|
+
"""
|
|
646
|
+
Scan for volume breakout candidates.
|
|
647
|
+
|
|
648
|
+
Args:
|
|
649
|
+
market: Market to scan
|
|
650
|
+
volume_multiplier: Minimum relative volume vs 10-day average (default 2.0)
|
|
651
|
+
min_price_change: Minimum absolute price change % (default 3.0)
|
|
652
|
+
limit: Number of results (1-100)
|
|
653
|
+
|
|
654
|
+
Returns:
|
|
655
|
+
List of symbols with volume breakouts
|
|
656
|
+
"""
|
|
657
|
+
market = sanitize_market(market)
|
|
658
|
+
limit = max(1, min(limit, 100))
|
|
659
|
+
volume_multiplier = max(1.2, min(10.0, volume_multiplier))
|
|
660
|
+
min_price_change = max(0.5, min(20.0, min_price_change))
|
|
661
|
+
|
|
662
|
+
cols = [
|
|
663
|
+
"name",
|
|
664
|
+
"close",
|
|
665
|
+
"volume",
|
|
666
|
+
"change",
|
|
667
|
+
"change_abs",
|
|
668
|
+
"relative_volume_10d_calc",
|
|
669
|
+
"average_volume_10d_calc",
|
|
670
|
+
]
|
|
671
|
+
|
|
672
|
+
query = (
|
|
673
|
+
Query()
|
|
674
|
+
.set_markets(market)
|
|
675
|
+
.select(*cols)
|
|
676
|
+
.where(
|
|
677
|
+
Column("relative_volume_10d_calc") >= volume_multiplier,
|
|
678
|
+
)
|
|
679
|
+
.order_by("relative_volume_10d_calc", ascending=False)
|
|
680
|
+
.limit(limit * 2) # Get more to filter
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
try:
|
|
684
|
+
total, df = query.get_scanner_data()
|
|
685
|
+
results = df.to_dict("records")
|
|
686
|
+
|
|
687
|
+
# Filter by price change
|
|
688
|
+
filtered = []
|
|
689
|
+
for r in results:
|
|
690
|
+
change = abs(r.get("change", 0) or 0)
|
|
691
|
+
if change >= min_price_change:
|
|
692
|
+
rel_vol = r.get("relative_volume_10d_calc", 1)
|
|
693
|
+
r["breakout_type"] = "bullish" if r.get("change", 0) > 0 else "bearish"
|
|
694
|
+
r["volume_strength"] = "VERY_STRONG" if rel_vol >= 3 else "STRONG" if rel_vol >= 2 else "MODERATE"
|
|
695
|
+
filtered.append(r)
|
|
696
|
+
|
|
697
|
+
return filtered[:limit]
|
|
698
|
+
except Exception as e:
|
|
699
|
+
return [{"error": str(e)}]
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
@mcp.tool()
|
|
703
|
+
def scan_moving_average_crossover(
|
|
704
|
+
market: str = "america",
|
|
705
|
+
crossover_type: str = "golden_cross",
|
|
706
|
+
limit: int = 25,
|
|
707
|
+
) -> list[dict[str, Any]]:
|
|
708
|
+
"""
|
|
709
|
+
Scan for moving average crossovers.
|
|
710
|
+
|
|
711
|
+
Args:
|
|
712
|
+
market: Market to scan
|
|
713
|
+
crossover_type: 'golden_cross' (SMA50 crosses above SMA200),
|
|
714
|
+
'death_cross' (SMA50 crosses below SMA200),
|
|
715
|
+
'above_all_mas', 'below_all_mas'
|
|
716
|
+
limit: Number of results (1-100)
|
|
717
|
+
|
|
718
|
+
Returns:
|
|
719
|
+
List of symbols with MA crossovers
|
|
720
|
+
"""
|
|
721
|
+
market = sanitize_market(market)
|
|
722
|
+
limit = max(1, min(limit, 100))
|
|
723
|
+
|
|
724
|
+
scanner_map = {
|
|
725
|
+
"golden_cross": Scanner.golden_cross,
|
|
726
|
+
"death_cross": Scanner.death_cross,
|
|
727
|
+
"above_all_mas": Scanner.above_all_mas,
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
scanner = scanner_map.get(crossover_type)
|
|
731
|
+
if scanner:
|
|
732
|
+
scanner = scanner.copy().set_markets(market).limit(limit)
|
|
733
|
+
else:
|
|
734
|
+
# below_all_mas
|
|
735
|
+
scanner = (
|
|
736
|
+
Query()
|
|
737
|
+
.set_markets(market)
|
|
738
|
+
.select("name", "close", "volume", "change", "SMA20", "SMA50", "SMA200")
|
|
739
|
+
.where(
|
|
740
|
+
Column("close") < Column("SMA20"),
|
|
741
|
+
Column("close") < Column("SMA50"),
|
|
742
|
+
Column("close") < Column("SMA200"),
|
|
743
|
+
)
|
|
744
|
+
.order_by("change", ascending=True)
|
|
745
|
+
.limit(limit)
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
try:
|
|
749
|
+
total, df = scanner.get_scanner_data()
|
|
750
|
+
results = df.to_dict("records")
|
|
751
|
+
|
|
752
|
+
for r in results:
|
|
753
|
+
r["crossover_type"] = crossover_type
|
|
754
|
+
|
|
755
|
+
return results
|
|
756
|
+
except Exception as e:
|
|
757
|
+
return [{"error": str(e)}]
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
@mcp.tool()
|
|
761
|
+
def scan_52_week_levels(
|
|
762
|
+
market: str = "america",
|
|
763
|
+
level_type: str = "near_high",
|
|
764
|
+
threshold_pct: float = 5.0,
|
|
765
|
+
limit: int = 25,
|
|
766
|
+
) -> list[dict[str, Any]]:
|
|
767
|
+
"""
|
|
768
|
+
Scan for stocks near 52-week highs or lows.
|
|
769
|
+
|
|
770
|
+
Args:
|
|
771
|
+
market: Market to scan
|
|
772
|
+
level_type: 'near_high', 'near_low', 'new_high', 'new_low'
|
|
773
|
+
threshold_pct: How close to the level (default 5%)
|
|
774
|
+
limit: Number of results (1-100)
|
|
775
|
+
|
|
776
|
+
Returns:
|
|
777
|
+
List of symbols near 52-week extremes
|
|
778
|
+
"""
|
|
779
|
+
market = sanitize_market(market)
|
|
780
|
+
limit = max(1, min(limit, 100))
|
|
781
|
+
threshold_pct = max(1.0, min(20.0, threshold_pct))
|
|
782
|
+
|
|
783
|
+
cols = ["name", "close", "volume", "change", "price_52_week_high", "price_52_week_low", "Perf.Y"]
|
|
784
|
+
|
|
785
|
+
query = Query().set_markets(market).select(*cols).limit(limit)
|
|
786
|
+
|
|
787
|
+
if level_type == "near_high":
|
|
788
|
+
# Price within threshold_pct of 52-week high
|
|
789
|
+
multiplier = 1 - (threshold_pct / 100)
|
|
790
|
+
query = query.where(Column("close").above_pct("price_52_week_high", multiplier)).order_by(
|
|
791
|
+
"change", ascending=False
|
|
792
|
+
)
|
|
793
|
+
elif level_type == "near_low":
|
|
794
|
+
# Price within threshold_pct of 52-week low
|
|
795
|
+
multiplier = 1 + (threshold_pct / 100)
|
|
796
|
+
query = query.where(Column("close").below_pct("price_52_week_low", multiplier)).order_by(
|
|
797
|
+
"change", ascending=True
|
|
798
|
+
)
|
|
799
|
+
elif level_type == "new_high":
|
|
800
|
+
query = query.where(Column("close") >= Column("price_52_week_high")).order_by(
|
|
801
|
+
"change", ascending=False
|
|
802
|
+
)
|
|
803
|
+
else: # new_low
|
|
804
|
+
query = query.where(Column("close") <= Column("price_52_week_low")).order_by(
|
|
805
|
+
"change", ascending=True
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
try:
|
|
809
|
+
total, df = query.get_scanner_data()
|
|
810
|
+
results = df.to_dict("records")
|
|
811
|
+
|
|
812
|
+
for r in results:
|
|
813
|
+
close = r.get("close", 0)
|
|
814
|
+
high52 = r.get("price_52_week_high", 0)
|
|
815
|
+
low52 = r.get("price_52_week_low", 0)
|
|
816
|
+
|
|
817
|
+
if high52:
|
|
818
|
+
r["pct_from_52w_high"] = round(((close - high52) / high52) * 100, 2)
|
|
819
|
+
if low52:
|
|
820
|
+
r["pct_from_52w_low"] = round(((close - low52) / low52) * 100, 2)
|
|
821
|
+
|
|
822
|
+
return results
|
|
823
|
+
except Exception as e:
|
|
824
|
+
return [{"error": str(e)}]
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
# =============================================================================
|
|
828
|
+
# MCP Tools - Symbol Analysis
|
|
829
|
+
# =============================================================================
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
@mcp.tool()
|
|
833
|
+
def get_symbol_analysis(
|
|
834
|
+
symbol: str,
|
|
835
|
+
include_fundamentals: bool = True,
|
|
836
|
+
) -> dict[str, Any]:
|
|
837
|
+
"""
|
|
838
|
+
Get comprehensive technical analysis for a specific symbol.
|
|
839
|
+
|
|
840
|
+
Args:
|
|
841
|
+
symbol: Symbol in format 'EXCHANGE:SYMBOL' (e.g., 'NASDAQ:AAPL', 'BINANCE:BTCUSDT')
|
|
842
|
+
include_fundamentals: Include fundamental data if available
|
|
843
|
+
|
|
844
|
+
Returns:
|
|
845
|
+
Comprehensive analysis with indicators, signals, and recommendations
|
|
846
|
+
"""
|
|
847
|
+
# Select columns based on what we want
|
|
848
|
+
cols = [
|
|
849
|
+
"name",
|
|
850
|
+
"description",
|
|
851
|
+
"type",
|
|
852
|
+
"exchange",
|
|
853
|
+
"close",
|
|
854
|
+
"open",
|
|
855
|
+
"high",
|
|
856
|
+
"low",
|
|
857
|
+
"volume",
|
|
858
|
+
"change",
|
|
859
|
+
"change_abs",
|
|
860
|
+
# Technical indicators
|
|
861
|
+
"RSI",
|
|
862
|
+
"RSI7",
|
|
863
|
+
"MACD.macd",
|
|
864
|
+
"MACD.signal",
|
|
865
|
+
"BB.upper",
|
|
866
|
+
"BB.lower",
|
|
867
|
+
"SMA20",
|
|
868
|
+
"SMA50",
|
|
869
|
+
"SMA200",
|
|
870
|
+
"EMA20",
|
|
871
|
+
"EMA50",
|
|
872
|
+
"EMA200",
|
|
873
|
+
"ADX",
|
|
874
|
+
"ATR",
|
|
875
|
+
"Stoch.K",
|
|
876
|
+
"Stoch.D",
|
|
877
|
+
"VWAP",
|
|
878
|
+
"Volatility.D",
|
|
879
|
+
# Performance
|
|
880
|
+
"Perf.W",
|
|
881
|
+
"Perf.1M",
|
|
882
|
+
"Perf.3M",
|
|
883
|
+
"Perf.Y",
|
|
884
|
+
# Recommendations
|
|
885
|
+
"Recommend.All",
|
|
886
|
+
"Recommend.MA",
|
|
887
|
+
"Recommend.Other",
|
|
888
|
+
]
|
|
889
|
+
|
|
890
|
+
if include_fundamentals:
|
|
891
|
+
cols.extend(
|
|
892
|
+
[
|
|
893
|
+
"market_cap_basic",
|
|
894
|
+
"price_earnings_ttm",
|
|
895
|
+
"earnings_per_share_basic_ttm",
|
|
896
|
+
"dividend_yield_recent",
|
|
897
|
+
"price_52_week_high",
|
|
898
|
+
"price_52_week_low",
|
|
899
|
+
]
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
try:
|
|
903
|
+
query = Query().set_tickers(symbol).select(*cols)
|
|
904
|
+
total, df = query.get_scanner_data()
|
|
905
|
+
|
|
906
|
+
if df.empty:
|
|
907
|
+
return {"error": f"No data found for {symbol}"}
|
|
908
|
+
|
|
909
|
+
row = df.iloc[0].to_dict()
|
|
910
|
+
|
|
911
|
+
# Perform technical analysis
|
|
912
|
+
analysis = analyze_indicators(row)
|
|
913
|
+
|
|
914
|
+
# Build result
|
|
915
|
+
result = {
|
|
916
|
+
"symbol": symbol,
|
|
917
|
+
"name": row.get("name"),
|
|
918
|
+
"exchange": row.get("exchange"),
|
|
919
|
+
"type": row.get("type"),
|
|
920
|
+
"price_data": {
|
|
921
|
+
"close": row.get("close"),
|
|
922
|
+
"open": row.get("open"),
|
|
923
|
+
"high": row.get("high"),
|
|
924
|
+
"low": row.get("low"),
|
|
925
|
+
"volume": row.get("volume"),
|
|
926
|
+
"change": row.get("change"),
|
|
927
|
+
"change_abs": row.get("change_abs"),
|
|
928
|
+
},
|
|
929
|
+
"technical_indicators": {
|
|
930
|
+
"rsi": row.get("RSI"),
|
|
931
|
+
"rsi7": row.get("RSI7"),
|
|
932
|
+
"macd": row.get("MACD.macd"),
|
|
933
|
+
"macd_signal": row.get("MACD.signal"),
|
|
934
|
+
"bb_upper": row.get("BB.upper"),
|
|
935
|
+
"bb_lower": row.get("BB.lower"),
|
|
936
|
+
"sma20": row.get("SMA20"),
|
|
937
|
+
"sma50": row.get("SMA50"),
|
|
938
|
+
"sma200": row.get("SMA200"),
|
|
939
|
+
"ema20": row.get("EMA20"),
|
|
940
|
+
"ema50": row.get("EMA50"),
|
|
941
|
+
"ema200": row.get("EMA200"),
|
|
942
|
+
"adx": row.get("ADX"),
|
|
943
|
+
"atr": row.get("ATR"),
|
|
944
|
+
"stoch_k": row.get("Stoch.K"),
|
|
945
|
+
"stoch_d": row.get("Stoch.D"),
|
|
946
|
+
"vwap": row.get("VWAP"),
|
|
947
|
+
"volatility": row.get("Volatility.D"),
|
|
948
|
+
},
|
|
949
|
+
"performance": {
|
|
950
|
+
"weekly": row.get("Perf.W"),
|
|
951
|
+
"monthly": row.get("Perf.1M"),
|
|
952
|
+
"quarterly": row.get("Perf.3M"),
|
|
953
|
+
"yearly": row.get("Perf.Y"),
|
|
954
|
+
},
|
|
955
|
+
"ratings": {
|
|
956
|
+
"overall": row.get("Recommend.All"),
|
|
957
|
+
"overall_label": format_technical_rating(row.get("Recommend.All", 0)),
|
|
958
|
+
"moving_averages": row.get("Recommend.MA"),
|
|
959
|
+
"oscillators": row.get("Recommend.Other"),
|
|
960
|
+
},
|
|
961
|
+
"analysis": analysis,
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if include_fundamentals:
|
|
965
|
+
result["fundamentals"] = {
|
|
966
|
+
"market_cap": row.get("market_cap_basic"),
|
|
967
|
+
"pe_ratio": row.get("price_earnings_ttm"),
|
|
968
|
+
"eps": row.get("earnings_per_share_basic_ttm"),
|
|
969
|
+
"dividend_yield": row.get("dividend_yield_recent"),
|
|
970
|
+
"52_week_high": row.get("price_52_week_high"),
|
|
971
|
+
"52_week_low": row.get("price_52_week_low"),
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
return result
|
|
975
|
+
|
|
976
|
+
except Exception as e:
|
|
977
|
+
return {"error": str(e), "symbol": symbol}
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
@mcp.tool()
|
|
981
|
+
def compare_symbols(
|
|
982
|
+
symbols: list[str],
|
|
983
|
+
columns: Optional[list[str]] = None,
|
|
984
|
+
) -> list[dict[str, Any]]:
|
|
985
|
+
"""
|
|
986
|
+
Compare multiple symbols side by side.
|
|
987
|
+
|
|
988
|
+
Args:
|
|
989
|
+
symbols: List of symbols (e.g., ['NASDAQ:AAPL', 'NASDAQ:MSFT', 'NASDAQ:GOOGL'])
|
|
990
|
+
columns: Columns to compare (default: standard comparison set)
|
|
991
|
+
|
|
992
|
+
Returns:
|
|
993
|
+
Comparison data for all symbols
|
|
994
|
+
"""
|
|
995
|
+
if not symbols:
|
|
996
|
+
return [{"error": "No symbols provided"}]
|
|
997
|
+
|
|
998
|
+
cols = columns or [
|
|
999
|
+
"name",
|
|
1000
|
+
"close",
|
|
1001
|
+
"change",
|
|
1002
|
+
"volume",
|
|
1003
|
+
"RSI",
|
|
1004
|
+
"MACD.macd",
|
|
1005
|
+
"market_cap_basic",
|
|
1006
|
+
"Perf.1M",
|
|
1007
|
+
"Perf.Y",
|
|
1008
|
+
"Recommend.All",
|
|
1009
|
+
]
|
|
1010
|
+
|
|
1011
|
+
try:
|
|
1012
|
+
query = Query().set_tickers(*symbols).select(*cols)
|
|
1013
|
+
total, df = query.get_scanner_data()
|
|
1014
|
+
|
|
1015
|
+
results = df.to_dict("records")
|
|
1016
|
+
|
|
1017
|
+
# Add technical rating labels
|
|
1018
|
+
for r in results:
|
|
1019
|
+
rec = r.get("Recommend.All")
|
|
1020
|
+
if rec is not None:
|
|
1021
|
+
r["rating_label"] = format_technical_rating(rec)
|
|
1022
|
+
|
|
1023
|
+
return results
|
|
1024
|
+
|
|
1025
|
+
except Exception as e:
|
|
1026
|
+
return [{"error": str(e)}]
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
# =============================================================================
|
|
1030
|
+
# MCP Tools - Utility Functions
|
|
1031
|
+
# =============================================================================
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
@mcp.tool()
|
|
1035
|
+
def get_all_market_symbols(market: str = "america") -> dict[str, Any]:
|
|
1036
|
+
"""
|
|
1037
|
+
Get all available symbols for a market.
|
|
1038
|
+
|
|
1039
|
+
Args:
|
|
1040
|
+
market: Market identifier (america, crypto, uk, etc.)
|
|
1041
|
+
|
|
1042
|
+
Returns:
|
|
1043
|
+
Dictionary with symbol count and list of symbols
|
|
1044
|
+
"""
|
|
1045
|
+
market = sanitize_market(market)
|
|
1046
|
+
|
|
1047
|
+
try:
|
|
1048
|
+
symbols = get_all_symbols(market)
|
|
1049
|
+
return {
|
|
1050
|
+
"market": market,
|
|
1051
|
+
"total_symbols": len(symbols),
|
|
1052
|
+
"symbols": symbols[:500], # Return first 500 to avoid huge responses
|
|
1053
|
+
"note": f"Showing first 500 of {len(symbols)} symbols" if len(symbols) > 500 else None,
|
|
1054
|
+
}
|
|
1055
|
+
except Exception as e:
|
|
1056
|
+
return {"error": str(e), "market": market}
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
@mcp.tool()
|
|
1060
|
+
def run_scanner_preset(
|
|
1061
|
+
preset_name: str,
|
|
1062
|
+
market: Optional[str] = None,
|
|
1063
|
+
limit: int = 50,
|
|
1064
|
+
) -> list[dict[str, Any]]:
|
|
1065
|
+
"""
|
|
1066
|
+
Run a pre-built scanner preset.
|
|
1067
|
+
|
|
1068
|
+
Args:
|
|
1069
|
+
preset_name: Name of the preset (see scanners://presets resource)
|
|
1070
|
+
market: Override market (optional)
|
|
1071
|
+
limit: Number of results (1-100)
|
|
1072
|
+
|
|
1073
|
+
Returns:
|
|
1074
|
+
Scanner results
|
|
1075
|
+
"""
|
|
1076
|
+
limit = max(1, min(limit, 100))
|
|
1077
|
+
|
|
1078
|
+
# Try stock scanners first
|
|
1079
|
+
scanner = getattr(Scanner, preset_name, None)
|
|
1080
|
+
|
|
1081
|
+
# Try crypto scanners
|
|
1082
|
+
if scanner is None:
|
|
1083
|
+
scanner = getattr(CryptoScanner, preset_name, None)
|
|
1084
|
+
|
|
1085
|
+
if scanner is None:
|
|
1086
|
+
available = Scanner.names() + CryptoScanner.names()
|
|
1087
|
+
return [{"error": f"Unknown preset: {preset_name}", "available_presets": available}]
|
|
1088
|
+
|
|
1089
|
+
scanner = scanner.copy().limit(limit)
|
|
1090
|
+
|
|
1091
|
+
if market:
|
|
1092
|
+
market = sanitize_market(market)
|
|
1093
|
+
scanner = scanner.set_markets(market)
|
|
1094
|
+
|
|
1095
|
+
try:
|
|
1096
|
+
total, df = scanner.get_scanner_data()
|
|
1097
|
+
return df.to_dict("records")
|
|
1098
|
+
except Exception as e:
|
|
1099
|
+
return [{"error": str(e)}]
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
@mcp.tool()
|
|
1103
|
+
def advanced_query(
|
|
1104
|
+
market: str = "america",
|
|
1105
|
+
select_columns: list[str] = None,
|
|
1106
|
+
conditions: list[dict[str, Any]] = None,
|
|
1107
|
+
logic: str = "and",
|
|
1108
|
+
sort_by: str = "volume",
|
|
1109
|
+
ascending: bool = False,
|
|
1110
|
+
limit: int = 50,
|
|
1111
|
+
) -> list[dict[str, Any]]:
|
|
1112
|
+
"""
|
|
1113
|
+
Execute an advanced query with complex AND/OR logic.
|
|
1114
|
+
|
|
1115
|
+
Args:
|
|
1116
|
+
market: Market to scan
|
|
1117
|
+
select_columns: Columns to return
|
|
1118
|
+
conditions: List of conditions, each with:
|
|
1119
|
+
- column: Column name
|
|
1120
|
+
- op: Operation (gt, gte, lt, lte, eq, neq, between, isin, crosses_above, crosses_below)
|
|
1121
|
+
- value: Value or [min, max] for between
|
|
1122
|
+
logic: 'and' or 'or' to combine conditions
|
|
1123
|
+
sort_by: Column to sort by
|
|
1124
|
+
ascending: Sort order
|
|
1125
|
+
limit: Maximum results (1-500)
|
|
1126
|
+
|
|
1127
|
+
Returns:
|
|
1128
|
+
Query results
|
|
1129
|
+
|
|
1130
|
+
Example:
|
|
1131
|
+
conditions=[
|
|
1132
|
+
{"column": "RSI", "op": "lt", "value": 30},
|
|
1133
|
+
{"column": "volume", "op": "gt", "value": 1000000},
|
|
1134
|
+
{"column": "change", "op": "between", "value": [-5, 5]}
|
|
1135
|
+
]
|
|
1136
|
+
"""
|
|
1137
|
+
market = sanitize_market(market)
|
|
1138
|
+
limit = max(1, min(limit, 500))
|
|
1139
|
+
cols = select_columns or TECHNICAL_COLUMNS
|
|
1140
|
+
|
|
1141
|
+
query = Query().set_markets(market).select(*cols).order_by(sort_by, ascending=ascending).limit(limit)
|
|
1142
|
+
|
|
1143
|
+
if conditions:
|
|
1144
|
+
expressions = []
|
|
1145
|
+
for cond in conditions:
|
|
1146
|
+
col = Column(cond.get("column", "close"))
|
|
1147
|
+
op = cond.get("op", "gt")
|
|
1148
|
+
val = cond.get("value")
|
|
1149
|
+
|
|
1150
|
+
if op == "gt":
|
|
1151
|
+
expressions.append(col > val)
|
|
1152
|
+
elif op == "gte":
|
|
1153
|
+
expressions.append(col >= val)
|
|
1154
|
+
elif op == "lt":
|
|
1155
|
+
expressions.append(col < val)
|
|
1156
|
+
elif op == "lte":
|
|
1157
|
+
expressions.append(col <= val)
|
|
1158
|
+
elif op == "eq":
|
|
1159
|
+
expressions.append(col == val)
|
|
1160
|
+
elif op == "neq":
|
|
1161
|
+
expressions.append(col != val)
|
|
1162
|
+
elif op == "between" and isinstance(val, (list, tuple)) and len(val) == 2:
|
|
1163
|
+
expressions.append(col.between(val[0], val[1]))
|
|
1164
|
+
elif op == "isin" and isinstance(val, (list, tuple)):
|
|
1165
|
+
expressions.append(col.isin(val))
|
|
1166
|
+
elif op == "crosses_above":
|
|
1167
|
+
expressions.append(col.crosses_above(Column(val) if isinstance(val, str) else val))
|
|
1168
|
+
elif op == "crosses_below":
|
|
1169
|
+
expressions.append(col.crosses_below(Column(val) if isinstance(val, str) else val))
|
|
1170
|
+
elif op == "above_pct" and isinstance(val, (list, tuple)) and len(val) == 2:
|
|
1171
|
+
expressions.append(col.above_pct(val[0], val[1]))
|
|
1172
|
+
elif op == "below_pct" and isinstance(val, (list, tuple)) and len(val) == 2:
|
|
1173
|
+
expressions.append(col.below_pct(val[0], val[1]))
|
|
1174
|
+
|
|
1175
|
+
if expressions:
|
|
1176
|
+
if logic == "or":
|
|
1177
|
+
query = query.where2(Or(*expressions))
|
|
1178
|
+
else:
|
|
1179
|
+
query = query.where(*expressions)
|
|
1180
|
+
|
|
1181
|
+
try:
|
|
1182
|
+
total, df = query.get_scanner_data()
|
|
1183
|
+
results = df.to_dict("records")
|
|
1184
|
+
return [{"total_count": total, "returned": len(results), "data": results}]
|
|
1185
|
+
except Exception as e:
|
|
1186
|
+
return [{"error": str(e)}]
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
# =============================================================================
|
|
1190
|
+
# MCP Tools - Crypto Specific
|
|
1191
|
+
# =============================================================================
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
@mcp.tool()
|
|
1195
|
+
def get_crypto_gainers(exchange: Optional[str] = None, limit: int = 25) -> list[dict[str, Any]]:
|
|
1196
|
+
"""
|
|
1197
|
+
Get top gaining cryptocurrencies.
|
|
1198
|
+
|
|
1199
|
+
Args:
|
|
1200
|
+
exchange: Specific exchange (binance, coinbase, etc.) or None for all
|
|
1201
|
+
limit: Number of results (1-100)
|
|
1202
|
+
|
|
1203
|
+
Returns:
|
|
1204
|
+
List of top crypto gainers
|
|
1205
|
+
"""
|
|
1206
|
+
limit = max(1, min(limit, 100))
|
|
1207
|
+
|
|
1208
|
+
cols = ["name", "close", "volume", "change", "change_abs", "market_cap_basic", "24h_vol|5"]
|
|
1209
|
+
|
|
1210
|
+
query = (
|
|
1211
|
+
Query()
|
|
1212
|
+
.set_markets("crypto")
|
|
1213
|
+
.select(*cols)
|
|
1214
|
+
.where(Column("change") > 0)
|
|
1215
|
+
.order_by("change", ascending=False)
|
|
1216
|
+
.limit(limit)
|
|
1217
|
+
)
|
|
1218
|
+
|
|
1219
|
+
if exchange:
|
|
1220
|
+
query = query.where(Column("exchange") == exchange.upper())
|
|
1221
|
+
|
|
1222
|
+
try:
|
|
1223
|
+
total, df = query.get_scanner_data()
|
|
1224
|
+
return df.to_dict("records")
|
|
1225
|
+
except Exception as e:
|
|
1226
|
+
return [{"error": str(e)}]
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
@mcp.tool()
|
|
1230
|
+
def get_crypto_losers(exchange: Optional[str] = None, limit: int = 25) -> list[dict[str, Any]]:
|
|
1231
|
+
"""
|
|
1232
|
+
Get top losing cryptocurrencies.
|
|
1233
|
+
|
|
1234
|
+
Args:
|
|
1235
|
+
exchange: Specific exchange (binance, coinbase, etc.) or None for all
|
|
1236
|
+
limit: Number of results (1-100)
|
|
1237
|
+
|
|
1238
|
+
Returns:
|
|
1239
|
+
List of top crypto losers
|
|
1240
|
+
"""
|
|
1241
|
+
limit = max(1, min(limit, 100))
|
|
1242
|
+
|
|
1243
|
+
cols = ["name", "close", "volume", "change", "change_abs", "market_cap_basic", "24h_vol|5"]
|
|
1244
|
+
|
|
1245
|
+
query = (
|
|
1246
|
+
Query()
|
|
1247
|
+
.set_markets("crypto")
|
|
1248
|
+
.select(*cols)
|
|
1249
|
+
.where(Column("change") < 0)
|
|
1250
|
+
.order_by("change", ascending=True)
|
|
1251
|
+
.limit(limit)
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
if exchange:
|
|
1255
|
+
query = query.where(Column("exchange") == exchange.upper())
|
|
1256
|
+
|
|
1257
|
+
try:
|
|
1258
|
+
total, df = query.get_scanner_data()
|
|
1259
|
+
return df.to_dict("records")
|
|
1260
|
+
except Exception as e:
|
|
1261
|
+
return [{"error": str(e)}]
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
@mcp.tool()
|
|
1265
|
+
def scan_crypto_technicals(
|
|
1266
|
+
scan_type: str = "oversold",
|
|
1267
|
+
exchange: Optional[str] = None,
|
|
1268
|
+
limit: int = 25,
|
|
1269
|
+
) -> list[dict[str, Any]]:
|
|
1270
|
+
"""
|
|
1271
|
+
Scan cryptocurrencies based on technical indicators.
|
|
1272
|
+
|
|
1273
|
+
Args:
|
|
1274
|
+
scan_type: Type of scan - 'oversold', 'overbought', 'macd_bullish', 'macd_bearish', 'high_volume'
|
|
1275
|
+
exchange: Specific exchange or None for all
|
|
1276
|
+
limit: Number of results (1-100)
|
|
1277
|
+
|
|
1278
|
+
Returns:
|
|
1279
|
+
Crypto technical scan results
|
|
1280
|
+
"""
|
|
1281
|
+
limit = max(1, min(limit, 100))
|
|
1282
|
+
|
|
1283
|
+
scanner_map = {
|
|
1284
|
+
"oversold": CryptoScanner.oversold,
|
|
1285
|
+
"overbought": CryptoScanner.overbought,
|
|
1286
|
+
"high_volume": CryptoScanner.high_volume,
|
|
1287
|
+
"most_volatile": CryptoScanner.most_volatile,
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
if scan_type in scanner_map:
|
|
1291
|
+
scanner = scanner_map[scan_type].copy().limit(limit)
|
|
1292
|
+
elif scan_type == "macd_bullish":
|
|
1293
|
+
scanner = (
|
|
1294
|
+
Query()
|
|
1295
|
+
.set_markets("crypto")
|
|
1296
|
+
.select("name", "close", "volume", "change", "MACD.macd", "MACD.signal", "RSI")
|
|
1297
|
+
.where(Column("MACD.macd").crosses_above(Column("MACD.signal")))
|
|
1298
|
+
.order_by("change", ascending=False)
|
|
1299
|
+
.limit(limit)
|
|
1300
|
+
)
|
|
1301
|
+
elif scan_type == "macd_bearish":
|
|
1302
|
+
scanner = (
|
|
1303
|
+
Query()
|
|
1304
|
+
.set_markets("crypto")
|
|
1305
|
+
.select("name", "close", "volume", "change", "MACD.macd", "MACD.signal", "RSI")
|
|
1306
|
+
.where(Column("MACD.macd").crosses_below(Column("MACD.signal")))
|
|
1307
|
+
.order_by("change", ascending=True)
|
|
1308
|
+
.limit(limit)
|
|
1309
|
+
)
|
|
1310
|
+
else:
|
|
1311
|
+
return [{"error": f"Unknown scan_type: {scan_type}"}]
|
|
1312
|
+
|
|
1313
|
+
if exchange:
|
|
1314
|
+
scanner = scanner.where(Column("exchange") == exchange.upper())
|
|
1315
|
+
|
|
1316
|
+
try:
|
|
1317
|
+
total, df = scanner.get_scanner_data()
|
|
1318
|
+
return df.to_dict("records")
|
|
1319
|
+
except Exception as e:
|
|
1320
|
+
return [{"error": str(e)}]
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
# =============================================================================
|
|
1324
|
+
# Server Entry Point
|
|
1325
|
+
# =============================================================================
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def main() -> None:
|
|
1329
|
+
"""Main entry point for the MCP server."""
|
|
1330
|
+
parser = argparse.ArgumentParser(description="TradingView Screener MCP Server")
|
|
1331
|
+
parser.add_argument(
|
|
1332
|
+
"transport",
|
|
1333
|
+
choices=["stdio", "streamable-http"],
|
|
1334
|
+
default="stdio",
|
|
1335
|
+
nargs="?",
|
|
1336
|
+
help="Transport protocol (default: stdio)",
|
|
1337
|
+
)
|
|
1338
|
+
parser.add_argument("--host", default=os.environ.get("HOST", "127.0.0.1"), help="HTTP host")
|
|
1339
|
+
parser.add_argument(
|
|
1340
|
+
"--port", type=int, default=int(os.environ.get("PORT", "8000")), help="HTTP port"
|
|
1341
|
+
)
|
|
1342
|
+
args = parser.parse_args()
|
|
1343
|
+
|
|
1344
|
+
if os.environ.get("DEBUG_MCP"):
|
|
1345
|
+
import sys
|
|
1346
|
+
|
|
1347
|
+
print(f"[DEBUG] TradingView MCP starting: transport={args.transport}", file=sys.stderr, flush=True)
|
|
1348
|
+
|
|
1349
|
+
if args.transport == "stdio":
|
|
1350
|
+
mcp.run()
|
|
1351
|
+
else:
|
|
1352
|
+
try:
|
|
1353
|
+
mcp.settings.host = args.host
|
|
1354
|
+
mcp.settings.port = args.port
|
|
1355
|
+
except AttributeError:
|
|
1356
|
+
pass
|
|
1357
|
+
mcp.run(transport="streamable-http")
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
if __name__ == "__main__":
|
|
1361
|
+
main()
|