tradingview-mcp 1.0.0__py3-none-any.whl → 1.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.
Files changed (34) hide show
  1. tradingview_mcp/constants.py +6 -4
  2. tradingview_mcp/data/__init__.py +9 -0
  3. tradingview_mcp/data/column_display_names.json +827 -0
  4. tradingview_mcp/data/extracted/__init__.py +1 -0
  5. tradingview_mcp/data/extracted/ai_quick_reference.json +212 -0
  6. tradingview_mcp/data/extracted/common_fields.json +3627 -0
  7. tradingview_mcp/data/extracted/fields_by_market.json +23299 -0
  8. tradingview_mcp/data/extracted/screener_code_examples.json +9 -0
  9. tradingview_mcp/data/markets.json +83 -0
  10. tradingview_mcp/data/metainfo/bond.json +17406 -0
  11. tradingview_mcp/data/metainfo/bonds.json +1303 -0
  12. tradingview_mcp/data/metainfo/cfd.json +21603 -0
  13. tradingview_mcp/data/metainfo/coin.json +21111 -0
  14. tradingview_mcp/data/metainfo/crypto.json +23078 -0
  15. tradingview_mcp/data/metainfo/economics2.json +1026 -0
  16. tradingview_mcp/data/metainfo/forex.json +21003 -0
  17. tradingview_mcp/data/metainfo/futures.json +2972 -0
  18. tradingview_mcp/data/metainfo/ireland.json +22517 -0
  19. tradingview_mcp/data/metainfo/options.json +499 -0
  20. tradingview_mcp/data/metainfo/stocks.json +29808 -0
  21. tradingview_mcp/data/screeners/main_screeners.json +540 -0
  22. tradingview_mcp/data/screeners/markets.json +70 -0
  23. tradingview_mcp/data/screeners/stocks.json +416 -0
  24. tradingview_mcp/data/screeners/stocks_failed.json +36081 -0
  25. tradingview_mcp/docs_data.py +297 -0
  26. tradingview_mcp/scanner.py +2 -1
  27. tradingview_mcp/server.py +839 -51
  28. tradingview_mcp-1.0.dist-info/METADATA +334 -0
  29. tradingview_mcp-1.0.dist-info/RECORD +37 -0
  30. {tradingview_mcp-1.0.0.dist-info → tradingview_mcp-1.0.dist-info}/licenses/LICENSE +0 -2
  31. tradingview_mcp-1.0.0.dist-info/METADATA +0 -182
  32. tradingview_mcp-1.0.0.dist-info/RECORD +0 -13
  33. {tradingview_mcp-1.0.0.dist-info → tradingview_mcp-1.0.dist-info}/WHEEL +0 -0
  34. {tradingview_mcp-1.0.0.dist-info → tradingview_mcp-1.0.dist-info}/entry_points.txt +0 -0
tradingview_mcp/server.py CHANGED
@@ -7,8 +7,12 @@ Provides comprehensive MCP tools and resources for TradingView market screening.
7
7
  from __future__ import annotations
8
8
 
9
9
  import argparse
10
+ import inspect
10
11
  import json
11
12
  import os
13
+ import traceback
14
+ from datetime import datetime
15
+ from functools import wraps
12
16
  from typing import Any, Optional
13
17
 
14
18
  from mcp.server.fastmcp import FastMCP
@@ -38,17 +42,141 @@ from tradingview_mcp.utils import (
38
42
  sanitize_timeframe,
39
43
  timeframe_to_resolution,
40
44
  )
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
+ paginate_data,
56
+ search_fields,
57
+ get_field_summary,
58
+ estimate_tokens,
59
+ truncate_for_tokens,
60
+ MAX_ITEMS_DEFAULT,
61
+ )
41
62
 
42
63
  # Initialize MCP Server
43
64
  mcp = FastMCP(
44
65
  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
- ),
66
+ instructions="""TradingView Market Screener MCP - Stocks/Crypto screening tools
67
+
68
+ 🚀 Quick start:
69
+ 1. Call `get_help()` for the full usage guide
70
+ 2. Call `ai_get_reference()` for markets, fields, and filter references
71
+
72
+ 📊 Common tools:
73
+ - `screen_market(market, limit)` - Market screening (stocks, crypto, etc.)
74
+ - `get_top_gainers(market, limit)` - Top gainers
75
+ - `get_top_losers(market, limit)` - Top losers
76
+ - `search_symbols(query, market)` - 🔍 Search by name (e.g., "Apple", "Tesla")
77
+ - `get_symbol_info(symbol)` - Get detailed symbol info
78
+
79
+ ⚠️ Important notes:
80
+ - `limit` controls result count (default 25, max 500)
81
+ - Results include the `description` field with full names (e.g., "Apple Inc.")
82
+ - `market` examples: america (US stocks), crypto, uk, etc.
83
+
84
+ 📖 Resources (read-only):
85
+ - docs://ai-reference - AI quick reference
86
+ - markets://list - All markets
87
+ - columns://list - Available fields
88
+ """,
50
89
  )
51
90
 
