From b1a7632bbed1a195c90e46f0a0fc7e6dcd225cc0 Mon Sep 17 00:00:00 2001 From: 21in7 Date: Sun, 1 Mar 2026 12:49:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20BinanceFuturesClient=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- src/exchange.py | 92 ++++++++++++++++++++++++++++++++++++++++++ tests/test_exchange.py | 37 +++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/exchange.py create mode 100644 tests/test_exchange.py diff --git a/src/exchange.py b/src/exchange.py new file mode 100644 index 0000000..60a307b --- /dev/null +++ b/src/exchange.py @@ -0,0 +1,92 @@ +import asyncio +from binance.client import Client +from binance.exceptions import BinanceAPIException +from loguru import logger +from src.config import Config + + +class BinanceFuturesClient: + def __init__(self, config: Config): + self.config = config + self.client = Client( + api_key=config.api_key, + api_secret=config.api_secret, + ) + + def calculate_quantity(self, balance: float, price: float, leverage: int) -> float: + """리스크 기반 포지션 크기 계산""" + risk_amount = balance * self.config.risk_per_trade + notional = risk_amount * leverage + quantity = notional / price + return round(quantity, 1) + + async def set_leverage(self, leverage: int) -> dict: + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + lambda: self.client.futures_change_leverage( + symbol=self.config.symbol, leverage=leverage + ), + ) + + async def get_balance(self) -> float: + loop = asyncio.get_event_loop() + balances = await loop.run_in_executor( + None, self.client.futures_account_balance + ) + for b in balances: + if b["asset"] == "USDT": + return float(b["balance"]) + return 0.0 + + async def place_order( + self, + side: str, + quantity: float, + order_type: str = "MARKET", + price: float = None, + stop_price: float = None, + reduce_only: bool = False, + ) -> dict: + loop = asyncio.get_event_loop() + params = dict( + symbol=self.config.symbol, + side=side, + type=order_type, + quantity=quantity, + reduceOnly=reduce_only, + ) + if price: + params["price"] = price + params["timeInForce"] = "GTC" + if stop_price: + params["stopPrice"] = stop_price + try: + return await loop.run_in_executor( + None, lambda: self.client.futures_create_order(**params) + ) + except BinanceAPIException as e: + logger.error(f"주문 실패: {e}") + raise + + async def get_position(self) -> dict | None: + loop = asyncio.get_event_loop() + positions = await loop.run_in_executor( + None, + lambda: self.client.futures_position_information( + symbol=self.config.symbol + ), + ) + for p in positions: + if float(p["positionAmt"]) != 0: + return p + return None + + async def cancel_all_orders(self): + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + lambda: self.client.futures_cancel_all_open_orders( + symbol=self.config.symbol + ), + ) diff --git a/tests/test_exchange.py b/tests/test_exchange.py new file mode 100644 index 0000000..7c8c9bc --- /dev/null +++ b/tests/test_exchange.py @@ -0,0 +1,37 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from src.exchange import BinanceFuturesClient +from src.config import Config +import os + + +@pytest.fixture +def config(): + os.environ.update({ + "BINANCE_API_KEY": "test_key", + "BINANCE_API_SECRET": "test_secret", + "SYMBOL": "XRPUSDT", + "LEVERAGE": "10", + "RISK_PER_TRADE": "0.02", + }) + return Config() + + +@pytest.mark.asyncio +async def test_set_leverage(config): + client = BinanceFuturesClient(config) + with patch.object( + client.client, + "futures_change_leverage", + return_value={"leverage": 10}, + ): + result = await client.set_leverage(10) + assert result is not None + + +def test_calculate_quantity(config): + client = BinanceFuturesClient(config) + # 잔고 1000 USDT, 리스크 2%, 레버리지 10, 가격 0.5 + qty = client.calculate_quantity(balance=1000.0, price=0.5, leverage=10) + # 1000 * 0.02 * 10 / 0.5 = 400 + assert qty == pytest.approx(400.0, rel=0.01)