tradingview-mcp 26.2.0__py3-none-any.whl → 26.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tradingview_mcp/constants.py +100 -331
- tradingview_mcp/docs_data.py +41 -0
- tradingview_mcp/resources.py +63 -0
- tradingview_mcp/server.py +79 -1445
- 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 +350 -0
- tradingview_mcp/tools/technical.py +136 -0
- tradingview_mcp/utils.py +62 -3
- {tradingview_mcp-26.2.0.dist-info → tradingview_mcp-26.3.1.dist-info}/METADATA +10 -3
- {tradingview_mcp-26.2.0.dist-info → tradingview_mcp-26.3.1.dist-info}/RECORD +15 -9
- {tradingview_mcp-26.2.0.dist-info → tradingview_mcp-26.3.1.dist-info}/WHEEL +0 -0
- {tradingview_mcp-26.2.0.dist-info → tradingview_mcp-26.3.1.dist-info}/entry_points.txt +0 -0
- {tradingview_mcp-26.2.0.dist-info → tradingview_mcp-26.3.1.dist-info}/licenses/LICENSE +0 -0
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from tradingview_mcp.docs_data import (
|
|
5
|
+
get_ai_quick_reference,
|
|
6
|
+
search_fields,
|
|
7
|
+
get_screener_code_examples,
|
|
8
|
+
get_valid_fields_for_market,
|
|
9
|
+
get_common_fields,
|
|
10
|
+
lookup_field,
|
|
11
|
+
get_field_summary,
|
|
12
|
+
get_filter_format,
|
|
13
|
+
validate_market,
|
|
14
|
+
get_quick_reference,
|
|
15
|
+
get_screener_presets,
|
|
16
|
+
get_metainfo
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def ai_get_reference() -> dict[str, Any]:
|
|
20
|
+
"""Get AI quick reference for using this MCP."""
|
|
21
|
+
return get_ai_quick_reference()
|
|
22
|
+
|
|
23
|
+
def search_available_fields(query: str, market: str = None) -> list[dict[str, Any]]:
|
|
24
|
+
"""Search for available fields/columns."""
|
|
25
|
+
return search_fields(query, market)
|
|
26
|
+
|
|
27
|
+
def get_code_example(name: str) -> str:
|
|
28
|
+
"""Get code example by name."""
|
|
29
|
+
examples = get_screener_code_examples()
|
|
30
|
+
return examples.get(name, "Example not found.")
|
|
31
|
+
|
|
32
|
+
def list_fields_for_market(market: str) -> list[str]:
|
|
33
|
+
"""List valid fields for a specific market."""
|
|
34
|
+
return get_valid_fields_for_market(market)
|
|
35
|
+
|
|
36
|
+
def get_common_fields_summary() -> dict[str, Any]:
|
|
37
|
+
"""Get summary of common fields."""
|
|
38
|
+
return get_common_fields()
|
|
39
|
+
|
|
40
|
+
def lookup_single_field(name: str) -> dict[str, Any]:
|
|
41
|
+
"""Lookup details for a single field."""
|
|
42
|
+
return lookup_field(name)
|
|
43
|
+
|
|
44
|
+
def get_field_info(name: str) -> dict[str, Any]:
|
|
45
|
+
"""Get info for a field."""
|
|
46
|
+
return get_field_summary(name) or {"error": "Field not found"}
|
|
47
|
+
|
|
48
|
+
def get_filter_example(type: str = "number") -> dict[str, Any]:
|
|
49
|
+
"""Get example filter format."""
|
|
50
|
+
return get_filter_format(type)
|
|
51
|
+
|
|
52
|
+
def check_market(market: str) -> dict[str, Any]:
|
|
53
|
+
"""Validate a market name."""
|
|
54
|
+
return validate_market(market)
|
|
55
|
+
|
|
56
|
+
def get_help() -> dict[str, Any]:
|
|
57
|
+
"""Get help and usage info."""
|
|
58
|
+
return get_quick_reference()
|
|
59
|
+
|
|
60
|
+
def get_screener_preset(name: str) -> dict[str, Any]:
|
|
61
|
+
"""Get a screener preset."""
|
|
62
|
+
presets = get_screener_presets()
|
|
63
|
+
for p in presets:
|
|
64
|
+
if p.get("name") == name:
|
|
65
|
+
return p
|
|
66
|
+
return {"error": "Preset not found"}
|
|
67
|
+
|
|
68
|
+
def get_market_metainfo(market: str) -> dict[str, Any]:
|
|
69
|
+
"""Get metainfo for a market."""
|
|
70
|
+
return get_metainfo(market)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from tradingview_mcp.column import Column
|
|
5
|
+
from tradingview_mcp.constants import DEFAULT_COLUMNS, get_column_name
|
|
6
|
+
from tradingview_mcp.query import Query
|
|
7
|
+
from tradingview_mcp.utils import sanitize_market
|
|
8
|
+
|
|
9
|
+
def screen_market(
|
|
10
|
+
market: str = "america",
|
|
11
|
+
columns: Optional[list[str]] = None,
|
|
12
|
+
sort_by: str = "volume",
|
|
13
|
+
ascending: bool = False,
|
|
14
|
+
limit: int = 25,
|
|
15
|
+
filters: Optional[list[dict[str, Any]]] = None,
|
|
16
|
+
) -> dict[str, Any]:
|
|
17
|
+
"""Run a custom market screening query."""
|
|
18
|
+
market = sanitize_market(market)
|
|
19
|
+
limit = max(1, min(limit, 500))
|
|
20
|
+
|
|
21
|
+
# Ensure description is always included
|
|
22
|
+
cols = columns or DEFAULT_COLUMNS
|
|
23
|
+
if "description" not in cols:
|
|
24
|
+
cols = ["description"] + list(cols)
|
|
25
|
+
|
|
26
|
+
query = Query().set_markets(market).select(*cols).order_by(sort_by, ascending=ascending).limit(limit)
|
|
27
|
+
|
|
28
|
+
# Apply filters if provided
|
|
29
|
+
if filters:
|
|
30
|
+
filter_expressions = []
|
|
31
|
+
for f in filters:
|
|
32
|
+
# Automatic translation of human-readable column names
|
|
33
|
+
col_name = get_column_name(f.get("column", "close"))
|
|
34
|
+
col = Column(col_name)
|
|
35
|
+
op = f.get("operation", "gt")
|
|
36
|
+
val = f.get("value")
|
|
37
|
+
|
|
38
|
+
if op == "gt":
|
|
39
|
+
filter_expressions.append(col > val)
|
|
40
|
+
elif op == "gte":
|
|
41
|
+
filter_expressions.append(col >= val)
|
|
42
|
+
elif op == "lt":
|
|
43
|
+
filter_expressions.append(col < val)
|
|
44
|
+
elif op == "lte":
|
|
45
|
+
filter_expressions.append(col <= val)
|
|
46
|
+
elif op == "eq":
|
|
47
|
+
filter_expressions.append(col == val)
|
|
48
|
+
elif op == "neq":
|
|
49
|
+
filter_expressions.append(col != val)
|
|
50
|
+
elif op == "between" and isinstance(val, (list, tuple)) and len(val) == 2:
|
|
51
|
+
filter_expressions.append(col.between(val[0], val[1]))
|
|
52
|
+
elif op == "isin" and isinstance(val, (list, tuple)):
|
|
53
|
+
filter_expressions.append(col.isin(val))
|
|
54
|
+
|
|
55
|
+
if filter_expressions:
|
|
56
|
+
query = query.where(*filter_expressions)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
total_count, df = query.get_scanner_data()
|
|
60
|
+
results = df.to_dict("records")
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
"total_count": total_count,
|
|
64
|
+
"returned": len(results),
|
|
65
|
+
"market": market,
|
|
66
|
+
"data": results,
|
|
67
|
+
}
|
|
68
|
+
except Exception as e:
|
|
69
|
+
return {"error": str(e), "market": market}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_top_gainers(market: str = "america", limit: int = 25) -> dict[str, Any]:
|
|
73
|
+
"""Get top gainers for a market."""
|
|
74
|
+
market = sanitize_market(market)
|
|
75
|
+
return screen_market(market, sort_by="change", ascending=False, limit=limit)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_top_losers(market: str = "america", limit: int = 25) -> dict[str, Any]:
|
|
79
|
+
"""Get top losers for a market."""
|
|
80
|
+
market = sanitize_market(market)
|
|
81
|
+
return screen_market(market, sort_by="change", ascending=True, limit=limit)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_most_active(market: str = "america", limit: int = 25) -> dict[str, Any]:
|
|
85
|
+
"""Get most active symbols by volume."""
|
|
86
|
+
market = sanitize_market(market)
|
|
87
|
+
return screen_market(market, sort_by="volume", ascending=False, limit=limit)
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any
|
|
3
|
+
import concurrent.futures
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import Context
|
|
6
|
+
|
|
7
|
+
from tradingview_mcp.column import Column
|
|
8
|
+
from tradingview_mcp.constants import EXCHANGE_SCREENER
|
|
9
|
+
from tradingview_mcp.docs_data import get_default_columns_for_market, STOCK_MARKETS
|
|
10
|
+
from tradingview_mcp.query import Query
|
|
11
|
+
from tradingview_mcp.utils import sanitize_market
|
|
12
|
+
|
|
13
|
+
# Common aliases for commodities/forex that don't match TV names
|
|
14
|
+
SYMBOL_ALIASES = {
|
|
15
|
+
# Precious Metals
|
|
16
|
+
"XAG": ["SILVER", "XAGUSD"],
|
|
17
|
+
"XAU": ["GOLD", "XAUUSD"],
|
|
18
|
+
"XPT": ["PLATINUM", "XPTUSD"],
|
|
19
|
+
"XPD": ["PALLADIUM", "XPDUSD"],
|
|
20
|
+
|
|
21
|
+
# Energy
|
|
22
|
+
"OIL": ["USOIL", "UKOIL", "WTI", "BRENT"],
|
|
23
|
+
"WTI": ["USOIL"],
|
|
24
|
+
"BRENT": ["UKOIL"],
|
|
25
|
+
"NATGAS": ["NG1!", "NATURALGAS"],
|
|
26
|
+
|
|
27
|
+
# Others
|
|
28
|
+
"COPPER": ["HG1!", "XCUUSD"],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def search_symbols(
|
|
32
|
+
query: str,
|
|
33
|
+
market: str = "america",
|
|
34
|
+
limit: int = 25,
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
"""
|
|
37
|
+
Search symbols by name or ticker. Returns strict locator format for downstream tools.
|
|
38
|
+
|
|
39
|
+
Output format includes 'locator': 'EXCHANGE:SYMBOL, market_name'.
|
|
40
|
+
Use this locator to call get_technical_analysis() or other tools precisely.
|
|
41
|
+
"""
|
|
42
|
+
market = sanitize_market(market)
|
|
43
|
+
limit = max(1, min(limit, 100))
|
|
44
|
+
|
|
45
|
+
# Use dynamic default columns for this market
|
|
46
|
+
cols = get_default_columns_for_market(market)
|
|
47
|
+
|
|
48
|
+
# Determine sort column
|
|
49
|
+
sort_col = "market_cap_basic" if "market_cap_basic" in cols else "name"
|
|
50
|
+
|
|
51
|
+
# Expand query with aliases if available
|
|
52
|
+
queries = [query]
|
|
53
|
+
if query.upper() in SYMBOL_ALIASES:
|
|
54
|
+
queries.extend(SYMBOL_ALIASES[query.upper()])
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
# Standard search in requested market
|
|
58
|
+
results = _execute_search(queries, market, limit, cols, sort_col)
|
|
59
|
+
|
|
60
|
+
# Smart Fallback: If default market ('america') yielded no results, try others
|
|
61
|
+
if not results["results"] and market == "america":
|
|
62
|
+
|
|
63
|
+
# 1. Asian Numeric Heuristic
|
|
64
|
+
if query.isdigit() and len(query) in [4, 5, 6]:
|
|
65
|
+
asian_markets = ["taiwan", "hongkong", "japan", "china", "korea"]
|
|
66
|
+
for asian_market in asian_markets:
|
|
67
|
+
asian_cols = get_default_columns_for_market(asian_market)
|
|
68
|
+
asian_sort = "volume"
|
|
69
|
+
asian_res = _execute_search(queries, asian_market, 5, asian_cols, asian_sort)
|
|
70
|
+
if asian_res["results"]:
|
|
71
|
+
return {**asian_res, "note": f"No results in 'america', using numeric heuristic for '{asian_market}'."}
|
|
72
|
+
|
|
73
|
+
# 2. True Global Search
|
|
74
|
+
try:
|
|
75
|
+
global_cols = ["name", "description", "close", "change", "volume", "market_cap_basic", "exchange", "type"]
|
|
76
|
+
q = (
|
|
77
|
+
Query()
|
|
78
|
+
.set_markets(*STOCK_MARKETS)
|
|
79
|
+
.select(*global_cols)
|
|
80
|
+
.where(Column("description").like(query))
|
|
81
|
+
.order_by("volume", ascending=False)
|
|
82
|
+
.limit(5)
|
|
83
|
+
)
|
|
84
|
+
_, df = q.get_scanner_data()
|
|
85
|
+
|
|
86
|
+
if df.empty:
|
|
87
|
+
q = (
|
|
88
|
+
Query()
|
|
89
|
+
.set_markets(*STOCK_MARKETS)
|
|
90
|
+
.select(*global_cols)
|
|
91
|
+
.where(Column("name").like(query))
|
|
92
|
+
.order_by("volume", ascending=False)
|
|
93
|
+
.limit(5)
|
|
94
|
+
)
|
|
95
|
+
_, df = q.get_scanner_data()
|
|
96
|
+
|
|
97
|
+
if not df.empty:
|
|
98
|
+
# Add locators for global results
|
|
99
|
+
records = df.to_dict("records")
|
|
100
|
+
for r in records:
|
|
101
|
+
_enrich_locator(r, "stock", query) # We can't know exact market easily from batch query, assume global context usage
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
"query": query,
|
|
105
|
+
"market": "global",
|
|
106
|
+
"total_found": len(df),
|
|
107
|
+
"returned": len(df),
|
|
108
|
+
"results": records,
|
|
109
|
+
"note": "Found matches in global stock markets."
|
|
110
|
+
}
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
# 3. Check Crypto/Forex/CFD
|
|
115
|
+
for other in ["crypto", "forex", "cfd"]:
|
|
116
|
+
if other == market: continue
|
|
117
|
+
|
|
118
|
+
cols = get_default_columns_for_market(other)
|
|
119
|
+
sort = "market_cap_basic" if "market_cap_basic" in cols else "name"
|
|
120
|
+
if other == "forex" or other == "cfd": sort = "name"
|
|
121
|
+
|
|
122
|
+
res = _execute_search(queries, other, 5, cols, sort)
|
|
123
|
+
if res["results"]:
|
|
124
|
+
return {**res, "note": f"Found matches in '{other}'."}
|
|
125
|
+
|
|
126
|
+
return results
|
|
127
|
+
|
|
128
|
+
except Exception as e:
|
|
129
|
+
return _search_symbols_fallback(query, market, limit, cols, str(e))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _enrich_locator(record: dict, market: str, category: str = "stock"):
|
|
133
|
+
"""Add standard locator string to a record."""
|
|
134
|
+
ex = record.get("exchange", "UNKNOWN")
|
|
135
|
+
name = record.get("name", "UNKNOWN")
|
|
136
|
+
|
|
137
|
+
# Try to deduce market if 'stock' is generic
|
|
138
|
+
# (This is hard without a map of Exchange->Country, but we do our best)
|
|
139
|
+
|
|
140
|
+
# Construct Locator
|
|
141
|
+
# Format: EXCHANGE:SYMBOL, Market
|
|
142
|
+
record["locator"] = f"{ex}:{name}, {market}"
|
|
143
|
+
record["market"] = market
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _execute_search(queries: list[str] | str, market: str, limit: int, cols: list[str], sort_col: str) -> dict[str, Any]:
|
|
147
|
+
"""Execute search, supporting multiple query terms (aliases)."""
|
|
148
|
+
if isinstance(queries, str):
|
|
149
|
+
queries = [queries]
|
|
150
|
+
|
|
151
|
+
all_results = []
|
|
152
|
+
|
|
153
|
+
# Try each query term until we get enough results
|
|
154
|
+
for q_term in queries:
|
|
155
|
+
if len(all_results) >= limit: break
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
# 1. Search description
|
|
159
|
+
q = (
|
|
160
|
+
Query()
|
|
161
|
+
.set_markets(market)
|
|
162
|
+
.select(*cols)
|
|
163
|
+
.where(Column("description").like(q_term))
|
|
164
|
+
.order_by(sort_col, ascending=False)
|
|
165
|
+
.limit(limit)
|
|
166
|
+
)
|
|
167
|
+
_, df = q.get_scanner_data()
|
|
168
|
+
if not df.empty:
|
|
169
|
+
all_results.extend(df.to_dict("records"))
|
|
170
|
+
|
|
171
|
+
# 2. Search name
|
|
172
|
+
if len(all_results) < limit:
|
|
173
|
+
q2 = (
|
|
174
|
+
Query()
|
|
175
|
+
.set_markets(market)
|
|
176
|
+
.select(*cols)
|
|
177
|
+
.where(Column("name").like(q_term))
|
|
178
|
+
.order_by(sort_col, ascending=False)
|
|
179
|
+
.limit(limit)
|
|
180
|
+
)
|
|
181
|
+
_, df2 = q2.get_scanner_data()
|
|
182
|
+
if not df2.empty:
|
|
183
|
+
all_results.extend(df2.to_dict("records"))
|
|
184
|
+
except:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
# Dedup
|
|
188
|
+
unique = {r.get("name")+r.get("exchange"): r for r in all_results}
|
|
189
|
+
results = list(unique.values())[:limit]
|
|
190
|
+
|
|
191
|
+
# Enhance with locator
|
|
192
|
+
for r in results:
|
|
193
|
+
_enrich_locator(r, market)
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
"query": queries[0],
|
|
197
|
+
"market": market,
|
|
198
|
+
"total_found": len(results),
|
|
199
|
+
"returned": len(results),
|
|
200
|
+
"results": results,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _search_symbols_fallback(query: str, market: str, limit: int, cols: list[str], error: str) -> dict[str, Any]:
|
|
205
|
+
return {"error": f"Search failed: {str(e)}", "original_error": error}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def get_symbol_info(symbol: str, include_technical: bool = False) -> dict[str, Any]:
|
|
209
|
+
"""
|
|
210
|
+
Get detailed information for a symbol.
|
|
211
|
+
Returns matches with strict locators: 'EXCHANGE:SYMBOL, Market'.
|
|
212
|
+
"""
|
|
213
|
+
technical_cols = [
|
|
214
|
+
"RSI", "RSI7", "MACD.macd", "MACD.signal", "SMA20", "SMA50", "SMA200",
|
|
215
|
+
"EMA20", "EMA50", "EMA200", "BB.upper", "BB.lower", "ATR", "ADX",
|
|
216
|
+
"Recommend.All", "Recommend.MA", "Recommend.Other"
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
# --- Strict Mode (Exchange Specified) ---
|
|
221
|
+
if ":" in symbol:
|
|
222
|
+
exchange, ticker = symbol.split(":", 1)
|
|
223
|
+
market = EXCHANGE_SCREENER.get(exchange.lower()) or "america"
|
|
224
|
+
|
|
225
|
+
# Allow override via sanitized lookup? No, specific exchange implies specific market logic usually.
|
|
226
|
+
# But let's be safe.
|
|
227
|
+
|
|
228
|
+
cols = get_default_columns_for_market(market)
|
|
229
|
+
if include_technical: cols.extend(technical_cols)
|
|
230
|
+
cols = list(dict.fromkeys(cols))
|
|
231
|
+
|
|
232
|
+
q = (
|
|
233
|
+
Query()
|
|
234
|
+
.set_markets(market)
|
|
235
|
+
.select(*cols)
|
|
236
|
+
.where(Column("name").isin([symbol, ticker, symbol.upper(), ticker.upper()]))
|
|
237
|
+
.limit(5)
|
|
238
|
+
)
|
|
239
|
+
_, df = q.get_scanner_data()
|
|
240
|
+
results = df.to_dict("records")
|
|
241
|
+
|
|
242
|
+
if results:
|
|
243
|
+
# Add strict locators
|
|
244
|
+
for r in results:
|
|
245
|
+
_enrich_locator(r, market)
|
|
246
|
+
|
|
247
|
+
if len(results) == 1:
|
|
248
|
+
return {"symbol": symbol, "found": True, "market": market, "data": results[0]}
|
|
249
|
+
return {"symbol": symbol, "found": True, "market": market, "matches": results}
|
|
250
|
+
else:
|
|
251
|
+
return {"symbol": symbol, "found": False, "hint": f"Symbol not found in {market} ({exchange}). Check format."}
|
|
252
|
+
|
|
253
|
+
# --- Universal Mode (Implicit Market) ---
|
|
254
|
+
all_matches = []
|
|
255
|
+
|
|
256
|
+
# Targets
|
|
257
|
+
targets = [symbol, symbol.upper(), f"{symbol}USDT", f"{symbol}USD"]
|
|
258
|
+
if symbol.upper() in SYMBOL_ALIASES:
|
|
259
|
+
targets.extend(SYMBOL_ALIASES[symbol.upper()])
|
|
260
|
+
|
|
261
|
+
# Helper
|
|
262
|
+
def run_search(market, col_getter):
|
|
263
|
+
try:
|
|
264
|
+
cols = col_getter(market)
|
|
265
|
+
if include_technical: cols.extend(technical_cols)
|
|
266
|
+
cols = list(dict.fromkeys(cols))
|
|
267
|
+
|
|
268
|
+
# We need to set markets properly
|
|
269
|
+
q = Query()
|
|
270
|
+
if market == "global_stocks":
|
|
271
|
+
q.set_markets(*STOCK_MARKETS)
|
|
272
|
+
else:
|
|
273
|
+
q.set_markets(market)
|
|
274
|
+
|
|
275
|
+
q.select(*cols).where(Column("name").isin(targets))
|
|
276
|
+
|
|
277
|
+
if market == "global_stocks":
|
|
278
|
+
q.order_by("market_cap_basic", ascending=False).limit(10)
|
|
279
|
+
else:
|
|
280
|
+
q.limit(5)
|
|
281
|
+
|
|
282
|
+
_, df = q.get_scanner_data()
|
|
283
|
+
if not df.empty:
|
|
284
|
+
matches = df.to_dict("records")
|
|
285
|
+
cat = "stock" if market == "global_stocks" else market
|
|
286
|
+
|
|
287
|
+
# Fixup locator logic
|
|
288
|
+
for m in matches:
|
|
289
|
+
m["_category"] = cat
|
|
290
|
+
# If global stock, we don't know exact country easily without map.
|
|
291
|
+
# But for now we pass 'global_stocks' or try to guess?
|
|
292
|
+
# It's better to tell AI "taiwan" if possible.
|
|
293
|
+
# But we queried ALL markets.
|
|
294
|
+
# HACK: We should query 'taiwan' explicitly if heuristics match?
|
|
295
|
+
# No, just return EXCHANGE:SYMBOL and let AI use 'search_symbols' if it needs precise market.
|
|
296
|
+
# Actually user wants "TWSE:0050, taiwan".
|
|
297
|
+
# If we used set_markets(*ALL), we lose the origin market info in the response unless we select 'market' column?
|
|
298
|
+
# TradingView API doesn't usually return 'market' column. It returns 'exchange'.
|
|
299
|
+
# We can map Exchange -> Market via EXCHANGE_SCREENER?
|
|
300
|
+
rec_market = EXCHANGE_SCREENER.get(m.get("exchange", "").lower(), cat)
|
|
301
|
+
_enrich_locator(m, rec_market)
|
|
302
|
+
|
|
303
|
+
return matches
|
|
304
|
+
except:
|
|
305
|
+
pass
|
|
306
|
+
return []
|
|
307
|
+
|
|
308
|
+
# Execute in parallel
|
|
309
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
|
|
310
|
+
futures = [
|
|
311
|
+
executor.submit(run_search, "global_stocks", lambda m: ["name", "description", "close", "change", "volume", "market_cap_basic", "exchange", "type"]),
|
|
312
|
+
executor.submit(run_search, "crypto", get_default_columns_for_market),
|
|
313
|
+
executor.submit(run_search, "forex", get_default_columns_for_market),
|
|
314
|
+
executor.submit(run_search, "cfd", get_default_columns_for_market)
|
|
315
|
+
]
|
|
316
|
+
for future in concurrent.futures.as_completed(futures):
|
|
317
|
+
all_matches.extend(future.result())
|
|
318
|
+
|
|
319
|
+
# --- Aggregate Results ---
|
|
320
|
+
if not all_matches:
|
|
321
|
+
return search_symbols(symbol, "america", 5)
|
|
322
|
+
|
|
323
|
+
# Remove duplicates
|
|
324
|
+
unique_matches = {}
|
|
325
|
+
for m in all_matches:
|
|
326
|
+
key = m.get("ticker", m.get("name"))
|
|
327
|
+
if key not in unique_matches:
|
|
328
|
+
unique_matches[key] = m
|
|
329
|
+
|
|
330
|
+
final_matches = list(unique_matches.values())
|
|
331
|
+
|
|
332
|
+
if len(final_matches) == 1:
|
|
333
|
+
first = final_matches[0]
|
|
334
|
+
cat = first.pop("_category", "global")
|
|
335
|
+
return {"symbol": symbol, "found": True, "market": cat, "data": first}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
"symbol": symbol,
|
|
339
|
+
"found": True,
|
|
340
|
+
"market": "global",
|
|
341
|
+
"count": len(final_matches),
|
|
342
|
+
"matches": final_matches,
|
|
343
|
+
"note": "Multiple matches found across global markets."
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
except Exception as e:
|
|
347
|
+
return {
|
|
348
|
+
"error": f"Failed to get symbol info: {str(e)}",
|
|
349
|
+
"hint": "Try using search_symbols to find the correct symbol format",
|
|
350
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from tradingview_mcp.column import Column
|
|
5
|
+
from tradingview_mcp.constants import TECHNICAL_COLUMNS
|
|
6
|
+
from tradingview_mcp.query import Query
|
|
7
|
+
from tradingview_mcp.utils import sanitize_market
|
|
8
|
+
|
|
9
|
+
def get_technical_analysis(symbol: str, market: str) -> dict[str, Any]:
|
|
10
|
+
"""
|
|
11
|
+
Get technical indicators for a symbol.
|
|
12
|
+
|
|
13
|
+
IMPORTANT: You must use a strictly formatted symbol and market.
|
|
14
|
+
1. Call `get_symbol_info(symbol)` first.
|
|
15
|
+
2. Use the `locator` field from the response (e.g. "EXCHANGE:SYMBOL") and the exact `market`.
|
|
16
|
+
3. Do NOT guess the market.
|
|
17
|
+
"""
|
|
18
|
+
# Strict validation
|
|
19
|
+
if ":" not in symbol:
|
|
20
|
+
return {
|
|
21
|
+
"error": "Ambiguous symbol format. Missing exchange prefix.",
|
|
22
|
+
"hint": f"Please run get_symbol_info('{symbol}') first to find the correct 'EXCHANGE:{symbol}' locator and 'market'."
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Parse strictly formatted symbol
|
|
26
|
+
exchange, ticker = symbol.split(":", 1)
|
|
27
|
+
|
|
28
|
+
market = sanitize_market(market)
|
|
29
|
+
|
|
30
|
+
# Use standard technical columns
|
|
31
|
+
cols = list(TECHNICAL_COLUMNS)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
q = (
|
|
35
|
+
Query()
|
|
36
|
+
.set_markets(market)
|
|
37
|
+
.select(*cols)
|
|
38
|
+
.where(Column("name").isin([ticker, ticker.upper()])) # Use ticker part for name lookup
|
|
39
|
+
.limit(1)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_, df = q.get_scanner_data()
|
|
43
|
+
|
|
44
|
+
if df.empty:
|
|
45
|
+
# Check if user maybe used wrong market?
|
|
46
|
+
# But we want to be strict.
|
|
47
|
+
return {
|
|
48
|
+
"error": f"Symbol '{symbol}' not found in market '{market}'.",
|
|
49
|
+
"hint": "Ensure you are using the correct market from get_symbol_info()."
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
data = df.iloc[0].to_dict()
|
|
53
|
+
return {
|
|
54
|
+
"symbol": symbol,
|
|
55
|
+
"market": market,
|
|
56
|
+
"indicators": data
|
|
57
|
+
}
|
|
58
|
+
except Exception as e:
|
|
59
|
+
return {"error": str(e)}
|
|
60
|
+
|
|
61
|
+
def scan_rsi_extremes(market: str = "america", limit: int = 25) -> dict[str, Any]:
|
|
62
|
+
"""Find symbols with extreme RSI values (<30 or >70)."""
|
|
63
|
+
market = sanitize_market(market)
|
|
64
|
+
|
|
65
|
+
q = (
|
|
66
|
+
Query()
|
|
67
|
+
.set_markets(market)
|
|
68
|
+
.select("name", "close", "RSI", "volume")
|
|
69
|
+
# RSI > 70 OR RSI < 30. Note: Current Query builder supports AND by default.
|
|
70
|
+
# For OR logic, we might need multiple queries or where2 if supported.
|
|
71
|
+
# Let's simple check oversold first (<30)
|
|
72
|
+
.where(Column("RSI") < 30)
|
|
73
|
+
.order_by("RSI", ascending=True)
|
|
74
|
+
.limit(limit)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
_, df_oversold = q.get_scanner_data()
|
|
79
|
+
oversold = df_oversold.to_dict("records")
|
|
80
|
+
|
|
81
|
+
# Overbought
|
|
82
|
+
q2 = (
|
|
83
|
+
Query()
|
|
84
|
+
.set_markets(market)
|
|
85
|
+
.select("name", "close", "RSI", "volume")
|
|
86
|
+
.where(Column("RSI") > 70)
|
|
87
|
+
.order_by("RSI", ascending=False)
|
|
88
|
+
.limit(limit)
|
|
89
|
+
)
|
|
90
|
+
_, df_overbought = q2.get_scanner_data()
|
|
91
|
+
overbought = df_overbought.to_dict("records")
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
"market": market,
|
|
95
|
+
"oversold": oversold,
|
|
96
|
+
"overbought": overbought
|
|
97
|
+
}
|
|
98
|
+
except Exception as e:
|
|
99
|
+
return {"error": str(e)}
|
|
100
|
+
|
|
101
|
+
def scan_bollinger_bands(market: str = "america", limit: int = 25) -> dict[str, Any]:
|
|
102
|
+
"""Find symbols trading outside Bollinger Bands."""
|
|
103
|
+
market = sanitize_market(market)
|
|
104
|
+
# This is complex to do with simple filters.
|
|
105
|
+
# We'll just return basic BB values for top volume stocks.
|
|
106
|
+
|
|
107
|
+
q = (
|
|
108
|
+
Query()
|
|
109
|
+
.set_markets(market)
|
|
110
|
+
.select("name", "close", "BB.upper", "BB.lower")
|
|
111
|
+
.order_by("volume", ascending=False)
|
|
112
|
+
.limit(limit)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
_, df = q.get_scanner_data()
|
|
117
|
+
return {"data": df.to_dict("records")}
|
|
118
|
+
except Exception as e:
|
|
119
|
+
return {"error": str(e)}
|
|
120
|
+
|
|
121
|
+
def scan_macd_crossover(market: str = "america", limit: int = 25) -> dict[str, Any]:
|
|
122
|
+
"""scan for MACD crossovers."""
|
|
123
|
+
market = sanitize_market(market)
|
|
124
|
+
q = (
|
|
125
|
+
Query()
|
|
126
|
+
.set_markets(market)
|
|
127
|
+
.select("name", "close", "MACD.macd", "MACD.signal")
|
|
128
|
+
.where(Column("MACD.macd") > Column("MACD.signal")) # Bullish crossoverish (simple comparison)
|
|
129
|
+
.order_by("volume", ascending=False)
|
|
130
|
+
.limit(limit)
|
|
131
|
+
)
|
|
132
|
+
try:
|
|
133
|
+
_, df = q.get_scanner_data()
|
|
134
|
+
return {"bullish_macd_momentum": df.to_dict("records")}
|
|
135
|
+
except Exception as e:
|
|
136
|
+
return {"error": str(e)}
|