91
+ DEBUG_MODE = os.getenv("TRADINGVIEW_MCP_DEBUG", "0").lower() in {"1", "true", "yes"}
92
+
93
+
94
+ def _safe_json(value: Any) -> Any:
95
+ try:
96
+ return json.loads(json.dumps(value, default=str))
97
+ except Exception:
98
+ return str(value)
99
+
100
+
101
+ def _debug_hint(exc: Exception) -> str | None:
102
+ message = str(exc).lower()
103
+ if "market" in message and "invalid" in message:
104
+ return "Check the market name using the markets://list or docs://screeners resources."
105
+ if "column" in message and "unknown" in message:
106
+ return "Check available columns via columns://list or docs://fields."
107
+ if "http" in message or "status" in message:
108
+ return "TradingView API may be rate-limiting or blocked. Retry with fewer requests."
109
+ return None
110
+
111
+
112
+ def _build_error_response(tool_name: str, exc: Exception, context: dict[str, Any]) -> dict[str, Any]:
113
+ payload: dict[str, Any] = {
114
+ "ok": False,
115
+ "tool": tool_name,
116
+ "error": {"type": exc.__class__.__name__, "message": str(exc)},
117
+ "context": _safe_json(context),
118
+ "timestamp": datetime.utcnow().isoformat() + "Z",
119
+ }
120
+ hint = _debug_hint(exc)
121
+ if hint:
122
+ payload["hint"] = hint
123
+ if DEBUG_MODE:
124
+ payload["trace"] = traceback.format_exc()
125
+ return payload
126
+
127
+
128
+ def _error_response(exc: Exception, context: dict[str, Any] | None = None) -> dict[str, Any]:
129
+ frame = inspect.currentframe()
130
+ caller = frame.f_back.f_code.co_name if frame and frame.f_back else "unknown"
131
+ return _build_error_response(caller, exc, context or {})
132
+
133
+
134
+ def debug_tool(fn):
135
+ """Decorator to return structured debug responses on failure."""
136
+
137
+ @wraps(fn)
138
+ def wrapper(*args, **kwargs):
139
+ try:
140
+ result = fn(*args, **kwargs)
141
+ if isinstance(result, dict) and "error" in result:
142
+ return _build_error_response(
143
+ fn.__name__,
144
+ Exception(str(result.get("error"))),
145
+ {"args": args, "kwargs": kwargs, "note": "error returned by tool"},
146
+ )
147
+ if (
148
+ isinstance(result, list)
149
+ and len(result) == 1
150
+ and isinstance(result[0], dict)
151
+ and "error" in result[0]
152
+ ):
153
+ return _build_error_response(
154
+ fn.__name__,
155
+ Exception(str(result[0].get("error"))),
156
+ {"args": args, "kwargs": kwargs, "note": "error returned by tool"},
157
+ )
158
+ return result
159
+ except Exception as exc: # pragma: no cover - defensive guard
160
+ context = {"args": args, "kwargs": kwargs}
161
+ return _build_error_response(fn.__name__, exc, context)
162
+
163
+ return wrapper
164
+
165
+
166
+ _original_mcp_tool = mcp.tool
167
+
168
+
169
+ def _debug_mcp_tool(*args, **kwargs):
170
+ """Wrap FastMCP tool registration with debug handling."""
171
+
172
+ def decorator(fn):
173
+ return _original_mcp_tool(*args, **kwargs)(debug_tool(fn))
174
+
175
+ return decorator
176
+
177
+
178
+ mcp.tool = _debug_mcp_tool
179
+
52
180
 
53
181
  # =============================================================================
54
182
  # MCP Resources
@@ -242,6 +370,623 @@ def list_scanner_presets() -> str:
242
370
  return result
243
371
 
244
372
 
