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

@@ -142,3 +142,5 @@ All design documents and implementation plans are stored in `docs/plans/` with t
| 2026-03-07 | `weekly-report` (plan) | Completed | | 2026-03-07 | `weekly-report` (plan) | Completed |
| 2026-03-07 | `code-review-improvements` | Partial (#1,#2,#4,#5,#6,#8 완료) | | 2026-03-07 | `code-review-improvements` | Partial (#1,#2,#4,#5,#6,#8 완료) |
| 2026-03-19 | `critical-bugfixes` (C5,C1,C3,C8) | Completed | | 2026-03-19 | `critical-bugfixes` (C5,C1,C3,C8) | Completed |
| 2026-03-21 | `dashboard-code-review-r2` (#14,#19) | Completed |
| 2026-03-21 | `code-review-fixes-r2` (9 issues) | Completed |

View File

@@ -0,0 +1,108 @@
# Code Review Fixes Round 2 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix 9 issues from code review re-evaluation (2 Critical, 3 Important, 4 Minor)
**Architecture:** Targeted fixes across risk_manager, exchange, bot, config, ml_filter. No new files — all modifications to existing modules.
**Tech Stack:** Python 3.12, asyncio, python-binance, LightGBM, ONNX Runtime
---
### Task 1: #2 Critical — Balance reservation lock for concurrent entry
**Files:**
- Modify: `src/risk_manager.py` — add `_entry_lock` to serialize entry flow
- Modify: `src/bot.py:405-413` — acquire entry lock around balance read → order
- Test: `tests/test_risk_manager.py`
The simplest fix: add an asyncio.Lock in RiskManager that serializes the entire _open_position flow across all bots. This prevents two bots from reading the same balance simultaneously.
- [ ] Add `_entry_lock = asyncio.Lock()` to RiskManager
- [ ] Add `async def entry_lock(self)` context manager
- [ ] In bot.py `_open_position`, wrap balance read + order under `async with self.risk.entry_lock()`
- [ ] Add test for concurrent entry serialization
- [ ] Run tests
### Task 2: #3 Critical — SYNC PnL startTime + single query
**Files:**
- Modify: `src/exchange.py:166-185` — add `start_time` param to `get_recent_income`
- Modify: `src/bot.py:75-82` — record `_entry_time` on position open
- Modify: `src/bot.py:620-629` — pass `start_time` to income query
- Test: `tests/test_exchange.py`
- [ ] Add `_entry_time: int | None = None` to TradingBot
- [ ] Set `_entry_time = int(time.time() * 1000)` on entry and recovery
- [ ] Add `start_time` parameter to `get_recent_income()`
- [ ] Use start_time in SYNC fallback
- [ ] Add test
- [ ] Run tests
### Task 3: #1 Important — Thread-safe Client access
**Files:**
- Modify: `src/exchange.py` — add `threading.Lock` per instance
- [ ] Add `self._api_lock = threading.Lock()` in `__init__`
- [ ] Wrap all `run_in_executor` lambdas with lock acquisition
- [ ] Add test
- [ ] Run tests
### Task 4: #4 Important — reset_daily async with lock
**Files:**
- Modify: `src/risk_manager.py:61-64` — make async + lock
- Modify: `main.py:22` — await reset_daily
- Test: `tests/test_risk_manager.py`
- [ ] Convert `reset_daily` to async, add lock
- [ ] Update `_daily_reset_loop` call
- [ ] Add test
- [ ] Run tests
### Task 5: #8 Important — exchange_info cache TTL
**Files:**
- Modify: `src/exchange.py:25-34` — add TTL (24h)
- [ ] Add `_exchange_info_time: float = 0.0`
- [ ] Check TTL in `_get_exchange_info`
- [ ] Add test
- [ ] Run tests
### Task 6: #7 Minor — Pass pre-computed indicators to _open_position
**Files:**
- Modify: `src/bot.py:392,415,736` — pass df_with_indicators
- [ ] Add `df_with_indicators` parameter to `_open_position`
- [ ] Use passed df instead of re-creating Indicators
- [ ] Run tests
### Task 7: #11 Minor — Config input validation
**Files:**
- Modify: `src/config.py:39` — add range checks
- Test: `tests/test_config.py`
- [ ] Add validation for LEVERAGE, MARGIN ratios, ML_THRESHOLD
- [ ] Add test for invalid values
- [ ] Run tests
### Task 8: #12 Minor — Dynamic correlation symbol access
**Files:**
- Modify: `src/bot.py:196-198` — iterate dynamically
- [ ] Replace hardcoded [0]/[1] with dict-based access
- [ ] Run tests
### Task 9: #14 Minor — Normalize NaN handling for LightGBM
**Files:**
- Modify: `src/ml_filter.py:144-147` — apply nan_to_num for LightGBM too
- [ ] Add `np.nan_to_num` to LightGBM path
- [ ] Run tests

View File

@@ -19,7 +19,7 @@ async def _daily_reset_loop(risk: RiskManager):
hour=0, minute=0, second=0, microsecond=0, hour=0, minute=0, second=0, microsecond=0,
) )
await asyncio.sleep((next_midnight - now).total_seconds()) await asyncio.sleep((next_midnight - now).total_seconds())
risk.reset_daily() await risk.reset_daily()
async def _graceful_shutdown(bots: list[TradingBot], tasks: list[asyncio.Task]): async def _graceful_shutdown(bots: list[TradingBot], tasks: list[asyncio.Task]):

