tradingview-mcp 26.2.0__py3-none-any.whl → 26.3.1__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/server.py CHANGED
@@ -6,75 +6,57 @@ Provides comprehensive MCP tools and resources for TradingView market screening.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- import argparse
10
9
  import inspect
11
10
  import json
12
11
  import os
13
12
  import traceback
14
13
  from datetime import datetime
15
14
  from functools import wraps
16
- from typing import Any, Optional
15
+ from typing import Any
17
16
 
18
17
  from mcp.server.fastmcp import FastMCP
19
18
 
20
- from tradingview_mcp.column import Column
21
- from tradingview_mcp.constants import (
22
- COLUMNS,
23
- CRYPTO_COLUMNS,
24
- DEFAULT_COLUMNS,
25
- EXCHANGE_SCREENER,
26
- MARKETS,
27
- POSTMARKET_COLUMNS,
28
- PREMARKET_COLUMNS,
29
- TECHNICAL_COLUMNS,
19
+ from tradingview_mcp.docs_data import suggest_fields_for_error
20
+ from tradingview_mcp.tools.search import search_symbols, get_symbol_info
21
+ from tradingview_mcp.tools.screener import (
22
+ screen_market,
23
+ get_top_gainers,
24
+ get_top_losers,
25
+ get_most_active
30
26
  )
31
- from tradingview_mcp.query import And, Or, Query, get_all_symbols
32
- from tradingview_mcp.scanner import CryptoScanner, Scanner
33
- from tradingview_mcp.utils import (
34
- analyze_indicators,
35
- compute_bollinger_band_width,
36
- compute_bollinger_rating,
37
- compute_percent_change,
38
- format_symbol,
39
- format_technical_rating,
40
- sanitize_exchange,
41
- sanitize_market,
42
- sanitize_timeframe,
43
- timeframe_to_resolution,
27
+ from tradingview_mcp.tools.technical import (
28
+ get_technical_analysis,
29
+ scan_rsi_extremes,
30
+ scan_bollinger_bands,
31
+ scan_macd_crossover
44
32
  )
45
- from tradingview_mcp.docs_data import (
46
- get_ai_quick_reference,
47
- get_column_display_names,
48
- get_common_fields,
49
- get_fields_by_market,
50
- get_markets_data,
51
- get_metainfo as load_metainfo,
52
- get_screener_code_examples,
53
- get_screener_markets,
54
- get_screener_presets,
55
- get_stock_screeners,
56
- get_stock_screeners_failed,
57
- get_stock_screener_presets,
58
- get_valid_fields_for_market,
59
- suggest_fields_for_error,
60
- paginate_data,
61
- search_fields,
62
- get_field_summary,
63
- estimate_tokens,
64
- truncate_for_tokens,
65
- MAX_ITEMS_DEFAULT,
66
- # New fast lookup functions
67
- get_quick_reference,
68
- lookup_field,
69
- get_filter_format,
70
- get_market_fields_summary,
71
- get_screener_preset_names,
72
- get_code_example_names,
73
- validate_market,
74
- build_error_with_format,
75
- ALL_MARKETS,
76
- FIELD_CATEGORIES,
77
- FILTER_OPS,
33
+ from tradingview_mcp.tools.reference import (
34
+ ai_get_reference,
35
+ search_available_fields,
36
+ get_code_example,
37
+ list_fields_for_market,
38
+ get_common_fields_summary,
39
+ lookup_single_field,
40
+ get_filter_example,
41
+ check_market,
42
+ get_help,
43
+ get_field_info,
44
+ get_screener_preset,
45
+ get_market_metainfo,
46
+ )
47
+ from tradingview_mcp.resources import (
48
+ list_markets,
49
+ list_columns,
50
+ list_crypto_exchanges,
51
+ list_scanner_presets,
52
+ docs_params,
53
+ docs_screeners,
54
+ docs_stock_screeners,
55
+ docs_stock_screeners_failed,
56
+ docs_fields,
57
+ docs_markets,
58
+ docs_ai_reference,
59
+ docs_code_examples,
78
60
  )
79
61
 
80
62
  # Initialize MCP Server
@@ -166,12 +148,6 @@ def _build_error_response(tool_name: str, exc: Exception, context: dict[str, Any
166
148
  return payload
167
149
 
168
150
 
169
- def _error_response(exc: Exception, context: dict[str, Any] | None = None) -> dict[str, Any]:
170
- frame = inspect.currentframe()
171
- caller = frame.f_back.f_code.co_name if frame and frame.f_back else "unknown"
172
- return _build_error_response(caller, exc, context or {})
173
-
174
-
175
151
  def debug_tool(fn):
176
152
  """Decorator to return structured debug responses on failure."""
177
153
 
@@ -197,7 +173,7 @@ def debug_tool(fn):
197
173
  {"args": args, "kwargs": kwargs, "note": "error returned by tool"},
198
174
  )
199
175
  return result
200
- except Exception as exc: # pragma: no cover - defensive guard
176
+ except Exception as exc:
201
177
  context = {"args": args, "kwargs": kwargs}
202
178
  return _build_error_response(fn.__name__, exc, context)
203
179
 
@@ -220,1396 +196,54 @@ mcp.tool = _debug_mcp_tool
220
196
 
221
197
 
222
198
  # =============================================================================
223
- # MCP Resources - Compact summaries only, use tools for full data
224
- # =============================================================================
225
-
226
-
227
- @mcp.resource("markets://list")
228
- def list_markets() -> str:
229
- """Available markets summary."""
230
- return json.dumps({
231
- "stock_markets": ["america", "uk", "germany", "japan", "china", "hongkong", "taiwan", "korea", "india", "australia", "canada", "brazil", "france", "singapore"],
232
- "other_markets": ["crypto", "coin", "forex", "futures", "bonds", "cfd", "options"],
233
- "total_stock_markets": 68,
234
- "hint": "Use validate_market(name) to check if a market is valid",
235
- }, indent=2)
236
-
237
-
238
- @mcp.resource("columns://list")
239
- def list_columns() -> str:
240
- """Field categories summary."""
241
- return json.dumps({
242
- "categories": FIELD_CATEGORIES,
243
- "hint": "Use lookup_field(name) or search_fields(query) for details",
244
- }, indent=2)
245
-
246
-
247
- @mcp.resource("exchanges://crypto")
248
- def list_crypto_exchanges() -> str:
249
- """Crypto exchanges."""
250
- exchanges = [ex for ex, screener in EXCHANGE_SCREENER.items() if screener == "crypto"]
251
- return json.dumps({"exchanges": sorted(exchanges)[:20], "total": len(exchanges)}, indent=2)
252
-
253
-
254
- @mcp.resource("scanners://presets")
255
- def list_scanner_presets() -> str:
256
- """Scanner presets summary."""
257
- return json.dumps({
258
- "stock_scanners": Scanner.names(),
259
- "crypto_scanners": CryptoScanner.names(),
260
- "hint": "Use run_scanner_preset(name) to execute",
261
- }, indent=2)
262
-
263
-
264
- @mcp.resource("docs://params")
265
- def docs_params() -> str:
266
- """Screener parameter format."""
267
- return json.dumps({
268
- "filter_format": {"column": "field_name", "operation": "op", "value": "value"},
269
- "operations": ["gt", "gte", "lt", "lte", "eq", "neq", "between", "isin"],
270
- "examples": [
271
- {"column": "change", "operation": "gt", "value": 5},
272
- {"column": "volume", "operation": "gt", "value": 1000000},
273
- {"column": "RSI", "operation": "between", "value": [20, 30]},
274
- ],
275
- "hint": "Use get_filter_format(type) for specific type formats",
276
- }, indent=2)
277
-
278
-
279
- @mcp.resource("docs://screeners")
280
- def docs_screeners() -> str:
281
- """Screener presets (names only)."""
282
- names = get_screener_preset_names()
283
- return json.dumps({"presets": names, "hint": "Use get_screener_preset(name) for full details"}, indent=2)
284
-
285
-
286
- @mcp.resource("docs://stock-screeners")
287
- def docs_stock_screeners() -> str:
288
- """Stock screener markets (summary)."""
289
- data = get_stock_screeners()
290
- markets = [{"market": d.get("market"), "title": d.get("title"), "group": d.get("group")} for d in data[:30]]
291
- return json.dumps({"markets": markets, "total": len(data)}, indent=2)
292
-
293
-
294
- @mcp.resource("docs://stock-screeners-failed")
295
- def docs_stock_screeners_failed() -> str:
296
- """Failed stock screener markets (summary)."""
297
- data = get_stock_screeners_failed()
298
- return json.dumps({"count": len(data), "markets": [d.get("market") for d in data][:20]}, indent=2)
299
-
300
-
301
- @mcp.resource("docs://fields")
302
- def docs_fields() -> str:
303
- """Field categories only."""
304
- return json.dumps({
305
- "categories": FIELD_CATEGORIES,
306
- "hint": "Use lookup_field(name) or search_fields(query) for details",
307
- }, indent=2)
308
-
309
-
310
- @mcp.resource("docs://markets")
311
- def docs_markets() -> str:
312
- """Markets summary."""
313
- return json.dumps({
314
- "stock_markets_count": 68,
315
- "other_markets": ["crypto", "coin", "forex", "futures", "bonds", "cfd", "options"],
316
- "hint": "Use validate_market(name) to check if a market is valid",
317
- }, indent=2)
318
-
319
-
320
- @mcp.tool()
321
- def get_field_info(field: str) -> dict[str, Any]:
322
- """Get display name and type for a field."""
323
- return lookup_field(field)
324
-
325
-
326
- @mcp.tool()
327
- def get_screener_preset(name: str) -> dict[str, Any]:
328
- """Return a full screener preset (query + code) by name."""
329
- presets = get_screener_presets()
330
- for preset in presets:
331
- if preset.get("name", "").lower() == name.lower():
332
- return preset
333
- return {
334
- "error": f"Preset '{name}' not found.",
335
- "available": get_screener_preset_names(),
336
- }
337
-
338
-
339
- @mcp.tool()
340
- def list_stock_screener_presets(market: str | None = None, limit: int = 20) -> dict[str, Any]:
341
- """List stock screener presets."""
342
- data = get_stock_screener_presets(market)
343
- if isinstance(data, dict):
344
- keys = list(data.keys())[:limit]
345
- return {"presets": keys, "total": len(data), "market": market}
346
- return {"data": data[:limit] if isinstance(data, list) else data, "market": market}
347
-
348
-
349
- @mcp.tool()
350
- def list_stock_screeners(limit: int = 30) -> dict[str, Any]:
351
- """List stock screener markets."""
352
- data = get_stock_screeners()
353
- markets = [{"market": d.get("market"), "title": d.get("title")} for d in data[:limit]]
354
- return {"markets": markets, "total": len(data)}
355
-
356
-
357
- @mcp.tool()
358
- def list_failed_stock_screeners() -> dict[str, Any]:
359
- """List failed stock screener markets."""
360
- data = get_stock_screeners_failed()
361
- return {"markets": [d.get("market") for d in data], "total": len(data)}
362
-
363
-
364
- @mcp.tool()
365
- def get_market_metainfo(market: str, limit: int = 30) -> dict[str, Any]:
366
- """Return metainfo summary for a market."""
367
- validation = validate_market(market)
368
- if not validation.get("valid"):
369
- return validation
370
-
371
- market = validation.get("market", market)
372
- return get_market_fields_summary(market)
373
-
374
-
375
- # =============================================================================
376
- # AI-Friendly Tools - Fast Lookup (no large data dumps)
377
- # =============================================================================
378
-
379
-
380
- @mcp.resource("docs://ai-reference")
381
- def docs_ai_reference() -> str:
382
- """AI quick reference (compact)."""
383
- return json.dumps({
384
- "field_categories": FIELD_CATEGORIES,
385
- "filter_operations": ["gt", "gte", "lt", "lte", "eq", "neq", "between", "isin"],
386
- "common_markets": ["america", "crypto", "forex", "uk", "germany", "japan"],
387
- "tools": ["lookup_field", "search_fields", "validate_market", "get_filter_format"],
388
- }, indent=2)
389
-
390
-
391
- @mcp.resource("docs://code-examples")
392
- def docs_code_examples() -> str:
393
- """Code example names only."""
394
- names = get_code_example_names()
395
- return json.dumps({"examples": names, "hint": "Use get_code_example(name) for code"}, indent=2)
396
-
397
-
398
- @mcp.tool()
399
- def ai_get_reference() -> dict[str, Any]:
400
- """Get quick reference for markets and fields."""
401
- return get_quick_reference()
402
-
403
-
404
- @mcp.tool()
405
- def search_available_fields(query: str, market: str = "stocks", limit: int = 15) -> dict[str, Any]:
406
- """Search fields by name or display name."""
407
- results = search_fields(query, market, min(limit, 30))
408
- return {
409
- "query": query,
410
- "market": market,
411
- "count": len(results),
412
- "fields": results,
413
- "hint": "Use the 'name' field as the column name in queries." if results else "No matches. Try different keywords.",
414
- }
415
-
416
-
417
- @mcp.tool()
418
- def get_code_example(screener_name: str) -> dict[str, Any]:
419
- """Get code example for a screener type."""
420
- examples = get_screener_code_examples()
421
-
422
- if screener_name in examples:
423
- return {"name": screener_name, "code": examples[screener_name]}
424
-
425
- for name, code in examples.items():
426
- if name.lower() == screener_name.lower():
427
- return {"name": name, "code": code}
428
-
429
- return {"error": f"'{screener_name}' not found.", "available": list(examples.keys())}
430
-
431
-
432
- @mcp.tool()
433
- def list_fields_for_market(market: str = "stocks", category: str | None = None, limit: int = 30) -> dict[str, Any]:
434
- """List fields for a market."""
435
- validation = validate_market(market)
436
- if not validation.get("valid"):
437
- return validation
438
-
439
- fields = get_fields_by_market(validation.get("market", market))
440
- if not fields:
441
- return {"error": f"No fields for '{market}'.", "use": "search_fields(query) instead"}
442
-
443
- if category and isinstance(fields, list):
444
- fields = [f for f in fields if category.lower() in f.get("type", "").lower()]
445
-
446
- if isinstance(fields, list):
447
- return {"fields": [f.get("name") for f in fields[:limit]], "total": len(fields)}
448
- return {"note": "Use search_fields() for this market"}
449
-
450
-
451
- @mcp.tool()
452
- def get_common_fields_summary(category: str | None = None) -> dict[str, Any]:
453
- """Get common fields by category."""
454
- if category:
455
- fields = FIELD_CATEGORIES.get(category.lower())
456
- if fields:
457
- return {"category": category, "fields": fields}
458
- return {"error": f"Category '{category}' not found.", "available": list(FIELD_CATEGORIES.keys())}
459
- return {"categories": FIELD_CATEGORIES}
460
-
461
-
462
- # =============================================================================
463
- # Fast Lookup Tools - Direct answers
464
- # =============================================================================
465
-
466
-
467
- @mcp.tool()
468
- def lookup_single_field(field_name: str) -> dict[str, Any]:
469
- """Lookup a single field by exact name."""
470
- return lookup_field(field_name)
471
-
472
-
473
- @mcp.tool()
474
- def get_filter_example(field_type: str = "number") -> dict[str, Any]:
475
- """Get correct filter format for a field type."""
476
- return get_filter_format(field_type)
477
-
478
-
479
- @mcp.tool()
480
- def check_market(market: str) -> dict[str, Any]:
481
- """Validate market name and get correct format."""
482
- return validate_market(market)
483
-
484
-
485
- # =============================================================================
486
- # Help Tool - Compact guide
487
- # =============================================================================
488
-
489
-
490
- @mcp.tool()
491
- def get_help(topic: str | None = None) -> dict[str, Any]:
492
- """Usage guide."""
493
- guide = {
494
- "quick_start": {
495
- "1": "get_top_gainers('america', 10) - top gainers",
496
- "2": "search_symbols('apple', 'america') - find symbols",
497
- "3": "get_symbol_info('NASDAQ:AAPL') - symbol details",
498
- },
499
- "markets": {
500
- "stocks": ["america", "uk", "germany", "japan", "china"],
501
- "crypto": ["crypto", "coin"],
502
- "others": ["forex", "futures", "bonds"],
503
- },
504
- "tools": {
505
- "screening": ["screen_market", "get_top_gainers", "get_top_losers", "get_most_active"],
506
- "lookup": ["search_symbols", "get_symbol_info", "get_technical_analysis"],
507
- "reference": ["lookup_field", "search_fields", "check_market", "get_filter_example"],
508
- },
509
- "columns": FIELD_CATEGORIES,
510
- "filters": {
511
- "format": {"column": "field", "operation": "op", "value": "val"},
512
- "operations": ["gt", "gte", "lt", "lte", "eq", "neq", "between", "isin"],
513
- },
514
- }
515
-
516
- if topic:
517
- t = topic.lower()
518
- if t in guide:
519
- return {topic: guide[t]}
520
- return {"error": f"Topic '{topic}' not found", "available": list(guide.keys())}
521
-
522
- return guide
523
-
524
-
525
- @mcp.tool()
526
- def search_symbols(
527
- query: str,
528
- market: str = "america",
529
- limit: int = 25,
530
- ) -> dict[str, Any]:
531
- """Search symbols by name or ticker."""
532
- market = sanitize_market(market)
533
- limit = max(1, min(limit, 100))
534
-
535
- # Search fields: name (ticker) and description (full name)
536
- cols = ["name", "description", "close", "change", "volume", "market_cap_basic", "type", "exchange"]
537
-
538
- try:
539
- # Use TradingView text search
540
- # Search description (company name)
541
- query_obj = (
542
- Query()
543
- .set_markets(market)
544
- .select(*cols)
545
- .where(Column("description").like(query))
546
- .order_by("market_cap_basic", ascending=False)
547
- .limit(limit)
548
- )
549
-
550
- total, df = query_obj.get_scanner_data()
551
- results = df.to_dict("records")
552
-
553
- # If nothing found, try searching the name (ticker) field
554
- if not results:
555
- query_obj2 = (
556
- Query()
557
- .set_markets(market)
558
- .select(*cols)
559
- .where(Column("name").like(query))
560
- .order_by("market_cap_basic", ascending=False)
561
- .limit(limit)
562
- )
563
- total, df = query_obj2.get_scanner_data()
564
- results = df.to_dict("records")
565
-
566
- return {
567
- "query": query,
568
- "market": market,
569
- "total_found": total,
570
- "returned": len(results),
571
- "results": results,
572
- "hint": "Use 'name' field as symbol for other tools (e.g., get_symbol_info)" if results else "No matches found. Try different keywords.",
573
- }
574
- except Exception as e:
575
- # If LIKE isn't supported, use fallback
576
- return _search_symbols_fallback(query, market, limit, cols, str(e))
577
-
578
-
579
- def _search_symbols_fallback(
580
- query: str, market: str, limit: int, cols: list[str], original_error: str
581
- ) -> dict[str, Any]:
582
- """Fallback search when LIKE is not supported."""
583
- try:
584
- # Fetch more data and filter locally
585
- query_obj = (
586
- Query()
587
- .set_markets(market)
588
- .select(*cols)
589
- .order_by("market_cap_basic", ascending=False)
590
- .limit(500) # Fetch top 500
591
- )
592
-
593
- total, df = query_obj.get_scanner_data()
594
-
595
- # Local filter
596
- query_lower = query.lower()
597
- filtered = df[
598
- df["name"].str.lower().str.contains(query_lower, na=False) |
599
- df["description"].str.lower().str.contains(query_lower, na=False)
600
- ].head(limit)
601
-
602
- results = filtered.to_dict("records")
603
-
604
- return {
605
- "query": query,
606
- "market": market,
607
- "total_found": len(results),
608
- "returned": len(results),
609
- "results": results,
610
- "note": "Results from local filtering of top 500 by market cap",
611
- "hint": "Use 'name' field as symbol for other tools" if results else "No matches found",
612
- }
613
- except Exception as e:
614
- return {
615
- "error": f"Search failed: {str(e)}",
616
- "original_error": original_error,
617
- "hint": "Try using screen_market with specific filters instead",
618
- }
619
-
620
-
621
- @mcp.tool()
622
- def get_symbol_info(
623
- symbol: str,
624
- include_technical: bool = False,
625
- ) -> dict[str, Any]:
626
- """Get detailed information for a symbol."""
627
- cols = [
628
- "name", "description", "close", "open", "high", "low",
629
- "change", "change_abs", "volume", "market_cap_basic",
630
- "price_earnings_ttm", "earnings_per_share_basic_ttm",
631
- "dividend_yield_recent", "sector", "industry", "exchange", "type",
632
- ]
633
-
634
- if include_technical:
635
- cols.extend([
636
- "RSI", "RSI7", "MACD.macd", "MACD.signal",
637
- "SMA20", "SMA50", "SMA200", "EMA20", "EMA50", "EMA200",
638
- "BB.upper", "BB.lower", "ATR", "ADX",
639
- "Recommend.All", "Recommend.MA", "Recommend.Other",
640
- ])
641
-
642
- try:
643
- # Determine market
644
- if ":" in symbol:
645
- exchange, ticker = symbol.split(":", 1)
646
- market = EXCHANGE_SCREENER.get(exchange.lower(), "america")
647
- else:
648
- ticker = symbol
649
- market = "america"
650
-
651
- query = (
652
- Query()
653
- .set_markets(market)
654
- .select(*cols)
655
- .where(Column("name").isin([symbol, ticker, symbol.upper(), ticker.upper()]))
656
- .limit(5)
657
- )
658
-
659
- total, df = query.get_scanner_data()
660
-
661
- if df.empty:
662
- # Fallback to search
663
- return search_symbols(ticker, market, 5)
664
-
665
- results = df.to_dict("records")
666
-
667
- if len(results) == 1:
668
- return {"symbol": symbol, "found": True, "data": results[0]}
669
- else:
670
- return {"symbol": symbol, "found": True, "matches": results}
671
-
672
- except Exception as e:
673
- return {
674
- "error": f"Failed to get symbol info: {str(e)}",
675
- "hint": "Try using search_symbols to find the correct symbol format",
676
- }
677
-
678
-
679
- # =============================================================================
680
- # MCP Tools - Basic Screening
681
- # =============================================================================
682
-
683
-
684
- @mcp.tool()
685
- def screen_market(
686
- market: str = "america",
687
- columns: Optional[list[str]] = None,
688
- sort_by: str = "volume",
689
- ascending: bool = False,
690
- limit: int = 25,
691
- filters: Optional[list[dict[str, Any]]] = None,
692
- ) -> dict[str, Any]:
693
- """Run a custom market screening query."""
694
- market = sanitize_market(market)
695
- limit = max(1, min(limit, 500))
696
-
697
- # Ensure description is always included
698
- cols = columns or DEFAULT_COLUMNS
699
- if "description" not in cols:
700
- cols = ["description"] + list(cols)
701
-
702
- query = Query().set_markets(market).select(*cols).order_by(sort_by, ascending=ascending).limit(limit)
703
-
704
- # Apply filters if provided
705
- if filters:
706
- filter_expressions = []
707
- for f in filters:
708
- col = Column(f.get("column", "close"))
709
- op = f.get("operation", "gt")
710
- val = f.get("value")
711
-
712
- if op == "gt":
713
- filter_expressions.append(col > val)
714
- elif op == "gte":
715
- filter_expressions.append(col >= val)
716
- elif op == "lt":
717
- filter_expressions.append(col < val)
718
- elif op == "lte":
719
- filter_expressions.append(col <= val)
720
- elif op == "eq":
721
- filter_expressions.append(col == val)
722
- elif op == "neq":
723
- filter_expressions.append(col != val)
724
- elif op == "between" and isinstance(val, (list, tuple)) and len(val) == 2:
725
- filter_expressions.append(col.between(val[0], val[1]))
726
- elif op == "isin" and isinstance(val, (list, tuple)):
727
- filter_expressions.append(col.isin(val))
728
-
729
- if filter_expressions:
730
- query = query.where(*filter_expressions)
731
-
732
- try:
733
- total_count, df = query.get_scanner_data()
734
- results = df.to_dict("records")
735
-
736
- return {
737
- "total_count": total_count,
738
- "returned": len(results),
739
- "market": market,
740
- "data": results,
741
- }
742
- except Exception as e:
743
- return {"error": str(e), "market": market}
744
-
745
-
746
- @mcp.tool()
747
- def get_top_gainers(market: str = "america", limit: int = 25) -> dict[str, Any]:
748
- """Get top gainers for a market."""
749
- market = sanitize_market(market)
750
- limit = max(1, min(limit, 100))
751
-
752
- # Ensure description is included
753
- cols = ["name", "description", "close", "change", "change_abs", "volume", "market_cap_basic"]
754
-
755
- query = (
756
- Query()
757
- .set_markets(market)
758
- .select(*cols)
759
- .where(Column("change") > 0)
760
- .order_by("change", ascending=False)
761
- .limit(limit)
762
- )
763
-
764
- try:
765
- total, df = query.get_scanner_data()
766
- results = df.to_dict("records")
767
- return {
768
- "market": market,
769
- "type": "top_gainers",
770
- "total_found": total,
771
- "returned": len(results),
772
- "data": results,
773
- }
774
- except Exception as e:
775
- return {"error": str(e), "market": market}
776
-
777
-
778
- @mcp.tool()
779
- def get_top_losers(market: str = "america", limit: int = 25) -> dict[str, Any]:
780
- """Get top losers for a market."""
781
- market = sanitize_market(market)
782
- limit = max(1, min(limit, 100))
783
-
784
- # Ensure description is included
785
- cols = ["name", "description", "close", "change", "change_abs", "volume", "market_cap_basic"]
786
-
787
- query = (
788
- Query()
789
- .set_markets(market)
790
- .select(*cols)
791
- .where(Column("change") < 0)
792
- .order_by("change", ascending=True)
793
- .limit(limit)
794
- )
795
-
796
- try:
797
- total, df = query.get_scanner_data()
798
- results = df.to_dict("records")
799
- return {
800
- "market": market,
801
- "type": "top_losers",
802
- "total_found": total,
803
- "returned": len(results),
804
- "data": results,
805
- }
806
- except Exception as e:
807
- return {"error": str(e), "market": market}
808
-
809
-
810
- @mcp.tool()
811
- def get_most_active(market: str = "america", limit: int = 25) -> dict[str, Any]:
812
- """Get most active by volume."""
813
- market = sanitize_market(market)
814
- limit = max(1, min(limit, 100))
815
-
816
- cols = ["name", "description", "close", "change", "volume", "relative_volume_10d_calc", "market_cap_basic"]
817
-
818
- query = Query().set_markets(market).select(*cols).order_by("volume", ascending=False).limit(limit)
819
-
820
- try:
821
- total, df = query.get_scanner_data()
822
- results = df.to_dict("records")
823
- return {
824
- "market": market,
825
- "type": "most_active",
826
- "total_found": total,
827
- "returned": len(results),
828
- "data": results,
829
- }
830
- except Exception as e:
831
- return {"error": str(e), "market": market}
832
-
833
-
834
- @mcp.tool()
835
- def get_premarket_movers(
836
- scan_type: str = "gainers", limit: int = 25
837
- ) -> dict[str, Any]:
838
- """Get pre-market movers (US only)."""
839
- limit = max(1, min(limit, 100))
840
-
841
- scanner_map = {
842
- "gainers": Scanner.premarket_gainers,
843
- "losers": Scanner.premarket_losers,
844
- "most_active": Scanner.premarket_most_active,
845
- "gappers": Scanner.premarket_gappers,
846
- }
847
-
848
- scanner = scanner_map.get(scan_type, Scanner.premarket_gainers).copy()
849
- scanner = scanner.limit(limit)
850
-
851
- try:
852
- total, df = scanner.get_scanner_data()
853
- return df.to_dict("records")
854
- except Exception as e:
855
- return [{"error": str(e)}]
856
-
857
-
858
- @mcp.tool()
859
- def get_postmarket_movers(
860
- scan_type: str = "gainers", limit: int = 25
861
- ) -> list[dict[str, Any]]:
862
- """Get post-market movers (US only)."""
863
- limit = max(1, min(limit, 100))
864
-
865
- scanner_map = {
866
- "gainers": Scanner.postmarket_gainers,
867
- "losers": Scanner.postmarket_losers,
868
- "most_active": Scanner.postmarket_most_active,
869
- }
870
-
871
- scanner = scanner_map.get(scan_type, Scanner.postmarket_gainers).copy()
872
- scanner = scanner.limit(limit)
873
-
874
- try:
875
- total, df = scanner.get_scanner_data()
876
- return df.to_dict("records")
877
- except Exception as e:
878
- return [{"error": str(e)}]
879
-
880
-
881
- # =============================================================================
882
- # MCP Tools - Technical Analysis Scanners
883
- # =============================================================================
884
-
885
-
886
- @mcp.tool()
887
- def scan_rsi_extremes(
888
- market: str = "america",
889
- condition: str = "oversold",
890
- threshold: float = 30,
891
- limit: int = 25,
892
- ) -> list[dict[str, Any]]:
893
- """Scan for RSI overbought/oversold conditions."""
894
- market = sanitize_market(market)
895
- limit = max(1, min(limit, 100))
896
-
897
- cols = ["name", "close", "volume", "change", "RSI", "RSI7", "market_cap_basic"]
898
-
899
- query = Query().set_markets(market).select(*cols).limit(limit)
900
-
901
- if condition == "oversold":
902
- threshold = min(threshold, 50)
903
- query = query.where(Column("RSI") < threshold).order_by("RSI", ascending=True)
904
- else: # overbought
905
- threshold = max(threshold, 50)
906
- query = query.where(Column("RSI") > threshold).order_by("RSI", ascending=False)
907
-
908
- try:
909
- total, df = query.get_scanner_data()
910
- results = df.to_dict("records")
911
-
912
- # Add signal interpretation
913
- for r in results:
914
- rsi = r.get("RSI", 50)
915
- if rsi < 20:
916
- r["signal"] = "EXTREMELY_OVERSOLD"
917
- elif rsi < 30:
918
- r["signal"] = "OVERSOLD"
919
- elif rsi > 80:
920
- r["signal"] = "EXTREMELY_OVERBOUGHT"
921
- elif rsi > 70:
922
- r["signal"] = "OVERBOUGHT"
923
- else:
924
- r["signal"] = "NEUTRAL"
925
-
926
- return results
927
- except Exception as e:
928
- return [{"error": str(e)}]
929
-
930
-
931
- @mcp.tool()
932
- def scan_macd_crossover(
933
- market: str = "america",
934
- crossover_type: str = "bullish",
935
- limit: int = 25,
936
- ) -> list[dict[str, Any]]:
937
- """Scan for MACD crossover signals."""
938
- market = sanitize_market(market)
939
- limit = max(1, min(limit, 100))
940
-
941
- cols = ["name", "close", "volume", "change", "MACD.macd", "MACD.signal", "market_cap_basic"]
942
-
943
- query = Query().set_markets(market).select(*cols).limit(limit)
944
-
945
- if crossover_type == "bullish":
946
- query = query.where(Column("MACD.macd").crosses_above(Column("MACD.signal"))).order_by(
947
- "change", ascending=False
948
- )
949
- else:
950
- query = query.where(Column("MACD.macd").crosses_below(Column("MACD.signal"))).order_by(
951
- "change", ascending=True
952
- )
953
-
954
- try:
955
- total, df = query.get_scanner_data()
956
- results = df.to_dict("records")
957
-
958
- for r in results:
959
- macd = r.get("MACD.macd", 0)
960
- signal = r.get("MACD.signal", 0)
961
- r["macd_histogram"] = round(macd - signal, 4) if macd and signal else None
962
- r["crossover"] = crossover_type
963
-
964
- return results
965
- except Exception as e:
966
- return [{"error": str(e)}]
967
-
968
-
969
- @mcp.tool()
970
- def scan_bollinger_bands(
971
- market: str = "america",
972
- condition: str = "squeeze",
973
- limit: int = 25,
974
- ) -> list[dict[str, Any]]:
975
- """Scan for Bollinger Band conditions."""
976
- market = sanitize_market(market)
977
- limit = max(1, min(limit, 100))
978
-
979
- cols = ["name", "close", "volume", "change", "BB.upper", "BB.lower", "SMA20", "ATR", "Volatility.D"]
980
-
981
- query = Query().set_markets(market).select(*cols).limit(limit)
982
-
983
- if condition == "squeeze":
984
- query = query.order_by("Volatility.D", ascending=True)
985
- elif condition == "above_upper":
986
- query = query.where(Column("close") > Column("BB.upper")).order_by("change", ascending=False)
987
- elif condition == "below_lower":
988
- query = query.where(Column("close") < Column("BB.lower")).order_by("change", ascending=True)
989
-
990
- try:
991
- total, df = query.get_scanner_data()
992
- results = df.to_dict("records")
993
-
994
- # Calculate BBW and add signals
995
- for r in results:
996
- bb_upper = r.get("BB.upper", 0)
997
- bb_lower = r.get("BB.lower", 0)
998
- sma20 = r.get("SMA20", 0)
999
- close = r.get("close", 0)
1000
-
1001
- if sma20 and bb_upper and bb_lower:
1002
- bbw = compute_bollinger_band_width(sma20, bb_upper, bb_lower)
1003
- r["bbw"] = round(bbw, 4) if bbw else None
1004
-
1005
- rating, signal = compute_bollinger_rating(close, bb_upper, sma20, bb_lower)
1006
- r["bb_rating"] = rating
1007
- r["bb_signal"] = signal
1008
-
1009
- return results
1010
- except Exception as e:
1011
- return [{"error": str(e)}]
1012
-
1013
-
1014
- @mcp.tool()
1015
- def scan_volume_breakout(
1016
- market: str = "america",
1017
- volume_multiplier: float = 2.0,
1018
- min_price_change: float = 3.0,
1019
- limit: int = 25,
1020
- ) -> list[dict[str, Any]]:
1021
- """Scan for volume breakout candidates."""
1022
- market = sanitize_market(market)
1023
- limit = max(1, min(limit, 100))
1024
- volume_multiplier = max(1.2, min(10.0, volume_multiplier))
1025
- min_price_change = max(0.5, min(20.0, min_price_change))
1026
-
1027
- cols = [
1028
- "name",
1029
- "close",
1030
- "volume",
1031
- "change",
1032
- "change_abs",
1033
- "relative_volume_10d_calc",
1034
- "average_volume_10d_calc",
1035
- ]
1036
-
1037
- query = (
1038
- Query()
1039
- .set_markets(market)
1040
- .select(*cols)
1041
- .where(
1042
- Column("relative_volume_10d_calc") >= volume_multiplier,
1043
- )
1044
- .order_by("relative_volume_10d_calc", ascending=False)
1045
- .limit(limit * 2) # Get more to filter
1046
- )
1047
-
1048
- try:
1049
- total, df = query.get_scanner_data()
1050
- results = df.to_dict("records")
1051
-
1052
- # Filter by price change
1053
- filtered = []
1054
- for r in results:
1055
- change = abs(r.get("change", 0) or 0)
1056
- if change >= min_price_change:
1057
- rel_vol = r.get("relative_volume_10d_calc", 1)
1058
- r["breakout_type"] = "bullish" if r.get("change", 0) > 0 else "bearish"
1059
- r["volume_strength"] = "VERY_STRONG" if rel_vol >= 3 else "STRONG" if rel_vol >= 2 else "MODERATE"
1060
- filtered.append(r)
1061
-
1062
- return filtered[:limit]
1063
- except Exception as e:
1064
- return [{"error": str(e)}]
1065
-
1066
-
1067
- @mcp.tool()
1068
- def scan_moving_average_crossover(
1069
- market: str = "america",
1070
- crossover_type: str = "golden_cross",
1071
- limit: int = 25,
1072
- ) -> list[dict[str, Any]]:
1073
- """Scan for moving average crossovers."""
1074
- market = sanitize_market(market)
1075
- limit = max(1, min(limit, 100))
1076
-
1077
- scanner_map = {
1078
- "golden_cross": Scanner.golden_cross,
1079
- "death_cross": Scanner.death_cross,
1080
- "above_all_mas": Scanner.above_all_mas,
1081
- }
1082
-
1083
- scanner = scanner_map.get(crossover_type)
1084
- if scanner:
1085
- scanner = scanner.copy().set_markets(market).limit(limit)
1086
- else:
1087
- # below_all_mas
1088
- scanner = (
1089
- Query()
1090
- .set_markets(market)
1091
- .select("name", "close", "volume", "change", "SMA20", "SMA50", "SMA200")
1092
- .where(
1093
- Column("close") < Column("SMA20"),
1094
- Column("close") < Column("SMA50"),
1095
- Column("close") < Column("SMA200"),
1096
- )
1097
- .order_by("change", ascending=True)
1098
- .limit(limit)
1099
- )
1100
-
1101
- try:
1102
- total, df = scanner.get_scanner_data()
1103
- results = df.to_dict("records")
1104
-
1105
- for r in results:
1106
- r["crossover_type"] = crossover_type
1107
-
1108
- return results
1109
- except Exception as e:
1110
- return [{"error": str(e)}]
1111
-
1112
-
1113
- @mcp.tool()
1114
- def scan_52_week_levels(
1115
- market: str = "america",
1116
- level_type: str = "near_high",
1117
- threshold_pct: float = 5.0,
1118
- limit: int = 25,
1119
- ) -> list[dict[str, Any]]:
1120
- """Scan for stocks near 52-week highs or lows."""
1121
- market = sanitize_market(market)
1122
- limit = max(1, min(limit, 100))
1123
- threshold_pct = max(1.0, min(20.0, threshold_pct))
1124
-
1125
- cols = ["name", "close", "volume", "change", "price_52_week_high", "price_52_week_low", "Perf.Y"]
1126
-
1127
- query = Query().set_markets(market).select(*cols).limit(limit)
1128
-
1129
- if level_type == "near_high":
1130
- # Price within threshold_pct of 52-week high
1131
- multiplier = 1 - (threshold_pct / 100)
1132
- query = query.where(Column("close").above_pct("price_52_week_high", multiplier)).order_by(
1133
- "change", ascending=False
1134
- )
1135
- elif level_type == "near_low":
1136
- # Price within threshold_pct of 52-week low
1137
- multiplier = 1 + (threshold_pct / 100)
1138
- query = query.where(Column("close").below_pct("price_52_week_low", multiplier)).order_by(
1139
- "change", ascending=True
1140
- )
1141
- elif level_type == "new_high":
1142
- query = query.where(Column("close") >= Column("price_52_week_high")).order_by(
1143
- "change", ascending=False
1144
- )
1145
- else: # new_low
1146
- query = query.where(Column("close") <= Column("price_52_week_low")).order_by(
1147
- "change", ascending=True
1148
- )
1149
-
1150
- try:
1151
- total, df = query.get_scanner_data()
1152
- results = df.to_dict("records")
1153
-
1154
- for r in results:
1155
- close = r.get("close", 0)
1156
- high52 = r.get("price_52_week_high", 0)
1157
- low52 = r.get("price_52_week_low", 0)
1158
-
1159
- if high52:
1160
- r["pct_from_52w_high"] = round(((close - high52) / high52) * 100, 2)
1161
- if low52:
1162
- r["pct_from_52w_low"] = round(((close - low52) / low52) * 100, 2)
1163
-
1164
- return results
1165
- except Exception as e:
1166
- return [{"error": str(e)}]
1167
-
1168
-
1169
- # =============================================================================
1170
- # MCP Tools - Symbol Analysis
1171
- # =============================================================================
1172
-
1173
-
1174
- @mcp.tool()
1175
- def get_symbol_analysis(
1176
- symbol: str,
1177
- include_fundamentals: bool = True,
1178
- ) -> dict[str, Any]:
1179
- """Get comprehensive technical analysis for a symbol."""
1180
- # Select columns based on what we want
1181
- cols = [
1182
- "name",
1183
- "description",
1184
- "type",
1185
- "exchange",
1186
- "close",
1187
- "open",
1188
- "high",
1189
- "low",
1190
- "volume",
1191
- "change",
1192
- "change_abs",
1193
- # Technical indicators
1194
- "RSI",
1195
- "RSI7",
1196
- "MACD.macd",
1197
- "MACD.signal",
1198
- "BB.upper",
1199
- "BB.lower",
1200
- "SMA20",
1201
- "SMA50",
1202
- "SMA200",
1203
- "EMA20",
1204
- "EMA50",
1205
- "EMA200",
1206
- "ADX",
1207
- "ATR",
1208
- "Stoch.K",
1209
- "Stoch.D",
1210
- "VWAP",
1211
- "Volatility.D",
1212
- # Performance
1213
- "Perf.W",
1214
- "Perf.1M",
1215
- "Perf.3M",
1216
- "Perf.Y",
1217
- # Recommendations
1218
- "Recommend.All",
1219
- "Recommend.MA",
1220
- "Recommend.Other",
1221
- ]
1222
-
1223
- if include_fundamentals:
1224
- cols.extend(
1225
- [
1226
- "market_cap_basic",
1227
- "price_earnings_ttm",
1228
- "earnings_per_share_basic_ttm",
1229
- "dividend_yield_recent",
1230
- "price_52_week_high",
1231
- "price_52_week_low",
1232
- ]
1233
- )
1234
-
1235
- try:
1236
- query = Query().set_tickers(symbol).select(*cols)
1237
- total, df = query.get_scanner_data()
1238
-
1239
- if df.empty:
1240
- return {"error": f"No data found for {symbol}"}
1241
-
1242
- row = df.iloc[0].to_dict()
1243
-
1244
- # Perform technical analysis
1245
- analysis = analyze_indicators(row)
1246
-
1247
- # Build result
1248
- result = {
1249
- "symbol": symbol,
1250
- "name": row.get("name"),
1251
- "exchange": row.get("exchange"),
1252
- "type": row.get("type"),
1253
- "price_data": {
1254
- "close": row.get("close"),
1255
- "open": row.get("open"),
1256
- "high": row.get("high"),
1257
- "low": row.get("low"),
1258
- "volume": row.get("volume"),
1259
- "change": row.get("change"),
1260
- "change_abs": row.get("change_abs"),
1261
- },
1262
- "technical_indicators": {
1263
- "rsi": row.get("RSI"),
1264
- "rsi7": row.get("RSI7"),
1265
- "macd": row.get("MACD.macd"),
1266
- "macd_signal": row.get("MACD.signal"),
1267
- "bb_upper": row.get("BB.upper"),
1268
- "bb_lower": row.get("BB.lower"),
1269
- "sma20": row.get("SMA20"),
1270
- "sma50": row.get("SMA50"),
1271
- "sma200": row.get("SMA200"),
1272
- "ema20": row.get("EMA20"),
1273
- "ema50": row.get("EMA50"),
1274
- "ema200": row.get("EMA200"),
1275
- "adx": row.get("ADX"),
1276
- "atr": row.get("ATR"),
1277
- "stoch_k": row.get("Stoch.K"),
1278
- "stoch_d": row.get("Stoch.D"),
1279
- "vwap": row.get("VWAP"),
1280
- "volatility": row.get("Volatility.D"),
1281
- },
1282
- "performance": {
1283
- "weekly": row.get("Perf.W"),
1284
- "monthly": row.get("Perf.1M"),
1285
- "quarterly": row.get("Perf.3M"),
1286
- "yearly": row.get("Perf.Y"),
1287
- },
1288
- "ratings": {
1289
- "overall": row.get("Recommend.All"),
1290
- "overall_label": format_technical_rating(row.get("Recommend.All", 0)),
1291
- "moving_averages": row.get("Recommend.MA"),
1292
- "oscillators": row.get("Recommend.Other"),
1293
- },
1294
- "analysis": analysis,
1295
- }
1296
-
1297
- if include_fundamentals:
1298
- result["fundamentals"] = {
1299
- "market_cap": row.get("market_cap_basic"),
1300
- "pe_ratio": row.get("price_earnings_ttm"),
1301
- "eps": row.get("earnings_per_share_basic_ttm"),
1302
- "dividend_yield": row.get("dividend_yield_recent"),
1303
- "52_week_high": row.get("price_52_week_high"),
1304
- "52_week_low": row.get("price_52_week_low"),
1305
- }
1306
-
1307
- return result
1308
-
1309
- except Exception as e:
1310
- return {"error": str(e), "symbol": symbol}
1311
-
1312
-
1313
- @mcp.tool()
1314
- def compare_symbols(
1315
- symbols: list[str],
1316
- columns: Optional[list[str]] = None,
1317
- ) -> list[dict[str, Any]]:
1318
- """Compare multiple symbols side by side."""
1319
- if not symbols:
1320
- return [{"error": "No symbols provided"}]
1321
-
1322
- cols = columns or [
1323
- "name",
1324
- "close",
1325
- "change",
1326
- "volume",
1327
- "RSI",
1328
- "MACD.macd",
1329
- "market_cap_basic",
1330
- "Perf.1M",
1331
- "Perf.Y",
1332
- "Recommend.All",
1333
- ]
1334
-
1335
- try:
1336
- query = Query().set_tickers(*symbols).select(*cols)
1337
- total, df = query.get_scanner_data()
1338
-
1339
- results = df.to_dict("records")
1340
-
1341
- # Add technical rating labels
1342
- for r in results:
1343
- rec = r.get("Recommend.All")
1344
- if rec is not None:
1345
- r["rating_label"] = format_technical_rating(rec)
1346
-
1347
- return results
1348
-
1349
- except Exception as e:
1350
- return [{"error": str(e)}]
1351
-
1352
-
1353
- # =============================================================================
1354
- # MCP Tools - Utility Functions
199
+ # Register Tools
1355
200
  # =============================================================================
1356
201
 
202
+ mcp.tool()(search_symbols)
203
+ mcp.tool()(get_symbol_info)
204
+ mcp.tool()(screen_market)
205
+ mcp.tool()(get_top_gainers)
206
+ mcp.tool()(get_top_losers)
207
+ mcp.tool()(get_most_active)
1357
208
 
1358
- @mcp.tool()
1359
- def get_all_market_symbols(market: str = "america") -> dict[str, Any]:
1360
- """Get available symbols for a market."""
1361
- market = sanitize_market(market)
1362
-
1363
- try:
1364
- symbols = get_all_symbols(market)
1365
- return {
1366
- "market": market,
1367
- "total_symbols": len(symbols),
1368
- "symbols": symbols[:500], # Return first 500 to avoid huge responses
1369
- "note": f"Showing first 500 of {len(symbols)} symbols" if len(symbols) > 500 else None,
1370
- }
1371
- except Exception as e:
1372
- return {"error": str(e), "market": market}
1373
-
1374
-
1375
- @mcp.tool()
1376
- def run_scanner_preset(
1377
- preset_name: str,
1378
- market: Optional[str] = None,
1379
- limit: int = 50,
1380
- ) -> list[dict[str, Any]]:
1381
- """Run a pre-built scanner preset."""
1382
- limit = max(1, min(limit, 100))
1383
-
1384
- # Try stock scanners first
1385
- scanner = getattr(Scanner, preset_name, None)
1386
-
1387
- # Try crypto scanners
1388
- if scanner is None:
1389
- scanner = getattr(CryptoScanner, preset_name, None)
1390
-
1391
- if scanner is None:
1392
- available = Scanner.names() + CryptoScanner.names()
1393
- return [{"error": f"Unknown preset: {preset_name}", "available_presets": available}]
1394
-
1395
- scanner = scanner.copy().limit(limit)
1396
-
1397
- if market:
1398
- market = sanitize_market(market)
1399
- scanner = scanner.set_markets(market)
1400
-
1401
- try:
1402
- total, df = scanner.get_scanner_data()
1403
- return df.to_dict("records")
1404
- except Exception as e:
1405
- return [{"error": str(e)}]
1406
-
1407
-
1408
- @mcp.tool()
1409
- def advanced_query(
1410
- market: str = "america",
1411
- select_columns: list[str] = None,
1412
- conditions: list[dict[str, Any]] = None,
1413
- logic: str = "and",
1414
- sort_by: str = "volume",
1415
- ascending: bool = False,
1416
- limit: int = 50,
1417
- ) -> list[dict[str, Any]]:
1418
- """Execute an advanced query with AND/OR logic."""
1419
- market = sanitize_market(market)
1420
- limit = max(1, min(limit, 500))
1421
- cols = select_columns or TECHNICAL_COLUMNS
1422
-
1423
- query = Query().set_markets(market).select(*cols).order_by(sort_by, ascending=ascending).limit(limit)
1424
-
1425
- if conditions:
1426
- expressions = []
1427
- for cond in conditions:
1428
- col = Column(cond.get("column", "close"))
1429
- op = cond.get("op", "gt")
1430
- val = cond.get("value")
1431
-
1432
- if op == "gt":
1433
- expressions.append(col > val)
1434
- elif op == "gte":
1435
- expressions.append(col >= val)
1436
- elif op == "lt":
1437
- expressions.append(col < val)
1438
- elif op == "lte":
1439
- expressions.append(col <= val)
1440
- elif op == "eq":
1441
- expressions.append(col == val)
1442
- elif op == "neq":
1443
- expressions.append(col != val)
1444
- elif op == "between" and isinstance(val, (list, tuple)) and len(val) == 2:
1445
- expressions.append(col.between(val[0], val[1]))
1446
- elif op == "isin" and isinstance(val, (list, tuple)):
1447
- expressions.append(col.isin(val))
1448
- elif op == "crosses_above":
1449
- expressions.append(col.crosses_above(Column(val) if isinstance(val, str) else val))
1450
- elif op == "crosses_below":
1451
- expressions.append(col.crosses_below(Column(val) if isinstance(val, str) else val))
1452
- elif op == "above_pct" and isinstance(val, (list, tuple)) and len(val) == 2:
1453
- expressions.append(col.above_pct(val[0], val[1]))
1454
- elif op == "below_pct" and isinstance(val, (list, tuple)) and len(val) == 2:
1455
- expressions.append(col.below_pct(val[0], val[1]))
1456
-
1457
- if expressions:
1458
- if logic == "or":
1459
- query = query.where2(Or(*expressions))
1460
- else:
1461
- query = query.where(*expressions)
1462
-
1463
- try:
1464
- total, df = query.get_scanner_data()
1465
- results = df.to_dict("records")
1466
- return [{"total_count": total, "returned": len(results), "data": results}]
1467
- except Exception as e:
1468
- return [{"error": str(e)}]
1469
-
1470
-
1471
- # =============================================================================
1472
- # MCP Tools - Crypto Specific
1473
- # =============================================================================
1474
-
1475
-
1476
- @mcp.tool()
1477
- def get_crypto_gainers(exchange: Optional[str] = None, limit: int = 25) -> list[dict[str, Any]]:
1478
- """Get top gaining cryptocurrencies."""
1479
- limit = max(1, min(limit, 100))
1480
-
1481
- cols = ["name", "close", "volume", "change", "change_abs", "market_cap_basic", "24h_vol|5"]
1482
-
1483
- query = (
1484
- Query()
1485
- .set_markets("crypto")
1486
- .select(*cols)
1487
- .where(Column("change") > 0)
1488
- .order_by("change", ascending=False)
1489
- .limit(limit)
1490
- )
1491
-
1492
- if exchange:
1493
- query = query.where(Column("exchange") == exchange.upper())
1494
-
1495
- try:
1496
- total, df = query.get_scanner_data()
1497
- return df.to_dict("records")
1498
- except Exception as e:
1499
- return [{"error": str(e)}]
1500
-
1501
-
1502
- @mcp.tool()
1503
- def get_crypto_losers(exchange: Optional[str] = None, limit: int = 25) -> list[dict[str, Any]]:
1504
- """Get top losing cryptocurrencies."""
1505
- limit = max(1, min(limit, 100))
1506
-
1507
- cols = ["name", "close", "volume", "change", "change_abs", "market_cap_basic", "24h_vol|5"]
1508
-
1509
- query = (
1510
- Query()
1511
- .set_markets("crypto")
1512
- .select(*cols)
1513
- .where(Column("change") < 0)
1514
- .order_by("change", ascending=True)
1515
- .limit(limit)
1516
- )
1517
-
1518
- if exchange:
1519
- query = query.where(Column("exchange") == exchange.upper())
1520
-
1521
- try:
1522
- total, df = query.get_scanner_data()
1523
- return df.to_dict("records")
1524
- except Exception as e:
1525
- return [{"error": str(e)}]
1526
-
1527
-
1528
- @mcp.tool()
1529
- def scan_crypto_technicals(
1530
- scan_type: str = "oversold",
1531
- exchange: Optional[str] = None,
1532
- limit: int = 25,
1533
- ) -> list[dict[str, Any]]:
1534
- """Scan cryptocurrencies based on technical indicators."""
1535
- limit = max(1, min(limit, 100))
1536
-
1537
- scanner_map = {
1538
- "oversold": CryptoScanner.oversold,
1539
- "overbought": CryptoScanner.overbought,
1540
- "high_volume": CryptoScanner.high_volume,
1541
- "most_volatile": CryptoScanner.most_volatile,
1542
- }
1543
-
1544
- if scan_type in scanner_map:
1545
- scanner = scanner_map[scan_type].copy().limit(limit)
1546
- elif scan_type == "macd_bullish":
1547
- scanner = (
1548
- Query()
1549
- .set_markets("crypto")
1550
- .select("name", "close", "volume", "change", "MACD.macd", "MACD.signal", "RSI")
1551
- .where(Column("MACD.macd").crosses_above(Column("MACD.signal")))
1552
- .order_by("change", ascending=False)
1553
- .limit(limit)
1554
- )
1555
- elif scan_type == "macd_bearish":
1556
- scanner = (
1557
- Query()
1558
- .set_markets("crypto")
1559
- .select("name", "close", "volume", "change", "MACD.macd", "MACD.signal", "RSI")
1560
- .where(Column("MACD.macd").crosses_below(Column("MACD.signal")))
1561
- .order_by("change", ascending=True)
1562
- .limit(limit)
1563
- )
1564
- else:
1565
- return [{"error": f"Unknown scan_type: {scan_type}"}]
1566
-
1567
- if exchange:
1568
- scanner = scanner.where(Column("exchange") == exchange.upper())
1569
-
1570
- try:
1571
- total, df = scanner.get_scanner_data()
1572
- return df.to_dict("records")
1573
- except Exception as e:
1574
- return [{"error": str(e)}]
209
+ mcp.tool()(get_technical_analysis)
210
+ mcp.tool()(scan_rsi_extremes)
211
+ mcp.tool()(scan_bollinger_bands)
212
+ mcp.tool()(scan_macd_crossover)
1575
213
 
214
+ mcp.tool()(ai_get_reference)
215
+ mcp.tool()(search_available_fields)
216
+ mcp.tool()(get_code_example)
217
+ mcp.tool()(list_fields_for_market)
218
+ mcp.tool()(get_common_fields_summary)
219
+ mcp.tool()(lookup_single_field)
220
+ mcp.tool()(get_field_info)
221
+ mcp.tool()(get_filter_example)
222
+ mcp.tool()(check_market)
223
+ mcp.tool()(get_help)
224
+ mcp.tool()(get_screener_preset)
225
+ mcp.tool()(get_market_metainfo)
1576
226
 
1577
227
  # =============================================================================
1578
- # Server Entry Point
228
+ # Register Resources
1579
229
  # =============================================================================
1580
230
 
231
+ mcp.resource("markets://list")(list_markets)
232
+ mcp.resource("columns://list")(list_columns)
233
+ mcp.resource("exchanges://crypto")(list_crypto_exchanges)
234
+ mcp.resource("scanners://presets")(list_scanner_presets)
235
+ mcp.resource("docs://params")(docs_params)
236
+ mcp.resource("docs://screeners")(docs_screeners)
237
+ mcp.resource("docs://stock-screeners")(docs_stock_screeners)
238
+ mcp.resource("docs://stock-screeners-failed")(docs_stock_screeners_failed)
239
+ mcp.resource("docs://fields")(docs_fields)
240
+ mcp.resource("docs://markets")(docs_markets)
241
+ mcp.resource("docs://ai-reference")(docs_ai_reference)
242
+ mcp.resource("docs://code-examples")(docs_code_examples)
1581
243
 
1582
- def main() -> None:
1583
- """Main entry point for the MCP server."""
1584
- parser = argparse.ArgumentParser(description="TradingView Screener MCP Server")
1585
- parser.add_argument(
1586
- "transport",
1587
- choices=["stdio", "streamable-http"],
1588
- default="stdio",
1589
- nargs="?",
1590
- help="Transport protocol (default: stdio)",
1591
- )
1592
- parser.add_argument("--host", default=os.environ.get("HOST", "127.0.0.1"), help="HTTP host")
1593
- parser.add_argument(
1594
- "--port", type=int, default=int(os.environ.get("PORT", "8000")), help="HTTP port"
1595
- )
1596
- args = parser.parse_args()
1597
-
1598
- if os.environ.get("DEBUG_MCP"):
1599
- import sys
1600
-
1601
- print(f"[DEBUG] TradingView MCP starting: transport={args.transport}", file=sys.stderr, flush=True)
1602
-
1603
- if args.transport == "stdio":
1604
- mcp.run()
1605
- else:
1606
- try:
1607
- mcp.settings.host = args.host
1608
- mcp.settings.port = args.port
1609
- except AttributeError:
1610
- pass
1611
- mcp.run(transport="streamable-http")
1612
244
 
245
+ def main():
246
+ mcp.run()
1613
247
 
1614
248
  if __name__ == "__main__":
1615
249
  main()