373
+ @mcp.resource("docs://params")
374
+ def docs_params() -> str:
375
+ """TradingView screener parameter format and schema."""
376
+ schema = {
377
+ "markets": ["america"],
378
+ "columns": ["close", "volume", "market_cap_basic"],
379
+ "filter": [
380
+ {"left": "change", "operation": "gt", "right": 2},
381
+ {"left": "volume", "operation": "greater", "right": 1000000},
382
+ ],
383
+ "filter2": {
384
+ "operator": "and",
385
+ "operands": [
386
+ {
387
+ "expression": {"left": "type", "operation": "equal", "right": "stock"}
388
+ },
389
+ {
390
+ "expression": {"left": "is_primary", "operation": "equal", "right": True}
391
+ },
392
+ ],
393
+ },
394
+ "symbols": {"query": {"types": []}, "tickers": []},
395
+ "sort": {"sortBy": "market_cap_basic", "sortOrder": "desc"},
396
+ "range": [0, 50],
397
+ "options": {"lang": "en"},
398
+ "ignore_unknown_fields": False,
399
+ "price_conversion": {"to_symbol": False},
400
+ }
401
+
402
+ format_notes = (
403
+ "# Screener Params Format\n\n"
404
+ "## Core fields\n"
405
+ "- markets: list of market identifiers (e.g., america, crypto, forex)\n"
406
+ "- columns: list of fields to return\n"
407
+ "- filter: flat list of expressions\n"
408
+ "- filter2: nested boolean logic tree (and/or)\n"
409
+ "- symbols: optional tickers/types filter\n"
410
+ "- sort: {sortBy, sortOrder}\n"
411
+ "- range: [offset, limit]\n"
412
+ "- options: {lang}\n"
413
+ "- ignore_unknown_fields: boolean\n"
414
+ "- price_conversion: {to_symbol}\n\n"
415
+ "## Expression format\n"
416
+ "{left: <field>, operation: <op>, right: <value>}\n\n"
417
+ "Common operations: equal, ne, gt, gte, lt, lte, in_range, has, has_none_of\n\n"
418
+ "## Example schema\n"
419
+ )
420
+ return format_notes + json.dumps(schema, ensure_ascii=False, indent=2)
421
+
422
+
423
+ @mcp.resource("docs://screeners")
424
+ def docs_screeners() -> str:
425
+ """Predefined screener presets from docs data (compact overview)."""
426
+ presets = get_screener_presets()
427
+ overview = [
428
+ {"name": p.get("name"), "url": p.get("url"), "has_query": bool(p.get("query"))}
429
+ for p in presets
430
+ ]
431
+ return json.dumps(overview, ensure_ascii=False, indent=2)
432
+
433
+
434
+ @mcp.resource("docs://fields")
435
+ def docs_fields() -> str:
436
+ """Field display names (first 100). Use search_available_fields tool for more."""
437
+ mapping = get_column_display_names()
438
+ # Limit output to save tokens
439
+ limited = dict(list(mapping.items())[:100])
440
+ result = {
441
+ "fields": limited,
442
+ "total": len(mapping),
443
+ "note": "Showing first 100 fields. Use search_available_fields() tool to search for specific fields.",
444
+ }
445
+ return json.dumps(result, ensure_ascii=False, indent=2)
446
+
447
+
448
+ @mcp.resource("docs://markets")
449
+ def docs_markets() -> str:
450
+ """Markets metadata (compact). Use ai_get_reference tool for categorized list."""
451
+ markets = get_markets_data()
452
+ screener_markets = get_screener_markets()
453
+ return json.dumps(
454
+ {
455
+ "markets_count": len(markets) if isinstance(markets, (list, dict)) else 0,
456
+ "screener_markets": screener_markets,
457
+ "hint": "Use ai_get_reference() tool for full categorized market list.",
458
+ },
459
+ ensure_ascii=False,
460
+ indent=2,
461
+ )
462
+
463
+
464
+ @mcp.tool()
465
+ def get_field_info(field: str) -> dict[str, Any]:
466
+ """Get display name, type, and related info for a field.
467
+
468
+ Args:
469
+ field: Field/column name to look up
470
+
471
+ Returns:
472
+ Field info including display name, type, and variants if available
473
+ """
474
+ # Try get_field_summary first (uses common_fields)
475
+ summary = get_field_summary(field)
476
+ if summary and summary.get("display_name"):
477
+ return summary
478
+
479
+ # Fallback to display names mapping
480
+ mapping = get_column_display_names()
481
+ display = mapping.get(field)
482
+ if display:
483
+ return {"field": field, "display_name": display}
484
+
485
+ # Try case-insensitive or partial matches
486
+ normalized = field.lower()
487
+ suggestions = [
488
+ name for name in list(mapping.keys())[:500]
489
+ if normalized in name.lower() or normalized in str(mapping[name]).lower()
490
+ ][:10]
491
+ return {
492
+ "field": field,
493
+ "display_name": None,
494
+ "suggestions": suggestions,
495
+ }
496
+
497
+
498
+ @mcp.tool()
499
+ def get_screener_preset(name: str) -> dict[str, Any]:
500
+ """Return a full screener preset (query + code) by name."""
501
+ presets = get_screener_presets()
502
+ for preset in presets:
503
+ if preset.get("name", "").lower() == name.lower():
504
+ return preset
505
+ return {
506
+ "error": f"Preset '{name}' not found.",
507
+ "available": [p.get("name") for p in presets],
508
+ }
509
+
510
+
511
+ @mcp.tool()
512
+ def get_market_metainfo(
513
+ market: str,
514
+ limit: int = 50,
515
+ offset: int = 0,
516
+ confirm_large: bool = False,
517
+ ) -> dict[str, Any]:
518
+ """Return metainfo (field definitions and allowed values) for a market.
519
+
520
+ Args:
521
+ market: Market type (stocks, crypto, forex, bond, etc.)
522
+ limit: Maximum fields to return (default 50, max 500)
523
+ offset: Starting offset for pagination
524
+ confirm_large: Set True to allow returning more than 100 items
525
+
526
+ Returns:
527
+ Paginated metainfo with field definitions
528
+ """
529
+ limit = min(limit, 500) # Hard cap
530
+
531
+ try:
532
+ metainfo = load_metainfo(market)
533
+
534
+ # Paginate if it's a list or large dict
535
+ if isinstance(metainfo, list):
536
+ return paginate_data(metainfo, offset, limit, confirm_large)
537
+ elif isinstance(metainfo, dict):
538
+ # Return summary if too large
539
+ total_fields = len(metainfo.get("fields", []))
540
+ if total_fields > MAX_ITEMS_DEFAULT and not confirm_large:
541
+ return {
542
+ "market": market,
543
+ "total_fields": total_fields,
544
+ "warning": f"⚠️ Large data ({total_fields} fields). Use limit/offset to paginate or set confirm_large=True.",
545
+ "sample_fields": list(metainfo.get("fields", {}).keys())[:20] if isinstance(metainfo.get("fields"), dict) else None,
546
+ }
547
+ return {"market": market, "metainfo": metainfo}
548
+ return {"market": market, "metainfo": metainfo}
549
+ except FileNotFoundError:
550
+ return {
551
+ "error": f"Metainfo for '{market}' not found.",
552
+ "available_markets": ["stocks", "crypto", "forex", "bond", "bonds", "cfd", "futures", "options", "coin", "economics2"],
553
+ "hint": "Use one of the available markets listed above.",
554
+ }
555
+
556
+
557
+ # =============================================================================
558
+ # AI-Friendly Tools - Quick Reference and Search
559
+ # =============================================================================
560
+
561
+
562
+ @mcp.resource("docs://ai-reference")
563
+ def docs_ai_reference() -> str:
564
+ """AI quick reference guide with common patterns, markets, and filters."""
565
+ ref = get_ai_quick_reference()
566
+ return json.dumps(ref, ensure_ascii=False, indent=2)
567
+
568
+
569
+ @mcp.resource("docs://code-examples")
570
+ def docs_code_examples() -> str:
571
+ """Python code examples for common screener queries."""
572
+ examples = get_screener_code_examples()
573
+ return json.dumps(examples, ensure_ascii=False, indent=2)
574
+
575
+
576
+ @mcp.tool()
577
+ def ai_get_reference() -> dict[str, Any]:
578
+ """Get AI-friendly quick reference for this MCP.
579
+
580
+ Returns a compact guide with:
581
+ - Available markets by category
582
+ - Common columns by type
583
+ - Filter operations
584
+ - Common filter patterns
585
+ - Timeframes
586
+
587
+ This is the recommended starting point for AI agents to understand how to use this MCP.
588
+ """
589
+ return get_ai_quick_reference()
590
+
591
+
592
+ @mcp.tool()
593
+ def search_available_fields(
594
+ query: str,
595
+ market: str = "stocks",
596
+ limit: int = 20,
597
+ ) -> dict[str, Any]:
598
+ """Search for fields/columns by name or description.
599
+
600
+ Args:
601
+ query: Search term (e.g., "volume", "RSI", "market cap")
602
+ market: Market type to search in (stocks, crypto, forex, etc.)
603
+ limit: Maximum results (default 20, max 50)
604
+
605
+ Returns:
606
+ Matching fields with their display names and types
607
+ """
608
+ limit = min(limit, 50)
609
+ results = search_fields(query, market, limit)
610
+
611
+ return {
612
+ "query": query,
613
+ "market": market,
614
+ "count": len(results),
615
+ "fields": results,
616
+ "hint": "Use the 'name' field as the column name in queries." if results else "No matches found. Try a different search term.",
617
+ }
618
+
619
+
620
+ @mcp.tool()
621
+ def get_code_example(screener_name: str) -> dict[str, Any]:
622
+ """Get Python code example for a specific screener type.
623
+
624
+ Args:
625
+ screener_name: Screener name (e.g., "Stocks (legacy)", "Crypto", "Forex", "ETFs", "Bonds")
626
+
627
+ Returns:
628
+ Python code example for building that screener query
629
+ """
630
+ examples = get_screener_code_examples()
631
+
632
+ # Try exact match first
633
+ if screener_name in examples:
634
+ return {
635
+ "name": screener_name,
636
+ "code": examples[screener_name],
637
+ }
638
+
639
+ # Try case-insensitive match
640
+ for name, code in examples.items():
641
+ if name.lower() == screener_name.lower():
642
+ return {"name": name, "code": code}
643
+
644
+ # Return available options
645
+ return {
646
+ "error": f"Example '{screener_name}' not found.",
647
+ "available": list(examples.keys()),
648
+ "hint": "Use one of the available screener names listed above.",
649
+ }
650
+
651
+
652
+ @mcp.tool()
653
+ def list_fields_for_market(
654
+ market: str = "stocks",
655
+ category: str | None = None,
656
+ limit: int = 50,
657
+ offset: int = 0,
658
+ confirm_large: bool = False,
659
+ ) -> dict[str, Any]:
660
+ """List available fields for a specific market with pagination.
661
+
662
+ Args:
663
+ market: Market type (stocks, crypto, forex, coin, bond, cfd, futures, options)
664
+ category: Optional filter by field type (price, volume, fundamental, technical)
665
+ limit: Maximum results (default 50, max 200)
666
+ offset: Starting offset for pagination
667
+ confirm_large: Set True to allow more than 100 results
668
+
669
+ Returns:
670
+ Paginated list of field definitions
671
+ """
672
+ limit = min(limit, 200)
673
+
674
+ fields = get_fields_by_market(market)
675
+
676
+ if not fields:
677
+ return {
678
+ "error": f"No fields found for market '{market}'.",
679
+ "available_markets": ["stocks", "crypto", "forex", "coin", "bond", "bonds", "cfd", "futures", "options", "economics2", "ireland"],
680
+ }
681
+
682
+ # Filter by category if specified
683
+ if category and isinstance(fields, list):
684
+ category_lower = category.lower()
685
+ fields = [f for f in fields if category_lower in f.get("type", "").lower()]
686
+
687
+ return paginate_data(fields, offset, limit, confirm_large)
688
+
689
+
690
+ @mcp.tool()
691
+ def get_common_fields_summary(
692
+ category: str | None = None,
693
+ limit: int = 30,
694
+ ) -> dict[str, Any]:
695
+ """Get summary of commonly used fields across all markets.
696
+
697
+ Args:
698
+ category: Optional filter (price, volume, fundamental, technical, rating)
699
+ limit: Maximum fields to return (default 30)
700
+
701
+ Returns:
702
+ Dict with field names, display names, and types
703
+ """
704
+ ref = get_ai_quick_reference()
705
+ common_cols = ref.get("common_columns", {})
706
+
707
+ if category:
708
+ category_lower = category.lower()
709
+ if category_lower in common_cols:
710
+ return {
711
+ "category": category,
712
+ "fields": common_cols[category_lower],
713
+ }
714
+ return {
715
+ "error": f"Category '{category}' not found.",
716
+ "available_categories": list(common_cols.keys()),
717
+ }
718
+
719
+ # Return all categories with limited fields
720
+ result = {}
721
+ for cat, fields in common_cols.items():
722
+ result[cat] = fields[:limit] if len(fields) > limit else fields
723
+
724
+ return {
725
+ "categories": result,
726
+ "total_categories": len(common_cols),
727
+ "hint": "Use category parameter to filter by type.",
728
+ }
729
+
730
+
731
+ # =============================================================================
732
+ # Help and Discovery Tools - Help AI understand usage
733
+ # =============================================================================
734
+
735
+
736
+ @mcp.tool()
737
+ def get_help(topic: str | None = None) -> dict[str, Any]:
738
+ """📖 Usage guide - required reading for AI!
739
+
740
+ Args:
741
+ topic: Optional topic: 'markets', 'columns', 'filters', 'examples', 'tools'
742
+ If omitted, returns the full guide
743
+
744
+ Returns:
745
+ Usage guide and examples
746
+ """
747
+ guide = {
748
+ "overview": {
749
+ "description": "TradingView MCP is a market screener for stocks and crypto",
750
+ "key_points": [
751
+ "All results include the 'description' field (full name like 'Apple Inc.')",
752
+ "Use the limit parameter to control output size (default 25, max 500)",
753
+ "Symbols are returned in ticker format: 'EXCHANGE:SYMBOL' (e.g., 'NASDAQ:AAPL')",
754
+ ],
755
+ },
756
+ "quick_start": {
757
+ "step_1": "Call get_top_gainers('america', 10) for top 10 US stock gainers",
758
+ "step_2": "Call search_symbols('apple', 'america') to find symbols by name",
759
+ "step_3": "Call get_symbol_info('NASDAQ:AAPL') for Apple details",
760
+ },
761
+ "markets": {
762
+ "stocks": ["america (US)", "uk (UK)", "germany (DE)", "japan (JP)", "china (CN)", "hongkong (HK)", "taiwan (TW)"],
763
+ "crypto": ["crypto (pairs)", "coin (coins)",],
764
+ "others": ["forex", "futures", "bonds"],
765
+ },
766
+ "common_tools": {
767
+ "screen_market": "Custom screening - screen_market(market='america', limit=50, sort_by='volume')",
768
+ "get_top_gainers": "Top gainers - get_top_gainers(market='crypto', limit=25)",
769
+ "get_top_losers": "Top losers - get_top_losers(market='america', limit=25)",
770
+ "search_symbols": "🔍 Name search - search_symbols(query='tesla', market='america')",
771
+ "get_symbol_info": "Symbol info - get_symbol_info(symbol='NASDAQ:AAPL')",
772
+ "get_technical_analysis": "Technical analysis - get_technical_analysis(symbol='NASDAQ:AAPL')",
773
+ },
774
+ "important_columns": {
775
+ "name": "Ticker (e.g., 'NASDAQ:AAPL')",
776
+ "description": "Full name (e.g., 'Apple Inc.')",
777
+ "close": "Close/last price",
778
+ "change": "Change (%)",
779
+ "volume": "Volume",
780
+ "market_cap_basic": "Market cap",
781
+ },
782
+ "filters_example": {
783
+ "description": "Use filters to apply conditions",
784
+ "example": [
785
+ {"column": "change", "operation": "gt", "value": 5},
786
+ {"column": "volume", "operation": "gt", "value": 1000000},
787
+ ],
788
+ "operations": ["gt (>)", "gte (>=)", "lt (<)", "lte (<=)", "eq (=)", "neq (!=)", "between", "isin"],
789
+ },
790
+ }
791
+
792
+ if topic:
793
+ topic_lower = topic.lower()
794
+ if topic_lower in guide:
795
+ return {"topic": topic, "content": guide[topic_lower]}
796
+ if topic_lower == "tools":
797
+ return {"topic": "tools", "content": guide["common_tools"]}
798
+ if topic_lower == "examples":
799
+ return {"topic": "examples", "content": guide["quick_start"]}
800
+ return {
801
+ "error": f"Topic '{topic}' not found",
802
+ "available_topics": list(guide.keys()) + ["tools", "examples"],
803
+ }
804
+
805
+ return guide
806
+
807
+
808
+ @mcp.tool()
809
+ def search_symbols(
810
+ query: str,
811
+ market: str = "america",
812
+ limit: int = 25,
813
+ ) -> dict[str, Any]:
814
+ """🔍 Search symbols by name (fuzzy search)
815
+
816
+ Supports company names or tickers, e.g., "Apple", "Tesla", "Microsoft".
817
+
818
+ Args:
819
+ query: Search keyword (company name or ticker)
820
+ market: Market (america, crypto, uk, etc.)
821
+ limit: Max results (default 25, max 100)
822
+
823
+ Returns:
824
+ Matching symbols with full names
825
+
826
+ Examples:
827
+ search_symbols("apple", "america") -> Apple Inc., Applebee's, etc.
828
+ search_symbols("BTC", "crypto") -> BTC pairs
829
+ """
830
+ market = sanitize_market(market)
831
+ limit = max(1, min(limit, 100))
832
+
833
+ # Search fields: name (ticker) and description (full name)
834
+ cols = ["name", "description", "close", "change", "volume", "market_cap_basic", "type", "exchange"]
835
+
836
+ try:
837
+ # Use TradingView text search
838
+ # Search description (company name)
839
+ query_obj = (
840
+ Query()
841
+ .set_markets(market)
842
+ .select(*cols)
843
+ .where(Column("description").like(query))
844
+ .order_by("market_cap_basic", ascending=False)
845
+ .limit(limit)
846
+ )
847
+
848
+ total, df = query_obj.get_scanner_data()
849
+ results = df.to_dict("records")
850
+
851
+ # If nothing found, try searching the name (ticker) field
852
+ if not results:
853
+ query_obj2 = (
854
+ Query()
855
+ .set_markets(market)
856
+ .select(*cols)
857
+ .where(Column("name").like(query))
858
+ .order_by("market_cap_basic", ascending=False)
859
+ .limit(limit)
860
+ )
861
+ total, df = query_obj2.get_scanner_data()
862
+ results = df.to_dict("records")
863
+
864
+ return {
865
+ "query": query,
866
+ "market": market,
867
+ "total_found": total,
868
+ "returned": len(results),
869
+ "results": results,
870
+ "hint": "Use 'name' field as symbol for other tools (e.g., get_symbol_info)" if results else "No matches found. Try different keywords.",
871
+ }
872
+ except Exception as e:
873
+ # If LIKE isn't supported, use fallback
874
+ return _search_symbols_fallback(query, market, limit, cols, str(e))
875
+
876
+
877
+ def _search_symbols_fallback(
878
+ query: str, market: str, limit: int, cols: list[str], original_error: str
879
+ ) -> dict[str, Any]:
880
+ """Fallback search when LIKE is not supported."""
881
+ try:
882
+ # Fetch more data and filter locally
883
+ query_obj = (
884
+ Query()
885
+ .set_markets(market)
886
+ .select(*cols)
887
+ .order_by("market_cap_basic", ascending=False)
888
+ .limit(500) # Fetch top 500
889
+ )
890
+
891
+ total, df = query_obj.get_scanner_data()
892
+
893
+ # Local filter
894
+ query_lower = query.lower()
895
+ filtered = df[
896
+ df["name"].str.lower().str.contains(query_lower, na=False) |
897
+ df["description"].str.lower().str.contains(query_lower, na=False)
898
+ ].head(limit)
899
+
900
+ results = filtered.to_dict("records")
901
+
902
+ return {
903
+ "query": query,
904
+ "market": market,
905
+ "total_found": len(results),
906
+ "returned": len(results),
907
+ "results": results,
908
+ "note": "Results from local filtering of top 500 by market cap",
909
+ "hint": "Use 'name' field as symbol for other tools" if results else "No matches found",
910
+ }
911
+ except Exception as e:
912
+ return {
913
+ "error": f"Search failed: {str(e)}",
914
+ "original_error": original_error,
915
+ "hint": "Try using screen_market with specific filters instead",
916
+ }
917
+
918
+
919
+ @mcp.tool()
920
+ def get_symbol_info(
921
+ symbol: str,
922
+ include_technical: bool = False,
923
+ ) -> dict[str, Any]:
924
+ """Get detailed information for a symbol.
925
+
926
+ Args:
927
+ symbol: Ticker in 'EXCHANGE:SYMBOL' format (e.g., 'NASDAQ:AAPL')
928
+ or just a ticker (e.g., 'AAPL', auto-search)
929
+ include_technical: Whether to include technical indicators
930
+
931
+ Returns:
932
+ Detailed symbol info including name, price, market cap, etc.
933
+
934
+ Examples:
935
+ get_symbol_info("NASDAQ:AAPL") -> Apple Inc. details
936
+ get_symbol_info("AAPL") -> auto-search
937
+ """
938
+ cols = [
939
+ "name", "description", "close", "open", "high", "low",
940
+ "change", "change_abs", "volume", "market_cap_basic",
941
+ "price_earnings_ttm", "earnings_per_share_basic_ttm",
942
+ "dividend_yield_recent", "sector", "industry", "exchange", "type",
943
+ ]
944
+
945
+ if include_technical:
946
+ cols.extend([
947
+ "RSI", "RSI7", "MACD.macd", "MACD.signal",
948
+ "SMA20", "SMA50", "SMA200", "EMA20", "EMA50", "EMA200",
949
+ "BB.upper", "BB.lower", "ATR", "ADX",
950
+ "Recommend.All", "Recommend.MA", "Recommend.Other",
951
+ ])
952
+
953
+ try:
954
+ # Determine market
955
+ if ":" in symbol:
956
+ exchange, ticker = symbol.split(":", 1)
957
+ market = EXCHANGE_SCREENER.get(exchange.lower(), "america")
958
+ else:
959
+ ticker = symbol
960
+ market = "america"
961
+
962
+ query = (
963
+ Query()
964
+ .set_markets(market)
965
+ .select(*cols)
966
+ .where(Column("name").isin([symbol, ticker, symbol.upper(), ticker.upper()]))
967
+ .limit(5)
968
+ )
969
+
970
+ total, df = query.get_scanner_data()
971
+
972
+ if df.empty:
973
+ # Fallback to search
974
+ return search_symbols(ticker, market, 5)
975
+
976
+ results = df.to_dict("records")
977
+
978
+ if len(results) == 1:
979
+ return {"symbol": symbol, "found": True, "data": results[0]}
980
+ else:
981
+ return {"symbol": symbol, "found": True, "matches": results}
982
+
983
+ except Exception as e:
984
+ return {
985
+ "error": f"Failed to get symbol info: {str(e)}",
986
+ "hint": "Try using search_symbols to find the correct symbol format",
987
+ }
988
+
989
+
245
990
  # =============================================================================
