From dfd4990ae52240979d3fcd1a9f3e77040be772b0 Mon Sep 17 00:00:00 2001 From: 21in7 Date: Mon, 2 Mar 2026 13:55:29 +0900 Subject: [PATCH] feat: fetch realtime OI and funding rate on candle close for ML features - Add asyncio import to bot.py - Add _prev_oi state for OI change rate calculation - Add _fetch_market_microstructure() for concurrent OI/funding rate fetch with exception fallback - Add _calc_oi_change() for relative OI change calculation - Always call build_features() before ML filter check in process_candle() - Pass oi_change/funding_rate kwargs to build_features() in both process_candle() and _close_and_reenter() - Update _close_and_reenter() signature to accept oi_change/funding_rate params Made-with: Cursor --- src/bot.py | 46 +++++++++++++++++++++++++++++++++++++++++++--- tests/test_bot.py | 26 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/bot.py b/src/bot.py index ef0aa4c..f4eebd5 100644 --- a/src/bot.py +++ b/src/bot.py @@ -1,3 +1,4 @@ +import asyncio import pandas as pd from loguru import logger from src.config import Config @@ -18,6 +19,7 @@ class TradingBot: self.risk = RiskManager(config) self.ml_filter = MLFilter() self.current_trade_side: str | None = None # "LONG" | "SHORT" + self._prev_oi: float | None = None # OI 변화율 계산용 이전 값 self.stream = MultiSymbolStream( symbols=[config.symbol, "BTCUSDT", "ETHUSDT"], interval="15m", @@ -49,9 +51,35 @@ class TradingBot: else: logger.info("기존 포지션 없음 - 신규 진입 대기") + async def _fetch_market_microstructure(self) -> tuple[float, float]: + """OI 변화율과 펀딩비를 실시간으로 조회한다. 실패 시 0.0으로 폴백.""" + oi_val, fr_val = await asyncio.gather( + self.exchange.get_open_interest(), + self.exchange.get_funding_rate(), + return_exceptions=True, + ) + oi_float = float(oi_val) if isinstance(oi_val, (int, float)) else 0.0 + fr_float = float(fr_val) if isinstance(fr_val, (int, float)) else 0.0 + + oi_change = self._calc_oi_change(oi_float) + logger.debug(f"OI={oi_float:.0f}, OI변화율={oi_change:.6f}, 펀딩비={fr_float:.6f}") + return oi_change, fr_float + + def _calc_oi_change(self, current_oi: float) -> float: + """이전 OI 대비 변화율을 계산한다. 첫 캔들은 0.0 반환.""" + if self._prev_oi is None or self._prev_oi == 0.0: + self._prev_oi = current_oi + return 0.0 + change = (current_oi - self._prev_oi) / self._prev_oi + self._prev_oi = current_oi + return change + async def process_candle(self, df, btc_df=None, eth_df=None): self.ml_filter.check_and_reload() + # 캔들 마감 시 OI/펀딩비 실시간 조회 (실패해도 0으로 폴백) + oi_change, funding_rate = await self._fetch_market_microstructure() + if not self.risk.is_trading_allowed(): logger.warning("리스크 한도 초과 - 거래 중단") return @@ -71,8 +99,12 @@ class TradingBot: logger.info("최대 포지션 수 도달") return signal = raw_signal + features = build_features( + df_with_indicators, signal, + btc_df=btc_df, eth_df=eth_df, + oi_change=oi_change, funding_rate=funding_rate, + ) if self.ml_filter.is_model_loaded(): - features = build_features(df_with_indicators, signal, btc_df=btc_df, eth_df=eth_df) if not self.ml_filter.should_enter(features): logger.info(f"ML 필터 차단: {signal} 신호 무시") return @@ -83,7 +115,9 @@ class TradingBot: if (pos_side == "LONG" and raw_signal == "SHORT") or \ (pos_side == "SHORT" and raw_signal == "LONG"): await self._close_and_reenter( - position, raw_signal, df_with_indicators, btc_df=btc_df, eth_df=eth_df + position, raw_signal, df_with_indicators, + btc_df=btc_df, eth_df=eth_df, + oi_change=oi_change, funding_rate=funding_rate, ) async def _open_position(self, signal: str, df): @@ -175,6 +209,8 @@ class TradingBot: df, btc_df=None, eth_df=None, + oi_change: float = 0.0, + funding_rate: float = 0.0, ) -> None: """기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다.""" await self._close_position(position) @@ -184,7 +220,11 @@ class TradingBot: return if self.ml_filter.is_model_loaded(): - features = build_features(df, signal, btc_df=btc_df, eth_df=eth_df) + features = build_features( + df, signal, + btc_df=btc_df, eth_df=eth_df, + oi_change=oi_change, funding_rate=funding_rate, + ) if not self.ml_filter.should_enter(features): logger.info(f"ML 필터 차단: {signal} 재진입 무시") return diff --git a/tests/test_bot.py b/tests/test_bot.py index 28aea92..8d23b6e 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -188,3 +188,29 @@ async def test_process_candle_passes_raw_signal_to_close_and_reenter_even_if_ml_ assert call_args.args[1] == "SHORT" # process_candle에서 ml_filter.should_enter가 호출되지 않아야 한다 bot.ml_filter.should_enter.assert_not_called() + + +@pytest.mark.asyncio +async def test_process_candle_fetches_oi_and_funding(config, sample_df): + """process_candle()이 OI와 펀딩비를 조회하고 build_features에 전달하는지 확인.""" + with patch("src.bot.BinanceFuturesClient"): + bot = TradingBot(config) + + bot.exchange = AsyncMock() + bot.exchange.get_balance = AsyncMock(return_value=1000.0) + bot.exchange.get_position = AsyncMock(return_value=None) + bot.exchange.place_order = AsyncMock(return_value={"orderId": "1"}) + bot.exchange.set_leverage = AsyncMock() + bot.exchange.get_open_interest = AsyncMock(return_value=5000000.0) + bot.exchange.get_funding_rate = AsyncMock(return_value=0.0001) + + with patch("src.bot.build_features") as mock_build: + from src.ml_features import FEATURE_COLS + mock_build.return_value = pd.Series({col: 0.0 for col in FEATURE_COLS}) + bot.ml_filter.is_model_loaded = MagicMock(return_value=False) + await bot.process_candle(sample_df) + + assert mock_build.called + call_kwargs = mock_build.call_args.kwargs + assert "oi_change" in call_kwargs + assert "funding_rate" in call_kwargs