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.
@@ -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()