246
991
  # MCP Tools - Basic Screening
247
992
  # =============================================================================
@@ -253,27 +998,33 @@ def screen_market(
253
998
  columns: Optional[list[str]] = None,
254
999
  sort_by: str = "volume",
255
1000
  ascending: bool = False,
256
- limit: int = 50,
1001
+ limit: int = 25,
257
1002
  filters: Optional[list[dict[str, Any]]] = None,
258
- ) -> list[dict[str, Any]]:
259
- """
260
- Execute a custom market screening query.
1003
+ ) -> dict[str, Any]:
1004
+ """Run a custom market screening query.
1005
+
1006
+ ⚠️ Note: Default returns 25 rows. Increase `limit` for more.
261
1007
 
262
1008
  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
1009
+ market: Market (america=US stocks, crypto, uk, etc.)
1010
+ columns: Columns to return (default includes name, description, close, change, volume, market_cap)
1011
+ sort_by: Sort column
1012
+ ascending: Sort order (True=asc, False=desc)
1013
+ limit: Max results (1-500, default 25)
1014
+ filters: Filter list, e.g. [{"column": "change", "operation": "gt", "value": 5}]
1015
+ Supported ops: gt, gte, lt, lte, eq, neq, between, isin
270
1016
 
271
1017
  Returns:
