#
tokens: 31746/50000 22/22 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
 4 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](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&timestamp=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&timestamp=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&timestamp=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 | 
```