feat: add OI history deque, cold start init, and derived features to bot runtime

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
21in7
2026-03-04 20:17:37 +09:00
parent ffa6e443c1
commit 448b3e016b
4 changed files with 143 additions and 6 deletions

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
from collections import deque
import pandas as pd import pandas as pd
from loguru import logger from loguru import logger
from src.config import Config from src.config import Config
@@ -24,6 +25,8 @@ class TradingBot:
self._entry_quantity: float | None = None self._entry_quantity: float | None = None
self._is_reentering: bool = False # _close_and_reenter 중 콜백 상태 초기화 방지 self._is_reentering: bool = False # _close_and_reenter 중 콜백 상태 초기화 방지
self._prev_oi: float | None = None # OI 변화율 계산용 이전 값 self._prev_oi: float | None = None # OI 변화율 계산용 이전 값
self._oi_history: deque = deque(maxlen=5)
self._latest_ret_1: float = 0.0
self.stream = MultiSymbolStream( self.stream = MultiSymbolStream(
symbols=[config.symbol, "BTCUSDT", "ETHUSDT"], symbols=[config.symbol, "BTCUSDT", "ETHUSDT"],
interval="15m", interval="15m",
@@ -57,21 +60,43 @@ class TradingBot:
else: else:
logger.info("기존 포지션 없음 - 신규 진입 대기") logger.info("기존 포지션 없음 - 신규 진입 대기")
async def _fetch_market_microstructure(self) -> tuple[float, float]: async def _init_oi_history(self) -> None:
"""OI 변화율과 펀딩비를 실시간으로 조회한다. 실패 시 0.0으로 폴백.""" """봇 시작 시 최근 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( oi_val, fr_val = await asyncio.gather(
self.exchange.get_open_interest(), self.exchange.get_open_interest(),
self.exchange.get_funding_rate(), self.exchange.get_funding_rate(),
return_exceptions=True, return_exceptions=True,
) )
# None(API 실패) 또는 Exception이면 _calc_oi_change를 호출하지 않고 0.0 반환
if isinstance(oi_val, (int, float)) and oi_val > 0: if isinstance(oi_val, (int, float)) and oi_val > 0:
oi_change = self._calc_oi_change(float(oi_val)) oi_change = self._calc_oi_change(float(oi_val))
else: else:
oi_change = 0.0 oi_change = 0.0
fr_float = float(fr_val) if isinstance(fr_val, (int, float)) else 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: def _calc_oi_change(self, current_oi: float) -> float:
"""이전 OI 대비 변화율을 계산한다. 첫 캔들은 0.0 반환.""" """이전 OI 대비 변화율을 계산한다. 첫 캔들은 0.0 반환."""
@@ -85,8 +110,14 @@ class TradingBot:
async def process_candle(self, df, btc_df=None, eth_df=None): async def process_candle(self, df, btc_df=None, eth_df=None):
self.ml_filter.check_and_reload() 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/펀딩비 실시간 조회 (실패해도 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(): if not self.risk.is_trading_allowed():
logger.warning("리스크 한도 초과 - 거래 중단") logger.warning("리스크 한도 초과 - 거래 중단")
@@ -111,6 +142,7 @@ class TradingBot:
df_with_indicators, signal, df_with_indicators, signal,
btc_df=btc_df, eth_df=eth_df, btc_df=btc_df, eth_df=eth_df,
oi_change=oi_change, funding_rate=funding_rate, 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 self.ml_filter.is_model_loaded():
if not self.ml_filter.should_enter(features): if not self.ml_filter.should_enter(features):
@@ -126,6 +158,7 @@ class TradingBot:
position, raw_signal, df_with_indicators, position, raw_signal, df_with_indicators,
btc_df=btc_df, eth_df=eth_df, btc_df=btc_df, eth_df=eth_df,
oi_change=oi_change, funding_rate=funding_rate, 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): async def _open_position(self, signal: str, df):
@@ -272,6 +305,8 @@ class TradingBot:
eth_df=None, eth_df=None,
oi_change: float = 0.0, oi_change: float = 0.0,
funding_rate: float = 0.0, funding_rate: float = 0.0,
oi_change_ma5: float = 0.0,
oi_price_spread: float = 0.0,
) -> None: ) -> None:
"""기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다.""" """기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다."""
# 재진입 플래그: User Data Stream 콜백이 신규 포지션 상태를 초기화하지 않도록 보호 # 재진입 플래그: User Data Stream 콜백이 신규 포지션 상태를 초기화하지 않도록 보호
@@ -288,6 +323,7 @@ class TradingBot:
df, signal, df, signal,
btc_df=btc_df, eth_df=eth_df, btc_df=btc_df, eth_df=eth_df,
oi_change=oi_change, funding_rate=funding_rate, 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): if not self.ml_filter.should_enter(features):
logger.info(f"ML 필터 차단: {signal} 재진입 무시") logger.info(f"ML 필터 차단: {signal} 재진입 무시")
@@ -300,6 +336,7 @@ class TradingBot:
async def run(self): async def run(self):
logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x") logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x")
await self._recover_position() await self._recover_position()
await self._init_oi_history()
balance = await self.exchange.get_balance() balance = await self.exchange.get_balance()
self.risk.set_base_balance(balance) self.risk.set_base_balance(balance)
logger.info(f"기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)") logger.info(f"기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)")

View File

@@ -173,6 +173,30 @@ class BinanceFuturesClient:
logger.warning(f"펀딩비 조회 실패 (무시): {e}") logger.warning(f"펀딩비 조회 실패 (무시): {e}")
return None 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: async def create_listen_key(self) -> str:
"""POST /fapi/v1/listenKey — listenKey 신규 발급""" """POST /fapi/v1/listenKey — listenKey 신규 발급"""
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()

View File

@@ -227,6 +227,42 @@ async def test_process_candle_fetches_oi_and_funding(config, sample_df):
assert "funding_rate" in call_kwargs 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): def test_calc_oi_change_first_candle_returns_zero(config):
"""첫 캔들은 0.0을 반환하고 _prev_oi를 설정한다.""" """첫 캔들은 0.0을 반환하고 _prev_oi를 설정한다."""
with patch("src.bot.BinanceFuturesClient"): with patch("src.bot.BinanceFuturesClient"):

View File

@@ -113,3 +113,43 @@ async def test_get_funding_rate_error_returns_none(exchange):
) )
result = await exchange.get_funding_rate() result = await exchange.get_funding_rate()
assert result is None 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 == []