fix: address code review round 2 — 9 issues (2 critical, 3 important, 4 minor)

Critical:
- #2: Add _entry_lock in RiskManager to serialize concurrent entry (balance race)
- #3: Add startTime to get_recent_income + record _entry_time_ms (SYNC PnL fix)

Important:
- #1: Add threading.Lock + _run_api() helper for thread-safe Client access
- #4: Convert reset_daily to async with lock
- #8: Add 24h TTL to exchange_info_cache

Minor:
- #7: Remove duplicate Indicators creation in _open_position (use ATR directly)
- #11: Add input validation for LEVERAGE, MARGIN ratios, ML_THRESHOLD
- #12: Replace hardcoded corr[0]/corr[1] with dict-based dynamic access
- #14: Add fillna(0.0) to LightGBM path for NaN consistency with ONNX

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
21in7
2026-03-21 17:26:15 +09:00
parent e3623293f7
commit 41b0aa3f28
11 changed files with 291 additions and 99 deletions

View File

@@ -1,5 +1,7 @@
import asyncio
import math
import threading
import time as _time
from binance.client import Client
from binance.exceptions import BinanceAPIException
from loguru import logger
@@ -7,8 +9,10 @@ from src.config import Config
class BinanceFuturesClient:
# 클래스 레벨 exchange info 캐시 (전체 심볼 1회만 조회)
# 클래스 레벨 exchange info 캐시 (TTL 24시간)
_exchange_info_cache: dict | None = None
_exchange_info_time: float = 0.0
_EXCHANGE_INFO_TTL: float = 86400.0 # 24시간
def __init__(self, config: Config, symbol: str = None):
self.config = config
@@ -19,18 +23,32 @@ class BinanceFuturesClient:
)
self._qty_precision: int | None = None
self._price_precision: int | None = None
self._api_lock = threading.Lock() # requests.Session 스레드 안전성 보장
MIN_NOTIONAL = 5.0 # 바이낸스 선물 최소 명목금액 (USDT)
async def _run_api(self, func):
"""동기 API 호출을 스레드 풀에서 실행하되, _api_lock으로 직렬화한다."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None, lambda: self._call_with_lock(func),
)
def _call_with_lock(self, func):
with self._api_lock:
return func()
@classmethod
def _get_exchange_info(cls, client: Client) -> dict | None:
"""exchange info를 클래스 레벨로 캐시하여 1회만 조회한다."""
if cls._exchange_info_cache is None:
"""exchange info를 클래스 레벨로 캐시한다 (TTL 24시간)."""
now = _time.monotonic()
if cls._exchange_info_cache is None or (now - cls._exchange_info_time) > cls._EXCHANGE_INFO_TTL:
try:
cls._exchange_info_cache = client.futures_exchange_info()
cls._exchange_info_time = now
except Exception as e:
logger.warning(f"exchange info 조회 실패: {e}")
return None
return cls._exchange_info_cache # 만료돼도 기존 캐시 반환
return cls._exchange_info_cache
def _load_symbol_precision(self) -> None:
@@ -83,19 +101,14 @@ class BinanceFuturesClient:
return qty_rounded
async def set_leverage(self, leverage: int) -> dict:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None,
return await self._run_api(
lambda: self.client.futures_change_leverage(
symbol=self.symbol, leverage=leverage
),
)
async def get_balance(self) -> float:
loop = asyncio.get_running_loop()
balances = await loop.run_in_executor(
None, self.client.futures_account_balance
)
balances = await self._run_api(self.client.futures_account_balance)
for b in balances:
if b["asset"] == "USDT":
return float(b["balance"])
@@ -110,8 +123,6 @@ class BinanceFuturesClient:
stop_price: float = None,
reduce_only: bool = False,
) -> dict:
loop = asyncio.get_running_loop()
params = dict(
symbol=self.symbol,
side=side,
@@ -125,17 +136,15 @@ class BinanceFuturesClient:
if stop_price is not None:
params["stopPrice"] = stop_price
try:
return await loop.run_in_executor(
None, lambda: self.client.futures_create_order(**params)
return await self._run_api(
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_running_loop()
positions = await loop.run_in_executor(
None,
positions = await self._run_api(
lambda: self.client.futures_position_information(
symbol=self.symbol
),
@@ -147,37 +156,37 @@ class BinanceFuturesClient:
async def get_open_orders(self) -> list[dict]:
"""현재 심볼의 오픈 주문 목록을 조회한다."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None,
return await self._run_api(
lambda: self.client.futures_get_open_orders(symbol=self.symbol),
)
async def cancel_all_orders(self):
"""오픈 주문을 모두 취소한다."""
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None,
await self._run_api(
lambda: self.client.futures_cancel_all_open_orders(
symbol=self.symbol
),
)
async def get_recent_income(self, limit: int = 5) -> list[dict]:
"""최근 REALIZED_PNL + COMMISSION 내역을 조회한다."""
loop = asyncio.get_running_loop()
async def get_recent_income(self, limit: int = 5, start_time: int | None = None) -> tuple[list[dict], list[dict]]:
"""최근 REALIZED_PNL + COMMISSION 내역을 조회한다.
Args:
limit: 최대 조회 건수
start_time: 밀리초 단위 시작 시각. 지정 시 해당 시각 이후 데이터만 반환.
"""
try:
rows = await loop.run_in_executor(
None,
lambda: self.client.futures_income_history(
symbol=self.symbol, incomeType="REALIZED_PNL", limit=limit,
),
pnl_params = dict(symbol=self.symbol, incomeType="REALIZED_PNL", limit=limit)
comm_params = dict(symbol=self.symbol, incomeType="COMMISSION", limit=limit)
if start_time is not None:
pnl_params["startTime"] = start_time
comm_params["startTime"] = start_time
rows = await self._run_api(
lambda: self.client.futures_income_history(**pnl_params),
)
commissions = await loop.run_in_executor(
None,
lambda: self.client.futures_income_history(
symbol=self.symbol, incomeType="COMMISSION", limit=limit,
),
commissions = await self._run_api(
lambda: self.client.futures_income_history(**comm_params),
)
return rows, commissions
except Exception as e:
@@ -186,10 +195,8 @@ class BinanceFuturesClient:
async def get_open_interest(self) -> float | None:
"""현재 미결제약정(OI)을 조회한다. 오류 시 None 반환."""
loop = asyncio.get_running_loop()
try:
result = await loop.run_in_executor(
None,
result = await self._run_api(
lambda: self.client.futures_open_interest(symbol=self.symbol),
)
return float(result["openInterest"])
@@ -199,10 +206,8 @@ class BinanceFuturesClient:
async def get_funding_rate(self) -> float | None:
"""현재 펀딩비를 조회한다. 오류 시 None 반환."""
loop = asyncio.get_running_loop()
try:
result = await loop.run_in_executor(
None,
result = await self._run_api(
lambda: self.client.futures_mark_price(symbol=self.symbol),
)
return float(result["lastFundingRate"])
@@ -212,10 +217,8 @@ class BinanceFuturesClient:
async def get_oi_history(self, limit: int = 5) -> list[float]:
"""최근 OI 변화율 히스토리를 조회한다 (봇 초기화용). 실패 시 빈 리스트."""
loop = asyncio.get_running_loop()
try:
result = await loop.run_in_executor(
None,
result = await self._run_api(
lambda: self.client.futures_open_interest_hist(
symbol=self.symbol, period="15m", limit=limit + 1,
),
@@ -236,27 +239,18 @@ class BinanceFuturesClient:
async def create_listen_key(self) -> str:
"""POST /fapi/v1/listenKey — listenKey 신규 발급"""
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
None,
lambda: self.client.futures_stream_get_listen_key(),
)
return result
return await self._run_api(self.client.futures_stream_get_listen_key)
async def keepalive_listen_key(self, listen_key: str) -> None:
"""PUT /fapi/v1/listenKey — listenKey 만료 연장 (60분 → 리셋)"""
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None,
await self._run_api(
lambda: self.client.futures_stream_keepalive(listenKey=listen_key),
)
async def delete_listen_key(self, listen_key: str) -> None:
"""DELETE /fapi/v1/listenKey — listenKey 삭제 (정상 종료 시)"""
loop = asyncio.get_running_loop()
try:
await loop.run_in_executor(
None,
await self._run_api(
lambda: self.client.futures_stream_close(listenKey=listen_key),
)
except Exception as e: