From 448b3e016b03afdfda6039b5af8bfce72167bb9d Mon Sep 17 00:00:00 2001 From: 21in7 Date: Wed, 4 Mar 2026 20:17:37 +0900 Subject: [PATCH] feat: add OI history deque, cold start init, and derived features to bot runtime Co-Authored-By: Claude Opus 4.6 --- src/bot.py | 49 ++++++++++++++++++++++++++++++++++++------ src/exchange.py | 24 +++++++++++++++++++++ tests/test_bot.py | 36 +++++++++++++++++++++++++++++++ tests/test_exchange.py | 40 ++++++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 6 deletions(-) diff --git a/src/bot.py b/src/bot.py index 59a7bba..3e72449 100644 --- a/src/bot.py +++ b/src/bot.py @@ -1,4 +1,5 @@ import asyncio +from collections import deque import pandas as pd from loguru import logger from src.config import Config @@ -24,6 +25,8 @@ class TradingBot: self._entry_quantity: float | None = None self._is_reentering: bool = False # _close_and_reenter 중 콜백 상태 초기화 방지 self._prev_oi: float | None = None # OI 변화율 계산용 이전 값 + self._oi_history: deque = deque(maxlen=5) + self._latest_ret_1: float = 0.0 self.stream = MultiSymbolStream( symbols=[config.symbol, "BTCUSDT", "ETHUSDT"], interval="15m", @@ -57,21 +60,43 @@ class TradingBot: else: logger.info("기존 포지션 없음 - 신규 진입 대기") - async def _fetch_market_microstructure(self) -> tuple[float, float]: - """OI 변화율과 펀딩비를 실시간으로 조회한다. 실패 시 0.0으로 폴백.""" + async def _init_oi_history(self) -> None: + """봇 시작 시 최근 OI 변화율 히스토리를 조회하여 deque를 채운다.""" + try: + changes = await self.exchange.get_oi_history(limit=5) + for c in changes: + self._oi_history.append(c) + if changes: + self._prev_oi = None + logger.info(f"OI 히스토리 초기화: {len(self._oi_history)}개") + except Exception as e: + logger.warning(f"OI 히스토리 초기화 실패 (무시): {e}") + + async def _fetch_market_microstructure(self) -> tuple[float, float, float, float]: + """OI 변화율, 펀딩비, OI MA5, OI-가격 스프레드를 실시간으로 조회한다.""" oi_val, fr_val = await asyncio.gather( self.exchange.get_open_interest(), self.exchange.get_funding_rate(), return_exceptions=True, ) - # None(API 실패) 또는 Exception이면 _calc_oi_change를 호출하지 않고 0.0 반환 if isinstance(oi_val, (int, float)) and oi_val > 0: oi_change = self._calc_oi_change(float(oi_val)) else: oi_change = 0.0 fr_float = float(fr_val) if isinstance(fr_val, (int, float)) else 0.0 - logger.debug(f"OI={oi_val}, OI변화율={oi_change:.6f}, 펀딩비={fr_float:.6f}") - return oi_change, fr_float + + # OI 히스토리 업데이트 및 MA5 계산 + self._oi_history.append(oi_change) + oi_ma5 = sum(self._oi_history) / len(self._oi_history) if self._oi_history else 0.0 + + # OI-가격 스프레드 + oi_price_spread = oi_change - self._latest_ret_1 + + logger.debug( + f"OI={oi_val}, OI변화율={oi_change:.6f}, 펀딩비={fr_float:.6f}, " + f"OI_MA5={oi_ma5:.6f}, OI_Price_Spread={oi_price_spread:.6f}" + ) + return oi_change, fr_float, oi_ma5, oi_price_spread def _calc_oi_change(self, current_oi: float) -> float: """이전 OI 대비 변화율을 계산한다. 첫 캔들은 0.0 반환.""" @@ -85,8 +110,14 @@ class TradingBot: async def process_candle(self, df, btc_df=None, eth_df=None): self.ml_filter.check_and_reload() + # 가격 수익률 계산 (oi_price_spread용) + if len(df) >= 2: + prev_close = df["close"].iloc[-2] + curr_close = df["close"].iloc[-1] + self._latest_ret_1 = (curr_close - prev_close) / prev_close if prev_close != 0 else 0.0 + # 캔들 마감 시 OI/펀딩비 실시간 조회 (실패해도 0으로 폴백) - oi_change, funding_rate = await self._fetch_market_microstructure() + oi_change, funding_rate, oi_ma5, oi_price_spread = await self._fetch_market_microstructure() if not self.risk.is_trading_allowed(): logger.warning("리스크 한도 초과 - 거래 중단") @@ -111,6 +142,7 @@ class TradingBot: df_with_indicators, signal, btc_df=btc_df, eth_df=eth_df, oi_change=oi_change, funding_rate=funding_rate, + oi_change_ma5=oi_ma5, oi_price_spread=oi_price_spread, ) if self.ml_filter.is_model_loaded(): if not self.ml_filter.should_enter(features): @@ -126,6 +158,7 @@ class TradingBot: position, raw_signal, df_with_indicators, btc_df=btc_df, eth_df=eth_df, oi_change=oi_change, funding_rate=funding_rate, + oi_change_ma5=oi_ma5, oi_price_spread=oi_price_spread, ) async def _open_position(self, signal: str, df): @@ -272,6 +305,8 @@ class TradingBot: eth_df=None, oi_change: float = 0.0, funding_rate: float = 0.0, + oi_change_ma5: float = 0.0, + oi_price_spread: float = 0.0, ) -> None: """기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다.""" # 재진입 플래그: User Data Stream 콜백이 신규 포지션 상태를 초기화하지 않도록 보호 @@ -288,6 +323,7 @@ class TradingBot: df, signal, btc_df=btc_df, eth_df=eth_df, oi_change=oi_change, funding_rate=funding_rate, + oi_change_ma5=oi_change_ma5, oi_price_spread=oi_price_spread, ) if not self.ml_filter.should_enter(features): logger.info(f"ML 필터 차단: {signal} 재진입 무시") @@ -300,6 +336,7 @@ class TradingBot: async def run(self): logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x") await self._recover_position() + await self._init_oi_history() balance = await self.exchange.get_balance() self.risk.set_base_balance(balance) logger.info(f"기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)") diff --git a/src/exchange.py b/src/exchange.py index ebf206a..1dba3bb 100644 --- a/src/exchange.py +++ b/src/exchange.py @@ -173,6 +173,30 @@ class BinanceFuturesClient: logger.warning(f"펀딩비 조회 실패 (무시): {e}") return None + async def get_oi_history(self, limit: int = 5) -> list[float]: + """최근 OI 변화율 히스토리를 조회한다 (봇 초기화용). 실패 시 빈 리스트.""" + loop = asyncio.get_event_loop() + try: + result = await loop.run_in_executor( + None, + lambda: self.client.futures_open_interest_hist( + symbol=self.config.symbol, period="15m", limit=limit + 1, + ), + ) + if len(result) < 2: + return [] + oi_values = [float(r["sumOpenInterest"]) for r in result] + changes = [] + for i in range(1, len(oi_values)): + if oi_values[i - 1] > 0: + changes.append((oi_values[i] - oi_values[i - 1]) / oi_values[i - 1]) + else: + changes.append(0.0) + return changes + except Exception as e: + logger.warning(f"OI 히스토리 조회 실패 (무시): {e}") + return [] + async def create_listen_key(self) -> str: """POST /fapi/v1/listenKey — listenKey 신규 발급""" loop = asyncio.get_event_loop() diff --git a/tests/test_bot.py b/tests/test_bot.py index f89f52f..dab47be 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -227,6 +227,42 @@ async def test_process_candle_fetches_oi_and_funding(config, sample_df): assert "funding_rate" in call_kwargs +def test_bot_has_oi_history_deque(config): + """봇이 OI 히스토리 deque를 가져야 한다.""" + from collections import deque + with patch("src.bot.BinanceFuturesClient"): + bot = TradingBot(config) + assert isinstance(bot._oi_history, deque) + assert bot._oi_history.maxlen == 5 + + +@pytest.mark.asyncio +async def test_init_oi_history_fills_deque(config): + """_init_oi_history가 deque를 채워야 한다.""" + with patch("src.bot.BinanceFuturesClient"): + bot = TradingBot(config) + bot.exchange = AsyncMock() + bot.exchange.get_oi_history = AsyncMock(return_value=[0.01, -0.02, 0.03, -0.01, 0.02]) + await bot._init_oi_history() + assert len(bot._oi_history) == 5 + + +@pytest.mark.asyncio +async def test_fetch_microstructure_returns_4_tuple(config): + """_fetch_market_microstructure가 4-tuple을 반환해야 한다.""" + with patch("src.bot.BinanceFuturesClient"): + bot = TradingBot(config) + bot.exchange = AsyncMock() + bot.exchange.get_open_interest = AsyncMock(return_value=5000000.0) + bot.exchange.get_funding_rate = AsyncMock(return_value=0.0001) + bot._prev_oi = 4900000.0 + bot._oi_history.extend([0.01, -0.02, 0.03, -0.01]) + bot._latest_ret_1 = 0.01 + + result = await bot._fetch_market_microstructure() + assert len(result) == 4 + + def test_calc_oi_change_first_candle_returns_zero(config): """첫 캔들은 0.0을 반환하고 _prev_oi를 설정한다.""" with patch("src.bot.BinanceFuturesClient"): diff --git a/tests/test_exchange.py b/tests/test_exchange.py index 92e1505..592d46c 100644 --- a/tests/test_exchange.py +++ b/tests/test_exchange.py @@ -113,3 +113,43 @@ async def test_get_funding_rate_error_returns_none(exchange): ) result = await exchange.get_funding_rate() assert result is None + + +@pytest.mark.asyncio +async def test_get_oi_history_returns_changes(exchange): + """get_oi_history()가 OI 변화율 리스트를 반환하는지 확인.""" + exchange.client.futures_open_interest_hist = MagicMock( + return_value=[ + {"sumOpenInterest": "1000000"}, + {"sumOpenInterest": "1010000"}, + {"sumOpenInterest": "1005000"}, + {"sumOpenInterest": "1020000"}, + {"sumOpenInterest": "1015000"}, + {"sumOpenInterest": "1030000"}, + ] + ) + result = await exchange.get_oi_history(limit=5) + assert len(result) == 5 + assert isinstance(result[0], float) + # 첫 번째 변화율: (1010000 - 1000000) / 1000000 = 0.01 + assert abs(result[0] - 0.01) < 1e-6 + + +@pytest.mark.asyncio +async def test_get_oi_history_error_returns_empty(exchange): + """API 오류 시 빈 리스트 반환 확인.""" + exchange.client.futures_open_interest_hist = MagicMock( + side_effect=Exception("API error") + ) + result = await exchange.get_oi_history(limit=5) + assert result == [] + + +@pytest.mark.asyncio +async def test_get_oi_history_insufficient_data_returns_empty(exchange): + """데이터가 부족하면 빈 리스트 반환 확인.""" + exchange.client.futures_open_interest_hist = MagicMock( + return_value=[{"sumOpenInterest": "1000000"}] + ) + result = await exchange.get_oi_history(limit=5) + assert result == []