diff --git a/docs/plans/2026-03-02-reverse-signal-reenter-design.md b/docs/plans/2026-03-02-reverse-signal-reenter-design.md new file mode 100644 index 0000000..cbb801a --- /dev/null +++ b/docs/plans/2026-03-02-reverse-signal-reenter-design.md @@ -0,0 +1,125 @@ +# 반대 시그널 시 청산 후 즉시 재진입 설계 + +- **날짜**: 2026-03-02 +- **파일**: `src/bot.py` +- **상태**: 설계 완료, 구현 대기 + +--- + +## 배경 + +현재 `TradingBot.process_candle`은 반대 방향 시그널이 오면 기존 포지션을 청산만 하고 종료한다. +새 포지션은 다음 캔들에서 시그널이 다시 나와야 잡힌다. + +``` +현재: 반대 시그널 → 청산 → 다음 캔들 대기 +목표: 반대 시그널 → 청산 → (ML 필터 통과 시) 즉시 반대 방향 재진입 +``` + +같은 방향 시그널이 오거나 HOLD이면 기존 포지션을 그대로 유지한다. + +--- + +## 요구사항 + +| 항목 | 결정 | +|------|------| +| 포지션 크기 | 재진입 시점 잔고 + 동적 증거금 비율로 새로 계산 | +| SL/TP | 청산 시 기존 주문 전부 취소, 재진입 시 새로 설정 | +| ML 필터 | 재진입에도 동일하게 적용 (차단 시 청산만 하고 대기) | +| 같은 방향 시그널 | 포지션 유지 (변경 없음) | +| HOLD 시그널 | 포지션 유지 (변경 없음) | + +--- + +## 설계 + +### 변경 범위 + +`src/bot.py` 한 파일만 수정한다. + +1. `_close_and_reenter` 메서드 신규 추가 +2. `process_candle` 내 반대 시그널 분기에서 `_close_position` 대신 `_close_and_reenter` 호출 + +### 데이터 흐름 + +``` +process_candle() + └─ 반대 시그널 감지 + └─ _close_and_reenter(position, signal, df, btc_df, eth_df) + ├─ _close_position(position) # 청산 + cancel_all_orders + ├─ risk.can_open_new_position() 체크 + │ └─ 불가 → 로그 + 종료 + ├─ ML 필터 체크 (ml_filter.is_model_loaded()) + │ ├─ 차단 → 로그 + 종료 (포지션 없는 상태로 대기) + │ └─ 통과 → 계속 + └─ _open_position(signal, df) # 재진입 + 새 SL/TP 설정 +``` + +### `process_candle` 수정 + +```python +# 변경 전 +elif position is not None: + pos_side = "LONG" if float(position["positionAmt"]) > 0 else "SHORT" + if (pos_side == "LONG" and signal == "SHORT") or \ + (pos_side == "SHORT" and signal == "LONG"): + await self._close_position(position) + +# 변경 후 +elif position is not None: + pos_side = "LONG" if float(position["positionAmt"]) > 0 else "SHORT" + if (pos_side == "LONG" and signal == "SHORT") or \ + (pos_side == "SHORT" and signal == "LONG"): + await self._close_and_reenter(position, signal, df_with_indicators, btc_df, eth_df) +``` + +### 신규 메서드 `_close_and_reenter` + +```python +async def _close_and_reenter( + self, + position: dict, + signal: str, + df, + btc_df=None, + eth_df=None, +) -> None: + """기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다.""" + await self._close_position(position) + + if not self.risk.can_open_new_position(): + logger.info("최대 포지션 수 도달 — 재진입 건너뜀") + return + + if self.ml_filter.is_model_loaded(): + features = build_features(df, signal, btc_df=btc_df, eth_df=eth_df) + if not self.ml_filter.should_enter(features): + logger.info(f"ML 필터 차단: {signal} 재진입 무시") + return + + await self._open_position(signal, df) +``` + +--- + +## 엣지 케이스 + +| 상황 | 처리 | +|------|------| +| 청산 후 ML 필터 차단 | 청산만 하고 포지션 없는 상태로 대기 | +| 청산 후 잔고 부족 (명목금액 미달) | `_open_position` 내부 경고 후 건너뜀 (기존 로직) | +| 청산 후 최대 포지션 수 초과 | 재진입 건너뜀 | +| 같은 방향 시그널 | 포지션 유지 (변경 없음) | +| HOLD 시그널 | 포지션 유지 (변경 없음) | +| 봇 재시작 후 포지션 복구 | `_recover_position` 로직 변경 없음 | + +--- + +## 영향 없는 코드 + +- `_close_position` — 변경 없음 +- `_open_position` — 변경 없음 +- `_recover_position` — 변경 없음 +- `RiskManager` — 변경 없음 +- `MLFilter` — 변경 없음 diff --git a/docs/plans/2026-03-02-reverse-signal-reenter-plan.md b/docs/plans/2026-03-02-reverse-signal-reenter-plan.md new file mode 100644 index 0000000..410c21a --- /dev/null +++ b/docs/plans/2026-03-02-reverse-signal-reenter-plan.md @@ -0,0 +1,269 @@ +# 반대 시그널 시 청산 후 즉시 재진입 구현 플랜 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 반대 방향 시그널이 오면 기존 포지션을 청산하고 ML 필터 통과 시 즉시 반대 방향으로 재진입한다. + +**Architecture:** `src/bot.py`에 `_close_and_reenter` 메서드를 추가하고, `process_candle`의 반대 시그널 분기에서 이를 호출한다. 기존 `_close_position`과 `_open_position`을 그대로 재사용하므로 중복 없음. + +**Tech Stack:** Python 3.12, pytest, unittest.mock + +--- + +## 테스트 스크립트 + +각 태스크 단계마다 아래 스크립트로 테스트를 실행한다. + +```bash +# Task 1 — 신규 테스트 실행 (구현 전, FAIL 확인용) +bash scripts/test_reverse_reenter.sh 1 + +# Task 2 — _close_and_reenter 메서드 테스트 (구현 후, PASS 확인) +bash scripts/test_reverse_reenter.sh 2 + +# Task 3 — process_candle 분기 테스트 (수정 후, PASS 확인) +bash scripts/test_reverse_reenter.sh 3 + +# test_bot.py 전체 +bash scripts/test_reverse_reenter.sh bot + +# 전체 테스트 스위트 +bash scripts/test_reverse_reenter.sh all +``` + +--- + +## 참고 파일 + +- 설계 문서: `docs/plans/2026-03-02-reverse-signal-reenter-design.md` +- 구현 대상: `src/bot.py` +- 기존 테스트: `tests/test_bot.py` + +--- + +## Task 1: `_close_and_reenter` 테스트 작성 + +**Files:** +- Modify: `tests/test_bot.py` + +### Step 1: 테스트 3개 추가 + +`tests/test_bot.py` 맨 아래에 다음 테스트를 추가한다. + +```python +@pytest.mark.asyncio +async def test_close_and_reenter_calls_open_when_ml_passes(config, sample_df): + """반대 시그널 + ML 필터 통과 시 청산 후 재진입해야 한다.""" + with patch("src.bot.BinanceFuturesClient"): + bot = TradingBot(config) + + bot._close_position = AsyncMock() + bot._open_position = AsyncMock() + bot.ml_filter = MagicMock() + bot.ml_filter.is_model_loaded.return_value = True + bot.ml_filter.should_enter.return_value = True + + position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"} + await bot._close_and_reenter(position, "SHORT", sample_df) + + bot._close_position.assert_awaited_once_with(position) + bot._open_position.assert_awaited_once_with("SHORT", sample_df) + + +@pytest.mark.asyncio +async def test_close_and_reenter_skips_open_when_ml_blocks(config, sample_df): + """ML 필터 차단 시 청산만 하고 재진입하지 않아야 한다.""" + with patch("src.bot.BinanceFuturesClient"): + bot = TradingBot(config) + + bot._close_position = AsyncMock() + bot._open_position = AsyncMock() + bot.ml_filter = MagicMock() + bot.ml_filter.is_model_loaded.return_value = True + bot.ml_filter.should_enter.return_value = False + + position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"} + await bot._close_and_reenter(position, "SHORT", sample_df) + + bot._close_position.assert_awaited_once_with(position) + bot._open_position.assert_not_called() + + +@pytest.mark.asyncio +async def test_close_and_reenter_skips_open_when_max_positions_reached(config, sample_df): + """최대 포지션 수 도달 시 청산만 하고 재진입하지 않아야 한다.""" + with patch("src.bot.BinanceFuturesClient"): + bot = TradingBot(config) + + bot._close_position = AsyncMock() + bot._open_position = AsyncMock() + bot.risk = MagicMock() + bot.risk.can_open_new_position.return_value = False + + position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"} + await bot._close_and_reenter(position, "SHORT", sample_df) + + bot._close_position.assert_awaited_once_with(position) + bot._open_position.assert_not_called() +``` + +### Step 2: 테스트 실행 — 실패 확인 + +```bash +bash scripts/test_reverse_reenter.sh 1 +``` + +예상 결과: `AttributeError: 'TradingBot' object has no attribute '_close_and_reenter'` 로 3개 FAIL + +--- + +## Task 2: `_close_and_reenter` 메서드 구현 + +**Files:** +- Modify: `src/bot.py:148` (`_close_position` 메서드 바로 아래에 추가) + +### Step 1: `_close_position` 다음에 메서드 추가 + +`src/bot.py`에서 `_close_position` 메서드(148~167번째 줄) 바로 뒤에 다음을 추가한다. + +```python + async def _close_and_reenter( + self, + position: dict, + signal: str, + df, + btc_df=None, + eth_df=None, + ) -> None: + """기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다.""" + await self._close_position(position) + + if not self.risk.can_open_new_position(): + logger.info("최대 포지션 수 도달 — 재진입 건너뜀") + return + + if self.ml_filter.is_model_loaded(): + features = build_features(df, signal, btc_df=btc_df, eth_df=eth_df) + if not self.ml_filter.should_enter(features): + logger.info(f"ML 필터 차단: {signal} 재진입 무시") + return + + await self._open_position(signal, df) +``` + +### Step 2: 테스트 실행 — 통과 확인 + +```bash +bash scripts/test_reverse_reenter.sh 2 +``` + +예상 결과: 3개 PASS + +### Step 3: 커밋 + +```bash +git add src/bot.py tests/test_bot.py +git commit -m "feat: add _close_and_reenter method for reverse signal handling" +``` + +--- + +## Task 3: `process_candle` 분기 수정 + +**Files:** +- Modify: `src/bot.py:83-85` + +### Step 1: 기존 분기 테스트 추가 + +`tests/test_bot.py`에 다음 테스트를 추가한다. + +```python +@pytest.mark.asyncio +async def test_process_candle_calls_close_and_reenter_on_reverse_signal(config, sample_df): + """반대 시그널 시 process_candle이 _close_and_reenter를 호출해야 한다.""" + with patch("src.bot.BinanceFuturesClient"): + bot = TradingBot(config) + + bot.exchange = AsyncMock() + bot.exchange.get_position = AsyncMock(return_value={ + "positionAmt": "100", + "entryPrice": "0.5", + "markPrice": "0.52", + }) + bot._close_and_reenter = AsyncMock() + bot.ml_filter = MagicMock() + bot.ml_filter.is_model_loaded.return_value = False + bot.ml_filter.should_enter.return_value = True + + with patch("src.bot.Indicators") as MockInd: + mock_ind = MagicMock() + mock_ind.calculate_all.return_value = sample_df + mock_ind.get_signal.return_value = "SHORT" # 현재 LONG 포지션에 반대 시그널 + MockInd.return_value = mock_ind + await bot.process_candle(sample_df) + + bot._close_and_reenter.assert_awaited_once() + call_args = bot._close_and_reenter.call_args + assert call_args.args[1] == "SHORT" +``` + +### Step 2: 테스트 실행 — 실패 확인 + +```bash +bash scripts/test_reverse_reenter.sh 3 +``` + +예상 결과: FAIL (`_close_and_reenter`가 아직 호출되지 않음) + +### Step 3: `process_candle` 수정 + +`src/bot.py`에서 아래 부분을 찾아 수정한다. + +```python +# 변경 전 (81~85번째 줄 근처) + elif position is not None: + pos_side = "LONG" if float(position["positionAmt"]) > 0 else "SHORT" + if (pos_side == "LONG" and signal == "SHORT") or \ + (pos_side == "SHORT" and signal == "LONG"): + await self._close_position(position) + +# 변경 후 + elif position is not None: + pos_side = "LONG" if float(position["positionAmt"]) > 0 else "SHORT" + if (pos_side == "LONG" and signal == "SHORT") or \ + (pos_side == "SHORT" and signal == "LONG"): + await self._close_and_reenter( + position, signal, df_with_indicators, btc_df=btc_df, eth_df=eth_df + ) +``` + +### Step 4: 전체 테스트 실행 — 통과 확인 + +```bash +bash scripts/test_reverse_reenter.sh bot +``` + +예상 결과: 전체 PASS (기존 테스트 포함) + +### Step 5: 커밋 + +```bash +git add src/bot.py tests/test_bot.py +git commit -m "feat: call _close_and_reenter on reverse signal in process_candle" +``` + +--- + +## Task 4: 전체 테스트 스위트 확인 + +### Step 1: 전체 테스트 실행 + +```bash +bash scripts/test_reverse_reenter.sh all +``` + +예상 결과: 모든 테스트 PASS + +### Step 2: 실패 테스트 있으면 수정 후 재실행 + +실패가 있으면 원인을 파악하고 수정한다. 기존 테스트를 깨뜨리지 않도록 주의. diff --git a/scripts/test_reverse_reenter.sh b/scripts/test_reverse_reenter.sh new file mode 100755 index 0000000..42a74d2 --- /dev/null +++ b/scripts/test_reverse_reenter.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# 반대 시그널 재진입 기능 테스트 스크립트 +# 사용법: bash scripts/test_reverse_reenter.sh [task] +# +# 예시: +# bash scripts/test_reverse_reenter.sh # 전체 태스크 순서대로 실행 +# bash scripts/test_reverse_reenter.sh 1 # Task 1: 신규 테스트만 (실패 확인) +# bash scripts/test_reverse_reenter.sh 2 # Task 2: _close_and_reenter 메서드 테스트 +# bash scripts/test_reverse_reenter.sh 3 # Task 3: process_candle 분기 테스트 +# bash scripts/test_reverse_reenter.sh bot # test_bot.py 전체 +# bash scripts/test_reverse_reenter.sh all # tests/ 전체 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +VENV_PATH="${VENV_PATH:-$PROJECT_ROOT/.venv}" +if [ -f "$VENV_PATH/bin/activate" ]; then + # shellcheck source=/dev/null + source "$VENV_PATH/bin/activate" +else + echo "경고: 가상환경을 찾을 수 없습니다 ($VENV_PATH). 시스템 Python을 사용합니다." >&2 +fi + +cd "$PROJECT_ROOT" + +TASK="${1:-all}" + +# ── 태스크별 테스트 이름 ────────────────────────────────────────────────────── +TASK1_TESTS=( + "tests/test_bot.py::test_close_and_reenter_calls_open_when_ml_passes" + "tests/test_bot.py::test_close_and_reenter_skips_open_when_ml_blocks" + "tests/test_bot.py::test_close_and_reenter_skips_open_when_max_positions_reached" +) + +TASK2_TESTS=( + "tests/test_bot.py::test_close_and_reenter_calls_open_when_ml_passes" + "tests/test_bot.py::test_close_and_reenter_skips_open_when_ml_blocks" + "tests/test_bot.py::test_close_and_reenter_skips_open_when_max_positions_reached" +) + +TASK3_TESTS=( + "tests/test_bot.py::test_process_candle_calls_close_and_reenter_on_reverse_signal" +) + +run_pytest() { + echo "" + echo "▶ pytest $*" + echo "────────────────────────────────────────" + python -m pytest "$@" -v +} + +case "$TASK" in + 1) + echo "=== Task 1: 신규 테스트 실행 (구현 전 → FAIL 예상) ===" + run_pytest "${TASK1_TESTS[@]}" + ;; + 2) + echo "=== Task 2: _close_and_reenter 메서드 테스트 (구현 후 → PASS 예상) ===" + run_pytest "${TASK2_TESTS[@]}" + ;; + 3) + echo "=== Task 3: process_candle 분기 테스트 (수정 후 → PASS 예상) ===" + run_pytest "${TASK3_TESTS[@]}" + ;; + bot) + echo "=== test_bot.py 전체 ===" + run_pytest tests/test_bot.py + ;; + all) + echo "=== 전체 테스트 스위트 ===" + run_pytest tests/ + ;; + *) + echo "알 수 없는 태스크: $TASK" + echo "사용법: bash scripts/test_reverse_reenter.sh [1|2|3|bot|all]" + exit 1 + ;; +esac + +echo "" +echo "=== 완료 ===" diff --git a/src/bot.py b/src/bot.py index bfc72f9..42a5a82 100644 --- a/src/bot.py +++ b/src/bot.py @@ -82,7 +82,9 @@ class TradingBot: pos_side = "LONG" if float(position["positionAmt"]) > 0 else "SHORT" if (pos_side == "LONG" and signal == "SHORT") or \ (pos_side == "SHORT" and signal == "LONG"): - await self._close_position(position) + await self._close_and_reenter( + position, signal, df_with_indicators, btc_df=btc_df, eth_df=eth_df + ) async def _open_position(self, signal: str, df): balance = await self.exchange.get_balance() @@ -166,6 +168,29 @@ class TradingBot: self.current_trade_side = None logger.success(f"포지션 청산: PnL={pnl:.4f} USDT") + async def _close_and_reenter( + self, + position: dict, + signal: str, + df, + btc_df=None, + eth_df=None, + ) -> None: + """기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다.""" + await self._close_position(position) + + if not self.risk.can_open_new_position(): + logger.info("최대 포지션 수 도달 — 재진입 건너뜀") + return + + if self.ml_filter.is_model_loaded(): + features = build_features(df, signal, btc_df=btc_df, eth_df=eth_df) + if not self.ml_filter.should_enter(features): + logger.info(f"ML 필터 차단: {signal} 재진입 무시") + return + + await self._open_position(signal, df) + async def run(self): logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x") await self._recover_position() diff --git a/tests/test_bot.py b/tests/test_bot.py index ac35f07..6494f61 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -69,3 +69,88 @@ async def test_bot_processes_signal(config, sample_df): mock_ind.get_atr_stop.return_value = (0.48, 0.56) MockInd.return_value = mock_ind await bot.process_candle(sample_df) + + +@pytest.mark.asyncio +async def test_close_and_reenter_calls_open_when_ml_passes(config, sample_df): + """반대 시그널 + ML 필터 통과 시 청산 후 재진입해야 한다.""" + with patch("src.bot.BinanceFuturesClient"): + bot = TradingBot(config) + + bot._close_position = AsyncMock() + bot._open_position = AsyncMock() + bot.ml_filter = MagicMock() + bot.ml_filter.is_model_loaded.return_value = True + bot.ml_filter.should_enter.return_value = True + + position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"} + await bot._close_and_reenter(position, "SHORT", sample_df) + + bot._close_position.assert_awaited_once_with(position) + bot._open_position.assert_awaited_once_with("SHORT", sample_df) + + +@pytest.mark.asyncio +async def test_close_and_reenter_skips_open_when_ml_blocks(config, sample_df): + """ML 필터 차단 시 청산만 하고 재진입하지 않아야 한다.""" + with patch("src.bot.BinanceFuturesClient"): + bot = TradingBot(config) + + bot._close_position = AsyncMock() + bot._open_position = AsyncMock() + bot.ml_filter = MagicMock() + bot.ml_filter.is_model_loaded.return_value = True + bot.ml_filter.should_enter.return_value = False + + position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"} + await bot._close_and_reenter(position, "SHORT", sample_df) + + bot._close_position.assert_awaited_once_with(position) + bot._open_position.assert_not_called() + + +@pytest.mark.asyncio +async def test_close_and_reenter_skips_open_when_max_positions_reached(config, sample_df): + """최대 포지션 수 도달 시 청산만 하고 재진입하지 않아야 한다.""" + with patch("src.bot.BinanceFuturesClient"): + bot = TradingBot(config) + + bot._close_position = AsyncMock() + bot._open_position = AsyncMock() + bot.risk = MagicMock() + bot.risk.can_open_new_position.return_value = False + + position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"} + await bot._close_and_reenter(position, "SHORT", sample_df) + + bot._close_position.assert_awaited_once_with(position) + bot._open_position.assert_not_called() + + +@pytest.mark.asyncio +async def test_process_candle_calls_close_and_reenter_on_reverse_signal(config, sample_df): + """반대 시그널 시 process_candle이 _close_and_reenter를 호출해야 한다.""" + with patch("src.bot.BinanceFuturesClient"): + bot = TradingBot(config) + + bot.exchange = AsyncMock() + bot.exchange.get_position = AsyncMock(return_value={ + "positionAmt": "100", + "entryPrice": "0.5", + "markPrice": "0.52", + }) + bot._close_and_reenter = AsyncMock() + bot.ml_filter = MagicMock() + bot.ml_filter.is_model_loaded.return_value = False + bot.ml_filter.should_enter.return_value = True + + with patch("src.bot.Indicators") as MockInd: + mock_ind = MagicMock() + mock_ind.calculate_all.return_value = sample_df + mock_ind.get_signal.return_value = "SHORT" # 현재 LONG 포지션에 반대 시그널 + MockInd.return_value = mock_ind + await bot.process_candle(sample_df) + + bot._close_and_reenter.assert_awaited_once() + call_args = bot._close_and_reenter.call_args + assert call_args.args[1] == "SHORT"