iflow-mcp_jcwleo-ccxt-mcp-server 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mcp_server.py ADDED
@@ -0,0 +1,1840 @@
1
+ # mcp_server.py
2
+
3
+ import json
4
+ from typing import Optional, List, Union, Dict, Annotated, Literal
5
+ import ccxt.async_support as ccxtasync # Changed for asynchronous support and alias
6
+ from fastmcp import FastMCP
7
+ import asyncio
8
+ from pydantic import Field
9
+ import pandas as pd # Added for DataFrame manipulation
10
+
11
+ # --- Constants for CCXT Exception Handling ---
12
+ CCXT_GENERAL_EXCEPTIONS = (
13
+ ccxtasync.AuthenticationError, # Covers PermissionDenied, AccountNotEnabled, AccountSuspended
14
+ ccxtasync.ArgumentsRequired,
15
+ ccxtasync.BadRequest, # Covers BadSymbol
16
+ ccxtasync.InsufficientFunds,
17
+ ccxtasync.InvalidAddress, # Covers AddressPending
18
+ ccxtasync.InvalidOrder, # Covers OrderNotFound, OrderNotCached, etc.
19
+ # NotSupported is handled specif
20
+ ccxtasync.NetworkError, # Covers DDoSProtection, RateLimitExceeded, ExchangeNotAvailable, InvalidNonce, RequestTimeout, OnMaintenance, ChecksumError
21
+ ccxtasync.BadResponse, # Covers NullResponse
22
+ ccxtasync.CancelPending,
23
+ ccxtasync.ExchangeError, # General ccxt exchange error, placed after more specific ones
24
+ ValueError
25
+ )
26
+ TimeframeLiteral = Literal[
27
+ '1m', '3m', '5m', '15m', '30m',
28
+ '1h', '2h', '4h', '6h', '8h', '12h',
29
+ '1d', '3d', '1w', '1M'
30
+ ]
31
+
32
+ # Initialize FastMCP
33
+ mcp = FastMCP("CCXT MCP Server πŸš€")
34
+
35
+ app = mcp.streamable_http_app()
36
+
37
+ import pandas as pd
38
+ from typing import Dict, Optional, Tuple
39
+ import numpy as np
40
+
41
+ def compute_rsi(df: pd.DataFrame, length: int = 14, price_source: str = 'close') -> Optional[pd.Series]:
42
+ """Calculates Relative Strength Index (RSI) using pandas.
43
+ Args:
44
+ df: Pandas DataFrame with OHLCV data, indexed by timestamp.
45
+ length: The period for RSI calculation.
46
+ price_source: The DataFrame column to use for price (e.g., 'close', 'hlc3').
47
+ Returns:
48
+ Pandas Series with RSI values, or None if calculation fails.
49
+ """
50
+ if price_source not in df.columns:
51
+ raise ValueError(f"Price source column '{price_source}' not found in DataFrame.")
52
+ if df[price_source].isnull().all():
53
+ # print(f"Warning: Price source column '{price_source}' for RSI is all NaN.")
54
+ return None
55
+ try:
56
+ delta = df[price_source].diff(1)
57
+ gain = delta.where(delta > 0, 0)
58
+ loss = -delta.where(delta < 0, 0)
59
+
60
+ # Calculate initial average gain and loss using SMA for the first period
61
+ avg_gain = gain.rolling(window=length, min_periods=length).mean()
62
+ avg_loss = loss.rolling(window=length, min_periods=length).mean()
63
+
64
+ # For subsequent periods, use Wilder's smoothing method (equivalent to EMA with alpha = 1/length)
65
+ # For pandas EWM, alpha = 2 / (span + 1), so span = (2 / alpha) - 1 = 2*length - 1
66
+ # However, it's more direct to use the recursive formula after the first value.
67
+
68
+ # Fill NaN for the first `length` periods because rolling mean needs `length` values
69
+ # For the very first RSI value, avg_gain and avg_loss are simple averages.
70
+ # Subsequent values are smoothed.
71
+
72
+ for i in range(length, len(df)):
73
+ avg_gain.iloc[i] = (avg_gain.iloc[i-1] * (length - 1) + gain.iloc[i]) / length
74
+ avg_loss.iloc[i] = (avg_loss.iloc[i-1] * (length - 1) + loss.iloc[i]) / length
75
+
76
+ rs = avg_gain / avg_loss
77
+ rsi = 100 - (100 / (1 + rs))
78
+ # RSI can be NaN if avg_loss is 0.
79
+ # If avg_loss is 0 and avg_gain is also 0, rs is NaN, rsi is NaN.
80
+ # If avg_loss is 0 and avg_gain is > 0, rs is inf, 100 / (1 + inf) is 0, so RSI is 100.
81
+ # To maintain consistency with other indicators that have initial NaNs,
82
+ # we will let NaNs propagate and handle them during the first_valid_index logic.
83
+ # rsi.fillna(100, inplace=True) # Removed: Let initial NaNs remain
84
+ # rsi[avg_loss == 0] = 100 # Removed for consistency, will be NaN if rs is NaN or inf if avg_loss is 0.
85
+ # Or, if we want to be strict to definition:
86
+ rsi.loc[avg_loss == 0] = 100.0 # Set to 100 where avg_loss is 0 and avg_gain > 0 (rs is inf)
87
+ # If both are 0, rs is nan, rsi remains nan. This is fine.
88
+
89
+ return rsi
90
+
91
+ except Exception as e:
92
+ print(f"Error calculating RSI: {e}")
93
+ return None
94
+
95
+ def compute_sma(df: pd.DataFrame, length: int = 20, price_source: str = 'close') -> Optional[pd.Series]:
96
+ """Calculates Simple Moving Average (SMA) using pandas.
97
+ Args:
98
+ df: Pandas DataFrame with OHLCV data, indexed by timestamp.
99
+ length: The period for SMA calculation.
100
+ price_source: The DataFrame column to use for price (e.g., 'close', 'hlc3').
101
+ Returns:
102
+ Pandas Series with SMA values, or None if calculation fails.
103
+ """
104
+ if price_source not in df.columns:
105
+ raise ValueError(f"Price source column '{price_source}' not found in DataFrame.")
106
+ if df[price_source].isnull().all():
107
+ # print(f"Warning: Price source column '{price_source}' for SMA is all NaN.")
108
+ return None
109
+ try:
110
+ sma_series = df[price_source].rolling(window=length, min_periods=length).mean()
111
+ return sma_series
112
+ except Exception as e:
113
+ print(f"Error calculating SMA: {e}")
114
+ return None
115
+
116
+ def compute_ema(df: pd.DataFrame, length: int = 20, price_source: str = 'close') -> Optional[pd.Series]:
117
+ """Calculates Exponential Moving Average (EMA) using pandas.
118
+ Args:
119
+ df: Pandas DataFrame with OHLCV data, indexed by timestamp.
120
+ length: The span for EMA calculation.
121
+ price_source: The DataFrame column to use for price (e.g., 'close', 'hlc3').
122
+ Returns:
123
+ Pandas Series with EMA values, or None if calculation fails.
124
+ """
125
+ if price_source not in df.columns:
126
+ raise ValueError(f"Price source column '{price_source}' not found in DataFrame.")
127
+ if df[price_source].isnull().all():
128
+ # print(f"Warning: Price source column '{price_source}' for EMA is all NaN.")
129
+ return None
130
+ try:
131
+ ema_series = df[price_source].ewm(span=length, adjust=False, min_periods=length).mean()
132
+ return ema_series
133
+ except Exception as e:
134
+ print(f"Error calculating EMA: {e}")
135
+ return None
136
+
137
+ def compute_macd(
138
+ df: pd.DataFrame,
139
+ fast_length: int = 12,
140
+ slow_length: int = 26,
141
+ signal_length: int = 9,
142
+ price_source: str = 'close'
143
+ ) -> Optional[Tuple[pd.Series, pd.Series, pd.Series]]:
144
+ """Calculates Moving Average Convergence Divergence (MACD) using pandas.
145
+ Args:
146
+ df: Pandas DataFrame with OHLCV data, indexed by timestamp.
147
+ fast_length: The period for the fast EMA.
148
+ slow_length: The period for the slow EMA.
149
+ signal_length: The period for the signal line EMA.
150
+ price_source: The DataFrame column to use for price.
151
+ Returns:
152
+ A tuple of (macd_line, signal_line, histogram), or None if calculation fails.
153
+ """
154
+ if price_source not in df.columns:
155
+ raise ValueError(f"Price source column '{price_source}' not found in DataFrame.")
156
+
157
+ try:
158
+ ema_fast = df[price_source].ewm(span=fast_length, adjust=False, min_periods=fast_length).mean()
159
+ ema_slow = df[price_source].ewm(span=slow_length, adjust=False, min_periods=slow_length).mean()
160
+
161
+ macd_line = ema_fast - ema_slow
162
+ signal_line = macd_line.ewm(span=signal_length, adjust=False, min_periods=signal_length).mean()
163
+ histogram = macd_line - signal_line
164
+
165
+ return macd_line, signal_line, histogram
166
+ except Exception as e:
167
+ print(f"Error calculating MACD: {e}")
168
+ return None
169
+
170
+ def compute_bbands(
171
+ df: pd.DataFrame,
172
+ length: int = 20,
173
+ std_dev: float = 2.0,
174
+ price_source: str = 'close'
175
+ ) -> Optional[Tuple[pd.Series, pd.Series, pd.Series]]:
176
+ """Calculates Bollinger Bands (BBANDS) using pandas.
177
+ Args:
178
+ df: Pandas DataFrame with OHLCV data, indexed by timestamp.
179
+ length: The period for the middle band (SMA) and standard deviation.
180
+ std_dev: The number of standard deviations for the upper and lower bands.
181
+ price_source: The DataFrame column to use for price.
182
+ Returns:
183
+ A tuple of (lower_band, middle_band, upper_band), or None if calculation fails.
184
+ """
185
+ if price_source not in df.columns:
186
+ raise ValueError(f"Price source column '{price_source}' not found in DataFrame.")
187
+
188
+ try:
189
+ middle_band = df[price_source].rolling(window=length, min_periods=length).mean()
190
+ rolling_std = df[price_source].rolling(window=length, min_periods=length).std()
191
+
192
+ upper_band = middle_band + (rolling_std * std_dev)
193
+ lower_band = middle_band - (rolling_std * std_dev)
194
+
195
+ return lower_band, middle_band, upper_band
196
+ except Exception as e:
197
+ print(f"Error calculating BBANDS: {e}")
198
+ return None
199
+
200
+ def compute_stochastic_oscillator(
201
+ df: pd.DataFrame,
202
+ k_period: int = 14,
203
+ d_period: int = 3,
204
+ smooth_k: int = 3,
205
+ price_source_high: str = 'high',
206
+ price_source_low: str = 'low',
207
+ price_source_close: str = 'close'
208
+ ) -> Optional[Tuple[pd.Series, pd.Series]]:
209
+ """
210
+ Calculates the Stochastic Oscillator (%K and %D).
211
+
212
+ Args:
213
+ df: Pandas DataFrame with OHLCV data, indexed by timestamp.
214
+ k_period: The look-back period for the K calculation.
215
+ d_period: The period for the D line (SMA of %K).
216
+ smooth_k: The smoothing period for %K (SMA of raw %K).
217
+ price_source_high: DataFrame column for high prices.
218
+ price_source_low: DataFrame column for low prices.
219
+ price_source_close: DataFrame column for close prices.
220
+
221
+ Returns:
222
+ A tuple of (percent_k, percent_d) pandas Series, or None if calculation fails.
223
+ """
224
+ if df.empty:
225
+ # print("Warning: DataFrame is empty for Stochastic Oscillator calculation.")
226
+ return None
227
+
228
+ required_cols = [price_source_high, price_source_low, price_source_close]
229
+ for col in required_cols:
230
+ if col not in df.columns:
231
+ raise ValueError(f"Price source column '{col}' not found in DataFrame.")
232
+ if df[col].isnull().all():
233
+ # print(f"Warning: Price source column '{col}' for Stochastic Oscillator is all NaN.")
234
+ return None
235
+
236
+ try:
237
+ lowest_low = df[price_source_low].rolling(window=k_period, min_periods=k_period).min()
238
+ highest_high = df[price_source_high].rolling(window=k_period, min_periods=k_period).max()
239
+
240
+ delta_high_low = highest_high - lowest_low
241
+
242
+ # Calculate raw %K
243
+ # Set to 50 if delta_high_low is 0 (flat price in k_period)
244
+ # otherwise calculate 100 * ((close - lowest_low) / delta_high_low)
245
+ raw_k_values = np.where(
246
+ delta_high_low == 0,
247
+ 50.0, # Set to 50 if no range (highest_high == lowest_low)
248
+ 100 * ((df[price_source_close] - lowest_low) / delta_high_low)
249
+ )
250
+ raw_k = pd.Series(raw_k_values, index=df.index)
251
+
252
+ # Handle cases where raw_k might still be NaN due to NaNs in input even if delta_high_low is not 0
253
+ # For example, if close, lowest_low, or highest_high had NaNs not caught by min_periods.
254
+ # Or if (close - lowest_low) is NaN / non-zero_delta is NaN.
255
+ # A common practice is to fill these with a mid-value or propagate.
256
+ # Given the np.where, the main source of NaNs would be if inputs to np.where are NaN.
257
+ # Rolling functions with min_periods handle initial NaNs.
258
+ # If raw_k has NaNs after np.where, it means some input to the calculation was NaN.
259
+ # We can fill these with 50, or propagate. Propagating is often safer.
260
+ # However, the problem description implies filling NaNs from 0/0 with 50.
261
+ # The `np.where(delta_high_low == 0, 50.0, ...)` handles the 0/0 case explicitly.
262
+ # NaNs resulting from other operations (e.g. NaN in close) should ideally propagate.
263
+
264
+ if smooth_k > 1:
265
+ percent_k = raw_k.rolling(window=smooth_k, min_periods=smooth_k).mean()
266
+ else:
267
+ percent_k = raw_k
268
+
269
+ percent_d = percent_k.rolling(window=d_period, min_periods=d_period).mean()
270
+
271
+ # Ensure no leading NaNs beyond what's necessary due to rolling windows
272
+ # This is generally handled by the rolling(min_periods=...)
273
+ # and how process_indicator_series (if used externally) would pick first_valid_index.
274
+
275
+ return percent_k, percent_d
276
+
277
+ except Exception as e:
278
+ print(f"Error calculating Stochastic Oscillator: {e}")
279
+ return None
280
+
281
+ def compute_atr(
282
+ df: pd.DataFrame,
283
+ period: int = 14,
284
+ price_source_high: str = 'high',
285
+ price_source_low: str = 'low',
286
+ price_source_close: str = 'close'
287
+ ) -> Optional[pd.Series]:
288
+ """
289
+ Calculates the Average True Range (ATR).
290
+
291
+ Args:
292
+ df: Pandas DataFrame with OHLCV data, indexed by timestamp.
293
+ period: The look-back period for ATR calculation.
294
+ price_source_high: DataFrame column for high prices.
295
+ price_source_low: DataFrame column for low prices.
296
+ price_source_close: DataFrame column for close prices.
297
+
298
+ Returns:
299
+ A pandas Series with ATR values, or None if calculation fails.
300
+ """
301
+ if df.empty or len(df) < 1: # Check for empty or too short DataFrame
302
+ # print("Warning: DataFrame is empty or too short for ATR calculation.")
303
+ return None
304
+
305
+ required_cols = [price_source_high, price_source_low, price_source_close]
306
+ for col in required_cols:
307
+ if col not in df.columns:
308
+ raise ValueError(f"Price source column '{col}' not found in DataFrame.")
309
+ if df[col].isnull().all():
310
+ # print(f"Warning: Price source column '{col}' for ATR is all NaN.")
311
+ return None
312
+
313
+ # Check if there's enough data for at least one ATR value after considering the period
314
+ # While ewm with min_periods handles this by returning NaNs,
315
+ # an explicit check for len(df) < period could be done here if strictness is desired.
316
+ # However, typically, if len(df) is 1, TR can be H-L, but ATR would be NaN until `period` TR values exist.
317
+ # The current logic with min_periods=period in ewm is standard.
318
+
319
+ try:
320
+ high_col = df[price_source_high]
321
+ low_col = df[price_source_low]
322
+ close_col = df[price_source_close]
323
+
324
+ high_low = high_col - low_col
325
+ high_prev_close = (high_col - close_col.shift(1)).abs()
326
+ low_prev_close = (low_col - close_col.shift(1)).abs()
327
+
328
+ # Create a DataFrame for TR components
329
+ # Ensure index alignment, especially if inputs had different NaNs initially
330
+ tr_components = [high_low, high_prev_close, low_prev_close]
331
+ # Filter out series that are all NaN before concat, to avoid issues if a price source was valid but led to all NaN here
332
+ tr_components_filtered = [s for s in tr_components if not s.isnull().all()]
333
+
334
+ if not tr_components_filtered: # Should not happen if input column checks passed
335
+ return None
336
+
337
+ tr_df = pd.concat(tr_components_filtered, axis=1)
338
+ true_range = tr_df.max(axis=1, skipna=False) # skipna=False to ensure NaNs propagate if all components are NaN for a row
339
+
340
+ # Handle the first TR value: TR1 = High1 - Low1
341
+ # .iat requires integer index, ensure df is not empty (already checked)
342
+ if len(df) > 0: # Redundant due to earlier check, but safe
343
+ true_range.iat[0] = high_col.iat[0] - low_col.iat[0]
344
+
345
+ # Calculate ATR using Wilder's Smoothing (approximated by EWM with adjust=False)
346
+ # min_periods=period ensures that ATR is NaN until there are `period` TR values.
347
+ # The first ATR value will be the SMA of the first `period` TR values.
348
+ # Subsequent values use the EMA formula.
349
+ # Pandas ewm with adjust=False and alpha = 1/N directly implements Wilder's smoothing.
350
+ atr = true_range.ewm(alpha=1/period, adjust=False, min_periods=period).mean()
351
+
352
+ return atr
353
+
354
+ except Exception as e:
355
+ print(f"Error calculating ATR: {e}")
356
+ return None
357
+
358
+ # --- Helper Function to Initialize CCXT Exchange ---
359
+ async def get_exchange_instance(
360
+ exchange_id: str,
361
+ api_key_info: Optional[Dict[str, str]] = None,
362
+ exchange_config_options: Optional[Dict] = None # Added to handle options like defaultType
363
+ ) -> ccxtasync.Exchange:
364
+ """
365
+ Asynchronously initializes and returns a CCXT exchange instance.
366
+ This function serves as a common utility to create authenticated or unauthenticated
367
+ exchange instances for interacting with various cryptocurrency exchanges.
368
+
369
+ (Note: Caching of instances is not currently implemented in this helper).
370
+
371
+ If `api_key_info` is provided, an authenticated instance is created,
372
+ suitable for private API calls (e.g., trading, balance fetching).
373
+ Otherwise, an unauthenticated instance is returned, suitable for public API calls
374
+ (e.g., fetching market data, tickers).
375
+
376
+ Args:
377
+ exchange_id: The lowercase string ID of the exchange (e.g., 'binance', 'kucoin', 'upbit').
378
+ This ID is used to dynamically load the appropriate CCXT exchange class.
379
+ api_key_info: Optional dictionary containing API credentials.
380
+ Expected keys: 'apiKey', 'secret'.
381
+ Some exchanges might also require a 'password' (for passphrase).
382
+ Example: `{'apiKey': 'YOUR_API_KEY', 'secret': 'YOUR_SECRET'}`
383
+ exchange_config_options: Optional dictionary for CCXT client configurations.
384
+ This is crucial for specifying market types (e.g., spot, futures, options)
385
+ or other exchange-specific settings.
386
+ Example: `{'defaultType': 'future'}` for futures trading,
387
+ `{'options': {'adjustForTimeDifference': True}}` for time sync.
388
+
389
+ Returns:
390
+ An initialized asynchronous CCXT exchange instance (`ccxtasync.Exchange`).
391
+
392
+ Raises:
393
+ ccxtasync.ExchangeNotFound: If the `exchange_id` does not correspond to a supported
394
+ exchange in the `ccxtasync` library.
395
+ """
396
+ exchange_id_lower = exchange_id.lower()
397
+ try:
398
+ exchange_class = getattr(ccxtasync, exchange_id_lower)
399
+ except AttributeError:
400
+ raise ccxtasync.ExchangeNotFound(f"Exchange '{exchange_id_lower}' not found in ccxtasync library.")
401
+
402
+ config = {
403
+ 'enableRateLimit': True,
404
+ # 'verbose': True, # 디버깅 μ‹œ 유용
405
+ }
406
+ if api_key_info:
407
+ config.update(api_key_info)
408
+
409
+ if exchange_config_options: # Merge additional exchange-specific config options
410
+ config.update(exchange_config_options)
411
+
412
+ instance = exchange_class(config)
413
+ return instance
414
+
415
+ # --- MCP Tools for CCXT Functions (Async) ---
416
+
417
+ # Note: All tools now accept optional api_key, secret_key, and passphrase.
418
+ # However, tools performing private actions (e.g., fetching balance, creating orders)
419
+ # will return an error internally if these are not provided.
420
+
421
+ @mcp.tool(
422
+ name="fetch_account_balance",
423
+ description="Fetches the current balance of an account from a specified cryptocurrency exchange. "
424
+ "API authentication (api_key, secret_key) is handled externally. "
425
+ "Use the `params` argument to specify account type (e.g., spot, margin, futures) if the exchange requires it, "
426
+ "or to pass other exchange-specific parameters for fetching balances.",
427
+ tags={"account", "balance", "wallet", "funds", "private", "spot", "margin", "futures", "swap", "unified"}
428
+ )
429
+ async def fetch_balance_tool(
430
+ exchange_id: Annotated[str, Field(description="The ID of the exchange (e.g., 'binance', 'coinbasepro', 'upbit'). Case-insensitive.")],
431
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key for the exchange. If not directly provided, the system may use pre-configured credentials. Authentication is required for this operation.")] = None,
432
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key for the exchange. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
433
+ passphrase: Annotated[Optional[str], Field(description="Optional: Your API passphrase, if required by the exchange (e.g., for KuCoin, OKX). Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
434
+ params: Annotated[Optional[Dict], Field(description="Optional: Extra parameters for the CCXT `fetchBalance` call or for CCXT client instantiation. "
435
+ "Use this to specify market types (e.g., `{'type': 'margin'}` or `{'options': {'defaultType': 'future'}}`), "
436
+ "or pass other exchange-specific arguments. "
437
+ "Example: `{'type': 'funding'}` or `{'options': {'defaultType': 'swap'}, 'symbol': 'BTC/USDT:USDT'}` for specific balance types.")] = None
438
+ ) -> Dict:
439
+ """Internal use: Fetches account balance. Primary description is in @mcp.tool decorator."""
440
+ if not api_key or not secret_key:
441
+ return {"error": "API key and secret key are required for fetch_account_balance."}
442
+
443
+ tool_params = params.copy() if params else {}
444
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
445
+ if passphrase:
446
+ api_key_info_dict['password'] = passphrase
447
+
448
+ client_config_options = tool_params.pop('options', None)
449
+ exchange : ccxtasync.Exchange = None
450
+ try:
451
+ exchange = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
452
+ if not exchange.has['fetchBalance']:
453
+ return {"error": f"Exchange '{exchange_id}' does not support fetchBalance."}
454
+ balance = await exchange.fetchBalance(params=tool_params)
455
+ return balance
456
+ except CCXT_GENERAL_EXCEPTIONS as e:
457
+ return {"error": str(e)}
458
+ except ccxtasync.NotSupported as e: # Example of specific handling if needed, though covered by general if not separately handled
459
+ return {"error": f"Operation Not Supported: {str(e)}"} # More specific message
460
+ except Exception as e:
461
+ return {"error": f"An unexpected error occurred in fetch_account_balance: {str(e)}"}
462
+ finally:
463
+ if exchange:
464
+ await exchange.close()
465
+
466
+ @mcp.tool(
467
+ name="fetch_deposit_address",
468
+ description="Fetches the deposit address for a specific cryptocurrency on a given exchange. "
469
+ "API authentication (api_key, secret_key) is handled externally. "
470
+ "The `params` argument can be used to specify the network or chain if the currency supports multiple (e.g., ERC20, TRC20).",
471
+ tags={"account", "deposit", "address", "funding", "receive", "private", "crypto"}
472
+ )
473
+ async def fetch_deposit_address_tool(
474
+ exchange_id: Annotated[str, Field(description="The ID of the exchange (e.g., 'binance', 'kraken'). Case-insensitive.")],
475
+ code: Annotated[str, Field(description="Currency code to fetch the deposit address for (e.g., 'BTC', 'ETH', 'USDT').")],
476
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key for the exchange. If not directly provided, the system may use pre-configured credentials. Authentication is required for this operation.")] = None,
477
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key for the exchange. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
478
+ passphrase: Annotated[Optional[str], Field(description="Optional: Your API passphrase, if required by the exchange. Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
479
+ params: Annotated[Optional[Dict], Field(description="Optional: Extra parameters for the CCXT `fetchDepositAddress` call or for client instantiation. "
480
+ "Crucially, use this to specify the network/chain if the cryptocurrency exists on multiple networks. "
481
+ "Example: `{'network': 'TRC20'}` for USDT on Tron network, or `{'chain': 'BEP20'}`. "
482
+ "Can also include `{'options': ...}` for client-specific settings if needed.")] = None
483
+ ) -> Dict:
484
+ """Internal use: Fetches deposit address. Primary description is in @mcp.tool decorator."""
485
+ if not api_key or not secret_key:
486
+ return {"error": "API key and secret key are required for fetch_deposit_address."}
487
+
488
+ tool_params = params.copy() if params else {}
489
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
490
+ if passphrase:
491
+ api_key_info_dict['password'] = passphrase
492
+
493
+ client_config_options = tool_params.pop('options', None)
494
+ exchange : ccxtasync.Exchange = None
495
+ try:
496
+ exchange = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
497
+ if not exchange.has['fetchDepositAddress']:
498
+ return {"error": f"Exchange '{exchange_id}' does not support fetchDepositAddress."}
499
+ address_info = await exchange.fetchDepositAddress(code, params=tool_params)
500
+ return address_info
501
+ except CCXT_GENERAL_EXCEPTIONS as e:
502
+ return {"error": str(e)}
503
+ except ccxtasync.NotSupported as e:
504
+ return {"error": f"Operation Not Supported: {str(e)}"}
505
+ except Exception as e:
506
+ return {"error": f"An unexpected error occurred in fetch_deposit_address: {str(e)}"}
507
+ finally:
508
+ if exchange:
509
+ await exchange.close()
510
+
511
+ @mcp.tool(
512
+ name="withdraw_cryptocurrency",
513
+ description="Initiates a cryptocurrency withdrawal to a specified address. "
514
+ "API authentication (api_key, secret_key) and withdrawal permissions on the API key are handled externally. "
515
+ "Use `params` to specify the network/chain if required by the exchange or currency, and for any other exchange-specific withdrawal parameters.",
516
+ tags={"account", "withdrawal", "transaction", "send", "crypto", "private"}
517
+ )
518
+ async def withdraw_tool(
519
+ exchange_id: Annotated[str, Field(description="The ID of the exchange (e.g., 'binance', 'ftx'). Case-insensitive.")],
520
+ code: Annotated[str, Field(description="Currency code for the withdrawal (e.g., 'BTC', 'ETH', 'USDT').")],
521
+ amount: Annotated[float, Field(description="The amount of currency to withdraw. Must be greater than 0.", gt=0)],
522
+ address: Annotated[str, Field(description="The destination address for the withdrawal.")],
523
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key with withdrawal permissions. If not directly provided, the system may use pre-configured credentials. Authentication is required for this operation.")] = None,
524
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key for the API. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
525
+ tag: Annotated[Optional[str], Field(description="Optional: Destination tag, memo, or payment ID for certain currencies (e.g., XRP, XLM, EOS). Check exchange/currency requirements.")] = None,
526
+ passphrase: Annotated[Optional[str], Field(description="Optional: API passphrase if required by the exchange for withdrawals. Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
527
+ params: Annotated[Optional[Dict], Field(description="Optional: Extra parameters for the CCXT `withdraw` call or for client instantiation. "
528
+ "Use this to specify the network/chain (e.g., `{'network': 'BEP20'}`), especially if the currency supports multiple. "
529
+ "May also be used for two-factor authentication codes if supported/required by the exchange via CCXT, or other specific withdrawal options. "
530
+ "Example: `{'network': 'TRC20', 'feeToUser': False}`")] = None
531
+ ) -> Dict:
532
+ """Internal use: Withdraws cryptocurrency. Primary description is in @mcp.tool decorator."""
533
+ if not api_key or not secret_key:
534
+ return {"error": "API key and secret key are required for withdraw_cryptocurrency."}
535
+
536
+ tool_params = params.copy() if params else {}
537
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
538
+ if passphrase:
539
+ api_key_info_dict['password'] = passphrase
540
+
541
+ client_config_options = tool_params.pop('options', None)
542
+ exchange : ccxtasync.Exchange = None
543
+ try:
544
+ exchange = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
545
+ if not exchange.has['withdraw']:
546
+ return {"error": f"Exchange '{exchange_id}' does not support withdraw."}
547
+
548
+ withdrawal_info = await exchange.withdraw(code, amount, address, tag, params=tool_params)
549
+ return withdrawal_info
550
+ except CCXT_GENERAL_EXCEPTIONS as e:
551
+ return {"error": str(e)}
552
+ except ccxtasync.NotSupported as e:
553
+ return {"error": f"Operation Not Supported: {str(e)}"}
554
+ except Exception as e:
555
+ return {"error": f"An unexpected error occurred in withdraw_cryptocurrency: {str(e)}"}
556
+ finally:
557
+ if exchange:
558
+ await exchange.close()
559
+
560
+ @mcp.tool(
561
+ name="fetch_open_positions",
562
+ description="Fetches currently open positions for futures, swaps, or other derivatives from an exchange. "
563
+ "API authentication (api_key, secret_key) is handled externally. "
564
+ "CRITICAL: The CCXT client MUST be initialized for the correct market type (e.g., futures, swap) using `params`. "
565
+ "For example, pass `{'options': {'defaultType': 'future'}}` or `{'options': {'defaultType': 'swap'}}` in `params` if not default for the exchange.",
566
+ tags={"account", "positions", "futures", "derivatives", "swap", "margin_trading", "private"}
567
+ )
568
+ async def fetch_positions_tool(
569
+ exchange_id: Annotated[str, Field(description="The ID of the exchange that supports derivatives trading (e.g., 'binance', 'bybit', 'okx'). Case-insensitive.")],
570
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key for the exchange. If not directly provided, the system may use pre-configured credentials. Authentication is required for this operation.")] = None,
571
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key for the exchange. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
572
+ passphrase: Annotated[Optional[str], Field(description="Optional: Your API passphrase, if required by the exchange. Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
573
+ params: Annotated[Optional[Dict], Field(description="Optional: Extra parameters for the CCXT `fetchPositions` call AND for CCXT client instantiation. "
574
+ "CRITICAL for client setup: Include `{'options': {'defaultType': 'future'}}` (or 'swap', 'linear', 'inverse') to specify market type if not the exchange default. "
575
+ "For the API call: Can be used to filter positions by symbol(s) if supported by the exchange (e.g., `{'symbols': ['BTC/USDT:USDT', 'ETH/USDT:USDT']}`). "
576
+ "Example for client init: `{'options': {'defaultType': 'future'}}`. Example for call: `{'symbol': 'BTC/USDT:USDT'}`")] = None
577
+ ) -> Union[List[Dict], Dict]:
578
+ """Internal use: Fetches open positions. Primary description is in @mcp.tool decorator."""
579
+ if not api_key or not secret_key:
580
+ return {"error": "API key and secret key are required for fetch_open_positions."}
581
+
582
+ tool_params = params.copy() if params else {}
583
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
584
+ if passphrase:
585
+ api_key_info_dict['password'] = passphrase
586
+
587
+ client_config_options = tool_params.pop('options', None)
588
+ exchange : ccxtasync.Exchange = None
589
+ try:
590
+ exchange = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
591
+ if not exchange.has.get('fetchPositions'):
592
+ return {"error": f"Exchange '{exchange_id}' may not support fetchPositions or requires specific market type configuration (e.g., {{'options': {{'defaultType': 'future'}}}} passed at client instantiation)."}
593
+
594
+ positions = await exchange.fetchPositions(params=tool_params)
595
+ return positions
596
+ except CCXT_GENERAL_EXCEPTIONS as e:
597
+ return {"error": str(e)}
598
+ except ccxtasync.NotSupported as e: # Catch NotSupported here if it wasn't in CCXT_GENERAL_EXCEPTIONS
599
+ return {"error": f"FetchPositions Not Supported or requires specific config: {str(e)}"}
600
+ except Exception as e:
601
+ return {"error": f"An unexpected error occurred in fetch_open_positions: {str(e)}"}
602
+ finally:
603
+ if exchange:
604
+ await exchange.close()
605
+
606
+ @mcp.tool(
607
+ name="set_trading_leverage",
608
+ description="Sets the leverage for a specific trading symbol, typically in futures or margin markets. "
609
+ "API authentication (api_key, secret_key) is handled externally. "
610
+ "CRITICAL: Ensure the CCXT client is initialized for the correct market type (e.g., futures, margin) using `params` (e.g., `{'options': {'defaultType': 'future'}}`). "
611
+ "The `symbol` parameter may or may not be required depending on the exchange and whether setting leverage for all symbols or a specific one.",
612
+ tags={"trading", "leverage", "futures", "margin", "derivatives", "private"}
613
+ )
614
+ async def set_leverage_tool(
615
+ exchange_id: Annotated[str, Field(description="The ID of the exchange (e.g., 'binance', 'ftx'). Case-insensitive.")],
616
+ leverage: Annotated[int, Field(description="The desired leverage multiplier (e.g., 10 for 10x). Must be greater than 0.", gt=0)],
617
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key for the exchange. If not directly provided, the system may use pre-configured credentials. Authentication is required for this operation.")] = None,
618
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key for the exchange. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
619
+ symbol: Annotated[Optional[str], Field(description="Optional/Required: The symbol (e.g., 'BTC/USDT:USDT' for futures, 'BTC/USDT' for margin) to set leverage for. "
620
+ "Some exchanges require it, others set it account-wide or per market type. Check exchange documentation.")] = None,
621
+ passphrase: Annotated[Optional[str], Field(description="Optional: Your API passphrase, if required by the exchange. Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
622
+ params: Annotated[Optional[Dict], Field(description="Optional: Extra parameters for the CCXT `setLeverage` call AND for CCXT client instantiation. "
623
+ "CRITICAL for client setup: Include `{'options': {'defaultType': 'future'}}` or `{'options': {'defaultType': 'margin'}}` if applicable. "
624
+ "For the API call: May include parameters like `{'marginMode': 'isolated'}` or `{'marginMode': 'cross'}` if supported. "
625
+ "Example for client init: `{'options': {'defaultType': 'future'}}`. Example for call: `{'marginMode': 'isolated'}`")] = None
626
+ ) -> Dict:
627
+ """Internal use: Sets trading leverage. Primary description is in @mcp.tool decorator."""
628
+ if not api_key or not secret_key:
629
+ return {"error": "API key and secret key are required for set_trading_leverage."}
630
+
631
+ tool_params = params.copy() if params else {}
632
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
633
+ if passphrase:
634
+ api_key_info_dict['password'] = passphrase
635
+
636
+ client_config_options = tool_params.pop('options', None)
637
+ exchange : ccxtasync.Exchange = None
638
+ try:
639
+ exchange = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
640
+ response = await exchange.setLeverage(leverage, symbol, params=tool_params)
641
+ return response
642
+ except CCXT_GENERAL_EXCEPTIONS as e:
643
+ return {"error": str(e)}
644
+ except ccxtasync.NotSupported as e: # Keep NotSupported specific for this as per original logic
645
+ return {"error": f"Exchange '{exchange_id}' does not support the unified setLeverage with the provided arguments. Error: {str(e)}. You may need to use exchange-specific 'params' or check symbol requirements."}
646
+ except CCXT_GENERAL_EXCEPTIONS as e: # Catch other general exceptions after specific NotSupported
647
+ return {"error": str(e)}
648
+ except Exception as e:
649
+ return {"error": f"An unexpected error occurred in set_trading_leverage: {str(e)}"}
650
+ finally:
651
+ if exchange:
652
+ await exchange.close()
653
+
654
+ # --- Tools for Public/Unauthenticated CCXT Functions ---
655
+
656
+ @mcp.tool(
657
+ name="fetch_ohlcv",
658
+ description="Fetches historical Open-High-Low-Close-Volume (OHLCV) candlestick data for a specific trading symbol and timeframe. "
659
+ "Authentication (api_key, secret_key) is optional; some exchanges might provide more data or higher rate limits with authentication. "
660
+ "Use `params` for exchange-specific options, like requesting 'mark' or 'index' price OHLCV for derivatives, or to set `defaultType` for client instantiation if fetching for non-spot markets.",
661
+ tags={"market_data", "ohlcv", "candles", "candlestick", "chart", "historical_data", "public", "private", "spot", "futures", "swap"}
662
+ )
663
+ async def fetch_ohlcv_tool(
664
+ exchange_id: Annotated[str, Field(description="The ID of the exchange (e.g., 'binance', 'kraken'). Case-insensitive.")],
665
+ symbol: Annotated[str, Field(description="The trading symbol to fetch OHLCV data for (e.g., 'BTC/USDT', 'ETH/BTC', 'BTC/USDT:USDT' for futures).")],
666
+ timeframe: Annotated[str, Field(description="The length of time each candle represents (e.g., '1m', '5m', '1h', '1d', '1w'). Check exchange for supported timeframes.")],
667
+ since: Annotated[Optional[int], Field(description="Optional: The earliest time in milliseconds (UTC epoch) to fetch OHLCV data from (e.g., 1502962800000 for 2017-08-17T10:00:00Z).", ge=0)] = None,
668
+ limit: Annotated[Optional[int], Field(description="Optional: The maximum number of OHLCV candles to return. Check exchange for default and maximum limits.", gt=0)] = None,
669
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key for the exchange. If not provided, the system may use pre-configured credentials or proceed unauthenticated. If authentication is used (with directly provided or pre-configured keys), it may offer benefits like enhanced access or higher rate limits.")] = None,
670
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key for the exchange. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
671
+ passphrase: Annotated[Optional[str], Field(description="Optional: Your API passphrase, if required by the exchange. Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
672
+ params: Annotated[Optional[Dict], Field(description="Optional: Extra parameters for the CCXT `fetchOHLCV` call or for client instantiation. "
673
+ "For client init (if fetching non-spot): `{'options': {'defaultType': 'future'}}`. "
674
+ "For API call: To specify price type for derivatives (e.g., `{'price': 'mark'}` or `{'price': 'index'}`) or other exchange-specific query params. "
675
+ "Example for mark price candles: `{'options': {'defaultType': 'future'}, 'price': 'mark'}`")] = None
676
+ ) -> Union[List[List[Union[int, float]]], Dict]:
677
+ """Internal use: Fetches OHLCV data. Primary description is in @mcp.tool decorator."""
678
+ tool_params = params.copy() if params else {}
679
+ api_key_info_dict = None
680
+ if api_key and secret_key:
681
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
682
+ if passphrase:
683
+ api_key_info_dict['password'] = passphrase
684
+
685
+ client_config_options = tool_params.pop('options', None)
686
+ exchange : ccxtasync.Exchange = None
687
+ try:
688
+ exchange = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
689
+ if not exchange.has['fetchOHLCV']:
690
+ return {"error": f"Exchange '{exchange_id}' does not support fetchOHLCV."}
691
+
692
+ ohlcv_data = await exchange.fetchOHLCV(symbol, timeframe, since, limit, params=tool_params)
693
+ return ohlcv_data
694
+ except CCXT_GENERAL_EXCEPTIONS as e:
695
+ return {"error": str(e)}
696
+ except ccxtasync.NotSupported as e:
697
+ return {"error": f"Operation Not Supported: {str(e)}"}
698
+ except Exception as e:
699
+ return {"error": f"An unexpected error occurred in fetch_ohlcv: {str(e)}"}
700
+ finally:
701
+ if exchange:
702
+ await exchange.close()
703
+
704
+ @mcp.tool(
705
+ name="fetch_funding_rate",
706
+ description="Fetches the current or historical funding rate for a perpetual futures contract symbol. "
707
+ "Authentication is optional. "
708
+ "CRITICAL: For many exchanges, the CCXT client must be initialized for futures/swap markets using `params` (e.g., `{'options': {'defaultType': 'future'}}`). "
709
+ "If `fetchFundingRate` is not supported, the exchange might support `fetchFundingRates` (plural) for multiple symbols or historical rates; check error messages or use a more specific tool if available.",
710
+ tags={"market_data", "funding_rate", "futures", "swap", "perpetual", "public", "private"}
711
+ )
712
+ async def fetch_funding_rate_tool(
713
+ exchange_id: Annotated[str, Field(description="The ID of the exchange (e.g., 'binance', 'bybit'). Case-insensitive.")],
714
+ symbol: Annotated[str, Field(description="The symbol to fetch the funding rate for (e.g., 'BTC/USDT:USDT', 'ETH-PERP'). Ensure correct perpetual contract symbol format for the exchange.")],
715
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key for the exchange. If not provided, the system may use pre-configured credentials or proceed unauthenticated. If authentication is used (with directly provided or pre-configured keys), it may offer benefits like enhanced access or higher rate limits.")] = None,
716
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key for the exchange. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
717
+ passphrase: Annotated[Optional[str], Field(description="Optional: Your API passphrase, if required by the exchange. Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
718
+ params: Annotated[Optional[Dict], Field(description="Optional: Extra parameters for CCXT `fetchFundingRate` call or client instantiation. "
719
+ "CRITICAL for client setup: Include `{'options': {'defaultType': 'future'}}` or `{'options': {'defaultType': 'swap'}}` for correct market type. "
720
+ "For API call: May be used for historical rates if supported (e.g., `{'since': timestamp, 'limit': N}`). "
721
+ "Example for client init: `{'options': {'defaultType': 'future'}}`")] = None
722
+ ) -> Dict:
723
+ """Internal use: Fetches funding rate. Primary description is in @mcp.tool decorator."""
724
+ tool_params = params.copy() if params else {}
725
+ api_key_info_dict = None
726
+ if api_key and secret_key:
727
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
728
+ if passphrase:
729
+ api_key_info_dict['password'] = passphrase
730
+
731
+ client_config_options = tool_params.pop('options', None)
732
+ exchange : ccxtasync.Exchange = None
733
+ try:
734
+ exchange = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
735
+ if not exchange.has['fetchFundingRate']:
736
+ if exchange.has['fetchFundingRates']:
737
+ return {"error": f"Exchange '{exchange_id}' supports fetchFundingRates (plural). Try that or check symbol format if fetchFundingRate (singular) is not supported."}
738
+ return {"error": f"Exchange '{exchange_id}' does not support fetchFundingRate."}
739
+
740
+ funding_rate = await exchange.fetchFundingRate(symbol, params=tool_params)
741
+ return funding_rate
742
+ except CCXT_GENERAL_EXCEPTIONS as e:
743
+ return {"error": str(e)}
744
+ except ccxtasync.NotSupported as e:
745
+ return {"error": f"Operation Not Supported: {str(e)}"}
746
+ except Exception as e:
747
+ return {"error": f"An unexpected error occurred in fetch_funding_rate: {str(e)}"}
748
+ finally:
749
+ if exchange:
750
+ await exchange.close()
751
+
752
+ @mcp.tool(
753
+ name="fetch_long_short_ratio",
754
+ description="Fetches the long/short ratio for a symbol, typically for futures markets, by calling exchange-specific (implicit) CCXT methods. "
755
+ "Authentication is optional. Requires specifying the `method_name` and `method_params` within the `params` argument. "
756
+ "Client may need to be initialized for futures/swap markets via `params` (e.g., `{'options': {'defaultType': 'future'}}`).",
757
+ tags={"market_data", "sentiment", "long_short_ratio", "futures", "derivatives", "public", "private", "exchange_specific"}
758
+ )
759
+ async def fetch_long_short_ratio_tool(
760
+ exchange_id: Annotated[str, Field(description="The ID of the exchange (e.g., 'binance', 'bybit'). Case-insensitive.")],
761
+ symbol: Annotated[str, Field(description="The symbol to fetch the long/short ratio for (e.g., 'BTC/USDT', 'BTC/USDT:USDT'). Format depends on the specific exchange method.")],
762
+ timeframe: Annotated[str, Field(description="Timeframe for the ratio data (e.g., '5m', '1h', '4h', '1d'). Format depends on the specific exchange method.")],
763
+ limit: Annotated[Optional[int], Field(description="Optional: Number of data points to retrieve. Depends on the specific exchange method.", gt=0)] = None,
764
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key for the exchange. If not provided, the system may use pre-configured credentials or proceed unauthenticated. If authentication is used (with directly provided or pre-configured keys), it may offer benefits like enhanced access or higher rate limits.")] = None,
765
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key for the exchange. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
766
+ passphrase: Annotated[Optional[str], Field(description="Optional: Your API passphrase, if required by the exchange. Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
767
+ params: Annotated[Optional[Dict], Field(description="CRUCIAL: Must contain `method_name` (string: the exact CCXT implicit method name, e.g., 'publicGetFuturesDataOpenInterestHist') and `method_params` (dict: arguments for that method). "
768
+ "Can also include `{'options': {'defaultType': 'future'}}` for client instantiation if needed. "
769
+ "Example: `{'options': {'defaultType': 'future'}, 'method_name': 'fapiPublicGetGlobalLongShortAccountRatio', 'method_params': {'period': '5m'}}`")] = None
770
+ ) -> Dict:
771
+ """Internal use: Fetches long/short ratio. Primary description is in @mcp.tool decorator."""
772
+ tool_params = params.copy() if params else {}
773
+ api_key_info_dict = None
774
+ if api_key and secret_key:
775
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
776
+ if passphrase:
777
+ api_key_info_dict['password'] = passphrase
778
+
779
+ client_config_options = tool_params.pop('options', None)
780
+ exchange : ccxtasync.Exchange = None
781
+ try:
782
+ exchange = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
783
+
784
+ method_name = tool_params.pop('method_name', None)
785
+ method_args_from_params = tool_params.pop('method_params', {})
786
+
787
+ if method_name and hasattr(exchange, method_name):
788
+ call_args = {'symbol': symbol, 'timeframe': timeframe}
789
+ if limit is not None:
790
+ call_args['limit'] = limit
791
+ call_args.update(method_args_from_params)
792
+
793
+ target_method = getattr(exchange, method_name)
794
+ if asyncio.iscoroutinefunction(target_method):
795
+ data = await target_method(call_args)
796
+ else:
797
+ data = target_method(call_args)
798
+ return data
799
+ else:
800
+ return {"error": f"fetchLongShortRatio is not standard or method_name missing. Exchange '{exchange_id}' may not have '{method_name}'. Provide 'method_name' and 'method_params' in 'params'."}
801
+ except CCXT_GENERAL_EXCEPTIONS as e:
802
+ return {"error": str(e)}
803
+ except ccxtasync.NotSupported as e: # If method_name was valid but underlying CCXT call is not supported for the exchange
804
+ return {"error": f"Implicit method call Not Supported: {str(e)}"}
805
+ except Exception as e:
806
+ return {"error": f"An unexpected error occurred in fetch_long_short_ratio: {str(e)}"}
807
+ finally:
808
+ if exchange:
809
+ await exchange.close()
810
+
811
+ @mcp.tool(
812
+ name="fetch_option_contract_data",
813
+ description="Fetches market data (typically ticker data) for a specific options contract. "
814
+ "Authentication is optional. "
815
+ "For many exchanges, the CCXT client may need to be initialized for options markets using `params` (e.g., `{'options': {'defaultType': 'option'}}`).",
816
+ tags={"market_data", "options", "ticker", "contract_data", "derivatives", "public", "private"}
817
+ )
818
+ async def fetch_option_tool(
819
+ exchange_id: Annotated[str, Field(description="The ID of the exchange that supports options trading (e.g., 'deribit', 'okx'). Case-insensitive.")],
820
+ symbol: Annotated[str, Field(description="The specific option contract symbol (e.g., 'BTC-28JUN24-70000-C' on Deribit). Format is exchange-specific.")],
821
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key for the exchange. If not provided, the system may use pre-configured credentials or proceed unauthenticated. If authentication is used (with directly provided or pre-configured keys), it may offer benefits like enhanced access or higher rate limits.")] = None,
822
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key for the exchange. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
823
+ passphrase: Annotated[Optional[str], Field(description="Optional: Your API passphrase, if required by the exchange. Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
824
+ params: Annotated[Optional[Dict], Field(description="Optional: Extra parameters for CCXT `fetchTicker` (or other relevant fetch calls for options) AND for client instantiation. "
825
+ "For client setup: Include `{'options': {'defaultType': 'option'}}` or similar for correct market type if needed. "
826
+ "For API call: May include exchange-specific params if `fetchTicker` is used or for other option data methods. "
827
+ "Example for client init: `{'options': {'defaultType': 'option'}}`")] = None
828
+ ) -> Dict:
829
+ """Internal use: Fetches option contract data. Primary description is in @mcp.tool decorator."""
830
+ tool_params = params.copy() if params else {}
831
+ api_key_info_dict = None
832
+ if api_key and secret_key:
833
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
834
+ if passphrase:
835
+ api_key_info_dict['password'] = passphrase
836
+
837
+ client_config_options = tool_params.pop('options', None)
838
+ exchange : ccxtasync.Exchange = None
839
+ try:
840
+ exchange = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
841
+
842
+ if exchange.has['fetchTicker']:
843
+ option_data = await exchange.fetchTicker(symbol, params=tool_params)
844
+ return option_data
845
+ else:
846
+ return {"error": f"Exchange '{exchange_id}' does not have a standard fetchOption or fetchTicker."}
847
+ except CCXT_GENERAL_EXCEPTIONS as e:
848
+ return {"error": str(e)}
849
+ except ccxtasync.NotSupported as e:
850
+ return {"error": f"Operation Not Supported: {str(e)}"}
851
+ except Exception as e:
852
+ return {"error": f"An unexpected error occurred in fetch_option_contract_data: {str(e)}"}
853
+ finally:
854
+ if exchange:
855
+ await exchange.close()
856
+
857
+ @mcp.tool(
858
+ name="fetch_market_ticker",
859
+ description="Fetches the latest ticker data for a specific trading symbol (e.g., price, volume, spread). "
860
+ "Authentication is optional; some exchanges might provide more data or higher rate limits with authentication. "
861
+ "If fetching for non-spot markets (futures, options, swaps), ensure the CCXT client is initialized correctly using `params` (e.g., `{'options': {'defaultType': 'future'}}`).",
862
+ tags={"market_data", "ticker", "price", "last_price", "volume", "public", "private", "spot", "futures", "options", "swap"}
863
+ )
864
+ async def fetch_ticker_tool(
865
+ exchange_id: Annotated[str, Field(description="The ID of the exchange (e.g., 'binance', 'coinbase'). Case-insensitive.")],
866
+ symbol: Annotated[str, Field(description="The symbol to fetch the ticker for (e.g., 'BTC/USDT', 'ETH/USD', 'BTC/USDT:USDT' for futures, 'BTC-28JUN24-70000-C' for options). Format depends on the market type and exchange.")],
867
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key for the exchange. If not provided, the system may use pre-configured credentials or proceed unauthenticated. If authentication is used (with directly provided or pre-configured keys), it may offer benefits like enhanced access or higher rate limits.")] = None,
868
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key for the exchange. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
869
+ passphrase: Annotated[Optional[str], Field(description="Optional: Your API passphrase, if required by the exchange. Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
870
+ params: Annotated[Optional[Dict], Field(description="Optional: Extra parameters for the CCXT `fetchTicker` call or for client instantiation. "
871
+ "For client init (if non-spot): `{'options': {'defaultType': 'future'}}` or `{'options': {'defaultType': 'option'}}`. "
872
+ "For API call: May include exchange-specific params if the exchange offers variations on ticker data. "
873
+ "Example for futures ticker: `{'options': {'defaultType': 'future'}}`")] = None
874
+ ) -> Dict:
875
+ """Internal use: Fetches market ticker. Primary description is in @mcp.tool decorator."""
876
+ tool_params = params.copy() if params else {}
877
+ api_key_info_dict = None
878
+ if api_key and secret_key:
879
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
880
+ if passphrase:
881
+ api_key_info_dict['password'] = passphrase
882
+
883
+ client_config_options = tool_params.pop('options', None)
884
+ exchange : ccxtasync.Exchange = None
885
+ try:
886
+ exchange = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
887
+ if not exchange.has['fetchTicker']:
888
+ return {"error": f"Exchange '{exchange_id}' does not support fetchTicker."}
889
+ ticker = await exchange.fetchTicker(symbol, params=tool_params)
890
+ return ticker
891
+ except CCXT_GENERAL_EXCEPTIONS as e:
892
+ return {"error": str(e)}
893
+ except ccxtasync.NotSupported as e:
894
+ return {"error": f"Operation Not Supported: {str(e)}"}
895
+ except Exception as e:
896
+ return {"error": f"An unexpected error occurred in fetch_market_ticker: {str(e)}"}
897
+ finally:
898
+ if exchange:
899
+ await exchange.close()
900
+
901
+ @mcp.tool(
902
+ name="fetch_public_market_trades",
903
+ description="Fetches recent public trades for a specific trading symbol. Does not require authentication, but providing API keys might increase rate limits or access. "
904
+ "If fetching for non-spot markets (futures, options, swaps), ensure the CCXT client is initialized correctly using `params` (e.g., `{'options': {'defaultType': 'future'}}`).",
905
+ tags={"market_data", "trades", "executions", "history", "public", "private", "spot", "futures", "options", "swap"}
906
+ )
907
+ async def fetch_trades_tool(
908
+ exchange_id: Annotated[str, Field(description="The ID of the exchange (e.g., 'binance', 'kraken'). Case-insensitive.")],
909
+ symbol: Annotated[str, Field(description="The symbol to fetch public trades for (e.g., 'BTC/USDT', 'ETH/USD', 'BTC/USDT:USDT' for futures). Format depends on the market type and exchange.")],
910
+ since: Annotated[Optional[int], Field(description="Optional: Timestamp in milliseconds (UTC epoch) to fetch trades since (e.g., 1609459200000 for 2021-01-01T00:00:00Z).", ge=0)] = None,
911
+ limit: Annotated[Optional[int], Field(description="Optional: Maximum number of trades to fetch. Check exchange for default and maximum limits.", gt=0)] = None,
912
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key for the exchange. If not provided, the system may use pre-configured credentials or proceed unauthenticated. If authentication is used (with directly provided or pre-configured keys), it may offer benefits like enhanced access or higher rate limits.")] = None,
913
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key for the exchange. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
914
+ passphrase: Annotated[Optional[str], Field(description="Optional: Your API passphrase, if required by the exchange. Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
915
+ params: Annotated[Optional[Dict], Field(description="Optional: Extra parameters for the CCXT `fetchTrades` call or for client instantiation. "
916
+ "For client init (if non-spot): `{'options': {'defaultType': 'future'}}` or `{'options': {'defaultType': 'option'}}`. "
917
+ "For API call: May include exchange-specific pagination or filtering parameters. "
918
+ "Example for futures trades: `{'options': {'defaultType': 'future'}}`")] = None
919
+ ) -> Union[List[Dict], Dict]:
920
+ """Internal use: Fetches public market trades. Primary description is in @mcp.tool decorator."""
921
+ tool_params = params.copy() if params else {}
922
+ api_key_info_dict = None
923
+ if api_key and secret_key:
924
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
925
+ if passphrase:
926
+ api_key_info_dict['password'] = passphrase
927
+
928
+ client_config_options = tool_params.pop('options', None)
929
+ exchange : ccxtasync.Exchange = None
930
+ try:
931
+ exchange = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
932
+ if not exchange.has['fetchTrades']:
933
+ return {"error": f"Exchange '{exchange_id}' does not support fetchTrades."}
934
+ trades = await exchange.fetchTrades(symbol, since, limit, params=tool_params)
935
+ return trades
936
+ except CCXT_GENERAL_EXCEPTIONS as e:
937
+ return {"error": str(e)}
938
+ except ccxtasync.NotSupported as e:
939
+ return {"error": f"Operation Not Supported: {str(e)}"}
940
+ except Exception as e:
941
+ return {"error": f"An unexpected error occurred in fetch_public_market_trades: {str(e)}"}
942
+ finally:
943
+ if exchange:
944
+ await exchange.close()
945
+
946
+ # --- Tools Requiring API Authentication ---
947
+
948
+ @mcp.tool(
949
+ name="create_spot_limit_order",
950
+ description="Places a new limit order in the spot market. "
951
+ "API authentication (api_key, secret_key) and trading permissions on the API key are handled externally. "
952
+ "Use `params` for exchange-specific order parameters like `clientOrderId`, `postOnly`, or time-in-force policies (e.g., `{'timeInForce': 'FOK'}`).",
953
+ tags={"trading", "order", "create", "spot", "limit", "buy", "sell", "private"}
954
+ )
955
+ async def create_spot_limit_order_tool(
956
+ exchange_id: Annotated[str, Field(description="The ID of the exchange (e.g., 'binance', 'coinbasepro'). Case-insensitive.")],
957
+ symbol: Annotated[str, Field(description="The spot market symbol to trade (e.g., 'BTC/USDT', 'ETH/BTC').")],
958
+ side: Annotated[Literal["buy", "sell"], Field(description="Order side: 'buy' to purchase the base asset, 'sell' to sell it.")],
959
+ amount: Annotated[float, Field(description="The quantity of the base currency to trade. Must be greater than 0.", gt=0)],
960
+ price: Annotated[float, Field(description="The price at which to place the limit order. Must be greater than 0.", gt=0)],
961
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key with trading permissions. If not directly provided, the system may use pre-configured credentials. Authentication is required for this operation.")] = None,
962
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key for the API. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
963
+ passphrase: Annotated[Optional[str], Field(description="Optional: API passphrase if required by the exchange for trading. Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
964
+ params: Annotated[Optional[Dict], Field(description="Optional: Extra parameters for the CCXT `createOrder` call. "
965
+ "Common uses include `{'clientOrderId': 'your_custom_id'}` for custom order identification, "
966
+ "or specifying order properties like `{'postOnly': True}` (maker-only) or time-in-force policies (e.g., `{'timeInForce': 'GTC' / 'IOC' / 'FOK'}`). "
967
+ "Example: `{'clientOrderId': 'my_spot_order_123', 'timeInForce': 'FOK'}`. "
968
+ "No `options` for client instantiation are typically needed for spot orders unless the exchange has specific requirements.")] = None
969
+ ) -> Dict:
970
+ """Internal use: Creates a spot limit order. Primary description is in @mcp.tool decorator."""
971
+ if not api_key or not secret_key:
972
+ return {"error": "API key and secret key are required for create_spot_limit_order."}
973
+
974
+ tool_params = params.copy() if params else {}
975
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
976
+ if passphrase:
977
+ api_key_info_dict['password'] = passphrase
978
+
979
+ client_config_options = tool_params.pop('options', None)
980
+ exchange : ccxtasync.Exchange = None
981
+ try:
982
+ exchange = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
983
+ if not exchange.has['createOrder']:
984
+ # Even if createOrder is marked as false, specific order methods might exist.
985
+ # We'll rely on hasattr for the specific methods.
986
+ pass
987
+
988
+ if side == "buy":
989
+ if not hasattr(exchange, 'create_limit_buy_order'):
990
+ return {"error": f"Exchange '{exchange_id}' does not support create_limit_buy_order via a dedicated method. Falling back to createOrder."}
991
+ order = await exchange.create_limit_buy_order(symbol, amount, price, params=tool_params)
992
+ elif side == "sell":
993
+ if not hasattr(exchange, 'create_limit_sell_order'):
994
+ return {"error": f"Exchange '{exchange_id}' does not support create_limit_sell_order via a dedicated method. Falling back to createOrder."}
995
+ order = await exchange.create_limit_sell_order(symbol, amount, price, params=tool_params)
996
+ else:
997
+ return {"error": f"Invalid side: {side}. Must be 'buy' or 'sell'."}
998
+
999
+ # order = await exchange.createOrder(symbol, 'limit', side, amount, price, params=tool_params)
1000
+ return order
1001
+ except CCXT_GENERAL_EXCEPTIONS as e:
1002
+ return {"error": str(e)}
1003
+ except ccxtasync.NotSupported as e:
1004
+ return {"error": f"Order creation Not Supported: {str(e)}"}
1005
+ except Exception as e:
1006
+ return {"error": f"An unexpected error occurred in create_spot_limit_order: {str(e)}"}
1007
+ finally:
1008
+ if exchange:
1009
+ await exchange.close()
1010
+
1011
+ @mcp.tool(
1012
+ name="create_spot_market_order",
1013
+ description="Places a new market order in the spot market, to be filled at the best available current price. "
1014
+ "API authentication (api_key, secret_key) and trading permissions on the API key are handled externally. "
1015
+ "Use `params` for exchange-specific order parameters like `clientOrderId` or quote order quantity (if supported).",
1016
+ tags={"trading", "order", "create", "spot", "market", "buy", "sell", "private"}
1017
+ )
1018
+ async def create_spot_market_order_tool(
1019
+ exchange_id: Annotated[str, Field(description="The ID of the exchange (e.g., 'binance', 'kraken'). Case-insensitive.")],
1020
+ symbol: Annotated[str, Field(description="The spot market symbol to trade (e.g., 'BTC/USDT', 'ETH/EUR').")],
1021
+ side: Annotated[Literal["buy", "sell"], Field(description="Order side: 'buy' to purchase the base asset, 'sell' to sell it.")],
1022
+ amount: Annotated[float, Field(description="The quantity of the base currency to trade (for a market buy, unless 'createMarketBuyOrderRequiresPrice' is False, then it's the quote currency amount for some exchanges like Upbit) or the quantity to sell (for a market sell). Must be greater than 0.", gt=0)],
1023
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key with trading permissions. If not directly provided, the system may use pre-configured credentials. Authentication is required for this operation.")] = None,
1024
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key for the API. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
1025
+ passphrase: Annotated[Optional[str], Field(description="Optional: API passphrase if required by the exchange for trading. Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
1026
+ params: Annotated[Optional[Dict], Field(description="Optional: Extra parameters for the CCXT `createOrder` call. "
1027
+ "Common uses include `{'clientOrderId': 'your_custom_id'}`. "
1028
+ "For market buy orders, some exchanges allow `{'quoteOrderQty': quote_amount}` to specify the amount in quote currency (e.g., spend 100 USDT on BTC). "
1029
+ "For exchanges like Upbit market buy, you might need to pass `{'createMarketBuyOrderRequiresPrice': False}` if `amount` represents the total cost in quote currency. "
1030
+ "Example: `{'clientOrderId': 'my_market_buy_001', 'quoteOrderQty': 100}`. "
1031
+ "No `options` for client instantiation are typically needed for spot orders.")] = None
1032
+ ) -> Dict:
1033
+ """Internal use: Creates a spot market order. Primary description is in @mcp.tool decorator."""
1034
+ if not api_key or not secret_key:
1035
+ return {"error": "API key and secret key are required for create_spot_market_order."}
1036
+
1037
+ tool_params = params.copy() if params else {}
1038
+ # For Upbit market buy orders, 'amount' is the total cost in KRW.
1039
+ # CCXT requires 'createMarketBuyOrderRequiresPrice': False to be set in params.
1040
+ if exchange_id.lower() == 'upbit' and side == 'buy':
1041
+ tool_params['createMarketBuyOrderRequiresPrice'] = False
1042
+
1043
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
1044
+ if passphrase:
1045
+ api_key_info_dict['password'] = passphrase
1046
+
1047
+ client_config_options = tool_params.pop('options', None)
1048
+ exchange : ccxtasync.Exchange = None
1049
+ try:
1050
+ exchange = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
1051
+ if not exchange.has['createOrder']:
1052
+ # Even if createOrder is marked as false, specific order methods might exist.
1053
+ # We'll rely on hasattr for the specific methods.
1054
+ pass
1055
+
1056
+ if side == "buy":
1057
+ if not hasattr(exchange, 'create_market_buy_order'):
1058
+ return {"error": f"Exchange '{exchange_id}' does not support create_market_buy_order via a dedicated method. Falling back to createOrder."}
1059
+ order = await exchange.create_market_buy_order(symbol, amount, params=tool_params)
1060
+ elif side == "sell":
1061
+ if not hasattr(exchange, 'create_market_sell_order'):
1062
+ return {"error": f"Exchange '{exchange_id}' does not support create_market_sell_order via a dedicated method. Falling back to createOrder."}
1063
+ order = await exchange.create_market_sell_order(symbol, amount, params=tool_params)
1064
+ else:
1065
+ return {"error": f"Invalid side: {side}. Must be 'buy' or 'sell'."}
1066
+
1067
+ # order = await exchange.createOrder(symbol, 'market', side, amount, params=tool_params)
1068
+ return order
1069
+ except CCXT_GENERAL_EXCEPTIONS as e:
1070
+ return {"error": str(e)}
1071
+ except ccxtasync.NotSupported as e:
1072
+ return {"error": f"Order creation Not Supported: {str(e)}"}
1073
+ except Exception as e:
1074
+ return {"error": f"An unexpected error occurred in create_spot_market_order: {str(e)}"}
1075
+ finally:
1076
+ if exchange:
1077
+ await exchange.close()
1078
+
1079
+ @mcp.tool(
1080
+ name="create_futures_limit_order",
1081
+ description="Places a new limit order in a futures/swap market. "
1082
+ "API authentication (api_key, secret_key) and trading permissions are handled externally. "
1083
+ "CRITICAL: The CCXT client MUST be initialized for the correct market type (e.g., 'future', 'swap') using `params` (e.g., `{'options': {'defaultType': 'future'}}`). "
1084
+ "Use `params` also for exchange-specific order parameters like `clientOrderId`, `postOnly`, `reduceOnly`, `timeInForce`.",
1085
+ tags={"trading", "order", "create", "futures", "swap", "derivatives", "limit", "buy", "sell", "private"}
1086
+ )
1087
+ async def create_futures_limit_order_tool(
1088
+ exchange_id: Annotated[str, Field(description="The ID of the exchange that supports futures/swap trading (e.g., 'binance', 'bybit', 'okx'). Case-insensitive.")],
1089
+ symbol: Annotated[str, Field(description="The futures/swap contract symbol to trade (e.g., 'BTC/USDT:USDT', 'ETH-PERP'). Format is exchange-specific.")],
1090
+ side: Annotated[Literal["buy", "sell"], Field(description="Order side: 'buy' for a long position, 'sell' for a short position.")],
1091
+ amount: Annotated[float, Field(description="The quantity of contracts or base currency to trade. Must be greater than 0.", gt=0)],
1092
+ price: Annotated[float, Field(description="The price at which to place the limit order. Must be greater than 0.", gt=0)],
1093
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key with trading permissions. If not directly provided, the system may use pre-configured credentials. Authentication is required for this operation.")] = None,
1094
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key for the API. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
1095
+ passphrase: Annotated[Optional[str], Field(description="Optional: API passphrase if required by the exchange for trading. Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
1096
+ params: Annotated[Optional[Dict], Field(description="Optional: Extra parameters for CCXT `createOrder` call AND for client instantiation. "
1097
+ "CRITICAL for client setup: Include `{'options': {'defaultType': 'future'}}` (or 'swap', 'linear', 'inverse' etc., depending on exchange and contract) to specify market type. "
1098
+ "For API call: Common uses include `{'clientOrderId': 'custom_id'}`, `{'postOnly': True}`, `{'reduceOnly': True}`, `{'timeInForce': 'GTC'}`. "
1099
+ "Example: `{'options': {'defaultType': 'future'}, 'reduceOnly': True, 'clientOrderId': 'my_fut_limit_001'}`")] = None
1100
+ ) -> Dict:
1101
+ """Internal use: Creates a futures limit order. Primary description is in @mcp.tool decorator."""
1102
+ if not api_key or not secret_key:
1103
+ return {"error": "API key and secret key are required for create_futures_limit_order."}
1104
+
1105
+ tool_params = params.copy() if params else {}
1106
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
1107
+ if passphrase:
1108
+ api_key_info_dict['password'] = passphrase
1109
+
1110
+ client_config_options = tool_params.pop('options', {'defaultType': 'future'}) # Default to future if not specified
1111
+ exchange : ccxtasync.Exchange = None
1112
+ try:
1113
+ exchange = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
1114
+ if not exchange.has['createOrder']:
1115
+ return {"error": f"Exchange '{exchange_id}' does not support createOrder for the configured market type."}
1116
+
1117
+ order = await exchange.createOrder(symbol, 'limit', side, amount, price, params=tool_params)
1118
+ return order
1119
+ except CCXT_GENERAL_EXCEPTIONS as e:
1120
+ return {"error": str(e)}
1121
+ except ccxtasync.NotSupported as e:
1122
+ return {"error": f"Order creation Not Supported: {str(e)}"}
1123
+ except Exception as e:
1124
+ return {"error": f"An unexpected error occurred in create_futures_limit_order: {str(e)}"}
1125
+ finally:
1126
+ if exchange:
1127
+ await exchange.close()
1128
+
1129
+ @mcp.tool(
1130
+ name="create_futures_market_order",
1131
+ description="Places a new market order in a futures/swap market, filled at the best available current price. "
1132
+ "API authentication (api_key, secret_key) and trading permissions are handled externally. "
1133
+ "CRITICAL: The CCXT client MUST be initialized for the correct market type (e.g., 'future', 'swap') using `params` (e.g., `{'options': {'defaultType': 'future'}}`). "
1134
+ "Use `params` also for exchange-specific parameters like `clientOrderId` or `reduceOnly`.",
1135
+ tags={"trading", "order", "create", "futures", "swap", "derivatives", "market", "buy", "sell", "private"}
1136
+ )
1137
+ async def create_futures_market_order_tool(
1138
+ exchange_id: Annotated[str, Field(description="The ID of the exchange that supports futures/swap trading (e.g., 'binance', 'bybit'). Case-insensitive.")],
1139
+ symbol: Annotated[str, Field(description="The futures/swap contract symbol to trade (e.g., 'BTC/USDT:USDT', 'ETH-PERP'). Format is exchange-specific.")],
1140
+ side: Annotated[Literal["buy", "sell"], Field(description="Order side: 'buy' for a long position, 'sell' for a short position.")],
1141
+ amount: Annotated[float, Field(description="The quantity of contracts or base currency to trade. Must be greater than 0.", gt=0)],
1142
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key with trading permissions. If not directly provided, the system may use pre-configured credentials. Authentication is required for this operation.")] = None,
1143
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key for the API. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
1144
+ passphrase: Annotated[Optional[str], Field(description="Optional: API passphrase if required by the exchange for trading. Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
1145
+ params: Annotated[Optional[Dict], Field(description="Optional: Extra parameters for CCXT `createOrder` call AND for client instantiation. "
1146
+ "CRITICAL for client setup: Include `{'options': {'defaultType': 'future'}}` (or 'swap', etc.) to specify market type. "
1147
+ "For API call: Common uses include `{'clientOrderId': 'custom_id'}`, `{'reduceOnly': True}`. "
1148
+ "Some exchanges might support `{'quoteOrderQty': quote_amount}` for market buys in quote currency, but this is less common for futures than spot. Check exchange docs. "
1149
+ "Example: `{'options': {'defaultType': 'future'}, 'reduceOnly': True, 'clientOrderId': 'my_fut_market_001'}`")] = None
1150
+ ) -> Dict:
1151
+ """Internal use: Creates a futures market order. Primary description is in @mcp.tool decorator."""
1152
+ if not api_key or not secret_key:
1153
+ return {"error": "API key and secret key are required for create_futures_market_order."}
1154
+
1155
+ tool_params = params.copy() if params else {}
1156
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
1157
+ if passphrase:
1158
+ api_key_info_dict['password'] = passphrase
1159
+
1160
+ client_config_options = tool_params.pop('options', {'defaultType': 'future'}) # Default to future if not specified
1161
+ exchange : ccxtasync.Exchange = None
1162
+ try:
1163
+ exchange = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
1164
+ if not exchange.has['createOrder']:
1165
+ return {"error": f"Exchange '{exchange_id}' does not support createOrder for the configured market type."}
1166
+
1167
+ order = await exchange.createOrder(symbol, 'market', side, amount, params=tool_params)
1168
+ return order
1169
+ except CCXT_GENERAL_EXCEPTIONS as e:
1170
+ return {"error": str(e)}
1171
+ except ccxtasync.NotSupported as e:
1172
+ return {"error": f"Order creation Not Supported: {str(e)}"}
1173
+ except Exception as e:
1174
+ return {"error": f"An unexpected error occurred in create_futures_market_order: {str(e)}"}
1175
+ finally:
1176
+ if exchange:
1177
+ await exchange.close()
1178
+
1179
+ @mcp.tool(
1180
+ name="cancel_order",
1181
+ description="Cancels an existing open order on an exchange. "
1182
+ "API authentication (api_key, secret_key) is handled externally. "
1183
+ "The `symbol` parameter is required by some exchanges, optional for others. "
1184
+ "If canceling an order in a non-spot market (futures, options), ensure the CCXT client is initialized correctly using `params` (e.g., `{'options': {'defaultType': 'future'}}`).",
1185
+ tags={"trading", "order", "cancel", "manage_order", "private"}
1186
+ )
1187
+ async def cancel_order_tool(
1188
+ exchange_id: Annotated[str, Field(description="The ID of the exchange (e.g., 'binance', 'ftx'). Case-insensitive.")],
1189
+ id: Annotated[str, Field(description="The order ID (string) of the order to be canceled.")],
1190
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key with trading permissions. If not directly provided, the system may use pre-configured credentials. Authentication is required for this operation.")] = None,
1191
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key for the API. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
1192
+ symbol: Annotated[Optional[str], Field(description="Optional/Required: The symbol of the order (e.g., 'BTC/USDT', 'BTC/USDT:USDT'). "
1193
+ "Required by some exchanges for `cancelOrder`, optional for others. Check exchange documentation.")] = None,
1194
+ passphrase: Annotated[Optional[str], Field(description="Optional: API passphrase if required by the exchange. Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
1195
+ params: Annotated[Optional[Dict], Field(description="Optional: Extra parameters for CCXT `cancelOrder` call or for client instantiation. "
1196
+ "For client init (if non-spot): `{'options': {'defaultType': 'future'}}` or `{'options': {'defaultType': 'option'}}`. "
1197
+ "For API call: Some exchanges might accept `clientOrderId` here if the main `id` is the exchange's ID, or other specific flags. "
1198
+ "Example for futures order cancel: `{'options': {'defaultType': 'future'}}`")] = None
1199
+ ) -> Dict:
1200
+ """Internal use: Cancels an order. Primary description is in @mcp.tool decorator."""
1201
+ if not api_key or not secret_key:
1202
+ return {"error": "API key and secret key are required for cancel_order."}
1203
+
1204
+ tool_params = params.copy() if params else {}
1205
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
1206
+ if passphrase:
1207
+ api_key_info_dict['password'] = passphrase
1208
+
1209
+ client_config_options = tool_params.pop('options', None)
1210
+ exchange : ccxtasync.Exchange = None
1211
+ try:
1212
+ exchange = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
1213
+ if not exchange.has['cancelOrder']:
1214
+ return {"error": f"Exchange '{exchange_id}' does not support cancelOrder."}
1215
+
1216
+ response = await exchange.cancelOrder(id, symbol, params=tool_params)
1217
+ return response
1218
+ except CCXT_GENERAL_EXCEPTIONS as e:
1219
+ return {"error": str(e)}
1220
+ except ccxtasync.NotSupported as e:
1221
+ return {"error": f"Cancel order Not Supported: {str(e)}"}
1222
+ except Exception as e:
1223
+ return {"error": f"An unexpected error occurred in cancel_order: {str(e)}"}
1224
+ finally:
1225
+ if exchange:
1226
+ await exchange.close()
1227
+
1228
+ @mcp.tool(
1229
+ name="fetch_order_history",
1230
+ description="Fetches a list of your orders (open, closed, canceled, etc.) for an account, optionally filtered by symbol, time, and limit. "
1231
+ "API authentication (api_key, secret_key) is handled externally. "
1232
+ "If fetching orders from a non-spot market (futures, options), ensure the CCXT client is initialized correctly using `params` (e.g., `{'options': {'defaultType': 'future'}}`). "
1233
+ "Some exchanges might use `fetchOrders` to get only open or closed orders by default; use `params` for finer control if supported (e.g. `{'status': 'open'}`).",
1234
+ tags={"account", "orders", "history", "trade_history", "manage_order", "private"}
1235
+ )
1236
+ async def fetch_orders_tool(
1237
+ exchange_id: Annotated[str, Field(description="The ID of the exchange (e.g., 'binance', 'kucoin'). Case-insensitive.")],
1238
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key. If not directly provided, the system may use pre-configured credentials. Authentication is required for this operation.")] = None,
1239
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
1240
+ symbol: Annotated[Optional[str], Field(description="Optional: The symbol (e.g., 'BTC/USDT', 'ETH/USDT:USDT') to fetch orders for. If omitted, orders for all symbols may be returned (exchange-dependent).")] = None,
1241
+ since: Annotated[Optional[int], Field(description="Optional: Timestamp in milliseconds (UTC epoch) to fetch orders created since this time.", ge=0)] = None,
1242
+ limit: Annotated[Optional[int], Field(description="Optional: Maximum number of orders to retrieve. Check exchange for default and maximum limits.", gt=0)] = None,
1243
+ passphrase: Annotated[Optional[str], Field(description="Optional: API passphrase if required by the exchange. Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
1244
+ params: Annotated[Optional[Dict], Field(description="Optional: Extra parameters for CCXT `fetchOrders` call or for client instantiation. "
1245
+ "For client init (if non-spot): `{'options': {'defaultType': 'future'}}`. "
1246
+ "For API call: Can be used to filter by order status (e.g., `{'status': 'open'/'closed'/'canceled'}` if supported), order type, or other exchange-specific filters. "
1247
+ "Example for open futures orders: `{'options': {'defaultType': 'future'}, 'status': 'open'}`")] = None
1248
+ ) -> Union[List[Dict], Dict]:
1249
+ """Internal use: Fetches order history. Primary description is in @mcp.tool decorator."""
1250
+ if not api_key or not secret_key:
1251
+ return {"error": "API key and secret key are required for fetch_order_history."}
1252
+
1253
+ tool_params = params.copy() if params else {}
1254
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
1255
+ if passphrase:
1256
+ api_key_info_dict['password'] = passphrase
1257
+
1258
+ client_config_options = tool_params.pop('options', None)
1259
+ exchange : ccxtasync.Exchange = None
1260
+ try:
1261
+ exchange = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
1262
+ if not exchange.has['fetchOrders']:
1263
+ if exchange.has['fetchOpenOrders'] or exchange.has['fetchClosedOrders']:
1264
+ return {"error": f"Exchange '{exchange_id}' does not support fetchOrders directly. Try fetchOpenOrders_tool or fetchClosedOrders_tool if available (not currently implemented as separate tools)."}
1265
+ return {"error": f"Exchange '{exchange_id}' does not support fetchOrders."}
1266
+
1267
+ orders = await exchange.fetchOrders(symbol, since, limit, params=tool_params)
1268
+ return orders
1269
+ except CCXT_GENERAL_EXCEPTIONS as e:
1270
+ return {"error": str(e)}
1271
+ except ccxtasync.NotSupported as e:
1272
+ return {"error": f"Fetching orders Not Supported: {str(e)}"}
1273
+ except Exception as e:
1274
+ return {"error": f"An unexpected error occurred in fetch_order_history: {str(e)}"}
1275
+ finally:
1276
+ if exchange:
1277
+ await exchange.close()
1278
+
1279
+ @mcp.tool(
1280
+ name="fetch_my_trade_history",
1281
+ description="Fetches the history of your executed trades (fills) for an account, optionally filtered by symbol, time, and limit. "
1282
+ "API authentication (api_key, secret_key) is handled externally. "
1283
+ "If fetching trades from a non-spot market (futures, options), ensure the CCXT client is initialized correctly using `params` (e.g., `{'options': {'defaultType': 'future'}}`). "
1284
+ "Use `params` for any exchange-specific filtering not covered by standard arguments (e.g., filtering by orderId).",
1285
+ tags={"account", "trades", "executions", "fills", "history", "trade_history", "private"}
1286
+ )
1287
+ async def fetch_my_trades_tool(
1288
+ exchange_id: Annotated[str, Field(description="The ID of the exchange (e.g., 'binance', 'ftx'). Case-insensitive.")],
1289
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key. If not directly provided, the system may use pre-configured credentials. Authentication is required for this operation.")] = None,
1290
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
1291
+ symbol: Annotated[Optional[str], Field(description="Optional: The symbol (e.g., 'BTC/USDT', 'BTC/USDT:USDT') to fetch your trades for. If omitted, trades for all symbols may be returned (exchange-dependent).")] = None,
1292
+ since: Annotated[Optional[int], Field(description="Optional: Timestamp in milliseconds (UTC epoch) to fetch trades executed since this time.", ge=0)] = None,
1293
+ limit: Annotated[Optional[int], Field(description="Optional: Maximum number of trades to retrieve. Check exchange for default and maximum limits.", gt=0)] = None,
1294
+ passphrase: Annotated[Optional[str], Field(description="Optional: API passphrase if required by the exchange. Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
1295
+ params: Annotated[Optional[Dict], Field(description="Optional: Extra parameters for CCXT `fetchMyTrades` call or for client instantiation. "
1296
+ "For client init (if non-spot): `{'options': {'defaultType': 'future'}}`. "
1297
+ "For API call: Can be used for exchange-specific filters like `{'orderId': 'some_order_id'}` to fetch trades for a specific order, or other types of filtering. "
1298
+ "Example for trades of a specific futures order: `{'options': {'defaultType': 'future'}, 'orderId': '12345'}`")] = None
1299
+ ) -> Union[List[Dict], Dict]:
1300
+ """Internal use: Fetches user's trade history. Primary description is in @mcp.tool decorator."""
1301
+ if not api_key or not secret_key:
1302
+ return {"error": "API key and secret key are required for fetch_my_trade_history."}
1303
+
1304
+ tool_params = params.copy() if params else {}
1305
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
1306
+ if passphrase:
1307
+ api_key_info_dict['password'] = passphrase
1308
+
1309
+ client_config_options = tool_params.pop('options', None)
1310
+ exchange : ccxtasync.Exchange = None
1311
+ try:
1312
+ exchange = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
1313
+ if not exchange.has['fetchMyTrades']:
1314
+ return {"error": f"Exchange '{exchange_id}' does not support fetchMyTrades."}
1315
+
1316
+ my_trades = await exchange.fetchMyTrades(symbol, since, limit, params=tool_params)
1317
+ return my_trades
1318
+ except CCXT_GENERAL_EXCEPTIONS as e:
1319
+ return {"error": str(e)}
1320
+ except ccxtasync.NotSupported as e:
1321
+ return {"error": f"Fetching trades Not Supported: {str(e)}"}
1322
+ except Exception as e:
1323
+ return {"error": f"An unexpected error occurred in fetch_my_trade_history: {str(e)}"}
1324
+ finally:
1325
+ if exchange:
1326
+ await exchange.close()
1327
+
1328
+ # --- 기술적 μ§€ν‘œ 계산 도ꡬ (λ¦¬νŒ©ν† λ§) ---
1329
+ # νŒŒλΌλ―Έν„° νŒŒμ‹± 헬퍼 ν•¨μˆ˜
1330
+ def parse_indicator_params(indicator_params):
1331
+ """μ§€ν‘œ νŒŒλΌλ―Έν„°λ₯Ό νŒŒμ‹±ν•˜λŠ” ν•¨μˆ˜"""
1332
+ parsed_params_dict = {}
1333
+ if indicator_params and isinstance(indicator_params, str):
1334
+ try:
1335
+ parsed_params_dict = json.loads(indicator_params)
1336
+ except json.JSONDecodeError:
1337
+ # μ—λŸ¬ μ²˜λ¦¬λŠ” μƒμœ„ ν•¨μˆ˜μ—μ„œ 함
1338
+ pass
1339
+ elif isinstance(indicator_params, dict):
1340
+ parsed_params_dict = indicator_params
1341
+ return parsed_params_dict
1342
+
1343
+ # OHLCV 데이터 κ°€μ Έμ˜€κΈ°
1344
+ async def fetch_ohlcv_data(exchange_id, symbol, timeframe, limit, api_key=None, secret_key=None, passphrase=None, params=None):
1345
+ """μ§€μ •λœ κ±°λž˜μ†Œμ—μ„œ OHLCV 데이터λ₯Ό κ°€μ Έμ˜€λŠ” ν•¨μˆ˜"""
1346
+ api_key_info_dict = None
1347
+ if api_key and secret_key:
1348
+ api_key_info_dict = {'apiKey': api_key, 'secret': secret_key}
1349
+ if passphrase:
1350
+ api_key_info_dict['password'] = passphrase
1351
+
1352
+ fetch_params = params.copy() if params else {}
1353
+ client_config_options = fetch_params.pop('options', None)
1354
+
1355
+ exchange_instance = None
1356
+ try:
1357
+ exchange_instance = await get_exchange_instance(exchange_id, api_key_info=api_key_info_dict, exchange_config_options=client_config_options)
1358
+ if not exchange_instance.has['fetchOHLCV']:
1359
+ return None, f"Exchange '{exchange_id}' does not support fetchOHLCV."
1360
+
1361
+ ohlcv_data = await exchange_instance.fetchOHLCV(symbol, timeframe, limit=limit, params=fetch_params)
1362
+ if not ohlcv_data:
1363
+ return None, f"No OHLCV data returned for {symbol} on {exchange_id} with timeframe {timeframe}."
1364
+
1365
+ return ohlcv_data, None
1366
+ except CCXT_GENERAL_EXCEPTIONS as e:
1367
+ return None, f"CCXT Error: {str(e)}"
1368
+ except ccxtasync.NotSupported as e:
1369
+ return None, f"Operation Not Supported: {str(e)}"
1370
+ except Exception as e:
1371
+ return None, f"Unexpected error: {str(e)}"
1372
+ finally:
1373
+ if exchange_instance:
1374
+ await exchange_instance.close()
1375
+
1376
+ # λ°μ΄ν„°ν”„λ ˆμž„ μ€€λΉ„ ν•¨μˆ˜
1377
+ def prepare_dataframe(ohlcv_data, price_source_col):
1378
+ """OHLCV 데이터λ₯Ό DataFrame으둜 λ³€ν™˜ν•˜κ³  κ°€κ³΅ν•˜λŠ” ν•¨μˆ˜"""
1379
+ if not ohlcv_data:
1380
+ return None, "Failed to fetch OHLCV data, or no data available for the period."
1381
+
1382
+ df = pd.DataFrame(ohlcv_data, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume'])
1383
+ if df.empty:
1384
+ return None, "OHLCV data was empty after converting to DataFrame."
1385
+
1386
+ df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
1387
+ df.set_index('timestamp', inplace=True)
1388
+
1389
+ for col in ['open', 'high', 'low', 'close', 'volume']:
1390
+ df[col] = pd.to_numeric(df[col], errors='coerce')
1391
+ df.dropna(subset=['open', 'high', 'low', 'close'], inplace=True)
1392
+
1393
+ if df.empty:
1394
+ return None, "OHLCV data became empty after cleaning."
1395
+
1396
+ if price_source_col == 'hlc3':
1397
+ df['hlc3'] = (df['high'] + df['low'] + df['close']) / 3
1398
+ elif price_source_col == 'ohlc4':
1399
+ df['ohlc4'] = (df['open'] + df['high'] + df['low'] + df['close']) / 4
1400
+
1401
+ if price_source_col not in df.columns:
1402
+ return None, f"Specified 'price_source' column '{price_source_col}' could not be found or derived."
1403
+ if df[price_source_col].isnull().all():
1404
+ return None, f"The 'price_source' column '{price_source_col}' contains all NaN values."
1405
+
1406
+ return df, None
1407
+
1408
+ # μ‹œλ¦¬μ¦ˆ 처리 곡톡 둜직
1409
+ def process_indicator_series(series, limit):
1410
+ """μ‹œλ¦¬μ¦ˆ 데이터λ₯Ό μ²˜λ¦¬ν•˜λŠ” 곡톡 ν•¨μˆ˜"""
1411
+ if series is None or series.empty:
1412
+ return pd.Series(dtype=float)
1413
+
1414
+ first_idx = series.first_valid_index()
1415
+ if first_idx is None:
1416
+ return pd.Series(dtype=float)
1417
+
1418
+ return series.loc[first_idx:].tail(limit)
1419
+
1420
+ # κ²°κ³Ό ν¬λ§·νŒ… ν•¨μˆ˜λ“€
1421
+ def timestamp_to_iso(timestamp):
1422
+ """Pandas νƒ€μž„μŠ€νƒ¬ν”„λ₯Ό ISO 8601 ν˜•μ‹ λ¬Έμžμ—΄λ‘œ λ³€ν™˜"""
1423
+ # λ°€λ¦¬μ΄ˆλ₯Ό μ œμ™Έν•œ ISO ν˜•μ‹ λ¬Έμžμ—΄ λ³€ν™˜
1424
+ return timestamp.strftime('%Y-%m-%dT%H:%M:%SZ')
1425
+
1426
+ def format_stochastic_to_list(percent_k, percent_d):
1427
+ """Stochastic Oscillator μ‹œλ¦¬μ¦ˆλ₯Ό 리슀트둜 λ³€ν™˜ν•˜λŠ” ν•¨μˆ˜"""
1428
+ if percent_k is None or percent_d is None:
1429
+ return []
1430
+
1431
+ results = []
1432
+ # Ensure both series are aligned and iterate through common indices
1433
+ common_index = percent_k.index.intersection(percent_d.index)
1434
+
1435
+ for idx in common_index:
1436
+ k_val = percent_k.get(idx)
1437
+ d_val = percent_d.get(idx)
1438
+ results.append({
1439
+ "datetime": timestamp_to_iso(idx),
1440
+ "percent_k": round(k_val, 4) if pd.notnull(k_val) else None,
1441
+ "percent_d": round(d_val, 4) if pd.notnull(d_val) else None,
1442
+ })
1443
+ return results
1444
+
1445
+ def format_series_to_list(series, name):
1446
+ """단일 μ‹œλ¦¬μ¦ˆλ₯Ό 리슀트둜 λ³€ν™˜ν•˜λŠ” ν•¨μˆ˜"""
1447
+ if series is None or series.empty:
1448
+ return []
1449
+ return [
1450
+ {"datetime": timestamp_to_iso(idx), name: round(val, 4) if pd.notnull(val) else None}
1451
+ for idx, val in series.items()
1452
+ ]
1453
+
1454
+ def format_macd_to_list(macd_line, signal_line, histogram):
1455
+ """MACD μ‹œλ¦¬μ¦ˆλ₯Ό 리슀트둜 λ³€ν™˜ν•˜λŠ” ν•¨μˆ˜"""
1456
+ if macd_line is None or signal_line is None or histogram is None:
1457
+ return []
1458
+
1459
+ results = []
1460
+ for i in range(len(macd_line)):
1461
+ results.append({
1462
+ "datetime": timestamp_to_iso(macd_line.index[i]),
1463
+ "macd": round(macd_line.iloc[i], 4) if pd.notnull(macd_line.iloc[i]) else None,
1464
+ "signal": round(signal_line.iloc[i], 4) if pd.notnull(signal_line.iloc[i]) else None,
1465
+ "histogram": round(histogram.iloc[i], 4) if pd.notnull(histogram.iloc[i]) else None,
1466
+ })
1467
+ return results
1468
+
1469
+ def format_bbands_to_list(lower, middle, upper):
1470
+ """λ³Όλ¦°μ €λ°΄λ“œ μ‹œλ¦¬μ¦ˆλ₯Ό 리슀트둜 λ³€ν™˜ν•˜λŠ” ν•¨μˆ˜"""
1471
+ if lower is None or middle is None or upper is None:
1472
+ return []
1473
+
1474
+ results = []
1475
+ for i in range(len(middle)):
1476
+ results.append({
1477
+ "datetime": timestamp_to_iso(middle.index[i]),
1478
+ "lower": round(lower.iloc[i], 4) if pd.notnull(lower.iloc[i]) else None,
1479
+ "middle": round(middle.iloc[i], 4) if pd.notnull(middle.iloc[i]) else None,
1480
+ "upper": round(upper.iloc[i], 4) if pd.notnull(upper.iloc[i]) else None,
1481
+ })
1482
+ return results
1483
+
1484
+ # μ§€ν‘œ 계산 ν•¨μˆ˜λ“€
1485
+ def calculate_rsi_indicator(df, params, limit, price_source):
1486
+ """RSI μ§€ν‘œλ₯Ό κ³„μ‚°ν•˜λŠ” ν•¨μˆ˜"""
1487
+ length = params.get('length', 14)
1488
+
1489
+ try:
1490
+ calculated_indicator = compute_rsi(df, length=length, price_source=price_source)
1491
+ if calculated_indicator is None or calculated_indicator.empty:
1492
+ return None, f"RSI 계산 κ²°κ³Όκ°€ λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€. (length: {length})"
1493
+
1494
+ processed_series = process_indicator_series(calculated_indicator, limit)
1495
+ return processed_series, None
1496
+ except Exception as e:
1497
+ return None, f"RSI 계산 쀑 였λ₯˜ λ°œμƒ: {str(e)}"
1498
+
1499
+ def calculate_sma_indicator(df, params, limit, price_source):
1500
+ """SMA μ§€ν‘œλ₯Ό κ³„μ‚°ν•˜λŠ” ν•¨μˆ˜"""
1501
+ length = params.get('length', 20)
1502
+
1503
+ try:
1504
+ calculated_indicator = compute_sma(df, length=length, price_source=price_source)
1505
+ if calculated_indicator is None or calculated_indicator.empty:
1506
+ return None, f"SMA 계산 κ²°κ³Όκ°€ λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€. (length: {length})"
1507
+
1508
+ processed_series = process_indicator_series(calculated_indicator, limit)
1509
+ return processed_series, None
1510
+ except Exception as e:
1511
+ return None, f"SMA 계산 쀑 였λ₯˜ λ°œμƒ: {str(e)}"
1512
+
1513
+ def calculate_ema_indicator(df, params, limit, price_source):
1514
+ """EMA μ§€ν‘œλ₯Ό κ³„μ‚°ν•˜λŠ” ν•¨μˆ˜"""
1515
+ length = params.get('length', 20)
1516
+
1517
+ try:
1518
+ calculated_indicator = compute_ema(df, length=length, price_source=price_source)
1519
+ if calculated_indicator is None or calculated_indicator.empty:
1520
+ return None, f"EMA 계산 κ²°κ³Όκ°€ λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€. (length: {length})"
1521
+
1522
+ processed_series = process_indicator_series(calculated_indicator, limit)
1523
+ return processed_series, None
1524
+ except Exception as e:
1525
+ return None, f"EMA 계산 쀑 였λ₯˜ λ°œμƒ: {str(e)}"
1526
+
1527
+ def calculate_macd_indicator(df, params, limit, price_source):
1528
+ """MACD μ§€ν‘œλ₯Ό κ³„μ‚°ν•˜λŠ” ν•¨μˆ˜"""
1529
+ fast_length = params.get('fast', 12)
1530
+ slow_length = params.get('slow', 26)
1531
+ signal_length = params.get('signal', 9)
1532
+
1533
+ try:
1534
+ macd_result = compute_macd(
1535
+ df,
1536
+ fast_length=fast_length,
1537
+ slow_length=slow_length,
1538
+ signal_length=signal_length,
1539
+ price_source=price_source
1540
+ )
1541
+
1542
+ if macd_result is None:
1543
+ return None, f"MACD 계산 κ²°κ³Όκ°€ λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€."
1544
+
1545
+ macd_line, signal_line, histogram = macd_result
1546
+
1547
+ # 각 μ‹œλ¦¬μ¦ˆ 처리
1548
+ processed_macd = process_indicator_series(macd_line, limit)
1549
+ processed_signal = process_indicator_series(signal_line, limit)
1550
+ processed_hist = process_indicator_series(histogram, limit)
1551
+
1552
+ return (processed_macd, processed_signal, processed_hist), None
1553
+ except Exception as e:
1554
+ return None, f"MACD 계산 쀑 였λ₯˜ λ°œμƒ: {str(e)}"
1555
+
1556
+ def calculate_bbands_indicator(df, params, limit, price_source):
1557
+ """λ³Όλ¦°μ €λ°΄λ“œ μ§€ν‘œλ₯Ό κ³„μ‚°ν•˜λŠ” ν•¨μˆ˜"""
1558
+ length = params.get('length', 20)
1559
+ std_dev = params.get('std', 2.0)
1560
+
1561
+ try:
1562
+ bbands_result = compute_bbands(
1563
+ df,
1564
+ length=length,
1565
+ std_dev=std_dev,
1566
+ price_source=price_source
1567
+ )
1568
+
1569
+ if bbands_result is None:
1570
+ return None, f"λ³Όλ¦°μ €λ°΄λ“œ 계산 κ²°κ³Όκ°€ λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€."
1571
+
1572
+ lower_band, middle_band, upper_band = bbands_result
1573
+
1574
+ # 각 μ‹œλ¦¬μ¦ˆ 처리
1575
+ processed_lower = process_indicator_series(lower_band, limit)
1576
+ processed_middle = process_indicator_series(middle_band, limit)
1577
+ processed_upper = process_indicator_series(upper_band, limit)
1578
+
1579
+ return (processed_lower, processed_middle, processed_upper), None
1580
+ except Exception as e:
1581
+ return None, f"λ³Όλ¦°μ €λ°΄λ“œ 계산 쀑 였λ₯˜ λ°œμƒ: {str(e)}"
1582
+
1583
+ def calculate_stochastic_indicator(df, params, limit, price_source_high, price_source_low, price_source_close):
1584
+ """Stochastic Oscillator μ§€ν‘œλ₯Ό κ³„μ‚°ν•˜λŠ” ν•¨μˆ˜"""
1585
+ k_period = params.get('k_period', 14)
1586
+ d_period = params.get('d_period', 3)
1587
+ smooth_k = params.get('smooth_k', 3)
1588
+
1589
+ try:
1590
+ stoch_result = compute_stochastic_oscillator(
1591
+ df,
1592
+ k_period=k_period,
1593
+ d_period=d_period,
1594
+ smooth_k=smooth_k,
1595
+ price_source_high=price_source_high,
1596
+ price_source_low=price_source_low,
1597
+ price_source_close=price_source_close
1598
+ )
1599
+
1600
+ if stoch_result is None:
1601
+ return None, f"Stochastic Oscillator 계산 κ²°κ³Όκ°€ λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€."
1602
+
1603
+ percent_k, percent_d = stoch_result
1604
+
1605
+ processed_k = process_indicator_series(percent_k, limit)
1606
+ processed_d = process_indicator_series(percent_d, limit)
1607
+
1608
+ return (processed_k, processed_d), None
1609
+ except Exception as e:
1610
+ return None, f"Stochastic Oscillator 계산 쀑 였λ₯˜ λ°œμƒ: {str(e)}"
1611
+
1612
+ def calculate_atr_indicator(df, params, limit, price_source_high, price_source_low, price_source_close):
1613
+ """ATR μ§€ν‘œλ₯Ό κ³„μ‚°ν•˜λŠ” ν•¨μˆ˜"""
1614
+ period = params.get('period', 14)
1615
+
1616
+ try:
1617
+ calculated_indicator = compute_atr(
1618
+ df,
1619
+ period=period,
1620
+ price_source_high=price_source_high,
1621
+ price_source_low=price_source_low,
1622
+ price_source_close=price_source_close
1623
+ )
1624
+ if calculated_indicator is None or calculated_indicator.empty:
1625
+ return None, f"ATR 계산 κ²°κ³Όκ°€ λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€. (period: {period})"
1626
+
1627
+ processed_series = process_indicator_series(calculated_indicator, limit)
1628
+ return processed_series, None
1629
+ except Exception as e:
1630
+ return None, f"ATR 계산 쀑 였λ₯˜ λ°œμƒ: {str(e)}"
1631
+
1632
+ @mcp.tool(
1633
+ name="calculate_technical_indicator",
1634
+ description="Fetches OHLCV data for a given symbol and timeframe, then calculates a specified technical indicator "
1635
+ "(e.g., RSI, SMA, EMA, MACD, Bollinger Bands, Stochastic Oscillator, ATR). Returns a time series of calculated indicator values. "
1636
+ "The number of data points returned corresponds to the OHLCV data fetched (controlled by 'ohlcv_limit' in indicator_params).",
1637
+ tags={"market_data", "technical_analysis", "indicator", "charting", "RSI", "SMA", "EMA", "MACD", "BBANDS", "STOCH", "ATR"}
1638
+ )
1639
+ async def calculate_technical_indicator_tool(
1640
+ exchange_id: Annotated[str, Field(description="The ID of the exchange (e.g., 'binance', 'upbit'). Case-insensitive.")],
1641
+ symbol: Annotated[str, Field(description="The trading symbol to calculate the indicator for (e.g., 'BTC/USDT', 'ETH/KRW').")],
1642
+ timeframe: Annotated[TimeframeLiteral, Field(description="The candle timeframe for OHLCV data. Common supported values are provided. Always check the specific exchange's documentation for their full list of supported timeframes as it can vary.")],
1643
+ indicator_name: Annotated[Literal["RSI", "SMA", "EMA", "MACD", "BBANDS", "STOCH", "ATR"],
1644
+ Field(description="The name of the technical indicator to calculate. Supported: RSI, SMA, EMA, MACD, BBANDS, STOCH, ATR.")],
1645
+ ohlcv_limit: Annotated[Optional[int], Field(description="Optional: The number of OHLCV data points to fetch. Default is 50. Check exchange for default and maximum limits.", gt=0)] = None,
1646
+ indicator_params: Annotated[Optional[str], Field(
1647
+ description='''Optional: A JSON string representing a dictionary of parameters for the chosen indicator. All parameters within the dictionary are optional and have defaults.
1648
+ Example JSON string for RSI: {"length": 14, "price_source": "close"}.
1649
+ Parameter details for the dictionary:
1650
+ For RSI: {'length': 14, 'price_source': 'close'}.
1651
+ For SMA/EMA: {'length': 20, 'price_source': 'close'}.
1652
+ For MACD: {'fast': 12, 'slow': 26, 'signal': 9, 'price_source': 'close'}.
1653
+ For BBANDS (Bollinger Bands): {'length': 20, 'std': 2.0, 'price_source': 'close'}.
1654
+ For STOCH (Stochastic Oscillator): {'k_period': 14, 'd_period': 3, 'smooth_k': 3, 'price_source_high': 'high', 'price_source_low': 'low', 'price_source_close': 'close'}.
1655
+ For ATR (Average True Range): {'period': 14, 'price_source_high': 'high', 'price_source_low': 'low', 'price_source_close': 'close'}.
1656
+ Valid 'price_source' values for single-price indicators: 'open', 'high', 'low', 'close' (default), 'hlc3', 'ohlc4'.
1657
+ Ensure the JSON string is correctly formatted.'''
1658
+ )] = None,
1659
+ api_key: Annotated[Optional[str], Field(description="Optional: Your API key for the exchange. If not provided, the system may use pre-configured credentials or proceed unauthenticated. If authentication is used (with directly provided or pre-configured keys), it may offer benefits like enhanced access or higher rate limits.")] = None,
1660
+ secret_key: Annotated[Optional[str], Field(description="Optional: Your secret key for the exchange. Used with an API key if authentication is performed (whether keys are provided directly or pre-configured).")] = None,
1661
+ passphrase: Annotated[Optional[str], Field(description="Optional: Your API passphrase, if required by the exchange. Used with an API key if authentication is performed and the exchange requires it (whether keys are provided directly or pre-configured).")] = None,
1662
+ params: Annotated[Optional[Dict], Field(description="Optional: Extra parameters for CCXT client instantiation when fetching OHLCV data, "
1663
+ "e.g., `{'options': {'defaultType': 'future'}}` if fetching for non-spot markets like futures.")] = None
1664
+ ) -> Dict:
1665
+ """기술적 μ§€ν‘œλ₯Ό κ³„μ‚°ν•˜λŠ” ν•¨μˆ˜ (λ¦¬νŒ©ν† λ§ 버전)"""
1666
+
1667
+ # 1. νŒŒλΌλ―Έν„° νŒŒμ‹± 및 검증
1668
+ parsed_params = parse_indicator_params(indicator_params)
1669
+
1670
+ # μš”μ²­λœ μ΅œμ’… 데이터 길이 κ²°μ •
1671
+ requested_final_length = ohlcv_limit or parsed_params.get('ohlcv_limit', 50)
1672
+ if not isinstance(requested_final_length, int) or requested_final_length <= 0:
1673
+ requested_final_length = 50
1674
+
1675
+ # μ‹€μ œ OHLCV 데이터 κ°€μ Έμ˜¬ 길이 (κ³„μ‚°μš© 버퍼 μΆ”κ°€)
1676
+ ohlcv_fetch_limit = requested_final_length + 100
1677
+
1678
+ # 가격 μ†ŒμŠ€ 컬럼 κ²°μ • (STOCHλŠ” 별도 처리)
1679
+ price_source_col = None
1680
+ price_source_high_col = None
1681
+ price_source_low_col = None
1682
+ price_source_close_col = None
1683
+
1684
+ multi_source_indicators = ["STOCH", "ATR"]
1685
+ if indicator_name not in multi_source_indicators:
1686
+ price_source_col = parsed_params.get('price_source', 'close').lower()
1687
+ valid_price_sources = ['open', 'high', 'low', 'close', 'hlc3', 'ohlc4']
1688
+ if price_source_col not in valid_price_sources:
1689
+ return {"error": f"잘λͺ»λœ 'price_source': {price_source_col}. λ‹€μŒ 쀑 ν•˜λ‚˜μ—¬μ•Ό ν•©λ‹ˆλ‹€: {valid_price_sources}."}
1690
+ else: # STOCH and ATR use specific high, low, close sources
1691
+ price_source_high_col = parsed_params.get('price_source_high', 'high').lower()
1692
+ price_source_low_col = parsed_params.get('price_source_low', 'low').lower()
1693
+ price_source_close_col = parsed_params.get('price_source_close', 'close').lower()
1694
+ # Validation for these columns will happen in prepare_dataframe or the specific compute function.
1695
+
1696
+ # 2. OHLCV 데이터 κ°€μ Έμ˜€κΈ°
1697
+ ohlcv_data, fetch_error = await fetch_ohlcv_data(
1698
+ exchange_id, symbol, timeframe, ohlcv_fetch_limit,
1699
+ api_key, secret_key, passphrase, params
1700
+ )
1701
+
1702
+ if fetch_error:
1703
+ return {"error": fetch_error}
1704
+
1705
+ # 3. λ°μ΄ν„°ν”„λ ˆμž„ μ€€λΉ„
1706
+ # For STOCH or ATR, price_source_col is not used directly by prepare_dataframe for selecting the final calculation column,
1707
+ # but it needs one of the price columns (e.g., 'close') to check for all NaNs initially during dataframe preparation.
1708
+ initial_check_price_source = price_source_close_col if indicator_name in multi_source_indicators else price_source_col
1709
+ df, df_error = prepare_dataframe(ohlcv_data, initial_check_price_source)
1710
+ if df_error:
1711
+ return {"error": df_error}
1712
+
1713
+ # 4. μ‹€μ œ νŒŒλΌλ―Έν„° μ‚¬μš©κ°’ 기둝 (결과에 ν¬ν•¨μ‹œν‚€κΈ° μœ„ν•¨)
1714
+ actual_params_used = parsed_params.copy()
1715
+ actual_params_used['ohlcv_limit'] = requested_final_length
1716
+
1717
+ price_source_display = {}
1718
+ if indicator_name in multi_source_indicators:
1719
+ price_source_display = {
1720
+ 'high': price_source_high_col,
1721
+ 'low': price_source_low_col,
1722
+ 'close': price_source_close_col
1723
+ }
1724
+ actual_params_used.update({
1725
+ 'price_source_high': price_source_high_col,
1726
+ 'price_source_low': price_source_low_col,
1727
+ 'price_source_close': price_source_close_col
1728
+ })
1729
+ # Remove single 'price_source' if it was in parsed_params for multi-source indicators, as it's not used.
1730
+ actual_params_used.pop('price_source', None)
1731
+ else:
1732
+ price_source_display = price_source_col
1733
+ actual_params_used['price_source'] = price_source_col
1734
+
1735
+ # 5. μ§€ν‘œ 계산 및 κ²°κ³Ό 생성
1736
+ indicator_output = {
1737
+ "indicator_name": indicator_name,
1738
+ "symbol": symbol,
1739
+ "timeframe": timeframe,
1740
+ "params_used": actual_params_used,
1741
+ "price_source_used": price_source_display,
1742
+ "data": []
1743
+ }
1744
+
1745
+ try:
1746
+ if indicator_name == "RSI":
1747
+ length = parsed_params.get('length', 14)
1748
+ actual_params_used['length'] = length
1749
+
1750
+ result, error = calculate_rsi_indicator(df, parsed_params, requested_final_length, price_source_col)
1751
+ if error:
1752
+ return {"error": error}
1753
+ indicator_output["data"] = format_series_to_list(result, "value")
1754
+
1755
+ elif indicator_name == "SMA":
1756
+ length = parsed_params.get('length', 20)
1757
+ actual_params_used['length'] = length
1758
+
1759
+ result, error = calculate_sma_indicator(df, parsed_params, requested_final_length, price_source_col)
1760
+ if error:
1761
+ return {"error": error}
1762
+ indicator_output["data"] = format_series_to_list(result, "value")
1763
+
1764
+ elif indicator_name == "EMA":
1765
+ length = parsed_params.get('length', 20)
1766
+ actual_params_used['length'] = length
1767
+
1768
+ result, error = calculate_ema_indicator(df, parsed_params, requested_final_length, price_source_col)
1769
+ if error:
1770
+ return {"error": error}
1771
+ indicator_output["data"] = format_series_to_list(result, "value")
1772
+
1773
+ elif indicator_name == "MACD":
1774
+ fast_length = parsed_params.get('fast', 12)
1775
+ slow_length = parsed_params.get('slow', 26)
1776
+ signal_length = parsed_params.get('signal', 9)
1777
+ actual_params_used.update({'fast': fast_length, 'slow': slow_length, 'signal': signal_length})
1778
+
1779
+ result, error = calculate_macd_indicator(df, parsed_params, requested_final_length, price_source_col)
1780
+ if error:
1781
+ return {"error": error}
1782
+ macd_series, signal_series, hist_series = result
1783
+ indicator_output["data"] = format_macd_to_list(macd_series, signal_series, hist_series)
1784
+
1785
+ elif indicator_name == "BBANDS":
1786
+ length = parsed_params.get('length', 20)
1787
+ std_dev = parsed_params.get('std', 2.0)
1788
+ actual_params_used.update({'length': length, 'std': std_dev})
1789
+
1790
+ result, error = calculate_bbands_indicator(df, parsed_params, requested_final_length, price_source_col)
1791
+ if error:
1792
+ return {"error": error}
1793
+ lower_band, middle_band, upper_band = result
1794
+ indicator_output["data"] = format_bbands_to_list(lower_band, middle_band, upper_band)
1795
+
1796
+ elif indicator_name == "STOCH":
1797
+ k_period = parsed_params.get('k_period', 14)
1798
+ d_period = parsed_params.get('d_period', 3)
1799
+ smooth_k = parsed_params.get('smooth_k', 3)
1800
+ actual_params_used.update({'k_period': k_period, 'd_period': d_period, 'smooth_k': smooth_k})
1801
+
1802
+ result, error = calculate_stochastic_indicator(
1803
+ df, parsed_params, requested_final_length,
1804
+ price_source_high_col, price_source_low_col, price_source_close_col
1805
+ )
1806
+ if error:
1807
+ return {"error": error}
1808
+ percent_k, percent_d = result
1809
+ indicator_output["data"] = format_stochastic_to_list(percent_k, percent_d)
1810
+
1811
+ elif indicator_name == "ATR":
1812
+ period = parsed_params.get('period', 14)
1813
+ actual_params_used['period'] = period
1814
+
1815
+ result, error = calculate_atr_indicator(
1816
+ df, parsed_params, requested_final_length,
1817
+ price_source_high_col, price_source_low_col, price_source_close_col
1818
+ )
1819
+ if error:
1820
+ return {"error": error}
1821
+ indicator_output["data"] = format_series_to_list(result, "value") # ATR is a single series
1822
+
1823
+ else:
1824
+ return {"error": f"μ§€ν‘œ '{indicator_name}'λŠ” ν˜„μž¬ μ§€μ›λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."}
1825
+
1826
+ # 데이터가 λΉ„μ–΄μžˆμ„ 경우 빈 리슀트 λ°˜ν™˜
1827
+ if indicator_output["data"] is None:
1828
+ indicator_output["data"] = []
1829
+
1830
+ return indicator_output
1831
+
1832
+ except Exception as e:
1833
+ # μ„œλ²„ λ‘œκ·Έμ— μžμ„Έν•œ 였λ₯˜ 기둝
1834
+ print(f"calculate_technical_indicator_toolμ—μ„œ μ˜ˆμƒμΉ˜ λͺ»ν•œ 였λ₯˜ λ°œμƒ - {indicator_name}/{symbol}: {e}")
1835
+ return {"error": f"{symbol}에 λŒ€ν•œ {indicator_name} 계산 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."}
1836
+
1837
+ # --- Main execution (for running the server) ---
1838
+ if __name__ == "__main__":
1839
+ print("Starting CCXT MCP Server (Async with Annotated Params and Tool Metadata)...")
1840
+ mcp.run()