feat: implement immediate re-entry after closing position on reverse signal
- Added `_close_and_reenter` method to handle immediate re-entry after closing a position when a reverse signal is detected, contingent on passing the ML filter. - Updated `process_candle` to call `_close_and_reenter` instead of `_close_position` for reverse signals. - Enhanced test coverage for the new functionality, ensuring correct behavior under various conditions, including ML filter checks and position limits.
This commit is contained in:
125
docs/plans/2026-03-02-reverse-signal-reenter-design.md
Normal file
125
docs/plans/2026-03-02-reverse-signal-reenter-design.md
Normal file
@@ -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` — 변경 없음
|
||||
269
docs/plans/2026-03-02-reverse-signal-reenter-plan.md
Normal file
269
docs/plans/2026-03-02-reverse-signal-reenter-plan.md
Normal file
@@ -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: 실패 테스트 있으면 수정 후 재실행
|
||||
|
||||
실패가 있으면 원인을 파악하고 수정한다. 기존 테스트를 깨뜨리지 않도록 주의.
|
||||
Reference in New Issue
Block a user