272
- List of matching symbols with requested data
1018
+ Dict with total_count, returned, data.
1019
+ Each row includes description (full name like "Apple Inc.").
273
1020
  """
274
1021
  market = sanitize_market(market)
275
1022
  limit = max(1, min(limit, 500))
1023
+
1024
+ # Ensure description is always included
276
1025
  cols = columns or DEFAULT_COLUMNS
1026
+ if "description" not in cols:
1027
+ cols = ["description"] + list(cols)
277
1028
 
278
1029
  query = Query().set_markets(market).select(*cols).order_by(sort_by, ascending=ascending).limit(limit)
279
1030
 
@@ -309,27 +1060,38 @@ def screen_market(
309
1060
  total_count, df = query.get_scanner_data()
310
1061
  results = df.to_dict("records")
311
1062
 
312
- return [{"total_count": total_count, "returned": len(results), "data": results}]
1063
+ return {
1064
+ "total_count": total_count,
1065
+ "returned": len(results),
1066
+ "market": market,
1067
+ "data": results,
1068
+ }
313
1069
  except Exception as e:
314
- return [{"error": str(e)}]
1070
+ return {"error": str(e), "market": market}
315
1071
 
316
1072
 
317
1073
  @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.
1074
+ def get_top_gainers(market: str = "america", limit: int = 25) -> dict[str, Any]:
1075
+ """Get top gainers for a market.
1076
+
1077
+ ⚠️ Note: Default returns 25 rows. Increase `limit` for more.
321
1078
 
322
1079
  Args:
323
- market: Market to scan (america, crypto, uk, etc.)
324
- limit: Number of results (1-100)
1080
+ market: Market (america=US stocks, crypto, uk, etc.)
1081
+ limit: Result count (1-100, default 25)
325
1082
 
326
1083
  Returns:
327
- List of top gainers with price and change data
1084
+ Top gainers with description (full name).
1085
+
1086
+ Example:
1087
+ get_top_gainers("america", 10) -> Top 10 US gainers
1088
+ get_top_gainers("crypto", 25) -> Top 25 crypto gainers
328
1089
  """
329
1090
  market = sanitize_market(market)
330
1091
  limit = max(1, min(limit, 100))
331
1092
 
332
- cols = ["name", "close", "volume", "change", "change_abs", "market_cap_basic"]
1093
+ # Ensure description is included
1094
+ cols = ["name", "description", "close", "change", "change_abs", "volume", "market_cap_basic"]
333
1095
 
334
1096
  query = (
335
1097
  Query()
@@ -342,27 +1104,39 @@ def get_top_gainers(market: str = "america", limit: int = 25) -> list[dict[str,
342
1104
 
343
1105
  try:
344
1106
  total, df = query.get_scanner_data()
345
- return df.to_dict("records")
1107
+ results = df.to_dict("records")
1108
+ return {
1109
+ "market": market,
1110
+ "type": "top_gainers",
1111
+ "total_found": total,
1112
+ "returned": len(results),
1113
+ "data": results,
1114
+ }
346
1115
  except Exception as e:
347
- return [{"error": str(e)}]
1116
+ return {"error": str(e), "market": market}
348
1117
 
349
1118
 
350
1119
  @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.
1120
+ def get_top_losers(market: str = "america", limit: int = 25) -> dict[str, Any]:
1121
+ """Get top losers for a market.
1122
+
1123
+ ⚠️ Note: Default returns 25 rows. Increase `limit` for more.
354
1124
 
