tradingview-mcp 26.0.0__py3-none-any.whl → 26.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tradingview_mcp/constants.py +100 -331
- tradingview_mcp/data/extracted/ai_quick_reference.json +130 -131
- tradingview_mcp/data/extracted/common_fields.json +5185 -1450
- tradingview_mcp/data/extracted/fields_by_market.json +23704 -3429
- tradingview_mcp/data/extracted/stock_screener_presets.json +1871 -0
- tradingview_mcp/docs_data.py +353 -98
- tradingview_mcp/resources.py +63 -0
- tradingview_mcp/server.py +130 -2030
- tradingview_mcp/tools/__init__.py +0 -0
- tradingview_mcp/tools/reference.py +70 -0
- tradingview_mcp/tools/screener.py +87 -0
- tradingview_mcp/tools/search.py +326 -0
- tradingview_mcp/tools/technical.py +113 -0
- tradingview_mcp-26.3.0.dist-info/METADATA +148 -0
- {tradingview_mcp-26.0.0.dist-info → tradingview_mcp-26.3.0.dist-info}/RECORD +18 -11
- tradingview_mcp-26.0.0.dist-info/METADATA +0 -333
- {tradingview_mcp-26.0.0.dist-info → tradingview_mcp-26.3.0.dist-info}/WHEEL +0 -0
- {tradingview_mcp-26.0.0.dist-info → tradingview_mcp-26.3.0.dist-info}/entry_points.txt +0 -0
- {tradingview_mcp-26.0.0.dist-info → tradingview_mcp-26.3.0.dist-info}/licenses/LICENSE +0 -0
tradingview_mcp/server.py
CHANGED
|
@@ -6,85 +6,83 @@ 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
|
|
15
|
+
from typing import Any
|
|
17
16
|
|
|
18
17
|
from mcp.server.fastmcp import FastMCP
|
|
19
18
|
|
|
20
|
-
from tradingview_mcp.
|
|
21
|
-
from tradingview_mcp.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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,
|
|
61
60
|
)
|
|
62
61
|
|
|
63
62
|
# Initialize MCP Server
|
|
64
63
|
mcp = FastMCP(
|
|
65
64
|
name="TradingView Screener MCP",
|
|
66
|
-
instructions="""TradingView Market Screener MCP
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
1. Call `get_help()` for
|
|
70
|
-
2. Call `ai_get_reference()` for markets
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
- `screen_market(market, limit)`
|
|
74
|
-
- `get_top_gainers(market, limit)`
|
|
75
|
-
- `get_top_losers(market, limit)`
|
|
76
|
-
- `search_symbols(query, market)`
|
|
77
|
-
- `get_symbol_info(symbol)`
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
- `limit`
|
|
81
|
-
- Results include
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
-
|
|
86
|
-
-
|
|
87
|
-
- columns://list - Available fields
|
|
65
|
+
instructions="""TradingView Market Screener MCP
|
|
66
|
+
|
|
67
|
+
Quick start:
|
|
68
|
+
1. Call `get_help()` for usage
|
|
69
|
+
2. Call `ai_get_reference()` for markets and fields
|
|
70
|
+
|
|
71
|
+
Common tools:
|
|
72
|
+
- `screen_market(market, limit)`
|
|
73
|
+
- `get_top_gainers(market, limit)`
|
|
74
|
+
- `get_top_losers(market, limit)`
|
|
75
|
+
- `search_symbols(query, market)`
|
|
76
|
+
- `get_symbol_info(symbol)`
|
|
77
|
+
|
|
78
|
+
Notes:
|
|
79
|
+
- `limit` default 25, max 500
|
|
80
|
+
- Results include `description`
|
|
81
|
+
|
|
82
|
+
Resources:
|
|
83
|
+
- docs://ai-reference
|
|
84
|
+
- markets://list
|
|
85
|
+
- columns://list
|
|
88
86
|
""",
|
|
89
87
|
)
|
|
90
88
|
|
|
@@ -98,14 +96,34 @@ def _safe_json(value: Any) -> Any:
|
|
|
98
96
|
return str(value)
|
|
99
97
|
|
|
100
98
|
|
|
101
|
-
def _debug_hint(exc: Exception) -> str | None:
|
|
99
|
+
def _debug_hint(exc: Exception, context: dict[str, Any] | None = None) -> dict[str, Any] | str | None:
|
|
100
|
+
"""Generate helpful hints based on error type."""
|
|
102
101
|
message = str(exc).lower()
|
|
102
|
+
|
|
103
|
+
# Extract market from context if available
|
|
104
|
+
market = None
|
|
105
|
+
if context:
|
|
106
|
+
market = context.get("kwargs", {}).get("market") or context.get("market")
|
|
107
|
+
|
|
103
108
|
if "market" in message and "invalid" in message:
|
|
104
109
|
return "Check the market name using the markets://list or docs://screeners resources."
|
|
105
|
-
|
|
106
|
-
|
|
110
|
+
|
|
111
|
+
if "column" in message or "unknown" in message or "field" in message:
|
|
112
|
+
# Return structured field suggestions
|
|
113
|
+
market_type = market or "stocks"
|
|
114
|
+
if market_type in ["crypto", "coin"]:
|
|
115
|
+
market_type = market_type
|
|
116
|
+
elif market_type in ["forex"]:
|
|
117
|
+
market_type = "forex"
|
|
118
|
+
else:
|
|
119
|
+
# Default to stocks for field suggestions
|
|
120
|
+
market_type = "stocks"
|
|
121
|
+
|
|
122
|
+
return suggest_fields_for_error(str(exc), market_type)
|
|
123
|
+
|
|
107
124
|
if "http" in message or "status" in message:
|
|
108
125
|
return "TradingView API may be rate-limiting or blocked. Retry with fewer requests."
|
|
126
|
+
|
|
109
127
|
return None
|
|
110
128
|
|
|
111
129
|
|
|
@@ -117,20 +135,19 @@ def _build_error_response(tool_name: str, exc: Exception, context: dict[str, Any
|
|
|
117
135
|
"context": _safe_json(context),
|
|
118
136
|
"timestamp": datetime.utcnow().isoformat() + "Z",
|
|
119
137
|
}
|
|
120
|
-
|
|
138
|
+
|
|
139
|
+
hint = _debug_hint(exc, context)
|
|
121
140
|
if hint:
|
|
122
|
-
|
|
141
|
+
if isinstance(hint, dict):
|
|
142
|
+
payload["field_suggestions"] = hint
|
|
143
|
+
else:
|
|
144
|
+
payload["hint"] = hint
|
|
145
|
+
|
|
123
146
|
if DEBUG_MODE:
|
|
124
147
|
payload["trace"] = traceback.format_exc()
|
|
125
148
|
return payload
|
|
126
149
|
|
|
127
150
|
|
|
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
151
|
def debug_tool(fn):
|
|
135
152
|
"""Decorator to return structured debug responses on failure."""
|
|
136
153
|
|
|
@@ -156,7 +173,7 @@ def debug_tool(fn):
|
|
|
156
173
|
{"args": args, "kwargs": kwargs, "note": "error returned by tool"},
|
|
157
174
|
)
|
|
158
175
|
return result
|
|
159
|
-
except Exception as exc:
|
|
176
|
+
except Exception as exc:
|
|
160
177
|
context = {"args": args, "kwargs": kwargs}
|
|
161
178
|
return _build_error_response(fn.__name__, exc, context)
|
|
162
179
|
|
|
@@ -179,1971 +196,54 @@ mcp.tool = _debug_mcp_tool
|
|
|
179
196
|
|
|
180
197
|
|
|
181
198
|
# =============================================================================
|
|
182
|
-
#
|
|
199
|
+
# Register Tools
|
|
183
200
|
# =============================================================================
|
|
184
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)
|
|
185
208
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
"crypto": ["crypto", "coin"],
|
|
191
|
-
"instruments": ["forex", "futures", "bonds", "cfd", "options", "economics2"],
|
|
192
|
-
"americas": ["america", "argentina", "brazil", "canada", "chile", "colombia", "mexico", "peru", "venezuela"],
|
|
193
|
-
"europe": [
|
|
194
|
-
"austria",
|
|
195
|
-
"belgium",
|
|
196
|
-
"cyprus",
|
|
197
|
-
"czech",
|
|
198
|
-
"denmark",
|
|
199
|
-
"estonia",
|
|
200
|
-
"finland",
|
|
201
|
-
"france",
|
|
202
|
-
"germany",
|
|
203
|
-
"greece",
|
|
204
|
-
"hungary",
|
|
205
|
-
"iceland",
|
|
206
|
-
"italy",
|
|
207
|
-
"latvia",
|
|
208
|
-
"lithuania",
|
|
209
|
-
"luxembourg",
|
|
210
|
-
"netherlands",
|
|
211
|
-
"norway",
|
|
212
|
-
"poland",
|
|
213
|
-
"portugal",
|
|
214
|
-
"romania",
|
|
215
|
-
"russia",
|
|
216
|
-
"serbia",
|
|
217
|
-
"slovakia",
|
|
218
|
-
"spain",
|
|
219
|
-
"sweden",
|
|
220
|
-
"switzerland",
|
|
221
|
-
"turkey",
|
|
222
|
-
"uk",
|
|
223
|
-
],
|
|
224
|
-
"asia_pacific": [
|
|
225
|
-
"australia",
|
|
226
|
-
"bangladesh",
|
|
227
|
-
"china",
|
|
228
|
-
"hongkong",
|
|
229
|
-
"india",
|
|
230
|
-
"indonesia",
|
|
231
|
-
"japan",
|
|
232
|
-
"korea",
|
|
233
|
-
"malaysia",
|
|
234
|
-
"newzealand",
|
|
235
|
-
"pakistan",
|
|
236
|
-
"philippines",
|
|
237
|
-
"singapore",
|
|
238
|
-
"srilanka",
|
|
239
|
-
"taiwan",
|
|
240
|
-
"thailand",
|
|
241
|
-
"vietnam",
|
|
242
|
-
],
|
|
243
|
-
"middle_east_africa": [
|
|
244
|
-
"bahrain",
|
|
245
|
-
"egypt",
|
|
246
|
-
"israel",
|
|
247
|
-
"kenya",
|
|
248
|
-
"ksa",
|
|
249
|
-
"kuwait",
|
|
250
|
-
"morocco",
|
|
251
|
-
"nigeria",
|
|
252
|
-
"qatar",
|
|
253
|
-
"rsa",
|
|
254
|
-
"tunisia",
|
|
255
|
-
"uae",
|
|
256
|
-
],
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
result = "# Available Markets\n\n"
|
|
260
|
-
for category, markets in categories.items():
|
|
261
|
-
result += f"## {category.replace('_', ' ').title()}\n"
|
|
262
|
-
result += ", ".join(sorted(markets)) + "\n\n"
|
|
263
|
-
return result
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
@mcp.resource("columns://list")
|
|
267
|
-
def list_columns() -> str:
|
|
268
|
-
"""List all available columns/indicators for screening."""
|
|
269
|
-
result = "# Available Columns\n\n"
|
|
270
|
-
|
|
271
|
-
# Group columns by category
|
|
272
|
-
categories = {
|
|
273
|
-
"Price & Change": [
|
|
274
|
-
"close",
|
|
275
|
-
"open",
|
|
276
|
-
"high",
|
|
277
|
-
"low",
|
|
278
|
-
"volume",
|
|
279
|
-
"change",
|
|
280
|
-
"change_abs",
|
|
281
|
-
"gap",
|
|
282
|
-
],
|
|
283
|
-
"Moving Averages": [
|
|
284
|
-
"SMA5",
|
|
285
|
-
"SMA10",
|
|
286
|
-
"SMA20",
|
|
287
|
-
"SMA50",
|
|
288
|
-
"SMA100",
|
|
289
|
-
"SMA200",
|
|
290
|
-
"EMA5",
|
|
291
|
-
"EMA10",
|
|
292
|
-
"EMA20",
|
|
293
|
-
"EMA50",
|
|
294
|
-
"EMA100",
|
|
295
|
-
"EMA200",
|
|
296
|
-
],
|
|
297
|
-
"Oscillators": [
|
|
298
|
-
"RSI",
|
|
299
|
-
"RSI7",
|
|
300
|
-
"MACD.macd",
|
|
301
|
-
"MACD.signal",
|
|
302
|
-
"Stoch.K",
|
|
303
|
-
"Stoch.D",
|
|
304
|
-
"CCI20",
|
|
305
|
-
"Mom",
|
|
306
|
-
"AO",
|
|
307
|
-
"UO",
|
|
308
|
-
],
|
|
309
|
-
"Volatility": ["BB.upper", "BB.lower", "ATR", "Volatility.D", "Volatility.W", "Volatility.M"],
|
|
310
|
-
"Trend": ["ADX", "ADX+DI", "ADX-DI", "Aroon.Up", "Aroon.Down", "P.SAR"],
|
|
311
|
-
"Volume": [
|
|
312
|
-
"volume",
|
|
313
|
-
"VWAP",
|
|
314
|
-
"VWMA",
|
|
315
|
-
"relative_volume_10d_calc",
|
|
316
|
-
"average_volume_10d_calc",
|
|
317
|
-
"average_volume_30d_calc",
|
|
318
|
-
],
|
|
319
|
-
"Pre/Post Market": [
|
|
320
|
-
"premarket_change",
|
|
321
|
-
"premarket_volume",
|
|
322
|
-
"premarket_gap",
|
|
323
|
-
"postmarket_change",
|
|
324
|
-
"postmarket_volume",
|
|
325
|
-
],
|
|
326
|
-
"Performance": ["Perf.W", "Perf.1M", "Perf.3M", "Perf.6M", "Perf.Y", "Perf.YTD", "Perf.All"],
|
|
327
|
-
"Fundamentals": [
|
|
328
|
-
"market_cap_basic",
|
|
329
|
-
"price_earnings_ttm",
|
|
330
|
-
"earnings_per_share_basic_ttm",
|
|
331
|
-
"dividend_yield_recent",
|
|
332
|
-
"price_book_fq",
|
|
333
|
-
],
|
|
334
|
-
"Ratings": ["Recommend.All", "Recommend.MA", "Recommend.Other"],
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
for category, cols in categories.items():
|
|
338
|
-
result += f"## {category}\n"
|
|
339
|
-
result += ", ".join(cols) + "\n\n"
|
|
340
|
-
|
|
341
|
-
result += "\n## Full Column Mapping\n"
|
|
342
|
-
result += "Use human-readable names or API names:\n\n"
|
|
343
|
-
for human_name, api_name in sorted(COLUMNS.items())[:50]: # First 50
|
|
344
|
-
result += f"- `{human_name}` → `{api_name}`\n"
|
|
345
|
-
result += f"\n... and {len(COLUMNS) - 50} more columns available."
|
|
346
|
-
|
|
347
|
-
return result
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
@mcp.resource("exchanges://crypto")
|
|
351
|
-
def list_crypto_exchanges() -> str:
|
|
352
|
-
"""List supported cryptocurrency exchanges."""
|
|
353
|
-
crypto_exchanges = [ex for ex, screener in EXCHANGE_SCREENER.items() if screener == "crypto"]
|
|
354
|
-
return f"# Supported Crypto Exchanges\n\n" + ", ".join(sorted(crypto_exchanges))
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
@mcp.resource("scanners://presets")
|
|
358
|
-
def list_scanner_presets() -> str:
|
|
359
|
-
"""List available pre-built scanner presets."""
|
|
360
|
-
result = "# Scanner Presets\n\n"
|
|
361
|
-
|
|
362
|
-
result += "## Stock Scanners\n"
|
|
363
|
-
for name in Scanner.names():
|
|
364
|
-
result += f"- `{name}`\n"
|
|
365
|
-
|
|
366
|
-
result += "\n## Crypto Scanners\n"
|
|
367
|
-
for name in CryptoScanner.names():
|
|
368
|
-
result += f"- `{name}`\n"
|
|
369
|
-
|
|
370
|
-
return result
|
|
371
|
-
|
|
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
|
-
}
|
|
209
|
+
mcp.tool()(get_technical_analysis)
|
|
210
|
+
mcp.tool()(scan_rsi_extremes)
|
|
211
|
+
mcp.tool()(scan_bollinger_bands)
|
|
212
|
+
mcp.tool()(scan_macd_crossover)
|
|
555
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)
|
|
556
226
|
|
|
557
227
|
# =============================================================================
|
|
558
|
-
#
|
|
228
|
+
# Register Resources
|
|
559
229
|
# =============================================================================
|
|
560
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)
|
|
561
243
|
|
|
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
|
-
|
|
990
|
-
# =============================================================================
|
|
991
|
-
# MCP Tools - Basic Screening
|
|
992
|
-
# =============================================================================
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
@mcp.tool()
|
|
996
|
-
def screen_market(
|
|
997
|
-
market: str = "america",
|
|
998
|
-
columns: Optional[list[str]] = None,
|
|
999
|
-
sort_by: str = "volume",
|
|
1000
|
-
ascending: bool = False,
|
|
1001
|
-
limit: int = 25,
|
|
1002
|
-
filters: Optional[list[dict[str, Any]]] = None,
|
|
1003
|
-
) -> dict[str, Any]:
|
|
1004
|
-
"""Run a custom market screening query.
|
|
1005
|
-
|
|
1006
|
-
⚠️ Note: Default returns 25 rows. Increase `limit` for more.
|
|
1007
|
-
|
|
1008
|
-
Args:
|
|
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
|
|
1016
|
-
|
|
1017
|
-
Returns:
|
|
1018
|
-
Dict with total_count, returned, data.
|
|
1019
|
-
Each row includes description (full name like "Apple Inc.").
|
|
1020
|
-
"""
|
|
1021
|
-
market = sanitize_market(market)
|
|
1022
|
-
limit = max(1, min(limit, 500))
|
|
1023
|
-
|
|
1024
|
-
# Ensure description is always included
|
|
1025
|
-
cols = columns or DEFAULT_COLUMNS
|
|
1026
|
-
if "description" not in cols:
|
|
1027
|
-
cols = ["description"] + list(cols)
|
|
1028
|
-
|
|
1029
|
-
query = Query().set_markets(market).select(*cols).order_by(sort_by, ascending=ascending).limit(limit)
|
|
1030
|
-
|
|
1031
|
-
# Apply filters if provided
|
|
1032
|
-
if filters:
|
|
1033
|
-
filter_expressions = []
|
|
1034
|
-
for f in filters:
|
|
1035
|
-
col = Column(f.get("column", "close"))
|
|
1036
|
-
op = f.get("operation", "gt")
|
|
1037
|
-
val = f.get("value")
|
|
1038
|
-
|
|
1039
|
-
if op == "gt":
|
|
1040
|
-
filter_expressions.append(col > val)
|
|
1041
|
-
elif op == "gte":
|
|
1042
|
-
filter_expressions.append(col >= val)
|
|
1043
|
-
elif op == "lt":
|
|
1044
|
-
filter_expressions.append(col < val)
|
|
1045
|
-
elif op == "lte":
|
|
1046
|
-
filter_expressions.append(col <= val)
|
|
1047
|
-
elif op == "eq":
|
|
1048
|
-
filter_expressions.append(col == val)
|
|
1049
|
-
elif op == "neq":
|
|
1050
|
-
filter_expressions.append(col != val)
|
|
1051
|
-
elif op == "between" and isinstance(val, (list, tuple)) and len(val) == 2:
|
|
1052
|
-
filter_expressions.append(col.between(val[0], val[1]))
|
|
1053
|
-
elif op == "isin" and isinstance(val, (list, tuple)):
|
|
1054
|
-
filter_expressions.append(col.isin(val))
|
|
1055
|
-
|
|
1056
|
-
if filter_expressions:
|
|
1057
|
-
query = query.where(*filter_expressions)
|
|
1058
|
-
|
|
1059
|
-
try:
|
|
1060
|
-
total_count, df = query.get_scanner_data()
|
|
1061
|
-
results = df.to_dict("records")
|
|
1062
|
-
|
|
1063
|
-
return {
|
|
1064
|
-
"total_count": total_count,
|
|
1065
|
-
"returned": len(results),
|
|
1066
|
-
"market": market,
|
|
1067
|
-
"data": results,
|
|
1068
|
-
}
|
|
1069
|
-
except Exception as e:
|
|
1070
|
-
return {"error": str(e), "market": market}
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
@mcp.tool()
|
|
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.
|
|
1078
|
-
|
|
1079
|
-
Args:
|
|
1080
|
-
market: Market (america=US stocks, crypto, uk, etc.)
|
|
1081
|
-
limit: Result count (1-100, default 25)
|
|
1082
|
-
|
|
1083
|
-
Returns:
|
|
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
|
|
1089
|
-
"""
|
|
1090
|
-
market = sanitize_market(market)
|
|
1091
|
-
limit = max(1, min(limit, 100))
|
|
1092
|
-
|
|
1093
|
-
# Ensure description is included
|
|
1094
|
-
cols = ["name", "description", "close", "change", "change_abs", "volume", "market_cap_basic"]
|
|
1095
|
-
|
|
1096
|
-
query = (
|
|
1097
|
-
Query()
|
|
1098
|
-
.set_markets(market)
|
|
1099
|
-
.select(*cols)
|
|
1100
|
-
.where(Column("change") > 0)
|
|
1101
|
-
.order_by("change", ascending=False)
|
|
1102
|
-
.limit(limit)
|
|
1103
|
-
)
|
|
1104
|
-
|
|
1105
|
-
try:
|
|
1106
|
-
total, df = query.get_scanner_data()
|
|
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
|
-
}
|
|
1115
|
-
except Exception as e:
|
|
1116
|
-
return {"error": str(e), "market": market}
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
@mcp.tool()
|
|
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.
|
|
1124
|
-
|
|
1125
|
-
Args:
|
|
1126
|
-
market: Market (america=US stocks, crypto, uk, etc.)
|
|
1127
|
-
limit: Result count (1-100, default 25)
|
|
1128
|
-
|
|
1129
|
-
Returns:
|
|
1130
|
-
Top losers with description (full name).
|
|
1131
|
-
|
|
1132
|
-
Example:
|
|
1133
|
-
get_top_losers("america", 10) -> Top 10 US losers
|
|
1134
|
-
"""
|
|
1135
|
-
market = sanitize_market(market)
|
|
1136
|
-
limit = max(1, min(limit, 100))
|
|
1137
|
-
|
|
1138
|
-
# Ensure description is included
|
|
1139
|
-
cols = ["name", "description", "close", "change", "change_abs", "volume", "market_cap_basic"]
|
|
1140
|
-
|
|
1141
|
-
query = (
|
|
1142
|
-
Query()
|
|
1143
|
-
.set_markets(market)
|
|
1144
|
-
.select(*cols)
|
|
1145
|
-
.where(Column("change") < 0)
|
|
1146
|
-
.order_by("change", ascending=True)
|
|
1147
|
-
.limit(limit)
|
|
1148
|
-
)
|
|
1149
|
-
|
|
1150
|
-
try:
|
|
1151
|
-
total, df = query.get_scanner_data()
|
|
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
|
-
}
|
|
1160
|
-
except Exception as e:
|
|
1161
|
-
return {"error": str(e), "market": market}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
@mcp.tool()
|
|
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.
|
|
1169
|
-
|
|
1170
|
-
Args:
|
|
1171
|
-
market: Market
|
|
1172
|
-
limit: Result count (1-100)
|
|
1173
|
-
|
|
1174
|
-
Returns:
|
|
1175
|
-
Most active list with description (full name)
|
|
1176
|
-
"""
|
|
1177
|
-
market = sanitize_market(market)
|
|
1178
|
-
limit = max(1, min(limit, 100))
|
|
1179
|
-
|
|
1180
|
-
cols = ["name", "description", "close", "change", "volume", "relative_volume_10d_calc", "market_cap_basic"]
|
|
1181
|
-
|
|
1182
|
-
query = Query().set_markets(market).select(*cols).order_by("volume", ascending=False).limit(limit)
|
|
1183
|
-
|
|
1184
|
-
try:
|
|
1185
|
-
total, df = query.get_scanner_data()
|
|
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
|
-
}
|
|
1194
|
-
except Exception as e:
|
|
1195
|
-
return {"error": str(e), "market": market}
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
@mcp.tool()
|
|
1199
|
-
def get_premarket_movers(
|
|
1200
|
-
scan_type: str = "gainers", limit: int = 25
|
|
1201
|
-
) -> dict[str, Any]:
|
|
1202
|
-
"""Get pre-market movers (US only).
|
|
1203
|
-
|
|
1204
|
-
Args:
|
|
1205
|
-
scan_type: 'gainers', 'losers', 'most_active', 'gappers'
|
|
1206
|
-
limit: Result count (1-100)
|
|
1207
|
-
|
|
1208
|
-
Returns:
|
|
1209
|
-
List of pre-market movers
|
|
1210
|
-
"""
|
|
1211
|
-
limit = max(1, min(limit, 100))
|
|
1212
|
-
|
|
1213
|
-
scanner_map = {
|
|
1214
|
-
"gainers": Scanner.premarket_gainers,
|
|
1215
|
-
"losers": Scanner.premarket_losers,
|
|
1216
|
-
"most_active": Scanner.premarket_most_active,
|
|
1217
|
-
"gappers": Scanner.premarket_gappers,
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
scanner = scanner_map.get(scan_type, Scanner.premarket_gainers).copy()
|
|
1221
|
-
scanner = scanner.limit(limit)
|
|
1222
|
-
|
|
1223
|
-
try:
|
|
1224
|
-
total, df = scanner.get_scanner_data()
|
|
1225
|
-
return df.to_dict("records")
|
|
1226
|
-
except Exception as e:
|
|
1227
|
-
return [{"error": str(e)}]
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
@mcp.tool()
|
|
1231
|
-
def get_postmarket_movers(
|
|
1232
|
-
scan_type: str = "gainers", limit: int = 25
|
|
1233
|
-
) -> list[dict[str, Any]]:
|
|
1234
|
-
"""
|
|
1235
|
-
Get post-market movers (US market only).
|
|
1236
|
-
|
|
1237
|
-
Args:
|
|
1238
|
-
scan_type: Type of scan - 'gainers', 'losers', 'most_active'
|
|
1239
|
-
limit: Number of results (1-100)
|
|
1240
|
-
|
|
1241
|
-
Returns:
|
|
1242
|
-
List of post-market movers
|
|
1243
|
-
"""
|
|
1244
|
-
limit = max(1, min(limit, 100))
|
|
1245
|
-
|
|
1246
|
-
scanner_map = {
|
|
1247
|
-
"gainers": Scanner.postmarket_gainers,
|
|
1248
|
-
"losers": Scanner.postmarket_losers,
|
|
1249
|
-
"most_active": Scanner.postmarket_most_active,
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
scanner = scanner_map.get(scan_type, Scanner.postmarket_gainers).copy()
|
|
1253
|
-
scanner = scanner.limit(limit)
|
|
1254
|
-
|
|
1255
|
-
try:
|
|
1256
|
-
total, df = scanner.get_scanner_data()
|
|
1257
|
-
return df.to_dict("records")
|
|
1258
|
-
except Exception as e:
|
|
1259
|
-
return [{"error": str(e)}]
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
# =============================================================================
|
|
1263
|
-
# MCP Tools - Technical Analysis Scanners
|
|
1264
|
-
# =============================================================================
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
@mcp.tool()
|
|
1268
|
-
def scan_rsi_extremes(
|
|
1269
|
-
market: str = "america",
|
|
1270
|
-
condition: str = "oversold",
|
|
1271
|
-
threshold: float = 30,
|
|
1272
|
-
limit: int = 25,
|
|
1273
|
-
) -> list[dict[str, Any]]:
|
|
1274
|
-
"""
|
|
1275
|
-
Scan for RSI overbought/oversold conditions.
|
|
1276
|
-
|
|
1277
|
-
Args:
|
|
1278
|
-
market: Market to scan
|
|
1279
|
-
condition: 'oversold' or 'overbought'
|
|
1280
|
-
threshold: RSI threshold (default 30 for oversold, 70 for overbought)
|
|
1281
|
-
limit: Number of results (1-100)
|
|
1282
|
-
|
|
1283
|
-
Returns:
|
|
1284
|
-
List of symbols matching RSI criteria
|
|
1285
|
-
"""
|
|
1286
|
-
market = sanitize_market(market)
|
|
1287
|
-
limit = max(1, min(limit, 100))
|
|
1288
|
-
|
|
1289
|
-
cols = ["name", "close", "volume", "change", "RSI", "RSI7", "market_cap_basic"]
|
|
1290
|
-
|
|
1291
|
-
query = Query().set_markets(market).select(*cols).limit(limit)
|
|
1292
|
-
|
|
1293
|
-
if condition == "oversold":
|
|
1294
|
-
threshold = min(threshold, 50)
|
|
1295
|
-
query = query.where(Column("RSI") < threshold).order_by("RSI", ascending=True)
|
|
1296
|
-
else: # overbought
|
|
1297
|
-
threshold = max(threshold, 50)
|
|
1298
|
-
query = query.where(Column("RSI") > threshold).order_by("RSI", ascending=False)
|
|
1299
|
-
|
|
1300
|
-
try:
|
|
1301
|
-
total, df = query.get_scanner_data()
|
|
1302
|
-
results = df.to_dict("records")
|
|
1303
|
-
|
|
1304
|
-
# Add signal interpretation
|
|
1305
|
-
for r in results:
|
|
1306
|
-
rsi = r.get("RSI", 50)
|
|
1307
|
-
if rsi < 20:
|
|
1308
|
-
r["signal"] = "EXTREMELY_OVERSOLD"
|
|
1309
|
-
elif rsi < 30:
|
|
1310
|
-
r["signal"] = "OVERSOLD"
|
|
1311
|
-
elif rsi > 80:
|
|
1312
|
-
r["signal"] = "EXTREMELY_OVERBOUGHT"
|
|
1313
|
-
elif rsi > 70:
|
|
1314
|
-
r["signal"] = "OVERBOUGHT"
|
|
1315
|
-
else:
|
|
1316
|
-
r["signal"] = "NEUTRAL"
|
|
1317
|
-
|
|
1318
|
-
return results
|
|
1319
|
-
except Exception as e:
|
|
1320
|
-
return [{"error": str(e)}]
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
@mcp.tool()
|
|
1324
|
-
def scan_macd_crossover(
|
|
1325
|
-
market: str = "america",
|
|
1326
|
-
crossover_type: str = "bullish",
|
|
1327
|
-
limit: int = 25,
|
|
1328
|
-
) -> list[dict[str, Any]]:
|
|
1329
|
-
"""
|
|
1330
|
-
Scan for MACD crossover signals.
|
|
1331
|
-
|
|
1332
|
-
Args:
|
|
1333
|
-
market: Market to scan
|
|
1334
|
-
crossover_type: 'bullish' (MACD crosses above signal) or 'bearish'
|
|
1335
|
-
limit: Number of results (1-100)
|
|
1336
|
-
|
|
1337
|
-
Returns:
|
|
1338
|
-
List of symbols with MACD crossover
|
|
1339
|
-
"""
|
|
1340
|
-
market = sanitize_market(market)
|
|
1341
|
-
limit = max(1, min(limit, 100))
|
|
1342
|
-
|
|
1343
|
-
cols = ["name", "close", "volume", "change", "MACD.macd", "MACD.signal", "market_cap_basic"]
|
|
1344
|
-
|
|
1345
|
-
query = Query().set_markets(market).select(*cols).limit(limit)
|
|
1346
|
-
|
|
1347
|
-
if crossover_type == "bullish":
|
|
1348
|
-
query = query.where(Column("MACD.macd").crosses_above(Column("MACD.signal"))).order_by(
|
|
1349
|
-
"change", ascending=False
|
|
1350
|
-
)
|
|
1351
|
-
else:
|
|
1352
|
-
query = query.where(Column("MACD.macd").crosses_below(Column("MACD.signal"))).order_by(
|
|
1353
|
-
"change", ascending=True
|
|
1354
|
-
)
|
|
1355
|
-
|
|
1356
|
-
try:
|
|
1357
|
-
total, df = query.get_scanner_data()
|
|
1358
|
-
results = df.to_dict("records")
|
|
1359
|
-
|
|
1360
|
-
for r in results:
|
|
1361
|
-
macd = r.get("MACD.macd", 0)
|
|
1362
|
-
signal = r.get("MACD.signal", 0)
|
|
1363
|
-
r["macd_histogram"] = round(macd - signal, 4) if macd and signal else None
|
|
1364
|
-
r["crossover"] = crossover_type
|
|
1365
|
-
|
|
1366
|
-
return results
|
|
1367
|
-
except Exception as e:
|
|
1368
|
-
return [{"error": str(e)}]
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
@mcp.tool()
|
|
1372
|
-
def scan_bollinger_bands(
|
|
1373
|
-
market: str = "america",
|
|
1374
|
-
condition: str = "squeeze",
|
|
1375
|
-
limit: int = 25,
|
|
1376
|
-
) -> list[dict[str, Any]]:
|
|
1377
|
-
"""
|
|
1378
|
-
Scan for Bollinger Band conditions.
|
|
1379
|
-
|
|
1380
|
-
Args:
|
|
1381
|
-
market: Market to scan
|
|
1382
|
-
condition: 'squeeze' (low volatility), 'above_upper', 'below_lower'
|
|
1383
|
-
limit: Number of results (1-100)
|
|
1384
|
-
|
|
1385
|
-
Returns:
|
|
1386
|
-
List of symbols matching BB criteria
|
|
1387
|
-
"""
|
|
1388
|
-
market = sanitize_market(market)
|
|
1389
|
-
limit = max(1, min(limit, 100))
|
|
1390
|
-
|
|
1391
|
-
cols = ["name", "close", "volume", "change", "BB.upper", "BB.lower", "SMA20", "ATR", "Volatility.D"]
|
|
1392
|
-
|
|
1393
|
-
query = Query().set_markets(market).select(*cols).limit(limit)
|
|
1394
|
-
|
|
1395
|
-
if condition == "squeeze":
|
|
1396
|
-
query = query.order_by("Volatility.D", ascending=True)
|
|
1397
|
-
elif condition == "above_upper":
|
|
1398
|
-
query = query.where(Column("close") > Column("BB.upper")).order_by("change", ascending=False)
|
|
1399
|
-
elif condition == "below_lower":
|
|
1400
|
-
query = query.where(Column("close") < Column("BB.lower")).order_by("change", ascending=True)
|
|
1401
|
-
|
|
1402
|
-
try:
|
|
1403
|
-
total, df = query.get_scanner_data()
|
|
1404
|
-
results = df.to_dict("records")
|
|
1405
|
-
|
|
1406
|
-
# Calculate BBW and add signals
|
|
1407
|
-
for r in results:
|
|
1408
|
-
bb_upper = r.get("BB.upper", 0)
|
|
1409
|
-
bb_lower = r.get("BB.lower", 0)
|
|
1410
|
-
sma20 = r.get("SMA20", 0)
|
|
1411
|
-
close = r.get("close", 0)
|
|
1412
|
-
|
|
1413
|
-
if sma20 and bb_upper and bb_lower:
|
|
1414
|
-
bbw = compute_bollinger_band_width(sma20, bb_upper, bb_lower)
|
|
1415
|
-
r["bbw"] = round(bbw, 4) if bbw else None
|
|
1416
|
-
|
|
1417
|
-
rating, signal = compute_bollinger_rating(close, bb_upper, sma20, bb_lower)
|
|
1418
|
-
r["bb_rating"] = rating
|
|
1419
|
-
r["bb_signal"] = signal
|
|
1420
|
-
|
|
1421
|
-
return results
|
|
1422
|
-
except Exception as e:
|
|
1423
|
-
return [{"error": str(e)}]
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
@mcp.tool()
|
|
1427
|
-
def scan_volume_breakout(
|
|
1428
|
-
market: str = "america",
|
|
1429
|
-
volume_multiplier: float = 2.0,
|
|
1430
|
-
min_price_change: float = 3.0,
|
|
1431
|
-
limit: int = 25,
|
|
1432
|
-
) -> list[dict[str, Any]]:
|
|
1433
|
-
"""
|
|
1434
|
-
Scan for volume breakout candidates.
|
|
1435
|
-
|
|
1436
|
-
Args:
|
|
1437
|
-
market: Market to scan
|
|
1438
|
-
volume_multiplier: Minimum relative volume vs 10-day average (default 2.0)
|
|
1439
|
-
min_price_change: Minimum absolute price change % (default 3.0)
|
|
1440
|
-
limit: Number of results (1-100)
|
|
1441
|
-
|
|
1442
|
-
Returns:
|
|
1443
|
-
List of symbols with volume breakouts
|
|
1444
|
-
"""
|
|
1445
|
-
market = sanitize_market(market)
|
|
1446
|
-
limit = max(1, min(limit, 100))
|
|
1447
|
-
volume_multiplier = max(1.2, min(10.0, volume_multiplier))
|
|
1448
|
-
min_price_change = max(0.5, min(20.0, min_price_change))
|
|
1449
|
-
|
|
1450
|
-
cols = [
|
|
1451
|
-
"name",
|
|
1452
|
-
"close",
|
|
1453
|
-
"volume",
|
|
1454
|
-
"change",
|
|
1455
|
-
"change_abs",
|
|
1456
|
-
"relative_volume_10d_calc",
|
|
1457
|
-
"average_volume_10d_calc",
|
|
1458
|
-
]
|
|
1459
|
-
|
|
1460
|
-
query = (
|
|
1461
|
-
Query()
|
|
1462
|
-
.set_markets(market)
|
|
1463
|
-
.select(*cols)
|
|
1464
|
-
.where(
|
|
1465
|
-
Column("relative_volume_10d_calc") >= volume_multiplier,
|
|
1466
|
-
)
|
|
1467
|
-
.order_by("relative_volume_10d_calc", ascending=False)
|
|
1468
|
-
.limit(limit * 2) # Get more to filter
|
|
1469
|
-
)
|
|
1470
|
-
|
|
1471
|
-
try:
|
|
1472
|
-
total, df = query.get_scanner_data()
|
|
1473
|
-
results = df.to_dict("records")
|
|
1474
|
-
|
|
1475
|
-
# Filter by price change
|
|
1476
|
-
filtered = []
|
|
1477
|
-
for r in results:
|
|
1478
|
-
change = abs(r.get("change", 0) or 0)
|
|
1479
|
-
if change >= min_price_change:
|
|
1480
|
-
rel_vol = r.get("relative_volume_10d_calc", 1)
|
|
1481
|
-
r["breakout_type"] = "bullish" if r.get("change", 0) > 0 else "bearish"
|
|
1482
|
-
r["volume_strength"] = "VERY_STRONG" if rel_vol >= 3 else "STRONG" if rel_vol >= 2 else "MODERATE"
|
|
1483
|
-
filtered.append(r)
|
|
1484
|
-
|
|
1485
|
-
return filtered[:limit]
|
|
1486
|
-
except Exception as e:
|
|
1487
|
-
return [{"error": str(e)}]
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
@mcp.tool()
|
|
1491
|
-
def scan_moving_average_crossover(
|
|
1492
|
-
market: str = "america",
|
|
1493
|
-
crossover_type: str = "golden_cross",
|
|
1494
|
-
limit: int = 25,
|
|
1495
|
-
) -> list[dict[str, Any]]:
|
|
1496
|
-
"""
|
|
1497
|
-
Scan for moving average crossovers.
|
|
1498
|
-
|
|
1499
|
-
Args:
|
|
1500
|
-
market: Market to scan
|
|
1501
|
-
crossover_type: 'golden_cross' (SMA50 crosses above SMA200),
|
|
1502
|
-
'death_cross' (SMA50 crosses below SMA200),
|
|
1503
|
-
'above_all_mas', 'below_all_mas'
|
|
1504
|
-
limit: Number of results (1-100)
|
|
1505
|
-
|
|
1506
|
-
Returns:
|
|
1507
|
-
List of symbols with MA crossovers
|
|
1508
|
-
"""
|
|
1509
|
-
market = sanitize_market(market)
|
|
1510
|
-
limit = max(1, min(limit, 100))
|
|
1511
|
-
|
|
1512
|
-
scanner_map = {
|
|
1513
|
-
"golden_cross": Scanner.golden_cross,
|
|
1514
|
-
"death_cross": Scanner.death_cross,
|
|
1515
|
-
"above_all_mas": Scanner.above_all_mas,
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
scanner = scanner_map.get(crossover_type)
|
|
1519
|
-
if scanner:
|
|
1520
|
-
scanner = scanner.copy().set_markets(market).limit(limit)
|
|
1521
|
-
else:
|
|
1522
|
-
# below_all_mas
|
|
1523
|
-
scanner = (
|
|
1524
|
-
Query()
|
|
1525
|
-
.set_markets(market)
|
|
1526
|
-
.select("name", "close", "volume", "change", "SMA20", "SMA50", "SMA200")
|
|
1527
|
-
.where(
|
|
1528
|
-
Column("close") < Column("SMA20"),
|
|
1529
|
-
Column("close") < Column("SMA50"),
|
|
1530
|
-
Column("close") < Column("SMA200"),
|
|
1531
|
-
)
|
|
1532
|
-
.order_by("change", ascending=True)
|
|
1533
|
-
.limit(limit)
|
|
1534
|
-
)
|
|
1535
|
-
|
|
1536
|
-
try:
|
|
1537
|
-
total, df = scanner.get_scanner_data()
|
|
1538
|
-
results = df.to_dict("records")
|
|
1539
|
-
|
|
1540
|
-
for r in results:
|
|
1541
|
-
r["crossover_type"] = crossover_type
|
|
1542
|
-
|
|
1543
|
-
return results
|
|
1544
|
-
except Exception as e:
|
|
1545
|
-
return [{"error": str(e)}]
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
@mcp.tool()
|
|
1549
|
-
def scan_52_week_levels(
|
|
1550
|
-
market: str = "america",
|
|
1551
|
-
level_type: str = "near_high",
|
|
1552
|
-
threshold_pct: float = 5.0,
|
|
1553
|
-
limit: int = 25,
|
|
1554
|
-
) -> list[dict[str, Any]]:
|
|
1555
|
-
"""
|
|
1556
|
-
Scan for stocks near 52-week highs or lows.
|
|
1557
|
-
|
|
1558
|
-
Args:
|
|
1559
|
-
market: Market to scan
|
|
1560
|
-
level_type: 'near_high', 'near_low', 'new_high', 'new_low'
|
|
1561
|
-
threshold_pct: How close to the level (default 5%)
|
|
1562
|
-
limit: Number of results (1-100)
|
|
1563
|
-
|
|
1564
|
-
Returns:
|
|
1565
|
-
List of symbols near 52-week extremes
|
|
1566
|
-
"""
|
|
1567
|
-
market = sanitize_market(market)
|
|
1568
|
-
limit = max(1, min(limit, 100))
|
|
1569
|
-
threshold_pct = max(1.0, min(20.0, threshold_pct))
|
|
1570
|
-
|
|
1571
|
-
cols = ["name", "close", "volume", "change", "price_52_week_high", "price_52_week_low", "Perf.Y"]
|
|
1572
|
-
|
|
1573
|
-
query = Query().set_markets(market).select(*cols).limit(limit)
|
|
1574
|
-
|
|
1575
|
-
if level_type == "near_high":
|
|
1576
|
-
# Price within threshold_pct of 52-week high
|
|
1577
|
-
multiplier = 1 - (threshold_pct / 100)
|
|
1578
|
-
query = query.where(Column("close").above_pct("price_52_week_high", multiplier)).order_by(
|
|
1579
|
-
"change", ascending=False
|
|
1580
|
-
)
|
|
1581
|
-
elif level_type == "near_low":
|
|
1582
|
-
# Price within threshold_pct of 52-week low
|
|
1583
|
-
multiplier = 1 + (threshold_pct / 100)
|
|
1584
|
-
query = query.where(Column("close").below_pct("price_52_week_low", multiplier)).order_by(
|
|
1585
|
-
"change", ascending=True
|
|
1586
|
-
)
|
|
1587
|
-
elif level_type == "new_high":
|
|
1588
|
-
query = query.where(Column("close") >= Column("price_52_week_high")).order_by(
|
|
1589
|
-
"change", ascending=False
|
|
1590
|
-
)
|
|
1591
|
-
else: # new_low
|
|
1592
|
-
query = query.where(Column("close") <= Column("price_52_week_low")).order_by(
|
|
1593
|
-
"change", ascending=True
|
|
1594
|
-
)
|
|
1595
|
-
|
|
1596
|
-
try:
|
|
1597
|
-
total, df = query.get_scanner_data()
|
|
1598
|
-
results = df.to_dict("records")
|
|
1599
|
-
|
|
1600
|
-
for r in results:
|
|
1601
|
-
close = r.get("close", 0)
|
|
1602
|
-
high52 = r.get("price_52_week_high", 0)
|
|
1603
|
-
low52 = r.get("price_52_week_low", 0)
|
|
1604
|
-
|
|
1605
|
-
if high52:
|
|
1606
|
-
r["pct_from_52w_high"] = round(((close - high52) / high52) * 100, 2)
|
|
1607
|
-
if low52:
|
|
1608
|
-
r["pct_from_52w_low"] = round(((close - low52) / low52) * 100, 2)
|
|
1609
|
-
|
|
1610
|
-
return results
|
|
1611
|
-
except Exception as e:
|
|
1612
|
-
return [{"error": str(e)}]
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
# =============================================================================
|
|
1616
|
-
# MCP Tools - Symbol Analysis
|
|
1617
|
-
# =============================================================================
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
@mcp.tool()
|
|
1621
|
-
def get_symbol_analysis(
|
|
1622
|
-
symbol: str,
|
|
1623
|
-
include_fundamentals: bool = True,
|
|
1624
|
-
) -> dict[str, Any]:
|
|
1625
|
-
"""
|
|
1626
|
-
Get comprehensive technical analysis for a specific symbol.
|
|
1627
|
-
|
|
1628
|
-
Args:
|
|
1629
|
-
symbol: Symbol in format 'EXCHANGE:SYMBOL' (e.g., 'NASDAQ:AAPL', 'BINANCE:BTCUSDT')
|
|
1630
|
-
include_fundamentals: Include fundamental data if available
|
|
1631
|
-
|
|
1632
|
-
Returns:
|
|
1633
|
-
Comprehensive analysis with indicators, signals, and recommendations
|
|
1634
|
-
"""
|
|
1635
|
-
# Select columns based on what we want
|
|
1636
|
-
cols = [
|
|
1637
|
-
"name",
|
|
1638
|
-
"description",
|
|
1639
|
-
"type",
|
|
1640
|
-
"exchange",
|
|
1641
|
-
"close",
|
|
1642
|
-
"open",
|
|
1643
|
-
"high",
|
|
1644
|
-
"low",
|
|
1645
|
-
"volume",
|
|
1646
|
-
"change",
|
|
1647
|
-
"change_abs",
|
|
1648
|
-
# Technical indicators
|
|
1649
|
-
"RSI",
|
|
1650
|
-
"RSI7",
|
|
1651
|
-
"MACD.macd",
|
|
1652
|
-
"MACD.signal",
|
|
1653
|
-
"BB.upper",
|
|
1654
|
-
"BB.lower",
|
|
1655
|
-
"SMA20",
|
|
1656
|
-
"SMA50",
|
|
1657
|
-
"SMA200",
|
|
1658
|
-
"EMA20",
|
|
1659
|
-
"EMA50",
|
|
1660
|
-
"EMA200",
|
|
1661
|
-
"ADX",
|
|
1662
|
-
"ATR",
|
|
1663
|
-
"Stoch.K",
|
|
1664
|
-
"Stoch.D",
|
|
1665
|
-
"VWAP",
|
|
1666
|
-
"Volatility.D",
|
|
1667
|
-
# Performance
|
|
1668
|
-
"Perf.W",
|
|
1669
|
-
"Perf.1M",
|
|
1670
|
-
"Perf.3M",
|
|
1671
|
-
"Perf.Y",
|
|
1672
|
-
# Recommendations
|
|
1673
|
-
"Recommend.All",
|
|
1674
|
-
"Recommend.MA",
|
|
1675
|
-
"Recommend.Other",
|
|
1676
|
-
]
|
|
1677
|
-
|
|
1678
|
-
if include_fundamentals:
|
|
1679
|
-
cols.extend(
|
|
1680
|
-
[
|
|
1681
|
-
"market_cap_basic",
|
|
1682
|
-
"price_earnings_ttm",
|
|
1683
|
-
"earnings_per_share_basic_ttm",
|
|
1684
|
-
"dividend_yield_recent",
|
|
1685
|
-
"price_52_week_high",
|
|
1686
|
-
"price_52_week_low",
|
|
1687
|
-
]
|
|
1688
|
-
)
|
|
1689
|
-
|
|
1690
|
-
try:
|
|
1691
|
-
query = Query().set_tickers(symbol).select(*cols)
|
|
1692
|
-
total, df = query.get_scanner_data()
|
|
1693
|
-
|
|
1694
|
-
if df.empty:
|
|
1695
|
-
return {"error": f"No data found for {symbol}"}
|
|
1696
|
-
|
|
1697
|
-
row = df.iloc[0].to_dict()
|
|
1698
|
-
|
|
1699
|
-
# Perform technical analysis
|
|
1700
|
-
analysis = analyze_indicators(row)
|
|
1701
|
-
|
|
1702
|
-
# Build result
|
|
1703
|
-
result = {
|
|
1704
|
-
"symbol": symbol,
|
|
1705
|
-
"name": row.get("name"),
|
|
1706
|
-
"exchange": row.get("exchange"),
|
|
1707
|
-
"type": row.get("type"),
|
|
1708
|
-
"price_data": {
|
|
1709
|
-
"close": row.get("close"),
|
|
1710
|
-
"open": row.get("open"),
|
|
1711
|
-
"high": row.get("high"),
|
|
1712
|
-
"low": row.get("low"),
|
|
1713
|
-
"volume": row.get("volume"),
|
|
1714
|
-
"change": row.get("change"),
|
|
1715
|
-
"change_abs": row.get("change_abs"),
|
|
1716
|
-
},
|
|
1717
|
-
"technical_indicators": {
|
|
1718
|
-
"rsi": row.get("RSI"),
|
|
1719
|
-
"rsi7": row.get("RSI7"),
|
|
1720
|
-
"macd": row.get("MACD.macd"),
|
|
1721
|
-
"macd_signal": row.get("MACD.signal"),
|
|
1722
|
-
"bb_upper": row.get("BB.upper"),
|
|
1723
|
-
"bb_lower": row.get("BB.lower"),
|
|
1724
|
-
"sma20": row.get("SMA20"),
|
|
1725
|
-
"sma50": row.get("SMA50"),
|
|
1726
|
-
"sma200": row.get("SMA200"),
|
|
1727
|
-
"ema20": row.get("EMA20"),
|
|
1728
|
-
"ema50": row.get("EMA50"),
|
|
1729
|
-
"ema200": row.get("EMA200"),
|
|
1730
|
-
"adx": row.get("ADX"),
|
|
1731
|
-
"atr": row.get("ATR"),
|
|
1732
|
-
"stoch_k": row.get("Stoch.K"),
|
|
1733
|
-
"stoch_d": row.get("Stoch.D"),
|
|
1734
|
-
"vwap": row.get("VWAP"),
|
|
1735
|
-
"volatility": row.get("Volatility.D"),
|
|
1736
|
-
},
|
|
1737
|
-
"performance": {
|
|
1738
|
-
"weekly": row.get("Perf.W"),
|
|
1739
|
-
"monthly": row.get("Perf.1M"),
|
|
1740
|
-
"quarterly": row.get("Perf.3M"),
|
|
1741
|
-
"yearly": row.get("Perf.Y"),
|
|
1742
|
-
},
|
|
1743
|
-
"ratings": {
|
|
1744
|
-
"overall": row.get("Recommend.All"),
|
|
1745
|
-
"overall_label": format_technical_rating(row.get("Recommend.All", 0)),
|
|
1746
|
-
"moving_averages": row.get("Recommend.MA"),
|
|
1747
|
-
"oscillators": row.get("Recommend.Other"),
|
|
1748
|
-
},
|
|
1749
|
-
"analysis": analysis,
|
|
1750
|
-
}
|
|
1751
|
-
|
|
1752
|
-
if include_fundamentals:
|
|
1753
|
-
result["fundamentals"] = {
|
|
1754
|
-
"market_cap": row.get("market_cap_basic"),
|
|
1755
|
-
"pe_ratio": row.get("price_earnings_ttm"),
|
|
1756
|
-
"eps": row.get("earnings_per_share_basic_ttm"),
|
|
1757
|
-
"dividend_yield": row.get("dividend_yield_recent"),
|
|
1758
|
-
"52_week_high": row.get("price_52_week_high"),
|
|
1759
|
-
"52_week_low": row.get("price_52_week_low"),
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
return result
|
|
1763
|
-
|
|
1764
|
-
except Exception as e:
|
|
1765
|
-
return {"error": str(e), "symbol": symbol}
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
@mcp.tool()
|
|
1769
|
-
def compare_symbols(
|
|
1770
|
-
symbols: list[str],
|
|
1771
|
-
columns: Optional[list[str]] = None,
|
|
1772
|
-
) -> list[dict[str, Any]]:
|
|
1773
|
-
"""
|
|
1774
|
-
Compare multiple symbols side by side.
|
|
1775
|
-
|
|
1776
|
-
Args:
|
|
1777
|
-
symbols: List of symbols (e.g., ['NASDAQ:AAPL', 'NASDAQ:MSFT', 'NASDAQ:GOOGL'])
|
|
1778
|
-
columns: Columns to compare (default: standard comparison set)
|
|
1779
|
-
|
|
1780
|
-
Returns:
|
|
1781
|
-
Comparison data for all symbols
|
|
1782
|
-
"""
|
|
1783
|
-
if not symbols:
|
|
1784
|
-
return [{"error": "No symbols provided"}]
|
|
1785
|
-
|
|
1786
|
-
cols = columns or [
|
|
1787
|
-
"name",
|
|
1788
|
-
"close",
|
|
1789
|
-
"change",
|
|
1790
|
-
"volume",
|
|
1791
|
-
"RSI",
|
|
1792
|
-
"MACD.macd",
|
|
1793
|
-
"market_cap_basic",
|
|
1794
|
-
"Perf.1M",
|
|
1795
|
-
"Perf.Y",
|
|
1796
|
-
"Recommend.All",
|
|
1797
|
-
]
|
|
1798
|
-
|
|
1799
|
-
try:
|
|
1800
|
-
query = Query().set_tickers(*symbols).select(*cols)
|
|
1801
|
-
total, df = query.get_scanner_data()
|
|
1802
|
-
|
|
1803
|
-
results = df.to_dict("records")
|
|
1804
|
-
|
|
1805
|
-
# Add technical rating labels
|
|
1806
|
-
for r in results:
|
|
1807
|
-
rec = r.get("Recommend.All")
|
|
1808
|
-
if rec is not None:
|
|
1809
|
-
r["rating_label"] = format_technical_rating(rec)
|
|
1810
|
-
|
|
1811
|
-
return results
|
|
1812
|
-
|
|
1813
|
-
except Exception as e:
|
|
1814
|
-
return [{"error": str(e)}]
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
# =============================================================================
|
|
1818
|
-
# MCP Tools - Utility Functions
|
|
1819
|
-
# =============================================================================
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
@mcp.tool()
|
|
1823
|
-
def get_all_market_symbols(market: str = "america") -> dict[str, Any]:
|
|
1824
|
-
"""
|
|
1825
|
-
Get all available symbols for a market.
|
|
1826
|
-
|
|
1827
|
-
Args:
|
|
1828
|
-
market: Market identifier (america, crypto, uk, etc.)
|
|
1829
|
-
|
|
1830
|
-
Returns:
|
|
1831
|
-
Dictionary with symbol count and list of symbols
|
|
1832
|
-
"""
|
|
1833
|
-
market = sanitize_market(market)
|
|
1834
|
-
|
|
1835
|
-
try:
|
|
1836
|
-
symbols = get_all_symbols(market)
|
|
1837
|
-
return {
|
|
1838
|
-
"market": market,
|
|
1839
|
-
"total_symbols": len(symbols),
|
|
1840
|
-
"symbols": symbols[:500], # Return first 500 to avoid huge responses
|
|
1841
|
-
"note": f"Showing first 500 of {len(symbols)} symbols" if len(symbols) > 500 else None,
|
|
1842
|
-
}
|
|
1843
|
-
except Exception as e:
|
|
1844
|
-
return {"error": str(e), "market": market}
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
@mcp.tool()
|
|
1848
|
-
def run_scanner_preset(
|
|
1849
|
-
preset_name: str,
|
|
1850
|
-
market: Optional[str] = None,
|
|
1851
|
-
limit: int = 50,
|
|
1852
|
-
) -> list[dict[str, Any]]:
|
|
1853
|
-
"""
|
|
1854
|
-
Run a pre-built scanner preset.
|
|
1855
|
-
|
|
1856
|
-
Args:
|
|
1857
|
-
preset_name: Name of the preset (see scanners://presets resource)
|
|
1858
|
-
market: Override market (optional)
|
|
1859
|
-
limit: Number of results (1-100)
|
|
1860
|
-
|
|
1861
|
-
Returns:
|
|
1862
|
-
Scanner results
|
|
1863
|
-
"""
|
|
1864
|
-
limit = max(1, min(limit, 100))
|
|
1865
|
-
|
|
1866
|
-
# Try stock scanners first
|
|
1867
|
-
scanner = getattr(Scanner, preset_name, None)
|
|
1868
|
-
|
|
1869
|
-
# Try crypto scanners
|
|
1870
|
-
if scanner is None:
|
|
1871
|
-
scanner = getattr(CryptoScanner, preset_name, None)
|
|
1872
|
-
|
|
1873
|
-
if scanner is None:
|
|
1874
|
-
available = Scanner.names() + CryptoScanner.names()
|
|
1875
|
-
return [{"error": f"Unknown preset: {preset_name}", "available_presets": available}]
|
|
1876
|
-
|
|
1877
|
-
scanner = scanner.copy().limit(limit)
|
|
1878
|
-
|
|
1879
|
-
if market:
|
|
1880
|
-
market = sanitize_market(market)
|
|
1881
|
-
scanner = scanner.set_markets(market)
|
|
1882
|
-
|
|
1883
|
-
try:
|
|
1884
|
-
total, df = scanner.get_scanner_data()
|
|
1885
|
-
return df.to_dict("records")
|
|
1886
|
-
except Exception as e:
|
|
1887
|
-
return [{"error": str(e)}]
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
@mcp.tool()
|
|
1891
|
-
def advanced_query(
|
|
1892
|
-
market: str = "america",
|
|
1893
|
-
select_columns: list[str] = None,
|
|
1894
|
-
conditions: list[dict[str, Any]] = None,
|
|
1895
|
-
logic: str = "and",
|
|
1896
|
-
sort_by: str = "volume",
|
|
1897
|
-
ascending: bool = False,
|
|
1898
|
-
limit: int = 50,
|
|
1899
|
-
) -> list[dict[str, Any]]:
|
|
1900
|
-
"""
|
|
1901
|
-
Execute an advanced query with complex AND/OR logic.
|
|
1902
|
-
|
|
1903
|
-
Args:
|
|
1904
|
-
market: Market to scan
|
|
1905
|
-
select_columns: Columns to return
|
|
1906
|
-
conditions: List of conditions, each with:
|
|
1907
|
-
- column: Column name
|
|
1908
|
-
- op: Operation (gt, gte, lt, lte, eq, neq, between, isin, crosses_above, crosses_below)
|
|
1909
|
-
- value: Value or [min, max] for between
|
|
1910
|
-
logic: 'and' or 'or' to combine conditions
|
|
1911
|
-
sort_by: Column to sort by
|
|
1912
|
-
ascending: Sort order
|
|
1913
|
-
limit: Maximum results (1-500)
|
|
1914
|
-
|
|
1915
|
-
Returns:
|
|
1916
|
-
Query results
|
|
1917
|
-
|
|
1918
|
-
Example:
|
|
1919
|
-
conditions=[
|
|
1920
|
-
{"column": "RSI", "op": "lt", "value": 30},
|
|
1921
|
-
{"column": "volume", "op": "gt", "value": 1000000},
|
|
1922
|
-
{"column": "change", "op": "between", "value": [-5, 5]}
|
|
1923
|
-
]
|
|
1924
|
-
"""
|
|
1925
|
-
market = sanitize_market(market)
|
|
1926
|
-
limit = max(1, min(limit, 500))
|
|
1927
|
-
cols = select_columns or TECHNICAL_COLUMNS
|
|
1928
|
-
|
|
1929
|
-
query = Query().set_markets(market).select(*cols).order_by(sort_by, ascending=ascending).limit(limit)
|
|
1930
|
-
|
|
1931
|
-
if conditions:
|
|
1932
|
-
expressions = []
|
|
1933
|
-
for cond in conditions:
|
|
1934
|
-
col = Column(cond.get("column", "close"))
|
|
1935
|
-
op = cond.get("op", "gt")
|
|
1936
|
-
val = cond.get("value")
|
|
1937
|
-
|
|
1938
|
-
if op == "gt":
|
|
1939
|
-
expressions.append(col > val)
|
|
1940
|
-
elif op == "gte":
|
|
1941
|
-
expressions.append(col >= val)
|
|
1942
|
-
elif op == "lt":
|
|
1943
|
-
expressions.append(col < val)
|
|
1944
|
-
elif op == "lte":
|
|
1945
|
-
expressions.append(col <= val)
|
|
1946
|
-
elif op == "eq":
|
|
1947
|
-
expressions.append(col == val)
|
|
1948
|
-
elif op == "neq":
|
|
1949
|
-
expressions.append(col != val)
|
|
1950
|
-
elif op == "between" and isinstance(val, (list, tuple)) and len(val) == 2:
|
|
1951
|
-
expressions.append(col.between(val[0], val[1]))
|
|
1952
|
-
elif op == "isin" and isinstance(val, (list, tuple)):
|
|
1953
|
-
expressions.append(col.isin(val))
|
|
1954
|
-
elif op == "crosses_above":
|
|
1955
|
-
expressions.append(col.crosses_above(Column(val) if isinstance(val, str) else val))
|
|
1956
|
-
elif op == "crosses_below":
|
|
1957
|
-
expressions.append(col.crosses_below(Column(val) if isinstance(val, str) else val))
|
|
1958
|
-
elif op == "above_pct" and isinstance(val, (list, tuple)) and len(val) == 2:
|
|
1959
|
-
expressions.append(col.above_pct(val[0], val[1]))
|
|
1960
|
-
elif op == "below_pct" and isinstance(val, (list, tuple)) and len(val) == 2:
|
|
1961
|
-
expressions.append(col.below_pct(val[0], val[1]))
|
|
1962
|
-
|
|
1963
|
-
if expressions:
|
|
1964
|
-
if logic == "or":
|
|
1965
|
-
query = query.where2(Or(*expressions))
|
|
1966
|
-
else:
|
|
1967
|
-
query = query.where(*expressions)
|
|
1968
|
-
|
|
1969
|
-
try:
|
|
1970
|
-
total, df = query.get_scanner_data()
|
|
1971
|
-
results = df.to_dict("records")
|
|
1972
|
-
return [{"total_count": total, "returned": len(results), "data": results}]
|
|
1973
|
-
except Exception as e:
|
|
1974
|
-
return [{"error": str(e)}]
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
# =============================================================================
|
|
1978
|
-
# MCP Tools - Crypto Specific
|
|
1979
|
-
# =============================================================================
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
@mcp.tool()
|
|
1983
|
-
def get_crypto_gainers(exchange: Optional[str] = None, limit: int = 25) -> list[dict[str, Any]]:
|
|
1984
|
-
"""
|
|
1985
|
-
Get top gaining cryptocurrencies.
|
|
1986
|
-
|
|
1987
|
-
Args:
|
|
1988
|
-
exchange: Specific exchange (binance, coinbase, etc.) or None for all
|
|
1989
|
-
limit: Number of results (1-100)
|
|
1990
|
-
|
|
1991
|
-
Returns:
|
|
1992
|
-
List of top crypto gainers
|
|
1993
|
-
"""
|
|
1994
|
-
limit = max(1, min(limit, 100))
|
|
1995
|
-
|
|
1996
|
-
cols = ["name", "close", "volume", "change", "change_abs", "market_cap_basic", "24h_vol|5"]
|
|
1997
|
-
|
|
1998
|
-
query = (
|
|
1999
|
-
Query()
|
|
2000
|
-
.set_markets("crypto")
|
|
2001
|
-
.select(*cols)
|
|
2002
|
-
.where(Column("change") > 0)
|
|
2003
|
-
.order_by("change", ascending=False)
|
|
2004
|
-
.limit(limit)
|
|
2005
|
-
)
|
|
2006
|
-
|
|
2007
|
-
if exchange:
|
|
2008
|
-
query = query.where(Column("exchange") == exchange.upper())
|
|
2009
|
-
|
|
2010
|
-
try:
|
|
2011
|
-
total, df = query.get_scanner_data()
|
|
2012
|
-
return df.to_dict("records")
|
|
2013
|
-
except Exception as e:
|
|
2014
|
-
return [{"error": str(e)}]
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
@mcp.tool()
|
|
2018
|
-
def get_crypto_losers(exchange: Optional[str] = None, limit: int = 25) -> list[dict[str, Any]]:
|
|
2019
|
-
"""
|
|
2020
|
-
Get top losing cryptocurrencies.
|
|
2021
|
-
|
|
2022
|
-
Args:
|
|
2023
|
-
exchange: Specific exchange (binance, coinbase, etc.) or None for all
|
|
2024
|
-
limit: Number of results (1-100)
|
|
2025
|
-
|
|
2026
|
-
Returns:
|
|
2027
|
-
List of top crypto losers
|
|
2028
|
-
"""
|
|
2029
|
-
limit = max(1, min(limit, 100))
|
|
2030
|
-
|
|
2031
|
-
cols = ["name", "close", "volume", "change", "change_abs", "market_cap_basic", "24h_vol|5"]
|
|
2032
|
-
|
|
2033
|
-
query = (
|
|
2034
|
-
Query()
|
|
2035
|
-
.set_markets("crypto")
|
|
2036
|
-
.select(*cols)
|
|
2037
|
-
.where(Column("change") < 0)
|
|
2038
|
-
.order_by("change", ascending=True)
|
|
2039
|
-
.limit(limit)
|
|
2040
|
-
)
|
|
2041
|
-
|
|
2042
|
-
if exchange:
|
|
2043
|
-
query = query.where(Column("exchange") == exchange.upper())
|
|
2044
|
-
|
|
2045
|
-
try:
|
|
2046
|
-
total, df = query.get_scanner_data()
|
|
2047
|
-
return df.to_dict("records")
|
|
2048
|
-
except Exception as e:
|
|
2049
|
-
return [{"error": str(e)}]
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
@mcp.tool()
|
|
2053
|
-
def scan_crypto_technicals(
|
|
2054
|
-
scan_type: str = "oversold",
|
|
2055
|
-
exchange: Optional[str] = None,
|
|
2056
|
-
limit: int = 25,
|
|
2057
|
-
) -> list[dict[str, Any]]:
|
|
2058
|
-
"""
|
|
2059
|
-
Scan cryptocurrencies based on technical indicators.
|
|
2060
|
-
|
|
2061
|
-
Args:
|
|
2062
|
-
scan_type: Type of scan - 'oversold', 'overbought', 'macd_bullish', 'macd_bearish', 'high_volume'
|
|
2063
|
-
exchange: Specific exchange or None for all
|
|
2064
|
-
limit: Number of results (1-100)
|
|
2065
|
-
|
|
2066
|
-
Returns:
|
|
2067
|
-
Crypto technical scan results
|
|
2068
|
-
"""
|
|
2069
|
-
limit = max(1, min(limit, 100))
|
|
2070
|
-
|
|
2071
|
-
scanner_map = {
|
|
2072
|
-
"oversold": CryptoScanner.oversold,
|
|
2073
|
-
"overbought": CryptoScanner.overbought,
|
|
2074
|
-
"high_volume": CryptoScanner.high_volume,
|
|
2075
|
-
"most_volatile": CryptoScanner.most_volatile,
|
|
2076
|
-
}
|
|
2077
|
-
|
|
2078
|
-
if scan_type in scanner_map:
|
|
2079
|
-
scanner = scanner_map[scan_type].copy().limit(limit)
|
|
2080
|
-
elif scan_type == "macd_bullish":
|
|
2081
|
-
scanner = (
|
|
2082
|
-
Query()
|
|
2083
|
-
.set_markets("crypto")
|
|
2084
|
-
.select("name", "close", "volume", "change", "MACD.macd", "MACD.signal", "RSI")
|
|
2085
|
-
.where(Column("MACD.macd").crosses_above(Column("MACD.signal")))
|
|
2086
|
-
.order_by("change", ascending=False)
|
|
2087
|
-
.limit(limit)
|
|
2088
|
-
)
|
|
2089
|
-
elif scan_type == "macd_bearish":
|
|
2090
|
-
scanner = (
|
|
2091
|
-
Query()
|
|
2092
|
-
.set_markets("crypto")
|
|
2093
|
-
.select("name", "close", "volume", "change", "MACD.macd", "MACD.signal", "RSI")
|
|
2094
|
-
.where(Column("MACD.macd").crosses_below(Column("MACD.signal")))
|
|
2095
|
-
.order_by("change", ascending=True)
|
|
2096
|
-
.limit(limit)
|
|
2097
|
-
)
|
|
2098
|
-
else:
|
|
2099
|
-
return [{"error": f"Unknown scan_type: {scan_type}"}]
|
|
2100
|
-
|
|
2101
|
-
if exchange:
|
|
2102
|
-
scanner = scanner.where(Column("exchange") == exchange.upper())
|
|
2103
|
-
|
|
2104
|
-
try:
|
|
2105
|
-
total, df = scanner.get_scanner_data()
|
|
2106
|
-
return df.to_dict("records")
|
|
2107
|
-
except Exception as e:
|
|
2108
|
-
return [{"error": str(e)}]
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
# =============================================================================
|
|
2112
|
-
# Server Entry Point
|
|
2113
|
-
# =============================================================================
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
def main() -> None:
|
|
2117
|
-
"""Main entry point for the MCP server."""
|
|
2118
|
-
parser = argparse.ArgumentParser(description="TradingView Screener MCP Server")
|
|
2119
|
-
parser.add_argument(
|
|
2120
|
-
"transport",
|
|
2121
|
-
choices=["stdio", "streamable-http"],
|
|
2122
|
-
default="stdio",
|
|
2123
|
-
nargs="?",
|
|
2124
|
-
help="Transport protocol (default: stdio)",
|
|
2125
|
-
)
|
|
2126
|
-
parser.add_argument("--host", default=os.environ.get("HOST", "127.0.0.1"), help="HTTP host")
|
|
2127
|
-
parser.add_argument(
|
|
2128
|
-
"--port", type=int, default=int(os.environ.get("PORT", "8000")), help="HTTP port"
|
|
2129
|
-
)
|
|
2130
|
-
args = parser.parse_args()
|
|
2131
|
-
|
|
2132
|
-
if os.environ.get("DEBUG_MCP"):
|
|
2133
|
-
import sys
|
|
2134
|
-
|
|
2135
|
-
print(f"[DEBUG] TradingView MCP starting: transport={args.transport}", file=sys.stderr, flush=True)
|
|
2136
|
-
|
|
2137
|
-
if args.transport == "stdio":
|
|
2138
|
-
mcp.run()
|
|
2139
|
-
else:
|
|
2140
|
-
try:
|
|
2141
|
-
mcp.settings.host = args.host
|
|
2142
|
-
mcp.settings.port = args.port
|
|
2143
|
-
except AttributeError:
|
|
2144
|
-
pass
|
|
2145
|
-
mcp.run(transport="streamable-http")
|
|
2146
244
|
|
|
245
|
+
def main():
|
|
246
|
+
mcp.run()
|
|
2147
247
|
|
|
2148
248
|
if __name__ == "__main__":
|
|
2149
249
|
main()
|