# Directory Structure ``` ├── .gitignore ├── docs │ └── demo.mp4 ├── LICENSE ├── pyproject.toml ├── pytest.ini ├── README.md ├── src │ └── crypto_trading_mcp │ ├── __init__.py │ ├── exceptions.py │ ├── exchanges │ │ ├── __init__.py │ │ ├── base.py │ │ ├── binance.py │ │ ├── factory.py │ │ ├── gateio.py │ │ └── upbit.py │ ├── http_handler.py │ ├── server.py │ └── utils.py ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_binance.py │ ├── test_gateio.py │ ├── test_requester.py │ ├── test_upbit.py │ └── test_utils.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .coverage.* 40 | .cache 41 | nosetests.xml 42 | coverage.xml 43 | *.cover 44 | .hypothesis/ 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | local_settings.py 53 | db.sqlite3 54 | 55 | # Flask stuff: 56 | instance/ 57 | .webassets-cache 58 | 59 | # Scrapy stuff: 60 | .scrapy 61 | 62 | # Sphinx documentation 63 | docs/_build/ 64 | 65 | # PyBuilder 66 | target/ 67 | 68 | # Jupyter Notebook 69 | .ipynb_checkpoints 70 | 71 | # pyenv 72 | .python-version 73 | 74 | # celery beat schedule file 75 | celerybeat-schedule 76 | 77 | # SageMath parsed files 78 | *.sage.py 79 | 80 | # Environments 81 | .env 82 | .venv 83 | env/ 84 | venv/ 85 | ENV/ 86 | env.bak/ 87 | venv.bak/ 88 | 89 | # Spyder project settings 90 | .spyderproject 91 | .spyproject 92 | 93 | # Rope project settings 94 | .ropeproject 95 | 96 | # mkdocs documentation 97 | /site 98 | 99 | # mypy 100 | .mypy_cache/ 101 | 102 | # IDE specific files 103 | .idea/ 104 | .vscode/ 105 | *.swp 106 | *.swo 107 | .DS_Store ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Crypto Trading MCP (Model Context Protocol) 2 | 3 | [](https://opensource.org/licenses/MIT) 4 | [](https://github.com/psf/black) 5 | 6 | 7 | A simple Model Context Protocol (MCP) server for price lookup and trading across multiple cryptocurrency exchanges. 8 | 9 | 10 | https://github.com/user-attachments/assets/34f3a431-9370-4832-923e-ab89bf1d4913 11 | 12 | 13 | ## Requirements 14 | 15 | - Python 3.10 or higher 16 | 17 | ## Supported Exchanges 18 | Currently supports spot trading only. 19 | 20 | - Upbit 21 | - Gate.io 22 | - Binance 23 | 24 | More exchanges will be added in the future. 25 | 26 | ## Environment Setup 27 | 28 | Add the authentication information required by each exchange to the environment variables. 29 | 30 | For example, Upbit is as follows: 31 | 32 | ```bash 33 | UPBIT_ACCESS_KEY="your-access-key" 34 | UPBIT_SECRET_KEY="your-secret-key" 35 | ``` 36 | 37 | ## Development Guide 38 | 39 | ### Adding a New Exchange 40 | 41 | 1. Create a new exchange class inheriting from `CryptoExchange` abstract class 42 | 2. Implement required API methods 43 | 3. Write test cases 44 | 4. Register the new exchange in the factory class 45 | 46 | ### Running Tests 47 | 48 | ```bash 49 | # Install test dependencies 50 | uv pip install -e ".[test]" 51 | 52 | # Run tests 53 | pytest 54 | ``` 55 | ``` -------------------------------------------------------------------------------- /src/crypto_trading_mcp/exchanges/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /src/crypto_trading_mcp/__init__.py: -------------------------------------------------------------------------------- ```python 1 | """Crypto MCP package.""" 2 | ``` -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- ``` 1 | [pytest] 2 | testpaths = tests 3 | python_files = test_*.py 4 | addopts = -v 5 | ``` -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | import sys 3 | 4 | # Add src directory to Python path 5 | src_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")) 6 | if src_path not in sys.path: 7 | sys.path.insert(0, src_path) 8 | ``` -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- ```python 1 | from crypto_trading_mcp.utils import iso_to_timestamp, timestamp_to_iso 2 | 3 | 4 | def test_iso_to_timestamp(): 5 | test_date = "2024-06-13T10:26:21+09:00" 6 | expected_timestamp = 1718241981000 # milliseconds 7 | 8 | result = iso_to_timestamp(test_date) 9 | assert result == expected_timestamp, f"Expected {expected_timestamp}, got {result}" 10 | 11 | 12 | def test_timestamp_to_iso(): 13 | timestamp = 1718241981000 # milliseconds 14 | expected_iso = "2024-06-13T10:26:21+09:00" 15 | 16 | result = timestamp_to_iso(timestamp, "Asia/Seoul") 17 | assert result == expected_iso, f"Expected {expected_iso}, got {result}" 18 | ``` -------------------------------------------------------------------------------- /src/crypto_trading_mcp/exceptions.py: -------------------------------------------------------------------------------- ```python 1 | import time 2 | 3 | from dataclasses import dataclass, field 4 | 5 | 6 | @dataclass 7 | class CryptoAPIException(Exception): 8 | code: str 9 | message: str 10 | timestamp: int = field(default_factory=lambda: int(time.time() * 1000)) 11 | success: bool = False 12 | 13 | 14 | @dataclass 15 | class AuthenticationException(CryptoAPIException): 16 | pass 17 | 18 | 19 | @dataclass 20 | class BadRequestException(CryptoAPIException): 21 | pass 22 | 23 | 24 | @dataclass 25 | class NotFoundException(CryptoAPIException): 26 | pass 27 | 28 | 29 | @dataclass 30 | class RateLimitException(CryptoAPIException): 31 | pass 32 | 33 | 34 | @dataclass 35 | class InternalServerErrorException(CryptoAPIException): 36 | pass 37 | ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [project] 2 | name = "crypto_mcp" 3 | version = "0.1.0" 4 | description = "MCP Server for Trading Cryptocurrency" 5 | dependencies = [ 6 | "fastmcp>=2.1.0", 7 | "pydantic>=2.11.3", 8 | "httpx>=0.28.1", 9 | "python-dotenv>=1.1.0", 10 | "PyJWT>=2.10.1", 11 | "black>=25.1.0", 12 | "isort>=6.0.1", 13 | "uvicorn>=0.34.0", 14 | "starlette>=0.46.1", 15 | "sse-starlette>=2.2.1", 16 | "pytz>=2025.2", 17 | ] 18 | requires-python = ">=3.10.16" 19 | 20 | [project.optional-dependencies] 21 | dev = [ 22 | "black>=25.1.0", 23 | "isort>=6.0.1", 24 | ] 25 | test = [ 26 | "pytest>=8.3.5", 27 | "pytest-asyncio>=0.26.0", 28 | ] 29 | 30 | [build-system] 31 | requires = ["hatchling"] 32 | build-backend = "hatchling.build" 33 | ``` -------------------------------------------------------------------------------- /src/crypto_trading_mcp/utils.py: -------------------------------------------------------------------------------- ```python 1 | import pytz 2 | 3 | from datetime import datetime 4 | 5 | 6 | def iso_to_timestamp(iso_date: str) -> int: 7 | """ 8 | Convert ISO 8601 date string to Unix timestamp (milliseconds since epoch) 9 | 10 | Args: 11 | iso_date (str): ISO 8601 formatted date string (e.g. "2024-06-13T10:26:21+09:00") 12 | 13 | Returns: 14 | int: Unix timestamp in milliseconds 15 | """ 16 | dt = datetime.fromisoformat(iso_date) 17 | return int(dt.timestamp() * 1000) 18 | 19 | 20 | def timestamp_to_iso(timestamp: int, tz: str) -> str: 21 | """ 22 | Convert Unix timestamp (milliseconds since epoch) to ISO 8601 date string 23 | """ 24 | return datetime.fromtimestamp(timestamp / 1000, tz=pytz.timezone(tz)).isoformat() 25 | ``` -------------------------------------------------------------------------------- /src/crypto_trading_mcp/exchanges/factory.py: -------------------------------------------------------------------------------- ```python 1 | from abc import ABC, abstractmethod 2 | 3 | from crypto_trading_mcp.exchanges.base import CryptoExchange 4 | from crypto_trading_mcp.exchanges.upbit import Upbit 5 | from crypto_trading_mcp.exchanges.gateio import GateIO 6 | from crypto_trading_mcp.http_handler import HTTPRequester 7 | from crypto_trading_mcp.exchanges.upbit import UpbitRequester 8 | from crypto_trading_mcp.exchanges.gateio import GateIOAuth 9 | from crypto_trading_mcp.exchanges.binance import Binance, BinanceAuth 10 | 11 | 12 | class ExchangeFactory(ABC): 13 | @abstractmethod 14 | def create_requester(self) -> HTTPRequester: 15 | pass 16 | 17 | @abstractmethod 18 | def create_exchange(self) -> CryptoExchange: 19 | pass 20 | 21 | 22 | class UpbitFactory(ExchangeFactory): 23 | def create_requester(self) -> HTTPRequester: 24 | return UpbitRequester() 25 | 26 | def create_exchange(self) -> CryptoExchange: 27 | return Upbit(self.create_requester()) 28 | 29 | 30 | class GateIOFactory(ExchangeFactory): 31 | def create_requester(self) -> HTTPRequester: 32 | return HTTPRequester(GateIOAuth()) 33 | 34 | def create_exchange(self) -> CryptoExchange: 35 | return GateIO(self.create_requester()) 36 | 37 | 38 | class BinanceFactory(ExchangeFactory): 39 | def create_requester(self) -> HTTPRequester: 40 | return HTTPRequester(BinanceAuth()) 41 | 42 | def create_exchange(self) -> CryptoExchange: 43 | return Binance(self.create_requester()) 44 | 45 | 46 | factories: dict[str, ExchangeFactory] = { 47 | "upbit": UpbitFactory, 48 | "gateio": GateIOFactory, 49 | "binance": BinanceFactory, 50 | } 51 | 52 | 53 | def get_factory(exchange_name: str) -> ExchangeFactory: 54 | return factories[exchange_name]() 55 | ``` -------------------------------------------------------------------------------- /tests/test_requester.py: -------------------------------------------------------------------------------- ```python 1 | import pytest 2 | import httpx 3 | 4 | 5 | from typing import Literal, Optional 6 | from crypto_trading_mcp.http_handler import HTTPRequester 7 | 8 | 9 | class FakeHTTPRequester(HTTPRequester): 10 | def __init__( 11 | self, fake_response: httpx.Response, authorization: Optional[httpx.Auth] = None 12 | ): 13 | self.fake_response = fake_response 14 | self.authorization = authorization 15 | 16 | async def send( 17 | self, 18 | url: str, 19 | method: Literal["GET", "POST", "PUT", "DELETE"], 20 | data: Optional[dict] = None, 21 | json: Optional[dict] = None, 22 | headers: Optional[dict] = None, 23 | params: Optional[dict] = None, 24 | ) -> httpx.Response: 25 | return self.fake_response 26 | 27 | 28 | @pytest.fixture 29 | def success_response(): 30 | response = httpx.Response(200) 31 | response._content = b'{"status": "success"}' 32 | return response 33 | 34 | 35 | @pytest.fixture 36 | def error_response(): 37 | return httpx.Response(500) 38 | 39 | 40 | @pytest.mark.asyncio 41 | async def test_send_success(success_response): 42 | requester = FakeHTTPRequester(success_response) 43 | 44 | response = await requester.send( 45 | url="https://api.example.com/test", 46 | method="GET", 47 | headers={"Content-Type": "application/json"}, 48 | ) 49 | 50 | assert response == success_response 51 | 52 | 53 | @pytest.mark.asyncio 54 | async def test_send_with_json_data(success_response): 55 | requester = FakeHTTPRequester(success_response) 56 | 57 | response = await requester.send( 58 | url="https://api.example.com/test", 59 | method="POST", 60 | data={"key": "value"}, 61 | headers={"Content-Type": "application/json"}, 62 | ) 63 | 64 | assert response == success_response 65 | 66 | 67 | @pytest.mark.asyncio 68 | async def test_send_error_handling(error_response): 69 | requester = FakeHTTPRequester(error_response) 70 | 71 | response = await requester.send( 72 | url="https://api.example.com/test", 73 | method="GET", 74 | ) 75 | 76 | assert response == error_response 77 | ``` -------------------------------------------------------------------------------- /src/crypto_trading_mcp/http_handler.py: -------------------------------------------------------------------------------- ```python 1 | import httpx 2 | 3 | from typing import Literal, Optional, Generator 4 | 5 | 6 | class HTTPRequester: 7 | def __init__(self, authorization: Optional[httpx.Auth] = None): 8 | self.authorization = authorization 9 | 10 | async def send( 11 | self, 12 | url: str, 13 | method: Literal["GET", "POST", "PUT", "DELETE"], 14 | data: Optional[dict] = None, 15 | json: Optional[dict] = None, 16 | headers: Optional[dict] = None, 17 | params: Optional[dict] = None, 18 | ) -> httpx.Response: 19 | async with httpx.AsyncClient() as client: 20 | try: 21 | response = await client.request( 22 | method=method, 23 | url=url, 24 | data=data, 25 | json=json, 26 | headers=headers, 27 | params=params, 28 | auth=self.authorization, 29 | ) 30 | 31 | return response 32 | except httpx.RequestError as e: 33 | return httpx.Response( 34 | status_code=500, 35 | content=e.response.content, 36 | headers=e.response.headers, 37 | request=e.request, 38 | ) 39 | 40 | async def get( 41 | self, url: str, headers: Optional[dict] = None, params: Optional[dict] = None 42 | ) -> httpx.Response: 43 | return await self.send(url, "GET", headers=headers, params=params) 44 | 45 | async def post( 46 | self, 47 | url: str, 48 | data: Optional[dict] = None, 49 | json: Optional[dict] = None, 50 | headers: Optional[dict] = None, 51 | params: Optional[dict] = None, 52 | ) -> httpx.Response: 53 | return await self.send( 54 | url, "POST", data=data, json=json, headers=headers, params=params 55 | ) 56 | 57 | async def put( 58 | self, 59 | url: str, 60 | data: Optional[dict] = None, 61 | json: Optional[dict] = None, 62 | headers: Optional[dict] = None, 63 | params: Optional[dict] = None, 64 | ) -> httpx.Response: 65 | return await self.send( 66 | url, "PUT", data=data, json=json, headers=headers, params=params 67 | ) 68 | 69 | async def delete( 70 | self, url: str, headers: Optional[dict] = None, params: Optional[dict] = None 71 | ) -> httpx.Response: 72 | return await self.send(url, "DELETE", headers=headers, params=params) 73 | 74 | 75 | class BearerAuth(httpx.Auth): 76 | def __init__(self, token: str): 77 | self.token = token 78 | 79 | def auth_flow( 80 | self, request: httpx.Request 81 | ) -> Generator[httpx.Request, httpx.Response, None]: 82 | request.headers["Authorization"] = f"Bearer {self.token}" 83 | yield request 84 | ``` -------------------------------------------------------------------------------- /src/crypto_trading_mcp/exchanges/base.py: -------------------------------------------------------------------------------- ```python 1 | import httpx 2 | import json 3 | 4 | from abc import ABC, abstractmethod 5 | from typing import Literal, Optional 6 | from dataclasses import dataclass 7 | 8 | from crypto_trading_mcp.http_handler import HTTPRequester 9 | from crypto_trading_mcp.exceptions import ( 10 | AuthenticationException, 11 | BadRequestException, 12 | NotFoundException, 13 | InternalServerErrorException, 14 | CryptoAPIException, 15 | RateLimitException, 16 | ) 17 | 18 | 19 | @dataclass 20 | class CryptoTradingPair: 21 | symbol: str 22 | name: str 23 | 24 | 25 | @dataclass 26 | class Ticker: 27 | symbol: str 28 | trade_timestamp: int 29 | trade_price: float 30 | trade_volume: float 31 | opening_price: float 32 | high_price: float 33 | low_price: float 34 | change_percentage: float 35 | change_price: float 36 | acc_trade_volume: float 37 | acc_trade_price: float 38 | timestamp: int 39 | 40 | def __post_init__(self): 41 | self.change_percentage = round(self.change_percentage, 2) 42 | 43 | 44 | @dataclass 45 | class Balance: 46 | currency: str 47 | balance: float 48 | locked: float 49 | avg_buy_price: float 50 | avg_buy_price_modified: bool 51 | unit_currency: str 52 | 53 | 54 | @dataclass 55 | class Order: 56 | order_id: str 57 | side: str 58 | amount: float 59 | price: float 60 | order_type: Literal["limit", "market"] 61 | status: Literal["wait", "done", "canceled"] 62 | executed_volume: float 63 | remaining_volume: float 64 | created_at: int 65 | 66 | 67 | @dataclass 68 | class OrderBookItem: 69 | ask_price: float 70 | ask_quantity: float 71 | bid_price: float 72 | bid_quantity: float 73 | 74 | 75 | @dataclass 76 | class OrderBook: 77 | symbol: str 78 | timestamp: int 79 | items: list[OrderBookItem] 80 | 81 | 82 | class CryptoExchange(ABC): 83 | """ 84 | Abstract base class for crypto exchanges. 85 | """ 86 | 87 | def __init__(self, requester: HTTPRequester) -> None: 88 | self.requester = requester 89 | 90 | def _get_error_message( 91 | self, response: httpx.Response, message_fields: list[str] 92 | ) -> str: 93 | # response: failed response from exchange API 94 | # message_fields: fields to extract a error message from body of the response 95 | # You can use dot notation to access nested fields, 96 | # e.g. "error.message" will be converted to ["error"]["message"] 97 | 98 | try: 99 | data = response.json() 100 | for field in message_fields.strip().split("."): 101 | data = data[field] 102 | 103 | return data 104 | except (AttributeError, KeyError, json.JSONDecodeError): 105 | return "" 106 | 107 | def _raise_for_failed_response(self, status_code: int, message: str = None): 108 | if status_code == 401: 109 | raise AuthenticationException( 110 | "401", message=message or "Authentication failed" 111 | ) 112 | elif status_code == 400: 113 | raise BadRequestException("400", message=message or "Bad Request") 114 | elif status_code == 404: 115 | raise NotFoundException("404", message=message or "Not Found") 116 | elif status_code == 429: 117 | raise RateLimitException("429", message=message or "Rate Limit Exceeded") 118 | elif status_code == 500: 119 | raise InternalServerErrorException( 120 | "500", message=message or "Internal Server Error" 121 | ) 122 | else: 123 | raise CryptoAPIException(str(status_code), message) 124 | 125 | @abstractmethod 126 | async def get_symbols(self) -> list[CryptoTradingPair]: 127 | pass 128 | 129 | @abstractmethod 130 | async def get_tickers(self, symbol: str = "") -> list[Ticker]: 131 | pass 132 | 133 | @abstractmethod 134 | async def get_balances(self) -> list[Balance]: 135 | pass 136 | 137 | @abstractmethod 138 | async def get_open_orders( 139 | self, 140 | symbol: str, 141 | page: int, 142 | limit: int, 143 | order_by: Literal["asc", "desc"] = "desc", 144 | ) -> list[Order]: 145 | pass 146 | 147 | @abstractmethod 148 | async def get_closed_orders( 149 | self, 150 | symbol: str, 151 | page: int, 152 | limit: int, 153 | status: Optional[Literal["done", "canceled"]] = None, 154 | start_date: Optional[int] = None, 155 | end_date: Optional[int] = None, 156 | order_by: Literal["asc", "desc"] = "desc", 157 | ) -> list[Order]: 158 | pass 159 | 160 | @abstractmethod 161 | async def get_order(self, order_id: str, symbol: str = None) -> Order: 162 | pass 163 | 164 | @abstractmethod 165 | async def get_order_book(self, symbol: str) -> OrderBook: 166 | pass 167 | 168 | @abstractmethod 169 | async def place_order( 170 | self, 171 | symbol: str, 172 | side: Literal["bid", "ask"], 173 | amount: float, 174 | price: float, 175 | order_type: Literal["limit", "market"] = "limit", 176 | ) -> Order: 177 | pass 178 | 179 | @abstractmethod 180 | async def cancel_order(self, order_id: str, symbol: str = None) -> bool: 181 | pass 182 | ``` -------------------------------------------------------------------------------- /src/crypto_trading_mcp/exchanges/gateio.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | import httpx 3 | import hashlib 4 | import time 5 | import hmac 6 | import json 7 | 8 | from typing import Literal, Optional, Generator 9 | from urllib.parse import unquote 10 | 11 | from crypto_trading_mcp.exchanges.base import ( 12 | CryptoExchange, 13 | CryptoTradingPair, 14 | Ticker, 15 | Balance, 16 | Order, 17 | OrderBook, 18 | OrderBookItem, 19 | ) 20 | from crypto_trading_mcp.http_handler import HTTPRequester 21 | 22 | 23 | class GateIOAuth(httpx.Auth): 24 | GATEIO_ACCESS_KEY = os.getenv("GATEIO_ACCESS_KEY") 25 | GATEIO_SECRET_KEY = os.getenv("GATEIO_SECRET_KEY") 26 | 27 | def generate_signature( 28 | self, 29 | endpoint: str, 30 | method: str, 31 | timestamp: int, 32 | query_string: str = "", 33 | payload_string: str = "", 34 | ) -> str: 35 | m = hashlib.sha512() 36 | m.update(payload_string.encode()) 37 | hashed_payload = m.hexdigest() 38 | 39 | message = f"{method}\n{endpoint}\n{query_string}\n{hashed_payload}\n{timestamp}" 40 | signature = hmac.new( 41 | self.GATEIO_SECRET_KEY.encode(), message.encode(), hashlib.sha512 42 | ).hexdigest() 43 | 44 | return signature 45 | 46 | def auth_flow( 47 | self, request: httpx.Request 48 | ) -> Generator[httpx.Request, httpx.Response, None]: 49 | body = request.content.decode() 50 | query_string = unquote(request.url.query.decode()) 51 | 52 | timestamp = time.time() 53 | signature = self.generate_signature( 54 | request.url.path, request.method, timestamp, query_string, body 55 | ) 56 | 57 | request.headers["KEY"] = self.GATEIO_ACCESS_KEY 58 | request.headers["SIGN"] = signature 59 | request.headers["Timestamp"] = str(timestamp) 60 | 61 | yield request 62 | 63 | 64 | class GateIO(CryptoExchange): 65 | def __init__(self, requester: HTTPRequester) -> None: 66 | super().__init__(requester) 67 | self.base_url = "https://api.gateio.ws/api/v4" 68 | 69 | async def get_symbols(self) -> list[CryptoTradingPair]: 70 | response = await self.requester.get(f"{self.base_url}/spot/currency_pairs") 71 | 72 | if response.is_error: 73 | self._raise_for_failed_response( 74 | response.status_code, self._get_error_message(response, "message") 75 | ) 76 | 77 | data = response.json() 78 | return [ 79 | CryptoTradingPair( 80 | symbol=item["id"], 81 | name=item["base_name"], 82 | ) 83 | for item in data 84 | ] 85 | 86 | async def get_tickers(self, symbol: str = "") -> list[Ticker]: 87 | response = await self.requester.get( 88 | f"{self.base_url}/spot/tickers", 89 | params={"currency_pair": symbol} if symbol else None, 90 | ) 91 | 92 | if response.is_error: 93 | self._raise_for_failed_response( 94 | response.status_code, self._get_error_message(response, "message") 95 | ) 96 | 97 | data = response.json() 98 | timestamp = time.time() * 1000 99 | return [ 100 | Ticker( 101 | symbol=item["currency_pair"], 102 | trade_timestamp=timestamp, 103 | trade_price=float(item["last"]), 104 | trade_volume=float(item["base_volume"]), 105 | opening_price=None, 106 | high_price=float(item["high_24h"]), 107 | low_price=float(item["low_24h"]), 108 | change_percentage=float(item["change_percentage"]), 109 | change_price=None, 110 | acc_trade_volume=float(item["quote_volume"]), 111 | acc_trade_price=float(item["quote_volume"]) * float(item["last"]), 112 | timestamp=timestamp, 113 | ) 114 | for item in data 115 | ] 116 | 117 | async def get_balances(self) -> list[Balance]: 118 | response = await self.requester.get(f"{self.base_url}/spot/accounts") 119 | 120 | if response.is_error: 121 | self._raise_for_failed_response( 122 | response.status_code, self._get_error_message(response, "message") 123 | ) 124 | 125 | data = response.json() 126 | return [ 127 | Balance( 128 | currency=item["currency"], 129 | balance=float(item["available"]), 130 | locked=float(item["locked"]), 131 | avg_buy_price=None, 132 | avg_buy_price_modified=None, 133 | unit_currency=None, 134 | ) 135 | for item in data 136 | ] 137 | 138 | def _convert_to_order(self, data: dict) -> Order: 139 | status_map = { 140 | "open": "wait", 141 | "closed": "done", 142 | "cancelled": "cancel", 143 | } 144 | 145 | return Order( 146 | order_id=data["id"], 147 | side="bid" if data["side"] == "buy" else "ask", 148 | amount=float(data["amount"]), 149 | price=float(data["price"]), 150 | order_type=data["type"], 151 | status=status_map[data["status"]], 152 | executed_volume=float(data["filled_amount"]), 153 | remaining_volume=float(data["left"]), 154 | created_at=data["create_time_ms"], 155 | ) 156 | 157 | async def get_open_orders( 158 | self, 159 | symbol: str, 160 | page: int, 161 | limit: int, 162 | order_by: Literal["asc", "desc"] = "desc", 163 | ) -> list[Order]: 164 | params = { 165 | "currency_pair": symbol, 166 | "page": page, 167 | "limit": limit, 168 | "status": "open", 169 | } 170 | 171 | response = await self.requester.get( 172 | f"{self.base_url}/spot/orders", params=params 173 | ) 174 | 175 | if response.is_error: 176 | self._raise_for_failed_response( 177 | response.status_code, self._get_error_message(response, "message") 178 | ) 179 | 180 | data = response.json() 181 | return [self._convert_to_order(item) for item in data] 182 | 183 | async def get_closed_orders( 184 | self, 185 | symbol: str, 186 | page: int, 187 | limit: int, 188 | status: Optional[Literal["done", "cancel"]] = None, 189 | start_date: Optional[int] = None, 190 | end_date: Optional[int] = None, 191 | order_by: Literal["asc", "desc"] = "desc", 192 | ) -> list[Order]: 193 | params = { 194 | "currency_pair": symbol, 195 | "page": page, 196 | "limit": limit, 197 | "status": "finished", 198 | } 199 | 200 | if start_date: 201 | params["from"] = start_date // 1000 202 | if end_date: 203 | params["to"] = end_date // 1000 204 | 205 | response = await self.requester.get( 206 | f"{self.base_url}/spot/orders", params=params 207 | ) 208 | 209 | if response.is_error: 210 | self._raise_for_failed_response( 211 | response.status_code, self._get_error_message(response, "message") 212 | ) 213 | 214 | data = response.json() 215 | return [self._convert_to_order(item) for item in data] 216 | 217 | async def get_order(self, order_id: str, symbol: str = None) -> Order: 218 | response = await self.requester.get( 219 | f"{self.base_url}/spot/orders/{order_id}", 220 | params={"currency_pair": symbol} if symbol else None, 221 | ) 222 | 223 | if response.is_error: 224 | self._raise_for_failed_response( 225 | response.status_code, self._get_error_message(response, "message") 226 | ) 227 | 228 | data = response.json() 229 | return self._convert_to_order(data) 230 | 231 | async def get_order_book(self, symbol: str) -> OrderBook: 232 | response = await self.requester.get( 233 | f"{self.base_url}/spot/order_book", params={"currency_pair": symbol} 234 | ) 235 | 236 | if response.is_error: 237 | self._raise_for_failed_response( 238 | response.status_code, self._get_error_message(response, "message") 239 | ) 240 | 241 | data = response.json() 242 | return OrderBook( 243 | symbol=symbol, 244 | timestamp=data["current"], 245 | items=[ 246 | OrderBookItem( 247 | ask_price=float(ask[0]), 248 | ask_quantity=float(ask[1]), 249 | bid_price=float(bid[0]), 250 | bid_quantity=float(bid[1]), 251 | ) 252 | for ask, bid in zip(data["asks"], data["bids"]) 253 | ], 254 | ) 255 | 256 | async def place_order( 257 | self, 258 | symbol: str, 259 | side: str, 260 | amount: float, 261 | price: float, 262 | order_type: Literal["limit", "market"] = "limit", 263 | ) -> Order: 264 | data = { 265 | "currency_pair": symbol, 266 | "side": "buy" if side == "bid" else "sell", 267 | "amount": str(amount), 268 | "price": str(price), 269 | "type": order_type, 270 | "time_in_force": "gtc" if order_type == "limit" else "ioc", 271 | } 272 | 273 | response = await self.requester.post(f"{self.base_url}/spot/orders", json=data) 274 | if response.is_error: 275 | self._raise_for_failed_response( 276 | response.status_code, self._get_error_message(response, "message") 277 | ) 278 | 279 | order_data = response.json() 280 | return self._convert_to_order(order_data) 281 | 282 | async def cancel_order(self, order_id: str, symbol: str = None) -> bool: 283 | response = await self.requester.delete( 284 | f"{self.base_url}/spot/orders/{order_id}", 285 | params={"currency_pair": symbol}, 286 | ) 287 | 288 | if response.is_error: 289 | self._raise_for_failed_response( 290 | response.status_code, self._get_error_message(response, "message") 291 | ) 292 | 293 | return True 294 | ``` -------------------------------------------------------------------------------- /src/crypto_trading_mcp/server.py: -------------------------------------------------------------------------------- ```python 1 | import logging 2 | import time 3 | import asyncio 4 | 5 | from typing import Optional, Literal, Callable 6 | from functools import wraps 7 | from fastmcp import FastMCP 8 | 9 | from crypto_trading_mcp.exchanges.factory import get_factory, factories 10 | from crypto_trading_mcp.exceptions import CryptoAPIException 11 | 12 | 13 | def envelope(func: Callable) -> Callable: 14 | @wraps(func) 15 | async def wrapped(*args, **kwargs): 16 | try: 17 | data = await func(*args, **kwargs) 18 | return { 19 | "success": True, 20 | "code": "200", 21 | "message": "OK", 22 | "data": data, 23 | "timestamp": int(time.time() * 1000), 24 | } 25 | except CryptoAPIException as e: 26 | return e 27 | except Exception as e: 28 | raise e 29 | 30 | return wrapped 31 | 32 | 33 | logging.basicConfig(level=logging.INFO) 34 | logger = logging.getLogger(__name__) 35 | 36 | app = FastMCP("CryptoTrading", debug=True) 37 | 38 | 39 | @app.prompt() 40 | async def get_exchange_names(): 41 | return f"Available exchange names: {', '.join(factories.keys())}" 42 | 43 | 44 | @app.tool() 45 | @envelope 46 | async def get_symbols(exchange_name: str): 47 | """ 48 | Get all Crypto Symbols 49 | 50 | This function retrieves all available trading pairs from the exchange. 51 | The response includes market information that can be used to query current prices 52 | for specific trading pairs. Each market represents a trading pair that can be 53 | used to get current price information. 54 | 55 | Args: 56 | exchange_name: str - The name of the exchange to get symbols from 57 | """ 58 | return await get_factory(exchange_name).create_exchange().get_symbols() 59 | 60 | 61 | @app.tool() 62 | @envelope 63 | async def get_balances(exchange_name: str): 64 | """ 65 | Get all Crypto Balances 66 | 67 | This function retrieves all available balances from the exchange. 68 | The response includes balance information that can be used to query current prices 69 | for specific trading pairs. Each market represents a trading pair that can be 70 | used to get current price information. 71 | 72 | Args: 73 | exchange_name: str - The name of the exchange to get balances from 74 | """ 75 | return await get_factory(exchange_name).create_exchange().get_balances() 76 | 77 | 78 | @app.tool() 79 | @envelope 80 | async def get_tickers(exchange_name: str, symbol: str): 81 | """ 82 | Get current price information for a specific trading pair 83 | 84 | The symbol parameter should be a valid trading pair code obtained from the get_markets function. 85 | For example, if get_markets returns "KRW-BTC", you can use that as the symbol to get 86 | the current price information for Bitcoin in Korean Won. 87 | 88 | Args: 89 | exchange_name: str - The name of the exchange to get tickers from 90 | symbol: str - The trading pair symbol (e.g., 'BTC-USDT') 91 | """ 92 | return await get_factory(exchange_name).create_exchange().get_tickers(symbol) 93 | 94 | 95 | @app.tool() 96 | @envelope 97 | async def get_order_detail(exchange_name: str, order_id: str, symbol: str): 98 | """ 99 | Get order detail by order id 100 | 101 | This function retrieves the details of a specific order by its order ID. 102 | It provides comprehensive information about the order, including the order ID, 103 | the trading pair, the side of the order, the amount, the price, the order type, 104 | the status, the executed volume, the remaining volume, and the creation time. 105 | 106 | Args: 107 | exchange_name: str - The name of the exchange to get order details from 108 | order_id: str - The order id of the order to get details for 109 | symbol: str - The trading pair symbol (e.g., 'BTC-USDT') 110 | """ 111 | return ( 112 | await get_factory(exchange_name).create_exchange().get_order(order_id, symbol) 113 | ) 114 | 115 | 116 | @app.tool() 117 | @envelope 118 | async def get_open_orders( 119 | exchange_name: str, 120 | symbol: str, 121 | page: int, # page number (starting from 1) 122 | limit: int, # number of orders per page (max 100) 123 | order_by: str = "desc", # order creation time sorting direction ('asc' for oldest first, 'desc' for newest first) 124 | ): 125 | """ 126 | Retrieve all waiting or reserved orders for a given trading pair 127 | 128 | This function retrieves the open order history for a specific trading pair from the exchange, 129 | allowing you to check the prices and timestamps of waiting or reserved orders for a given asset. 130 | 131 | It supports pagination (using integer values for page and limit parameters), 132 | and sorting by creation time. 133 | The response includes detailed information about each order, such as order ID, 134 | creation time, price, amount, and order status. 135 | 136 | Args: 137 | exchange_name: str - The name of the exchange to get open orders from 138 | symbol: str - The trading pair symbol (e.g., 'BTC-USDT') 139 | page: int - The page number (starting from 1) 140 | limit: int - The number of orders per page (max 100) 141 | order_by: str = "desc" - Order creation time sorting direction ('asc' for oldest first, 'desc' for newest first) 142 | """ 143 | return ( 144 | await get_factory(exchange_name) 145 | .create_exchange() 146 | .get_open_orders(symbol, page, limit, order_by) 147 | ) 148 | 149 | 150 | @app.tool() 151 | @envelope 152 | async def get_closed_orders( 153 | exchange_name: str, 154 | symbol: str, 155 | page: int, # page number (starting from 1) 156 | limit: int, # number of orders per page (max 100) 157 | order_by: str = "desc", 158 | status: Optional[Literal["done", "cancel"]] = None, 159 | start_date: Optional[int] = None, 160 | end_date: Optional[int] = None, 161 | ): 162 | """ 163 | Retrieve all closed orders for a given trading pair 164 | 165 | This function retrieves the closed order history for a specific trading pair from the exchange, 166 | allowing you to check the prices and timestamps of executed orders for a given asset. 167 | 168 | It supports pagination (using integer values for page and limit parameters), 169 | and sorting by creation time. 170 | 171 | Args: 172 | exchange_name: str - The name of the exchange to get closed orders from 173 | symbol: str - The trading pair symbol (e.g., 'BTC-USDT') 174 | page: int - The page number (starting from 1) 175 | limit: int - The number of orders per page (max 100) 176 | order_by: str = "desc" - Order creation time sorting direction ('asc' for oldest first, 'desc' for newest first) 177 | status: Optional[Literal["done", "cancel"]] = None - The status of the order ('done' for completed, 'cancel' for canceled) 178 | start_date: Optional[int] = None - The start date of the order (timestamp milliseconds) 179 | end_date: Optional[int] = None - The end date of the order (timestamp milliseconds) 180 | """ 181 | return ( 182 | await get_factory(exchange_name) 183 | .create_exchange() 184 | .get_closed_orders(symbol, page, limit, status, start_date, end_date, order_by) 185 | ) 186 | 187 | 188 | @app.tool() 189 | @envelope 190 | async def get_order_book(exchange_name: str, symbol: str): 191 | """ 192 | Get order book by symbol 193 | 194 | This function retrieves the order book for a specific trading pair from the exchange. 195 | It provides comprehensive information about the order book, including the order ID, 196 | the trading pair, the side of the order, the amount, the price, the order type, 197 | the status, the executed volume, the remaining volume, and the creation time. 198 | 199 | Args: 200 | exchange_name: str - The name of the exchange to get order book from 201 | symbol: str - The trading pair symbol (e.g., 'BTC-USDT') 202 | """ 203 | return await get_factory(exchange_name).create_exchange().get_order_book(symbol) 204 | 205 | 206 | @app.tool() 207 | @envelope 208 | async def place_order( 209 | exchange_name: str, 210 | symbol: str, 211 | side: str, 212 | amount: float, 213 | price: float, 214 | order_type: Literal["limit", "market"] = "limit", 215 | ): 216 | """ 217 | Place an order 218 | 219 | This function places an order on the exchange. 220 | It supports both limit and market orders. 221 | The order type can be specified as either "limit" or "market". 222 | The side of the order can be specified as either "bid" for buy or "ask" for sell. 223 | The amount and price parameters are required for both limit and market orders. 224 | The order type can be specified as either "limit" or "market". 225 | 226 | Args: 227 | exchange_name: str - The name of the exchange to place an order on 228 | symbol: str - The trading pair symbol (e.g., 'BTC-USDT') 229 | side: str - The side of the order ('bid' for buy, 'ask' for sell) 230 | amount: float - The amount of the order 231 | price: float - The price of the order 232 | order_type: Literal["limit", "market"] - Requires one of two values: "limit" for limit order or "market" for market order. Defaults to "limit". 233 | """ 234 | return ( 235 | await get_factory(exchange_name) 236 | .create_exchange() 237 | .place_order(symbol, side, amount, price, order_type) 238 | ) 239 | 240 | 241 | @app.tool() 242 | @envelope 243 | async def cancel_order(exchange_name: str, order_id: str, symbol: str): 244 | """ 245 | Cancel an order 246 | 247 | This function cancels an order on the exchange. 248 | It requires an order ID as input. 249 | 250 | Args: 251 | exchange_name: str - The name of the exchange to cancel an order on 252 | order_id: str - The order id of the order to cancel 253 | symbol: str - The trading pair symbol (e.g., 'BTC-USDT') 254 | """ 255 | return ( 256 | await get_factory(exchange_name) 257 | .create_exchange() 258 | .cancel_order(order_id, symbol) 259 | ) 260 | 261 | 262 | if __name__ == "__main__": 263 | logger.info("Starting server") 264 | 265 | asyncio.run(app.run("sse"), debug=True) 266 | ``` -------------------------------------------------------------------------------- /src/crypto_trading_mcp/exchanges/binance.py: -------------------------------------------------------------------------------- ```python 1 | import hmac 2 | import hashlib 3 | import time 4 | import json 5 | import httpx 6 | import os 7 | 8 | from urllib.parse import unquote 9 | from typing import Literal, Optional, Generator 10 | 11 | from crypto_trading_mcp.exchanges.base import ( 12 | CryptoExchange, 13 | CryptoTradingPair, 14 | Ticker, 15 | Balance, 16 | Order, 17 | OrderBook, 18 | OrderBookItem, 19 | ) 20 | 21 | 22 | class BinanceAuth(httpx.Auth): 23 | BINANCE_ACCESS_KEY = os.getenv("BINANCE_ACCESS_KEY") 24 | BINANCE_SECRET_KEY = os.getenv("BINANCE_SECRET_KEY") 25 | 26 | def is_signature_required(self, path: str) -> bool: 27 | endpoints = ( 28 | "/api/v3/order", 29 | "/api/v3/openOrders", 30 | "/api/v3/allOrders", 31 | "/api/v3/account", 32 | ) 33 | 34 | return path.endswith(endpoints) 35 | 36 | def generate_signature( 37 | self, query_string: str = "", payload_string: str = "" 38 | ) -> str: 39 | message = "" 40 | if query_string: 41 | message += query_string 42 | 43 | if payload_string: 44 | message += payload_string 45 | 46 | signature = hmac.new( 47 | self.BINANCE_SECRET_KEY.encode(), message.encode(), hashlib.sha256 48 | ).hexdigest() 49 | 50 | return signature 51 | 52 | def auth_flow( 53 | self, request: httpx.Request 54 | ) -> Generator[httpx.Request, httpx.Response, None]: 55 | if self.is_signature_required(request.url.path): 56 | query_string = unquote(request.url.query.decode()) 57 | payload_string = unquote(request.content.decode()) 58 | 59 | signature = self.generate_signature(query_string, payload_string) 60 | request.url = request.url.copy_merge_params({"signature": signature}) 61 | 62 | request.headers["X-MBX-APIKEY"] = self.BINANCE_ACCESS_KEY 63 | yield request 64 | 65 | 66 | class Binance(CryptoExchange): 67 | BASE_URL = "https://api.binance.com/api/v3" 68 | 69 | async def get_symbols(self) -> list[CryptoTradingPair]: 70 | response = await self.requester.get(f"{self.BASE_URL}/exchangeInfo") 71 | 72 | if not response.is_success: 73 | self._raise_for_failed_response( 74 | response.status_code, self._get_error_message(response, "msg") 75 | ) 76 | 77 | data = response.json() 78 | symbols = [] 79 | for symbol_info in data["symbols"]: 80 | if symbol_info["status"] == "TRADING": 81 | symbols.append( 82 | CryptoTradingPair( 83 | symbol=symbol_info["symbol"], name=symbol_info["baseAsset"] 84 | ) 85 | ) 86 | 87 | return symbols 88 | 89 | async def get_tickers(self, symbol: str) -> Ticker: 90 | response = await self.requester.get( 91 | f"{self.BASE_URL}/ticker/24hr", params={"symbol": symbol} 92 | ) 93 | 94 | if not response.is_success: 95 | self._raise_for_failed_response( 96 | response.status_code, self._get_error_message(response, "msg") 97 | ) 98 | 99 | data = response.json() 100 | return Ticker( 101 | symbol=data["symbol"], 102 | trade_price=float(data["lastPrice"]), 103 | trade_volume=float(data["volume"]), 104 | trade_timestamp=int(time.time() * 1000), 105 | opening_price=float(data["openPrice"]), 106 | high_price=float(data["highPrice"]), 107 | low_price=float(data["lowPrice"]), 108 | change_percentage=float(data["priceChangePercent"]), 109 | change_price=float(data["priceChange"]), 110 | acc_trade_volume=float(data["quoteVolume"]), 111 | acc_trade_price=float(data["quoteVolume"]) * float(data["lastPrice"]), 112 | timestamp=int(time.time() * 1000), 113 | ) 114 | 115 | async def get_balances(self) -> list[Balance]: 116 | response = await self.requester.get( 117 | f"{self.BASE_URL}/account", params={"timestamp": int(time.time() * 1000)} 118 | ) 119 | 120 | if not response.is_success: 121 | self._raise_for_failed_response( 122 | response.status_code, self._get_error_message(response, "msg") 123 | ) 124 | 125 | data = response.json() 126 | balances = [] 127 | for balance in data["balances"]: 128 | balances.append( 129 | Balance( 130 | currency=balance["asset"], 131 | balance=float(balance["free"]), 132 | locked=float(balance["locked"]), 133 | avg_buy_price=None, 134 | avg_buy_price_modified=False, 135 | unit_currency=None, 136 | ) 137 | ) 138 | return balances 139 | 140 | async def get_open_orders( 141 | self, 142 | symbol: str, 143 | page: int, 144 | limit: int, 145 | order_by: Literal["asc", "desc"] = "desc", 146 | ) -> list[Order]: 147 | response = await self.requester.get( 148 | f"{self.BASE_URL}/openOrders", 149 | params={ 150 | "symbol": symbol, 151 | "timestamp": int(time.time() * 1000), 152 | }, 153 | ) 154 | 155 | if not response.is_success: 156 | self._raise_for_failed_response( 157 | response.status_code, self._get_error_message(response, "msg") 158 | ) 159 | 160 | data = response.json() 161 | return [self._convert_to_order(order) for order in data] 162 | 163 | async def get_closed_orders( 164 | self, 165 | symbol: str, 166 | page: int, 167 | limit: int, 168 | status: Optional[Literal["done", "cancel"]] = None, 169 | start_date: Optional[int] = None, 170 | end_date: Optional[int] = None, 171 | order_by: Literal["asc", "desc"] = "desc", 172 | ) -> list[Order]: 173 | params = { 174 | "symbol": symbol, 175 | "limit": limit, 176 | "timestamp": int(time.time() * 1000), 177 | } 178 | 179 | if start_date: 180 | params["startTime"] = start_date 181 | if end_date: 182 | params["endTime"] = end_date 183 | 184 | response = await self.requester.get( 185 | f"{self.BASE_URL}/allOrders", 186 | params=params, 187 | ) 188 | 189 | if not response.is_success: 190 | self._raise_for_failed_response( 191 | response.status_code, self._get_error_message(response) 192 | ) 193 | 194 | data = response.json() 195 | return [ 196 | self._convert_to_order(order) 197 | for order in data 198 | if order["status"] 199 | in ("FILLED", "CANCELED", "REJECTED", "EXPIRED", "EXPIRED_IN_MATCH") 200 | ] 201 | 202 | async def get_order(self, order_id: str, symbol: str = None) -> Order: 203 | response = await self.requester.get( 204 | f"{self.BASE_URL}/order", 205 | params={ 206 | "symbol": symbol, 207 | "orderId": order_id, 208 | "timestamp": int(time.time() * 1000), 209 | }, 210 | ) 211 | 212 | if not response.is_success: 213 | self._raise_for_failed_response( 214 | response.status_code, self._get_error_message(response) 215 | ) 216 | 217 | data = response.json() 218 | return self._convert_to_order(data) 219 | 220 | async def get_order_book(self, symbol: str) -> OrderBook: 221 | response = await self.requester.get( 222 | f"{self.BASE_URL}/depth", params={"symbol": symbol} 223 | ) 224 | 225 | if not response.is_success: 226 | self._raise_for_failed_response( 227 | response.status_code, self._get_error_message(response) 228 | ) 229 | 230 | data = response.json() 231 | return OrderBook( 232 | symbol=symbol, 233 | timestamp=int(time.time() * 1000), 234 | items=[ 235 | OrderBookItem( 236 | ask_price=float(ask[0]), 237 | ask_quantity=float(ask[1]), 238 | bid_price=float(bid[0]), 239 | bid_quantity=float(bid[1]), 240 | ) 241 | for ask, bid in zip(data["asks"], data["bids"]) 242 | ], 243 | ) 244 | 245 | async def place_order( 246 | self, 247 | symbol: str, 248 | side: Literal["bid", "ask"], 249 | amount: float, 250 | price: float, 251 | order_type: Literal["limit", "market"] = "limit", 252 | ) -> Order: 253 | params = { 254 | "symbol": symbol, 255 | "side": "BUY" if side == "bid" else "SELL", 256 | "quantity": str(amount), 257 | "price": str(price), 258 | "type": order_type.upper(), 259 | "timestamp": int(time.time() * 1000), 260 | } 261 | 262 | if order_type == "limit": 263 | params["timeInForce"] = "GTC" 264 | elif order_type == "market": 265 | params["timeInForce"] = "IOC" 266 | 267 | response = await self.requester.post(f"{self.BASE_URL}/order", params=params) 268 | 269 | if not response.is_success: 270 | self._raise_for_failed_response( 271 | response.status_code, self._get_error_message(response) 272 | ) 273 | 274 | data = response.json() 275 | data["time"] = data["transactTime"] 276 | return self._convert_to_order(data) 277 | 278 | async def cancel_order(self, order_id: str, symbol: str = None) -> Order: 279 | params = { 280 | "symbol": symbol, 281 | "orderId": order_id, 282 | "timestamp": int(time.time() * 1000), 283 | } 284 | response = await self.requester.delete(f"{self.BASE_URL}/order", params=params) 285 | 286 | if not response.is_success: 287 | self._raise_for_failed_response( 288 | response.status_code, self._get_error_message(response) 289 | ) 290 | 291 | data = response.json() 292 | data["time"] = data["transactTime"] 293 | return self._convert_to_order(data) 294 | 295 | def _convert_to_order(self, data: dict) -> Order: 296 | status_map = { 297 | "NEW": "wait", 298 | "PENDING_NEW": "wait", 299 | "PARTIALLY_FILLED": "wait", 300 | "FILLED": "done", 301 | } 302 | 303 | return Order( 304 | order_id=str(data["orderId"]), 305 | side="bid" if data["side"] == "BUY" else "ask", 306 | price=float(data.get("price", 0)), 307 | order_type=data["type"].lower(), 308 | amount=float(data["origQty"]), 309 | status=status_map.get(data["status"], "canceled"), 310 | executed_volume=float(data.get("executedQty", 0)), 311 | remaining_volume=float(data.get("origQty", 0)) 312 | - float(data.get("executedQty", 0)), 313 | created_at=data["time"], 314 | ) 315 | ``` -------------------------------------------------------------------------------- /src/crypto_trading_mcp/exchanges/upbit.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | import httpx 3 | import uuid 4 | import hashlib 5 | import jwt 6 | import json 7 | 8 | from typing import List, Optional, Literal 9 | from urllib.parse import urlencode, unquote 10 | 11 | from crypto_trading_mcp.exchanges.base import ( 12 | CryptoExchange, 13 | Balance, 14 | CryptoTradingPair, 15 | Order, 16 | OrderBook, 17 | OrderBookItem, 18 | Ticker, 19 | ) 20 | from crypto_trading_mcp.http_handler import HTTPRequester, BearerAuth 21 | from crypto_trading_mcp.utils import iso_to_timestamp 22 | 23 | 24 | class UpbitRequester(HTTPRequester): 25 | UPBIT_ACCESS_KEY = os.getenv("UPBIT_ACCESS_KEY") 26 | UPBIT_SECRET_KEY = os.getenv("UPBIT_SECRET_KEY") 27 | 28 | def generate_auth( 29 | self, params: Optional[dict] = None, json: Optional[dict] = None 30 | ) -> BearerAuth: 31 | payload = { 32 | "access_key": self.UPBIT_ACCESS_KEY, 33 | "nonce": str(uuid.uuid4()), 34 | } 35 | 36 | if params or json: 37 | query_string = unquote(urlencode(params or json, doseq=True)).encode() 38 | 39 | m = hashlib.sha512() 40 | m.update(query_string) 41 | payload["query_hash"] = m.hexdigest() 42 | payload["query_hash_alg"] = "SHA512" 43 | 44 | token = jwt.encode(payload, self.UPBIT_SECRET_KEY, algorithm="HS256") 45 | return BearerAuth(token) 46 | 47 | async def send(self, *args, **kwargs) -> httpx.Response: 48 | self.authorization = self.generate_auth( 49 | kwargs.get("params"), kwargs.get("json") 50 | ) 51 | return await super().send(*args, **kwargs) 52 | 53 | 54 | class Upbit(CryptoExchange): 55 | def __init__(self, requester: HTTPRequester): 56 | self.requester = requester 57 | self.base_url = "https://api.upbit.com/v1" 58 | 59 | async def get_symbols(self) -> List[CryptoTradingPair]: 60 | response = await self.requester.get( 61 | url=f"{self.base_url}/market/all", 62 | ) 63 | if response.is_error: 64 | self._raise_for_failed_response( 65 | response.status_code, self._get_error_message(response, "error.message") 66 | ) 67 | 68 | markets = response.json() 69 | return [ 70 | CryptoTradingPair( 71 | symbol=market["market"], 72 | name=market["english_name"], 73 | ) 74 | for market in markets 75 | ] 76 | 77 | async def get_tickers(self, symbol: str = "") -> List[Ticker]: 78 | params = {"markets": symbol} if symbol else None 79 | response = await self.requester.get( 80 | url=f"{self.base_url}/ticker", 81 | params=params, 82 | ) 83 | if response.is_error: 84 | self._raise_for_failed_response( 85 | response.status_code, self._get_error_message(response, "error.message") 86 | ) 87 | 88 | tickers = response.json() 89 | return [ 90 | Ticker( 91 | symbol=ticker["market"], 92 | trade_timestamp=ticker["trade_timestamp"], 93 | trade_price=ticker["trade_price"], 94 | trade_volume=ticker["trade_volume"], 95 | opening_price=ticker["opening_price"], 96 | high_price=ticker["high_price"], 97 | low_price=ticker["low_price"], 98 | change_percentage=ticker["signed_change_rate"] * 100, 99 | change_price=ticker["change_price"], 100 | acc_trade_volume=ticker["acc_trade_volume"], 101 | acc_trade_price=ticker["acc_trade_price"], 102 | timestamp=ticker["timestamp"], 103 | ) 104 | for ticker in tickers 105 | ] 106 | 107 | async def get_balances(self) -> List[Balance]: 108 | response = await self.requester.get( 109 | url=f"{self.base_url}/accounts", 110 | ) 111 | if response.is_error: 112 | self._raise_for_failed_response( 113 | response.status_code, self._get_error_message(response, "error.message") 114 | ) 115 | 116 | balances = response.json() 117 | return [ 118 | Balance( 119 | currency=balance["currency"], 120 | balance=float(balance["balance"]), 121 | locked=float(balance["locked"]), 122 | avg_buy_price=float(balance["avg_buy_price"]), 123 | avg_buy_price_modified=balance["avg_buy_price_modified"], 124 | unit_currency=balance["unit_currency"], 125 | ) 126 | for balance in balances 127 | ] 128 | 129 | async def get_open_orders( 130 | self, 131 | symbol: str, 132 | page: int, 133 | limit: int, 134 | order_by: Literal["asc", "desc"] = "desc", 135 | ) -> List[Order]: 136 | params = { 137 | "market": symbol, 138 | "page": page, 139 | "limit": limit, 140 | "order_by": order_by, 141 | "states[]": ["wait", "watch"], 142 | } 143 | 144 | response = await self.requester.get( 145 | url=f"{self.base_url}/orders/open", 146 | params=params, 147 | ) 148 | if response.is_error: 149 | self._raise_for_failed_response( 150 | response.status_code, self._get_error_message(response, "error.message") 151 | ) 152 | 153 | orders = response.json() 154 | return [ 155 | Order( 156 | order_id=order["uuid"], 157 | side=order["side"], 158 | amount=float(order["volume"]), 159 | price=float(order["price"]), 160 | order_type=order["ord_type"], 161 | status=order["state"], 162 | executed_volume=float(order["executed_volume"]), 163 | remaining_volume=float(order["remaining_volume"]), 164 | created_at=iso_to_timestamp(order["created_at"]), 165 | ) 166 | for order in orders 167 | ] 168 | 169 | async def get_closed_orders( 170 | self, 171 | symbol: str, 172 | page: int, 173 | limit: int, 174 | status: Optional[Literal["done", "canceled"]] = None, 175 | start_date: Optional[int] = None, 176 | end_date: Optional[int] = None, 177 | order_by: Literal["asc", "desc"] = "desc", 178 | ) -> List[Order]: 179 | params = {"market": symbol, "limit": limit, "order_by": order_by} 180 | if status: 181 | params["state"] = "cancel" if status == "canceled" else status 182 | else: 183 | params["states[]"] = ["done", "cancel"] 184 | 185 | if start_date: 186 | params["start_date"] = start_date 187 | 188 | if end_date: 189 | params["end_date"] = end_date 190 | 191 | response = await self.requester.get( 192 | url=f"{self.base_url}/orders/closed", 193 | params=params, 194 | ) 195 | if response.is_error: 196 | self._raise_for_failed_response( 197 | response.status_code, self._get_error_message(response, "error.message") 198 | ) 199 | 200 | orders = response.json() 201 | return [ 202 | Order( 203 | order_id=order["uuid"], 204 | side=order["side"], 205 | amount=float(order["volume"]), 206 | price=float(order["price"]), 207 | order_type=order["ord_type"], 208 | status=order["state"], 209 | executed_volume=float(order["executed_volume"]), 210 | remaining_volume=float(order["remaining_volume"]), 211 | created_at=iso_to_timestamp(order["created_at"]), 212 | ) 213 | for order in orders 214 | ] 215 | 216 | async def get_order(self, order_id: str, symbol: str = None) -> Order: 217 | response = await self.requester.get( 218 | url=f"{self.base_url}/order", 219 | params={"uuid": order_id}, 220 | ) 221 | if response.is_error: 222 | self._raise_for_failed_response( 223 | response.status_code, self._get_error_message(response, "error.message") 224 | ) 225 | 226 | orders = response.json() 227 | order = orders[0] 228 | return Order( 229 | order_id=order["uuid"], 230 | side=order["side"], 231 | amount=float(order["volume"]), 232 | price=float(order["price"]), 233 | order_type=order["ord_type"], 234 | status=order["state"], 235 | executed_volume=float(order["executed_volume"]), 236 | remaining_volume=float(order["remaining_volume"]), 237 | created_at=iso_to_timestamp(order["created_at"]), 238 | ) 239 | 240 | async def get_order_book(self, market: str) -> OrderBook: 241 | response = await self.requester.get( 242 | url=f"{self.base_url}/orderbook", 243 | params={"markets": market}, 244 | ) 245 | if response.is_error: 246 | self._raise_for_failed_response( 247 | response.status_code, self._get_error_message(response, "error.message") 248 | ) 249 | 250 | order_book = response.json()[0] 251 | return OrderBook( 252 | symbol=order_book["market"], 253 | timestamp=order_book["timestamp"], 254 | items=[ 255 | OrderBookItem( 256 | ask_price=float(unit["ask_price"]), 257 | ask_quantity=float(unit["ask_size"]), 258 | bid_price=float(unit["bid_price"]), 259 | bid_quantity=float(unit["bid_size"]), 260 | ) 261 | for unit in order_book["orderbook_units"] 262 | ], 263 | ) 264 | 265 | async def place_order( 266 | self, 267 | symbol: str, 268 | side: Literal["bid", "ask"], 269 | amount: float, 270 | price: float, 271 | order_type: Literal["limit", "market"] = "limit", 272 | ) -> Order: 273 | order_type = "price" if order_type == "market" and side == "bid" else order_type 274 | 275 | response = await self.requester.post( 276 | url=f"{self.base_url}/orders", 277 | json={ 278 | "market": symbol, 279 | "side": side, 280 | "volume": None if order_type == "price" else amount, 281 | "price": None if order_type == "market" and side == "ask" else price, 282 | "ord_type": order_type, 283 | }, 284 | ) 285 | if response.is_error: 286 | self._raise_for_failed_response( 287 | response.status_code, self._get_error_message(response, "error.message") 288 | ) 289 | 290 | order = response.json() 291 | return Order( 292 | order_id=order["uuid"], 293 | side=order["side"], 294 | amount=float(order["volume"]), 295 | price=float(order["price"]), 296 | order_type=order["ord_type"], 297 | status=order["state"], 298 | executed_volume=float(order["executed_volume"]), 299 | remaining_volume=float(order["remaining_volume"]), 300 | created_at=iso_to_timestamp(order["created_at"]), 301 | ) 302 | 303 | async def cancel_order(self, order_id: str, symbol: str = None) -> bool: 304 | response = await self.requester.delete( 305 | url=f"{self.base_url}/order", 306 | params={"uuid": order_id}, 307 | ) 308 | 309 | if response.is_error: 310 | self._raise_for_failed_response( 311 | response.status_code, self._get_error_message(response, "error.message") 312 | ) 313 | 314 | return True 315 | ``` -------------------------------------------------------------------------------- /tests/test_binance.py: -------------------------------------------------------------------------------- ```python 1 | import pytest 2 | import httpx 3 | 4 | from crypto_trading_mcp.exchanges.binance import Binance, BinanceAuth 5 | from crypto_trading_mcp.exchanges.base import ( 6 | CryptoTradingPair, 7 | OrderBook, 8 | OrderBookItem, 9 | Ticker, 10 | Balance, 11 | Order, 12 | ) 13 | from tests.test_requester import FakeHTTPRequester 14 | 15 | 16 | @pytest.fixture 17 | def success_symbols_response(): 18 | return httpx.Response( 19 | 200, 20 | json={ 21 | "timezone": "UTC", 22 | "serverTime": 1565246363776, 23 | "symbols": [ 24 | { 25 | "symbol": "ETHBTC", 26 | "status": "TRADING", 27 | "baseAsset": "ETH", 28 | "baseAssetPrecision": 8, 29 | "quoteAsset": "BTC", 30 | "quotePrecision": 8, 31 | "quoteAssetPrecision": 8, 32 | "baseCommissionPrecision": 8, 33 | "quoteCommissionPrecision": 8, 34 | "orderTypes": [ 35 | "LIMIT", 36 | "LIMIT_MAKER", 37 | "MARKET", 38 | "STOP_LOSS", 39 | "STOP_LOSS_LIMIT", 40 | "TAKE_PROFIT", 41 | "TAKE_PROFIT_LIMIT", 42 | ], 43 | "icebergAllowed": True, 44 | "ocoAllowed": True, 45 | "otoAllowed": True, 46 | "quoteOrderQtyMarketAllowed": True, 47 | "allowTrailingStop": False, 48 | "cancelReplaceAllowed": False, 49 | "allowAmend": False, 50 | "isSpotTradingAllowed": True, 51 | "isMarginTradingAllowed": True, 52 | "filters": [], 53 | "permissions": [], 54 | "permissionSets": [["SPOT", "MARGIN"]], 55 | "defaultSelfTradePreventionMode": "NONE", 56 | "allowedSelfTradePreventionModes": ["NONE"], 57 | } 58 | ], 59 | "sors": [{"baseAsset": "BTC", "symbols": ["BTCUSDT", "BTCUSDC"]}], 60 | }, 61 | ) 62 | 63 | 64 | @pytest.fixture 65 | def success_tickers_response(): 66 | return httpx.Response( 67 | 200, 68 | json={ 69 | "symbol": "BNBBTC", 70 | "priceChange": "-94.99999800", 71 | "priceChangePercent": "-95.960", 72 | "weightedAvgPrice": "0.29628482", 73 | "prevClosePrice": "0.10002000", 74 | "lastPrice": "4.00000200", 75 | "lastQty": "200.00000000", 76 | "bidPrice": "4.00000000", 77 | "bidQty": "100.00000000", 78 | "askPrice": "4.00000200", 79 | "askQty": "100.00000000", 80 | "openPrice": "99.00000000", 81 | "highPrice": "100.00000000", 82 | "lowPrice": "0.10000000", 83 | "volume": "8913.30000000", 84 | "quoteVolume": "15.30000000", 85 | "openTime": 1499783499040, 86 | "closeTime": 1499869899040, 87 | "firstId": 28385, 88 | "lastId": 28460, 89 | "count": 76, 90 | }, 91 | ) 92 | 93 | 94 | @pytest.fixture 95 | def success_balances_response(): 96 | return httpx.Response( 97 | 200, 98 | json={ 99 | "makerCommission": 15, 100 | "takerCommission": 15, 101 | "buyerCommission": 0, 102 | "sellerCommission": 0, 103 | "commissionRates": { 104 | "maker": "0.00150000", 105 | "taker": "0.00150000", 106 | "buyer": "0.00000000", 107 | "seller": "0.00000000", 108 | }, 109 | "canTrade": True, 110 | "canWithdraw": True, 111 | "canDeposit": True, 112 | "brokered": False, 113 | "requireSelfTradePrevention": False, 114 | "preventSor": False, 115 | "updateTime": 123456789, 116 | "accountType": "SPOT", 117 | "balances": [ 118 | {"asset": "BTC", "free": "4723846.89208129", "locked": "0.00000000"}, 119 | {"asset": "LTC", "free": "4763368.68006011", "locked": "0.00000000"}, 120 | ], 121 | "permissions": ["SPOT"], 122 | "uid": 354937868, 123 | }, 124 | ) 125 | 126 | 127 | @pytest.fixture 128 | def success_order_response(): 129 | return httpx.Response( 130 | 200, 131 | json={ 132 | "symbol": "LTCBTC", 133 | "orderId": 1, 134 | "orderListId": -1, 135 | "clientOrderId": "myOrder1", 136 | "price": "0.1", 137 | "origQty": "1.0", 138 | "executedQty": "0.0", 139 | "cummulativeQuoteQty": "0.0", 140 | "status": "NEW", 141 | "timeInForce": "GTC", 142 | "type": "LIMIT", 143 | "side": "BUY", 144 | "stopPrice": "0.0", 145 | "icebergQty": "0.0", 146 | "time": 1499827319559, 147 | "updateTime": 1499827319559, 148 | "isWorking": True, 149 | "workingTime": 1499827319559, 150 | "origQuoteOrderQty": "0.000000", 151 | "selfTradePreventionMode": "NONE", 152 | }, 153 | ) 154 | 155 | 156 | @pytest.fixture 157 | def success_order_book_response(): 158 | return httpx.Response( 159 | 200, 160 | json={ 161 | "lastUpdateId": 1027024, 162 | "bids": [["4.00000000", "431.00000000"]], 163 | "asks": [["4.00000200", "12.00000000"]], 164 | }, 165 | ) 166 | 167 | 168 | @pytest.fixture 169 | def success_open_orders_response(): 170 | return httpx.Response( 171 | 200, 172 | json=[ 173 | { 174 | "symbol": "LTCBTC", 175 | "orderId": 1, 176 | "orderListId": -1, 177 | "clientOrderId": "myOrder1", 178 | "price": "0.1", 179 | "origQty": "1.0", 180 | "executedQty": "0.0", 181 | "cummulativeQuoteQty": "0.0", 182 | "status": "NEW", 183 | "timeInForce": "GTC", 184 | "type": "LIMIT", 185 | "side": "BUY", 186 | "stopPrice": "0.0", 187 | "icebergQty": "0.0", 188 | "time": 1499827319559, 189 | "updateTime": 1499827319559, 190 | "isWorking": True, 191 | "origQuoteOrderQty": "0.000000", 192 | "workingTime": 1499827319559, 193 | "selfTradePreventionMode": "NONE", 194 | } 195 | ], 196 | ) 197 | 198 | 199 | @pytest.fixture 200 | def success_closed_orders_response(): 201 | return httpx.Response( 202 | 200, 203 | json=[ 204 | { 205 | "symbol": "LTCBTC", 206 | "orderId": 1, 207 | "orderListId": -1, 208 | "clientOrderId": "myOrder1", 209 | "price": "0.1", 210 | "origQty": "1.0", 211 | "executedQty": "0.0", 212 | "cummulativeQuoteQty": "0.0", 213 | "status": "FILLED", 214 | "timeInForce": "GTC", 215 | "type": "LIMIT", 216 | "side": "BUY", 217 | "stopPrice": "0.0", 218 | "icebergQty": "0.0", 219 | "time": 1499827319559, 220 | "updateTime": 1499827319559, 221 | "isWorking": True, 222 | "origQuoteOrderQty": "0.000000", 223 | "workingTime": 1499827319559, 224 | "selfTradePreventionMode": "NONE", 225 | } 226 | ], 227 | ) 228 | 229 | 230 | @pytest.fixture 231 | def success_place_order_response(): 232 | return httpx.Response( 233 | 200, 234 | json={ 235 | "symbol": "BTCUSDT", 236 | "orderId": 28, 237 | "orderListId": -1, 238 | "clientOrderId": "6gCrw2kRUAF9CvJDGP16IP", 239 | "transactTime": 1507725176595, 240 | "price": "0.00000000", 241 | "origQty": "10.00000000", 242 | "executedQty": "10.00000000", 243 | "origQuoteOrderQty": "0.000000", 244 | "cummulativeQuoteQty": "10.00000000", 245 | "status": "FILLED", 246 | "timeInForce": "GTC", 247 | "type": "MARKET", 248 | "side": "SELL", 249 | "workingTime": 1507725176595, 250 | "selfTradePreventionMode": "NONE", 251 | }, 252 | ) 253 | 254 | 255 | @pytest.fixture 256 | def success_cancel_order_response(): 257 | return httpx.Response( 258 | 200, 259 | json={ 260 | "symbol": "LTCBTC", 261 | "origClientOrderId": "myOrder1", 262 | "orderId": 4, 263 | "orderListId": -1, 264 | "clientOrderId": "cancelMyOrder1", 265 | "transactTime": 1684804350068, 266 | "price": "2.00000000", 267 | "origQty": "1.00000000", 268 | "executedQty": "0.00000000", 269 | "cummulativeQuoteQty": "0.00000000", 270 | "status": "CANCELED", 271 | "timeInForce": "GTC", 272 | "type": "LIMIT", 273 | "side": "BUY", 274 | "selfTradePreventionMode": "NONE", 275 | }, 276 | ) 277 | 278 | 279 | @pytest.mark.asyncio 280 | async def test_get_symbols(success_symbols_response): 281 | binance = Binance(FakeHTTPRequester(success_symbols_response)) 282 | symbols = await binance.get_symbols() 283 | 284 | assert symbols[0].symbol == "ETHBTC" 285 | assert symbols[0].name == "ETH" 286 | 287 | 288 | @pytest.mark.asyncio 289 | async def test_get_tickers(success_tickers_response): 290 | binance = Binance(FakeHTTPRequester(success_tickers_response)) 291 | ticker = await binance.get_tickers("BNBBTC") 292 | 293 | assert ticker.symbol == "BNBBTC" 294 | assert ticker.trade_price == 4.00000200 295 | assert ticker.change_percentage == -95.96 296 | assert ticker.change_price == -94.99999800 297 | assert ticker.trade_volume == 8913.30000000 298 | assert ticker.acc_trade_volume == 15.30000000 299 | assert ticker.opening_price == 99.00000000 300 | assert ticker.high_price == 100.00000000 301 | assert ticker.low_price == 0.10000000 302 | 303 | 304 | @pytest.mark.asyncio 305 | async def test_get_balances(success_balances_response): 306 | binance = Binance(FakeHTTPRequester(success_balances_response)) 307 | balances = await binance.get_balances() 308 | 309 | assert balances[0].currency == "BTC" 310 | assert balances[0].balance == 4723846.89208129 311 | assert balances[0].locked == 0.00000000 312 | 313 | assert balances[1].currency == "LTC" 314 | assert balances[1].balance == 4763368.68006011 315 | assert balances[1].locked == 0.00000000 316 | 317 | 318 | @pytest.mark.asyncio 319 | async def test_get_open_orders(success_open_orders_response): 320 | binance = Binance(FakeHTTPRequester(success_open_orders_response)) 321 | orders = await binance.get_open_orders("LTCBTC", 1, 10) 322 | 323 | assert orders[0].order_id == "1" 324 | assert orders[0].price == 0.1 325 | assert orders[0].amount == 1.0 326 | assert orders[0].status == "wait" 327 | assert orders[0].created_at == 1499827319559 328 | 329 | 330 | @pytest.mark.asyncio 331 | async def test_get_closed_orders(success_closed_orders_response): 332 | binance = Binance(FakeHTTPRequester(success_closed_orders_response)) 333 | orders = await binance.get_closed_orders("LTCBTC", 1, 10) 334 | 335 | assert orders[0].order_id == "1" 336 | assert orders[0].price == 0.1 337 | assert orders[0].amount == 1.0 338 | assert orders[0].status == "done" 339 | assert orders[0].created_at == 1499827319559 340 | 341 | 342 | @pytest.mark.asyncio 343 | async def test_get_order(success_order_response): 344 | binance = Binance(FakeHTTPRequester(success_order_response)) 345 | order = await binance.get_order("1", "LTCBTC") 346 | 347 | assert order.order_id == "1" 348 | assert order.price == 0.1 349 | assert order.amount == 1.0 350 | assert order.status == "wait" 351 | assert order.created_at == 1499827319559 352 | 353 | 354 | @pytest.mark.asyncio 355 | async def test_get_order_book(success_order_book_response): 356 | binance = Binance(FakeHTTPRequester(success_order_book_response)) 357 | order_book = await binance.get_order_book("LTCBTC") 358 | 359 | assert order_book.symbol == "LTCBTC" 360 | assert order_book.items[0].ask_price == 4.00000200 361 | assert order_book.items[0].ask_quantity == 12.00000000 362 | assert order_book.items[0].bid_price == 4.00000000 363 | assert order_book.items[0].bid_quantity == 431.00000000 364 | 365 | 366 | @pytest.mark.asyncio 367 | async def test_place_order(success_place_order_response): 368 | binance = Binance(FakeHTTPRequester(success_place_order_response)) 369 | order = await binance.place_order("BTCUSDT", "ask", 10.00000000, 0.00000000) 370 | 371 | assert order.order_id == "28" 372 | assert order.price == 0.00000000 373 | assert order.amount == 10.00000000 374 | assert order.status == "done" 375 | assert order.created_at == 1507725176595 376 | 377 | 378 | @pytest.mark.asyncio 379 | async def test_cancel_order(success_cancel_order_response): 380 | binance = Binance(FakeHTTPRequester(success_cancel_order_response)) 381 | order = await binance.cancel_order("1", "LTCBTC") 382 | 383 | assert order.order_id == "4" 384 | assert order.price == 2.00000000 385 | assert order.amount == 1.00000000 386 | assert order.status == "canceled" 387 | assert order.created_at == 1684804350068 388 | 389 | 390 | def test_generate_signature(): 391 | auth = BinanceAuth() 392 | auth.BINANCE_SECRET_KEY = "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j" # secret for testing 393 | 394 | payload_only_signature = auth.generate_signature( 395 | payload_string="symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000×tamp=1499827319559" 396 | ) 397 | 398 | query_string_only_signature = auth.generate_signature( 399 | query_string="symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000×tamp=1499827319559" 400 | ) 401 | 402 | both_signature = auth.generate_signature( 403 | "symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC", 404 | "quantity=1&price=0.1&recvWindow=5000×tamp=1499827319559", 405 | ) 406 | 407 | assert ( 408 | payload_only_signature 409 | == "c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71" 410 | ) 411 | 412 | assert ( 413 | query_string_only_signature 414 | == "c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71" 415 | ) 416 | 417 | assert ( 418 | both_signature 419 | == "0fd168b8ddb4876a0358a8d14d0c9f3da0e9b20c5d52b2a00fcf7d1c602f9a77" 420 | ) 421 | ``` -------------------------------------------------------------------------------- /tests/test_gateio.py: -------------------------------------------------------------------------------- ```python 1 | import pytest 2 | import httpx 3 | 4 | from crypto_trading_mcp.exchanges.gateio import GateIO, GateIOAuth 5 | from crypto_trading_mcp.exchanges.base import ( 6 | CryptoTradingPair, 7 | OrderBook, 8 | OrderBookItem, 9 | Ticker, 10 | Balance, 11 | Order, 12 | ) 13 | from tests.test_requester import FakeHTTPRequester 14 | 15 | 16 | @pytest.fixture 17 | def success_symbols_response(): 18 | return httpx.Response( 19 | 200, 20 | json=[ 21 | { 22 | "id": "ETH_USDT", 23 | "base": "ETH", 24 | "base_name": "Ethereum", 25 | "quote": "USDT", 26 | "quote_name": "Tether", 27 | "fee": "0.2", 28 | "min_base_amount": "0.001", 29 | "min_quote_amount": "1.0", 30 | "max_base_amount": "10000", 31 | "max_quote_amount": "10000000", 32 | "amount_precision": 3, 33 | "precision": 6, 34 | "trade_status": "tradable", 35 | "sell_start": 1516378650, 36 | "buy_start": 1516378650, 37 | } 38 | ], 39 | ) 40 | 41 | 42 | @pytest.fixture 43 | def success_tickers_response(): 44 | return httpx.Response( 45 | 200, 46 | json=[ 47 | { 48 | "currency_pair": "BTC3L_USDT", 49 | "last": "2.46140352", 50 | "lowest_ask": "2.477", 51 | "highest_bid": "2.4606821", 52 | "change_percentage": "-8.91", 53 | "change_utc0": "-8.91", 54 | "change_utc8": "-8.91", 55 | "base_volume": "656614.0845820589", 56 | "quote_volume": "1602221.66468375534639404191", 57 | "high_24h": "2.7431", 58 | "low_24h": "1.9863", 59 | "etf_net_value": "2.46316141", 60 | "etf_pre_net_value": "2.43201848", 61 | "etf_pre_timestamp": 1611244800, 62 | "etf_leverage": "2.2803019447281203", 63 | } 64 | ], 65 | ) 66 | 67 | 68 | @pytest.fixture 69 | def success_balances_response(): 70 | return httpx.Response( 71 | 200, 72 | json=[ 73 | {"currency": "ETH", "available": "968.8", "locked": "0", "update_id": 98} 74 | ], 75 | ) 76 | 77 | 78 | @pytest.fixture 79 | def success_order_response(): 80 | return httpx.Response( 81 | 200, 82 | json={ 83 | "id": "1852454420", 84 | "create_time": "1710488334", 85 | "update_time": "1710488334", 86 | "create_time_ms": 1710488334073, 87 | "update_time_ms": 1710488334074, 88 | "status": "closed", 89 | "currency_pair": "BTC_USDT", 90 | "type": "limit", 91 | "account": "unified", 92 | "side": "buy", 93 | "amount": "0.001", 94 | "price": "65000", 95 | "time_in_force": "gtc", 96 | "iceberg": "0", 97 | "left": "0", 98 | "filled_amount": "0.001", 99 | "fill_price": "63.4693", 100 | "filled_total": "63.4693", 101 | "avg_deal_price": "63469.3", 102 | "fee": "0.00000022", 103 | "fee_currency": "BTC", 104 | "point_fee": "0", 105 | "gt_fee": "0", 106 | "gt_maker_fee": "0", 107 | "gt_taker_fee": "0", 108 | "gt_discount": False, 109 | "rebated_fee": "0", 110 | "rebated_fee_currency": "USDT", 111 | "finish_as": "filled", 112 | }, 113 | ) 114 | 115 | 116 | @pytest.fixture 117 | def success_order_book_response(): 118 | return httpx.Response( 119 | 200, 120 | json={ 121 | "id": 123456, 122 | "current": 1623898993123, 123 | "update": 1623898993121, 124 | "asks": [["1.52", "1.151"], ["1.53", "1.218"]], 125 | "bids": [["1.17", "201.863"], ["1.16", "725.464"]], 126 | }, 127 | ) 128 | 129 | 130 | @pytest.fixture 131 | def success_open_orders_response(): 132 | return httpx.Response( 133 | 200, 134 | json=[ 135 | { 136 | "id": "1852454420", 137 | "create_time": "1710488334", 138 | "update_time": "1710488334", 139 | "create_time_ms": 1710488334073, 140 | "update_time_ms": 1710488334074, 141 | "status": "open", 142 | "currency_pair": "BTC_USDT", 143 | "type": "limit", 144 | "account": "unified", 145 | "side": "buy", 146 | "amount": "0.001", 147 | "price": "65000", 148 | "time_in_force": "gtc", 149 | "iceberg": "0", 150 | "left": "0", 151 | "filled_amount": "0.001", 152 | "fill_price": "63.4693", 153 | "filled_total": "63.4693", 154 | "avg_deal_price": "63469.3", 155 | "fee": "0.00000022", 156 | "fee_currency": "BTC", 157 | "point_fee": "0", 158 | "gt_fee": "0", 159 | "gt_maker_fee": "0", 160 | "gt_taker_fee": "0", 161 | "gt_discount": False, 162 | "rebated_fee": "0", 163 | "rebated_fee_currency": "USDT", 164 | "finish_as": "filled", 165 | }, 166 | ], 167 | ) 168 | 169 | 170 | @pytest.fixture 171 | def success_closed_orders_response(): 172 | return httpx.Response( 173 | 200, 174 | json=[ 175 | { 176 | "id": "1852454425", 177 | "create_time": "1710488334", 178 | "update_time": "1710488334", 179 | "create_time_ms": 1710488334073, 180 | "update_time_ms": 1710488334074, 181 | "status": "closed", 182 | "currency_pair": "BTC_USDT", 183 | "type": "limit", 184 | "account": "unified", 185 | "side": "sell", 186 | "amount": "0.001", 187 | "price": "65000", 188 | "time_in_force": "gtc", 189 | "iceberg": "0", 190 | "left": "0", 191 | "filled_amount": "0.001", 192 | "fill_price": "63.4693", 193 | "filled_total": "63.4693", 194 | "avg_deal_price": "63469.3", 195 | "fee": "0.00000022", 196 | "fee_currency": "BTC", 197 | "point_fee": "0", 198 | "gt_fee": "0", 199 | "gt_maker_fee": "0", 200 | "gt_taker_fee": "0", 201 | "gt_discount": False, 202 | "rebated_fee": "0", 203 | "rebated_fee_currency": "USDT", 204 | "finish_as": "filled", 205 | }, 206 | ], 207 | ) 208 | 209 | 210 | @pytest.fixture 211 | def success_place_order_response(): 212 | return httpx.Response( 213 | 200, 214 | json={ 215 | "id": "1852454420", 216 | "text": "t-abc123", 217 | "amend_text": "-", 218 | "create_time": "1710488334", 219 | "update_time": "1710488334", 220 | "create_time_ms": 1710488334073, 221 | "update_time_ms": 1710488334074, 222 | "status": "closed", 223 | "currency_pair": "BTC_USDT", 224 | "type": "limit", 225 | "account": "unified", 226 | "side": "buy", 227 | "amount": "0.001", 228 | "price": "65000", 229 | "time_in_force": "gtc", 230 | "iceberg": "0", 231 | "left": "0", 232 | "filled_amount": "0.001", 233 | "fill_price": "63.4693", 234 | "filled_total": "63.4693", 235 | "avg_deal_price": "63469.3", 236 | "fee": "0.00000022", 237 | "fee_currency": "BTC", 238 | "point_fee": "0", 239 | "gt_fee": "0", 240 | "gt_maker_fee": "0", 241 | "gt_taker_fee": "0", 242 | "gt_discount": False, 243 | "rebated_fee": "0", 244 | "rebated_fee_currency": "USDT", 245 | "finish_as": "filled", 246 | }, 247 | ) 248 | 249 | 250 | @pytest.fixture 251 | def success_cancel_order_response(): 252 | return httpx.Response( 253 | 200, 254 | json={ 255 | "id": "1852454420", 256 | "create_time": "1710488334", 257 | "update_time": "1710488334", 258 | "create_time_ms": 1710488334073, 259 | "update_time_ms": 1710488334074, 260 | "status": "closed", 261 | "currency_pair": "BTC_USDT", 262 | "type": "limit", 263 | "account": "unified", 264 | "side": "buy", 265 | "amount": "0.001", 266 | "price": "65000", 267 | "time_in_force": "gtc", 268 | "iceberg": "0", 269 | "left": "0", 270 | "filled_amount": "0.001", 271 | "fill_price": "63.4693", 272 | "filled_total": "63.4693", 273 | "avg_deal_price": "63469.3", 274 | "fee": "0.00000022", 275 | "fee_currency": "BTC", 276 | "point_fee": "0", 277 | "gt_fee": "0", 278 | "gt_maker_fee": "0", 279 | "gt_taker_fee": "0", 280 | "gt_discount": False, 281 | "rebated_fee": "0", 282 | "rebated_fee_currency": "USDT", 283 | "finish_as": "filled", 284 | }, 285 | ) 286 | 287 | 288 | def test_generate_signature(): 289 | auth = GateIOAuth() 290 | signature = auth.generate_signature( 291 | "fake-endpoint", 292 | "POST", 293 | "1710488334", 294 | "currency_pair=BTC_USDT", 295 | '{"side":"buy","amount":"0.001","price":"65000","type":"limit","time_in_force":"gtc"}', 296 | ) 297 | 298 | assert ( 299 | signature 300 | == "ce0372c44f5fe877702fe7ae35c272157baaa58939449535ee45ae17a393820e27ca4e16aa190f23302592870aa10bff17e9d80bfe09c909aab323aed7f69419" 301 | ) 302 | 303 | 304 | @pytest.mark.asyncio 305 | async def test_get_symbols(success_symbols_response): 306 | requester = FakeHTTPRequester(success_symbols_response) 307 | sut = GateIO(requester) 308 | symbols = await sut.get_symbols() 309 | assert symbols == [ 310 | CryptoTradingPair( 311 | symbol="ETH_USDT", 312 | name="Ethereum", 313 | ), 314 | ] 315 | 316 | 317 | @pytest.mark.asyncio 318 | async def test_get_tickers(success_tickers_response): 319 | requester = FakeHTTPRequester(success_tickers_response) 320 | sut = GateIO(requester) 321 | tickers = await sut.get_tickers() 322 | ticker = tickers[0] 323 | 324 | assert ticker.symbol == "BTC3L_USDT" 325 | assert ticker.trade_price == 2.46140352 326 | assert ticker.trade_volume == 656614.0845820589 327 | assert ticker.high_price == 2.7431 328 | assert ticker.low_price == 1.9863 329 | assert ticker.acc_trade_volume == 1602221.66468375534639404191 330 | 331 | 332 | @pytest.mark.asyncio 333 | async def test_get_balances(success_balances_response): 334 | requester = FakeHTTPRequester(success_balances_response) 335 | sut = GateIO(requester) 336 | balances = await sut.get_balances() 337 | balance = balances[0] 338 | 339 | assert balance.currency == "ETH" 340 | assert balance.balance == 968.8 341 | assert balance.locked == 0.0 342 | assert balance.unit_currency is None 343 | 344 | 345 | @pytest.mark.asyncio 346 | async def test_get_order(success_order_response): 347 | requester = FakeHTTPRequester(success_order_response) 348 | sut = GateIO(requester) 349 | order = await sut.get_order("1852454420", "BTC_USDT") 350 | 351 | assert order.order_id == "1852454420" 352 | assert order.side == "bid" 353 | assert order.amount == 0.001 354 | assert order.price == 65000 355 | assert order.order_type == "limit" 356 | assert order.status == "done" 357 | 358 | 359 | @pytest.mark.asyncio 360 | async def test_get_open_orders(success_open_orders_response): 361 | requester = FakeHTTPRequester(success_open_orders_response) 362 | sut = GateIO(requester) 363 | orders = await sut.get_open_orders("BTC_USDT", 1, 100) 364 | 365 | assert orders == [ 366 | Order( 367 | order_id="1852454420", 368 | side="bid", 369 | amount=0.001, 370 | price=65000.0, 371 | order_type="limit", 372 | status="wait", 373 | executed_volume=0.001, 374 | remaining_volume=0.0, 375 | created_at=1710488334073, 376 | ), 377 | ] 378 | 379 | 380 | @pytest.mark.asyncio 381 | async def test_get_closed_orders(success_closed_orders_response): 382 | requester = FakeHTTPRequester(success_closed_orders_response) 383 | sut = GateIO(requester) 384 | orders = await sut.get_closed_orders("BTC_USDT", 1, 100) 385 | 386 | assert orders == [ 387 | Order( 388 | order_id="1852454425", 389 | side="ask", 390 | amount=0.001, 391 | price=65000.0, 392 | order_type="limit", 393 | status="done", 394 | executed_volume=0.001, 395 | remaining_volume=0.0, 396 | created_at=1710488334073, 397 | ), 398 | ] 399 | 400 | 401 | @pytest.mark.asyncio 402 | async def test_get_closed_orders(success_closed_orders_response): 403 | requester = FakeHTTPRequester(success_closed_orders_response) 404 | sut = GateIO(requester) 405 | orders = await sut.get_closed_orders("BTC_USDT", 1, 100) 406 | 407 | assert orders == [ 408 | Order( 409 | order_id="1852454425", 410 | side="ask", 411 | amount=0.001, 412 | price=65000.0, 413 | order_type="limit", 414 | status="done", 415 | executed_volume=0.001, 416 | remaining_volume=0.0, 417 | created_at=1710488334073, 418 | ), 419 | ] 420 | 421 | 422 | @pytest.mark.asyncio 423 | async def test_get_order_book(success_order_book_response): 424 | requester = FakeHTTPRequester(success_order_book_response) 425 | sut = GateIO(requester) 426 | order_book = await sut.get_order_book("BTC_USDT") 427 | 428 | assert order_book.symbol == "BTC_USDT" 429 | assert order_book.timestamp == 1623898993123 430 | assert order_book.items[0].ask_price == 1.52 431 | assert order_book.items[0].ask_quantity == 1.151 432 | assert order_book.items[0].bid_price == 1.17 433 | assert order_book.items[0].bid_quantity == 201.863 434 | 435 | assert order_book.items[1].ask_price == 1.53 436 | assert order_book.items[1].ask_quantity == 1.218 437 | assert order_book.items[1].bid_price == 1.16 438 | assert order_book.items[1].bid_quantity == 725.464 439 | 440 | 441 | @pytest.mark.asyncio 442 | async def test_place_order(success_place_order_response): 443 | requester = FakeHTTPRequester(success_place_order_response) 444 | sut = GateIO(requester) 445 | order = await sut.place_order("BTC_USDT", "bid", 0.001, 65000) 446 | 447 | assert order.order_id == "1852454420" 448 | assert order.side == "bid" 449 | assert order.amount == 0.001 450 | assert order.price == 65000 451 | assert order.order_type == "limit" 452 | assert order.status == "done" 453 | 454 | 455 | @pytest.mark.asyncio 456 | async def test_cancel_order(success_cancel_order_response): 457 | requester = FakeHTTPRequester(success_cancel_order_response) 458 | sut = GateIO(requester) 459 | result = await sut.cancel_order("1852454420", "BTC_USDT") 460 | 461 | assert result is True 462 | ``` -------------------------------------------------------------------------------- /tests/test_upbit.py: -------------------------------------------------------------------------------- ```python 1 | import pytest 2 | import httpx 3 | 4 | from crypto_trading_mcp.exchanges.upbit import Upbit 5 | from crypto_trading_mcp.exchanges.base import ( 6 | CryptoTradingPair, 7 | OrderBook, 8 | OrderBookItem, 9 | Ticker, 10 | Balance, 11 | Order, 12 | ) 13 | from tests.test_requester import FakeHTTPRequester 14 | from crypto_trading_mcp.exceptions import CryptoAPIException 15 | 16 | 17 | @pytest.fixture 18 | def success_symbols_response(): 19 | return httpx.Response( 20 | 200, 21 | json=[ 22 | { 23 | "market": "KRW-BTC", 24 | "korean_name": "비트코인", 25 | "english_name": "Bitcoin", 26 | "market_event": { 27 | "warning": False, 28 | "caution": { 29 | "PRICE_FLUCTUATIONS": False, 30 | "TRADING_VOLUME_SOARING": False, 31 | "DEPOSIT_AMOUNT_SOARING": True, 32 | "GLOBAL_PRICE_DIFFERENCES": False, 33 | "CONCENTRATION_OF_SMALL_ACCOUNTS": False, 34 | }, 35 | }, 36 | }, 37 | { 38 | "market": "KRW-ETH", 39 | "korean_name": "이더리움", 40 | "english_name": "Ethereum", 41 | "market_event": { 42 | "warning": True, 43 | "caution": { 44 | "PRICE_FLUCTUATIONS": False, 45 | "TRADING_VOLUME_SOARING": False, 46 | "DEPOSIT_AMOUNT_SOARING": False, 47 | "GLOBAL_PRICE_DIFFERENCES": False, 48 | "CONCENTRATION_OF_SMALL_ACCOUNTS": False, 49 | }, 50 | }, 51 | }, 52 | ], 53 | ) 54 | 55 | 56 | @pytest.fixture 57 | def success_tickers_response(): 58 | return httpx.Response( 59 | 200, 60 | json=[ 61 | { 62 | "market": "KRW-BTC", 63 | "trade_date": "20240822", 64 | "trade_time": "071602", 65 | "trade_date_kst": "20240822", 66 | "trade_time_kst": "161602", 67 | "trade_timestamp": 1724310962713, 68 | "opening_price": 82900000, 69 | "high_price": 83000000, 70 | "low_price": 81280000, 71 | "trade_price": 82324000, 72 | "prev_closing_price": 82900000, 73 | "change": "FALL", 74 | "change_price": 576000, 75 | "change_rate": 0.0069481303, 76 | "signed_change_price": -576000, 77 | "signed_change_rate": -0.0069481303, 78 | "trade_volume": 0.00042335, 79 | "acc_trade_price": 66058843588.46906, 80 | "acc_trade_price_24h": 250206655398.15125, 81 | "acc_trade_volume": 803.00214714, 82 | "acc_trade_volume_24h": 3047.01625142, 83 | "highest_52_week_price": 105000000, 84 | "highest_52_week_date": "2024-03-14", 85 | "lowest_52_week_price": 34100000, 86 | "lowest_52_week_date": "2023-09-11", 87 | "timestamp": 1724310962747, 88 | }, 89 | { 90 | "market": "KRW-ETH", 91 | "trade_date": "20240822", 92 | "trade_time": "071600", 93 | "trade_date_kst": "20240822", 94 | "trade_time_kst": "161600", 95 | "trade_timestamp": 1724310960320, 96 | "opening_price": 3564000, 97 | "high_price": 3576000, 98 | "low_price": 3515000, 99 | "trade_price": 3560000, 100 | "prev_closing_price": 3564000, 101 | "change": "FALL", 102 | "change_price": 4000, 103 | "change_rate": 0.0011223345, 104 | "signed_change_price": -4000, 105 | "signed_change_rate": -0.0011223345, 106 | "trade_volume": 0.00281214, 107 | "acc_trade_price": 14864479133.80843, 108 | "acc_trade_price_24h": 59043494176.58761, 109 | "acc_trade_volume": 4188.3697943, 110 | "acc_trade_volume_24h": 16656.93091147, 111 | "highest_52_week_price": 5783000, 112 | "highest_52_week_date": "2024-03-13", 113 | "lowest_52_week_price": 2087000, 114 | "lowest_52_week_date": "2023-10-12", 115 | "timestamp": 1724310960351, 116 | }, 117 | ], 118 | ) 119 | 120 | 121 | @pytest.fixture 122 | def success_balances_response(): 123 | return httpx.Response( 124 | 200, 125 | json=[ 126 | { 127 | "currency": "KRW", 128 | "balance": "1000000.0", 129 | "locked": "0.0", 130 | "avg_buy_price": "0", 131 | "avg_buy_price_modified": False, 132 | "unit_currency": "KRW", 133 | }, 134 | { 135 | "currency": "BTC", 136 | "balance": "2.0", 137 | "locked": "0.0", 138 | "avg_buy_price": "101000", 139 | "avg_buy_price_modified": True, 140 | "unit_currency": "KRW", 141 | }, 142 | ], 143 | ) 144 | 145 | 146 | @pytest.fixture 147 | def success_order_response(): 148 | return httpx.Response( 149 | 200, 150 | json=[ 151 | { 152 | "uuid": "d098ceaf-6811-4df8-97f2-b7e01aefc03f", 153 | "side": "bid", 154 | "ord_type": "limit", 155 | "price": "104812000", 156 | "state": "wait", 157 | "market": "KRW-BTC", 158 | "created_at": "2024-06-13T10:26:21+09:00", 159 | "volume": "0.00101749", 160 | "remaining_volume": "0.00006266", 161 | "reserved_fee": "53.32258094", 162 | "remaining_fee": "3.28375996", 163 | "paid_fee": "50.03882098", 164 | "locked": "6570.80367996", 165 | "executed_volume": "0.00095483", 166 | "executed_funds": "100077.64196", 167 | "trades_count": 1, 168 | } 169 | ], 170 | ) 171 | 172 | 173 | @pytest.fixture 174 | def success_open_orders_response(): 175 | return httpx.Response( 176 | 200, 177 | json=[ 178 | { 179 | "uuid": "d098ceaf-6811-4df8-97f2-b7e01aefc03f", 180 | "side": "bid", 181 | "ord_type": "limit", 182 | "price": "104812000", 183 | "state": "wait", 184 | "market": "KRW-BTC", 185 | "created_at": "2024-06-13T10:26:21+09:00", 186 | "volume": "0.00101749", 187 | "remaining_volume": "0.00006266", 188 | "reserved_fee": "53.32258094", 189 | "remaining_fee": "3.28375996", 190 | "paid_fee": "50.03882098", 191 | "locked": "6570.80367996", 192 | "executed_volume": "0.00095483", 193 | "executed_funds": "100077.64196", 194 | "trades_count": 1, 195 | }, 196 | ], 197 | ) 198 | 199 | 200 | @pytest.fixture 201 | def success_closed_orders_response(): 202 | return httpx.Response( 203 | 200, 204 | json=[ 205 | { 206 | "uuid": "e5715c44-2d1a-41e6-91d8-afa579e28731", 207 | "side": "ask", 208 | "ord_type": "limit", 209 | "price": "103813000", 210 | "state": "done", 211 | "market": "KRW-BTC", 212 | "created_at": "2024-06-13T10:28:36+09:00", 213 | "volume": "0.00039132", 214 | "remaining_volume": "0", 215 | "reserved_fee": "0", 216 | "remaining_fee": "0", 217 | "paid_fee": "20.44627434", 218 | "locked": "0", 219 | "executed_volume": "0.00039132", 220 | "executed_funds": "40892.54868", 221 | "trades_count": 2, 222 | }, 223 | ], 224 | ) 225 | 226 | 227 | @pytest.fixture 228 | def success_order_book_response(): 229 | return httpx.Response( 230 | 200, 231 | json=[ 232 | { 233 | "market": "KRW-BTC", 234 | "timestamp": 1720597558776, 235 | "total_ask_size": 1.20339227, 236 | "total_bid_size": 1.08861101, 237 | "orderbook_units": [ 238 | { 239 | "ask_price": 83186000, 240 | "bid_price": 83184000, 241 | "ask_size": 0.02565269, 242 | "bid_size": 0.07744926, 243 | }, 244 | { 245 | "ask_price": 83206000, 246 | "bid_price": 83182000, 247 | "ask_size": 0.02656392, 248 | "bid_size": 0.51562837, 249 | }, 250 | { 251 | "ask_price": 83207000, 252 | "bid_price": 83181000, 253 | "ask_size": 0.00172255, 254 | "bid_size": 0.00173694, 255 | }, 256 | ], 257 | "level": 0, 258 | } 259 | ], 260 | ) 261 | 262 | 263 | @pytest.fixture 264 | def success_place_order_response(): 265 | return httpx.Response( 266 | 200, 267 | json={ 268 | "uuid": "cdd92199-2897-4e14-9448-f923320408ad", 269 | "side": "bid", 270 | "ord_type": "limit", 271 | "price": "100.0", 272 | "state": "wait", 273 | "market": "KRW-BTC", 274 | "created_at": "2018-04-10T15:42:23+09:00", 275 | "volume": "0.01", 276 | "remaining_volume": "0.01", 277 | "reserved_fee": "0.0015", 278 | "remaining_fee": "0.0015", 279 | "paid_fee": "0.0", 280 | "locked": "1.0015", 281 | "executed_volume": "0.0", 282 | "trades_count": 0, 283 | }, 284 | ) 285 | 286 | 287 | @pytest.fixture 288 | def success_cancel_order_response(): 289 | return httpx.Response( 290 | 200, 291 | json={ 292 | "uuid": "cdd92199-2897-4e14-9448-f923320408ad", 293 | "side": "bid", 294 | "ord_type": "limit", 295 | "price": "100.0", 296 | "state": "wait", 297 | "market": "KRW-BTC", 298 | "created_at": "2018-04-10T15:42:23+09:00", 299 | "volume": "0.01", 300 | "remaining_volume": "0.01", 301 | "reserved_fee": "0.0015", 302 | "remaining_fee": "0.0015", 303 | "paid_fee": "0.0", 304 | "locked": "1.0015", 305 | "executed_volume": "0.0", 306 | "trades_count": 0, 307 | }, 308 | ) 309 | 310 | 311 | @pytest.mark.asyncio 312 | async def test_get_symbols(success_symbols_response): 313 | requester = FakeHTTPRequester(success_symbols_response) 314 | sut = Upbit(requester) 315 | symbols = await sut.get_symbols() 316 | 317 | assert symbols == [ 318 | CryptoTradingPair( 319 | symbol="KRW-BTC", 320 | name="Bitcoin", 321 | ), 322 | CryptoTradingPair( 323 | symbol="KRW-ETH", 324 | name="Ethereum", 325 | ), 326 | ] 327 | 328 | 329 | @pytest.mark.asyncio 330 | async def test_get_tickers(success_tickers_response): 331 | requester = FakeHTTPRequester(success_tickers_response) 332 | sut = Upbit(requester) 333 | tickers = await sut.get_tickers() 334 | 335 | assert tickers == [ 336 | Ticker( 337 | symbol="KRW-BTC", 338 | trade_timestamp=1724310962713, 339 | trade_price=82324000.0, 340 | trade_volume=0.00042335, 341 | opening_price=82900000.0, 342 | high_price=83000000.0, 343 | low_price=81280000.0, 344 | change_percentage=-0.69, 345 | change_price=576000.0, 346 | acc_trade_volume=803.00214714, 347 | acc_trade_price=66058843588.46906, 348 | timestamp=1724310962747, 349 | ), 350 | Ticker( 351 | symbol="KRW-ETH", 352 | trade_timestamp=1724310960320, 353 | trade_price=3560000.0, 354 | trade_volume=0.00281214, 355 | opening_price=3564000.0, 356 | high_price=3576000.0, 357 | low_price=3515000.0, 358 | change_percentage=-0.11, 359 | change_price=4000.0, 360 | acc_trade_volume=4188.3697943, 361 | acc_trade_price=14864479133.80843, 362 | timestamp=1724310960351, 363 | ), 364 | ] 365 | 366 | 367 | @pytest.mark.asyncio 368 | async def test_get_balances(success_balances_response): 369 | requester = FakeHTTPRequester(success_balances_response) 370 | sut = Upbit(requester) 371 | balances = await sut.get_balances() 372 | 373 | assert balances == [ 374 | Balance( 375 | currency="KRW", 376 | balance=1000000.0, 377 | locked=0.0, 378 | avg_buy_price=0, 379 | avg_buy_price_modified=False, 380 | unit_currency="KRW", 381 | ), 382 | Balance( 383 | currency="BTC", 384 | balance=2.0, 385 | locked=0.0, 386 | avg_buy_price=101000, 387 | avg_buy_price_modified=True, 388 | unit_currency="KRW", 389 | ), 390 | ] 391 | 392 | 393 | @pytest.mark.asyncio 394 | async def test_get_order(success_order_response): 395 | requester = FakeHTTPRequester(success_order_response) 396 | sut = Upbit(requester) 397 | order = await sut.get_order("d098ceaf-6811-4df8-97f2-b7e01aefc03f") 398 | 399 | assert order == Order( 400 | order_id="d098ceaf-6811-4df8-97f2-b7e01aefc03f", 401 | side="bid", 402 | amount=0.00101749, 403 | price=104812000, 404 | order_type="limit", 405 | status="wait", 406 | executed_volume=0.00095483, 407 | remaining_volume=0.00006266, 408 | created_at=1718241981000, 409 | ) 410 | 411 | 412 | @pytest.mark.asyncio 413 | async def test_get_open_orders(success_open_orders_response): 414 | requester = FakeHTTPRequester(success_open_orders_response) 415 | sut = Upbit(requester) 416 | orders = await sut.get_open_orders("KRW-BTC", 1, 100) 417 | 418 | assert orders == [ 419 | Order( 420 | order_id="d098ceaf-6811-4df8-97f2-b7e01aefc03f", 421 | side="bid", 422 | amount=0.00101749, 423 | price=104812000, 424 | order_type="limit", 425 | status="wait", 426 | executed_volume=0.00095483, 427 | remaining_volume=0.00006266, 428 | created_at=1718241981000, 429 | ), 430 | ] 431 | 432 | 433 | @pytest.mark.asyncio 434 | async def test_get_closed_orders(success_closed_orders_response): 435 | requester = FakeHTTPRequester(success_closed_orders_response) 436 | sut = Upbit(requester) 437 | orders = await sut.get_closed_orders("KRW-BTC", 1, 100) 438 | 439 | assert orders == [ 440 | Order( 441 | order_id="e5715c44-2d1a-41e6-91d8-afa579e28731", 442 | side="ask", 443 | amount=0.00039132, 444 | price=103813000, 445 | order_type="limit", 446 | status="done", 447 | executed_volume=0.00039132, 448 | remaining_volume=0, 449 | created_at=1718242116000, 450 | ), 451 | ] 452 | 453 | 454 | @pytest.mark.asyncio 455 | async def test_get_order_book(success_order_book_response): 456 | requester = FakeHTTPRequester(success_order_book_response) 457 | sut = Upbit(requester) 458 | order_book = await sut.get_order_book("KRW-BTC") 459 | 460 | assert order_book == OrderBook( 461 | symbol="KRW-BTC", 462 | timestamp=1720597558776, 463 | items=[ 464 | OrderBookItem( 465 | ask_price=83186000, 466 | ask_quantity=0.02565269, 467 | bid_price=83184000, 468 | bid_quantity=0.07744926, 469 | ), 470 | OrderBookItem( 471 | ask_price=83206000, 472 | ask_quantity=0.02656392, 473 | bid_price=83182000, 474 | bid_quantity=0.51562837, 475 | ), 476 | OrderBookItem( 477 | ask_price=83207000, 478 | ask_quantity=0.00172255, 479 | bid_price=83181000, 480 | bid_quantity=0.00173694, 481 | ), 482 | ], 483 | ) 484 | 485 | 486 | @pytest.mark.asyncio 487 | async def test_place_order(success_place_order_response): 488 | requester = FakeHTTPRequester(success_place_order_response) 489 | sut = Upbit(requester) 490 | order = await sut.place_order("KRW-BTC", "bid", 0.001, 104812000) 491 | 492 | assert order == Order( 493 | order_id="cdd92199-2897-4e14-9448-f923320408ad", 494 | side="bid", 495 | amount=0.01, 496 | price=100.0, 497 | order_type="limit", 498 | status="wait", 499 | executed_volume=0.0, 500 | remaining_volume=0.01, 501 | created_at=1523342543000, 502 | ) 503 | 504 | 505 | @pytest.mark.asyncio 506 | async def test_cancel_order(success_cancel_order_response): 507 | requester = FakeHTTPRequester(success_cancel_order_response) 508 | sut = Upbit(requester) 509 | result = await sut.cancel_order("cdd92199-2897-4e14-9448-f923320408ad") 510 | 511 | assert result is True 512 | 513 | 514 | @pytest.mark.asyncio 515 | async def test_get_failed_message(): 516 | requester = FakeHTTPRequester( 517 | httpx.Response(400, json={"error": {"message": "Get Balances Failed"}}) 518 | ) 519 | sut = Upbit(requester) 520 | 521 | with pytest.raises(CryptoAPIException) as e: 522 | await sut.get_balances() 523 | 524 | assert e.value.code == "400" 525 | assert e.value.message == "Get Balances Failed" 526 | ```