355
1125
  Args:
356
- market: Market to scan (america, crypto, uk, etc.)
357
- limit: Number of results (1-100)
1126
+ market: Market (america=US stocks, crypto, uk, etc.)
1127
+ limit: Result count (1-100, default 25)
358
1128
 
359
1129
  Returns:
360
- List of top losers with price and change data
1130
+ Top losers with description (full name).
1131
+
1132
+ Example:
1133
+ get_top_losers("america", 10) -> Top 10 US losers
361
1134
  """
362
1135
  market = sanitize_market(market)
363
1136
  limit = max(1, min(limit, 100))
364
1137
 
365
- cols = ["name", "close", "volume", "change", "change_abs", "market_cap_basic"]
1138
+ # Ensure description is included
1139
+ cols = ["name", "description", "close", "change", "change_abs", "volume", "market_cap_basic"]
366
1140
 
367
1141
  query = (
368
1142
  Query()
@@ -375,47 +1149,61 @@ def get_top_losers(market: str = "america", limit: int = 25) -> list[dict[str, A
375
1149
 
376
1150
  try:
377
1151
  total, df = query.get_scanner_data()
378
- return df.to_dict("records")
1152
+ results = df.to_dict("records")
1153
+ return {
1154
+ "market": market,
1155
+ "type": "top_losers",
1156
+ "total_found": total,
1157
+ "returned": len(results),
1158
+ "data": results,
1159
+ }
379
1160
  except Exception as e:
380
- return [{"error": str(e)}]
1161
+ return {"error": str(e), "market": market}
381
1162
 
382
1163
 
383
1164
  @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.
1165
+ def get_most_active(market: str = "america", limit: int = 25) -> dict[str, Any]:
1166
+ """Get most active by volume.
1167
+
1168
+ ⚠️ Note: Default returns 25 rows.
387
1169
 