View File

@@ -1,6 +1,7 @@
import asyncio import asyncio
import json import json
import os import os
import time
from collections import deque from collections import deque
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -76,6 +77,7 @@ class TradingBot:
self._entry_price: float | None = None self._entry_price: float | None = None
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._entry_time_ms: int | None = None # 포지션 진입 시각 (ms, SYNC PnL 범위 제한용)
self._close_event = asyncio.Event() # 콜백 청산 완료 대기용 self._close_event = asyncio.Event() # 콜백 청산 완료 대기용
self._close_lock = asyncio.Lock() # 청산 처리 원자성 보장 (C3 fix) self._close_lock = asyncio.Lock() # 청산 처리 원자성 보장 (C3 fix)
self._prev_oi: float | None = None # OI 변화율 계산용 이전 값 self._prev_oi: float | None = None # OI 변화율 계산용 이전 값
@@ -194,8 +196,9 @@ class TradingBot:
async def _on_candle_closed(self, candle: dict): async def _on_candle_closed(self, candle: dict):
primary_df = self.stream.get_dataframe(self.symbol) primary_df = self.stream.get_dataframe(self.symbol)
corr = self.config.correlation_symbols corr = self.config.correlation_symbols
btc_df = self.stream.get_dataframe(corr[0]) if len(corr) > 0 else None corr_dfs = {s: self.stream.get_dataframe(s) for s in corr}
eth_df = self.stream.get_dataframe(corr[1]) if len(corr) > 1 else None btc_df = corr_dfs.get("BTCUSDT")
eth_df = corr_dfs.get("ETHUSDT")
if primary_df is not None: if primary_df is not None:
await self.process_candle(primary_df, btc_df=btc_df, eth_df=eth_df) await self.process_candle(primary_df, btc_df=btc_df, eth_df=eth_df)
@@ -208,6 +211,7 @@ class TradingBot:
self.current_trade_side = "LONG" if amt > 0 else "SHORT" self.current_trade_side = "LONG" if amt > 0 else "SHORT"
self._entry_price = float(position["entryPrice"]) self._entry_price = float(position["entryPrice"])
self._entry_quantity = abs(amt) self._entry_quantity = abs(amt)
self._entry_time_ms = int(float(position.get("updateTime", time.time() * 1000)))
entry = float(position["entryPrice"]) entry = float(position["entryPrice"])
logger.info( logger.info(
f"[{self.symbol}] 기존 포지션 복구: {self.current_trade_side} | " f"[{self.symbol}] 기존 포지션 복구: {self.current_trade_side} | "
@@ -403,44 +407,51 @@ class TradingBot:
) )
async def _open_position(self, signal: str, df): async def _open_position(self, signal: str, df):
balance = await self.exchange.get_balance() # 동시 진입 시 잔고 레이스 방지: entry_lock으로 잔고 조회→주문→등록을 직렬화
num_symbols = len(self.config.symbols) async with self.risk._entry_lock:
per_symbol_balance = balance / num_symbols balance = await self.exchange.get_balance()
price = df["close"].iloc[-1] num_symbols = len(self.config.symbols)
margin_ratio = self.risk.get_dynamic_margin_ratio(per_symbol_balance) per_symbol_balance = balance / num_symbols
quantity = self.exchange.calculate_quantity( price = df["close"].iloc[-1]
balance=per_symbol_balance, price=price, leverage=self.config.leverage, margin_ratio=margin_ratio margin_ratio = self.risk.get_dynamic_margin_ratio(per_symbol_balance)
) quantity = self.exchange.calculate_quantity(
logger.info(f"[{self.symbol}] 포지션 크기: 잔고={per_symbol_balance:.2f}/{balance:.2f} USDT, 증거금비율={margin_ratio:.1%}, 수량={quantity}") balance=per_symbol_balance, price=price, leverage=self.config.leverage, margin_ratio=margin_ratio
stop_loss, take_profit = Indicators(df).get_atr_stop(
df, signal, price,
atr_sl_mult=self.strategy.atr_sl_mult,
atr_tp_mult=self.strategy.atr_tp_mult,
)
notional = quantity * price
if quantity <= 0 or notional < self.exchange.MIN_NOTIONAL:
logger.warning(
f"주문 건너뜀: 명목금액 {notional:.2f} USDT < 최소 {self.exchange.MIN_NOTIONAL} USDT "
f"(잔고={balance:.2f}, 수량={quantity})"
) )
return logger.info(f"[{self.symbol}] 포지션 크기: 잔고={per_symbol_balance:.2f}/{balance:.2f} USDT, 증거금비율={margin_ratio:.1%}, 수량={quantity}")
# df는 이미 calculate_all() 적용된 df_with_indicators이므로
# Indicators를 재생성하지 않고 ATR을 직접 사용
atr = df["atr"].iloc[-1]
if signal == "LONG":
stop_loss = price - atr * self.strategy.atr_sl_mult
take_profit = price + atr * self.strategy.atr_tp_mult
else:
stop_loss = price + atr * self.strategy.atr_sl_mult
take_profit = price - atr * self.strategy.atr_tp_mult
side = "BUY" if signal == "LONG" else "SELL" notional = quantity * price
await self.exchange.set_leverage(self.config.leverage) if quantity <= 0 or notional < self.exchange.MIN_NOTIONAL:
await self.exchange.place_order(side=side, quantity=quantity) logger.warning(
f"주문 건너뜀: 명목금액 {notional:.2f} USDT < 최소 {self.exchange.MIN_NOTIONAL} USDT "
f"(잔고={balance:.2f}, 수량={quantity})"
)
return
last_row = df.iloc[-1] side = "BUY" if signal == "LONG" else "SELL"
signal_snapshot = { await self.exchange.set_leverage(self.config.leverage)
"rsi": float(last_row["rsi"]) if "rsi" in last_row.index and pd.notna(last_row["rsi"]) else 0.0, await self.exchange.place_order(side=side, quantity=quantity)
"macd_hist": float(last_row["macd_hist"]) if "macd_hist" in last_row.index and pd.notna(last_row["macd_hist"]) else 0.0,
"atr": float(last_row["atr"]) if "atr" in last_row.index and pd.notna(last_row["atr"]) else 0.0,
}
await self.risk.register_position(self.symbol, signal) last_row = df.iloc[-1]
self.current_trade_side = signal signal_snapshot = {
self._entry_price = price "rsi": float(last_row["rsi"]) if "rsi" in last_row.index and pd.notna(last_row["rsi"]) else 0.0,
self._entry_quantity = quantity "macd_hist": float(last_row["macd_hist"]) if "macd_hist" in last_row.index and pd.notna(last_row["macd_hist"]) else 0.0,
"atr": float(last_row["atr"]) if "atr" in last_row.index and pd.notna(last_row["atr"]) else 0.0,
}
await self.risk.register_position(self.symbol, signal)
self.current_trade_side = signal
self._entry_price = price
self._entry_quantity = quantity
self._entry_time_ms = int(time.time() * 1000)
self.notifier.notify_open( self.notifier.notify_open(
symbol=self.symbol, symbol=self.symbol,
side=signal, side=signal,
@@ -592,6 +603,7 @@ class TradingBot:
self.current_trade_side = None self.current_trade_side = None
self._entry_price = None self._entry_price = None
self._entry_quantity = None self._entry_quantity = None
self._entry_time_ms = None
_MONITOR_INTERVAL = 300 # 5분 _MONITOR_INTERVAL = 300 # 5분
@@ -619,7 +631,9 @@ class TradingBot:
commission = 0.0 commission = 0.0
exit_price = 0.0 exit_price = 0.0
try: try:
pnl_rows, comm_rows = await self.exchange.get_recent_income(limit=10) pnl_rows, comm_rows = await self.exchange.get_recent_income(
limit=10, start_time=self._entry_time_ms,
)
if pnl_rows: if pnl_rows:
realized_pnl = sum(float(r.get("income", "0")) for r in pnl_rows) realized_pnl = sum(float(r.get("income", "0")) for r in pnl_rows)
if comm_rows: if comm_rows:
@@ -654,6 +668,7 @@ class TradingBot:
self.current_trade_side = None self.current_trade_side = None
self._entry_price = None self._entry_price = None
self._entry_quantity = None self._entry_quantity = None
self._entry_time_ms = None
self._close_event.set() self._close_event.set()
continue continue
except Exception as e: except Exception as e:
@@ -711,6 +726,7 @@ class TradingBot:
self.current_trade_side = None self.current_trade_side = None
self._entry_price = None self._entry_price = None
self._entry_quantity = None self._entry_quantity = None
self._entry_time_ms = None
if self._killed: if self._killed:
logger.info(f"[{self.symbol}] 킬스위치 활성 — 재진입 건너뜀 (청산만 수행)") logger.info(f"[{self.symbol}] 킬스위치 활성 — 재진입 건너뜀 (청산만 수행)")

View File

@@ -64,6 +64,18 @@ class Config:
corr_env = os.getenv("CORRELATION_SYMBOLS", "BTCUSDT,ETHUSDT") corr_env = os.getenv("CORRELATION_SYMBOLS", "BTCUSDT,ETHUSDT")
self.correlation_symbols = [s.strip() for s in corr_env.split(",") if s.strip()] self.correlation_symbols = [s.strip() for s in corr_env.split(",") if s.strip()]
# 입력 검증
if self.leverage < 1:
raise ValueError(f"LEVERAGE는 1 이상이어야 합니다: {self.leverage}")
if not (0.0 < self.margin_max_ratio <= 1.0):
raise ValueError(f"MARGIN_MAX_RATIO는 (0, 1] 범위여야 합니다: {self.margin_max_ratio}")
if not (0.0 < self.margin_min_ratio <= 1.0):
raise ValueError(f"MARGIN_MIN_RATIO는 (0, 1] 범위여야 합니다: {self.margin_min_ratio}")
if self.margin_min_ratio > self.margin_max_ratio:
raise ValueError(f"MARGIN_MIN_RATIO({self.margin_min_ratio}) > MARGIN_MAX_RATIO({self.margin_max_ratio})")
if not (0.0 < self.ml_threshold <= 1.0):
raise ValueError(f"ML_THRESHOLD는 (0, 1] 범위여야 합니다: {self.ml_threshold}")
# Per-symbol strategy params: {symbol: SymbolStrategyParams} # Per-symbol strategy params: {symbol: SymbolStrategyParams}
self._symbol_params: dict[str, SymbolStrategyParams] = {} self._symbol_params: dict[str, SymbolStrategyParams] = {}
for sym in self.symbols: for sym in self.symbols:

View File

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

View File

@@ -144,6 +144,7 @@ class MLFilter:
else: else:
available = [c for c in FEATURE_COLS if c in features.index] available = [c for c in FEATURE_COLS if c in features.index]
X = pd.DataFrame([features[available].values.astype(np.float64)], columns=available) X = pd.DataFrame([features[available].values.astype(np.float64)], columns=available)
X = X.fillna(0.0) # ONNX(nan_to_num)와 동일한 NaN 처리
proba = float(self._lgbm_model.predict_proba(X)[0][1]) proba = float(self._lgbm_model.predict_proba(X)[0][1])
logger.debug( logger.debug(
f"ML 필터 [{self.active_backend}] 확률: {proba:.3f} " f"ML 필터 [{self.active_backend}] 확률: {proba:.3f} "

View File

@@ -11,6 +11,7 @@ class RiskManager:
self.initial_balance: float = 0.0 self.initial_balance: float = 0.0
self.open_positions: dict[str, str] = {} # {symbol: side} self.open_positions: dict[str, str] = {} # {symbol: side}
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._entry_lock = asyncio.Lock() # 동시 진입 시 잔고 레이스 방지
async def is_trading_allowed(self) -> bool: async def is_trading_allowed(self) -> bool:
"""일일 최대 손실 초과 시 거래 중단""" """일일 최대 손실 초과 시 거래 중단"""
@@ -58,10 +59,11 @@ class RiskManager:
self.daily_pnl += pnl self.daily_pnl += pnl
logger.info(f"오늘 누적 PnL: {self.daily_pnl:.4f} USDT") logger.info(f"오늘 누적 PnL: {self.daily_pnl:.4f} USDT")
def reset_daily(self): async def reset_daily(self):
"""매일 자정 초기화""" """매일 자정 초기화"""
self.daily_pnl = 0.0 async with self._lock:
logger.info("일일 PnL 초기화") self.daily_pnl = 0.0
logger.info("일일 PnL 초기화")
def set_base_balance(self, balance: float) -> None: def set_base_balance(self, balance: float) -> None:
"""봇 시작 시 기준 잔고 설정""" """봇 시작 시 기준 잔고 설정"""

View File

@@ -48,3 +48,28 @@ def test_config_max_same_direction_default():
"""동일 방향 최대 수 기본값 2.""" """동일 방향 최대 수 기본값 2."""
cfg = Config() cfg = Config()
assert cfg.max_same_direction == 2 assert cfg.max_same_direction == 2
def test_config_rejects_zero_leverage():
"""LEVERAGE=0은 ValueError."""
os.environ["LEVERAGE"] = "0"
with pytest.raises(ValueError, match="LEVERAGE"):
Config()
os.environ["LEVERAGE"] = "10" # 복원
def test_config_rejects_invalid_margin_ratio():
"""MARGIN_MAX_RATIO가 0이면 ValueError."""
os.environ["MARGIN_MAX_RATIO"] = "0"
with pytest.raises(ValueError, match="MARGIN_MAX_RATIO"):
Config()
os.environ["MARGIN_MAX_RATIO"] = "0.50" # 복원
def test_config_rejects_min_gt_max_margin():
"""MARGIN_MIN > MAX이면 ValueError."""
os.environ["MARGIN_MIN_RATIO"] = "0.80"
os.environ["MARGIN_MAX_RATIO"] = "0.50"
with pytest.raises(ValueError, match="MARGIN_MIN_RATIO"):
Config()
os.environ["MARGIN_MIN_RATIO"] = "0.20" # 복원

View File

@@ -1,3 +1,4 @@
import threading
import pytest import pytest
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from src.exchange import BinanceFuturesClient from src.exchange import BinanceFuturesClient
@@ -25,6 +26,7 @@ def client():
c.symbol = config.symbol c.symbol = config.symbol
c._qty_precision = 1 c._qty_precision = 1
c._price_precision = 4 c._price_precision = 4
c._api_lock = threading.Lock()
return c return c
@@ -43,6 +45,7 @@ def exchange():
c.client = MagicMock() c.client = MagicMock()
c._qty_precision = 1 c._qty_precision = 1
c._price_precision = 4 c._price_precision = 4
c._api_lock = threading.Lock()
return c return c

View File

@@ -1,3 +1,4 @@
import asyncio
import pytest import pytest
import os import os
from src.risk_manager import RiskManager from src.risk_manager import RiskManager
@@ -137,3 +138,31 @@ async def test_max_positions_global_limit(shared_risk):
await shared_risk.register_position("XRPUSDT", "LONG") await shared_risk.register_position("XRPUSDT", "LONG")
await shared_risk.register_position("TRXUSDT", "SHORT") await shared_risk.register_position("TRXUSDT", "SHORT")
assert await shared_risk.can_open_new_position("DOGEUSDT", "LONG") is False assert await shared_risk.can_open_new_position("DOGEUSDT", "LONG") is False
@pytest.mark.asyncio
async def test_reset_daily_with_lock(shared_risk):
"""reset_daily가 lock 하에서 PnL을 초기화한다."""
await shared_risk.close_position("DUMMY", 5.0) # dummy 기록
shared_risk.open_positions.clear() # clean up
assert shared_risk.daily_pnl == 5.0
await shared_risk.reset_daily()
assert shared_risk.daily_pnl == 0.0
@pytest.mark.asyncio
async def test_entry_lock_serializes_access(shared_risk):
"""_entry_lock이 동시 접근을 직렬화하는지 확인."""
order = []
async def simulated_entry(name: str):
async with shared_risk._entry_lock:
order.append(f"{name}_start")
await asyncio.sleep(0.05)
order.append(f"{name}_end")
await asyncio.gather(simulated_entry("A"), simulated_entry("B"))
# 직렬화 확인: A_start, A_end, B_start, B_end 또는 B_start, B_end, A_start, A_end
assert order[0].endswith("_start")
assert order[1].endswith("_end")
assert order[0][0] == order[1][0] # 같은 이름으로 시작/끝