388
1170
  Args:
389
- market: Market to scan
390
- limit: Number of results (1-100)
1171
+ market: Market
1172
+ limit: Result count (1-100)
391
1173
 
392
1174
  Returns:
393
- List of most active assets by volume
1175
+ Most active list with description (full name)
394
1176
  """
395
1177
  market = sanitize_market(market)
396
1178
  limit = max(1, min(limit, 100))
397
1179
 
398
- cols = ["name", "close", "volume", "change", "relative_volume_10d_calc", "market_cap_basic"]
1180
+ cols = ["name", "description", "close", "change", "volume", "relative_volume_10d_calc", "market_cap_basic"]
399
1181
 
400
1182
  query = Query().set_markets(market).select(*cols).order_by("volume", ascending=False).limit(limit)
401
1183
 
402
1184
  try:
403
1185
  total, df = query.get_scanner_data()
404
- return df.to_dict("records")
1186
+ results = df.to_dict("records")
1187
+ return {
1188
+ "market": market,
1189
+ "type": "most_active",
1190
+ "total_found": total,
1191
+ "returned": len(results),
1192
+ "data": results,
1193
+ }
405
1194
  except Exception as e:
406
- return [{"error": str(e)}]
1195
+ return {"error": str(e), "market": market}
407
1196
 
408
1197
 
409
1198
  @mcp.tool()
410
1199
  def get_premarket_movers(
411
1200
  scan_type: str = "gainers", limit: int = 25
412
- ) -> list[dict[str, Any]]:
413
- """
414
- Get pre-market movers (US market only).
1201
+ ) -> dict[str, Any]:
1202
+ """Get pre-market movers (US only).
415
1203
 
416
1204
  Args:
417
- scan_type: Type of scan - 'gainers', 'losers', 'most_active', 'gappers'
418
- limit: Number of results (1-100)
1205
+ scan_type: 'gainers', 'losers', 'most_active', 'gappers'
1206
+ limit: Result count (1-100)
419
1207
 
420
1208
  Returns:
421
1209
  List of pre-market movers